@datamoc/mw_games 0.17.1 → 0.18.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.
- package/CHANGELOG.md +14 -0
- package/dist/audio/Channels.d.ts +198 -0
- package/dist/audio/Channels.d.ts.map +1 -0
- package/dist/audio/Channels.js +456 -0
- package/dist/audio/Channels.js.map +1 -0
- package/dist/audio/Midi.d.ts +71 -9
- package/dist/audio/Midi.d.ts.map +1 -1
- package/dist/audio/Midi.js +107 -15
- package/dist/audio/Midi.js.map +1 -1
- package/dist/audio/MidiRender.d.ts +57 -0
- package/dist/audio/MidiRender.d.ts.map +1 -0
- package/dist/audio/MidiRender.js +261 -0
- package/dist/audio/MidiRender.js.map +1 -0
- package/dist/audio/SoundFont.d.ts +64 -0
- package/dist/audio/SoundFont.d.ts.map +1 -0
- package/dist/audio/SoundFont.js +219 -0
- package/dist/audio/SoundFont.js.map +1 -0
- package/dist/audio/index.d.ts +9 -2
- package/dist/audio/index.d.ts.map +1 -1
- package/dist/audio/index.js +4 -1
- package/dist/audio/index.js.map +1 -1
- package/dist/mw_games.global.js +10 -10
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,20 @@ All notable changes to `mwg` are documented here. Format follows
|
|
|
5
5
|
[Semantic Versioning](https://semver.org/) as of this first release - a 0.y.z version means
|
|
6
6
|
the public API may still change between minor versions.
|
|
7
7
|
|
|
8
|
+
## [0.18.0] - 2026-09-25
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `audio.parseMidi` keeps program-change, control-change and pitch-bend events and reports
|
|
13
|
+
the controller-111 loop marker as `loopStartTick`; `scheduleMidi` notes carry their channel
|
|
14
|
+
voice (`MidiVoice`: program, bank, gain, pan) and `midiLoopStart` resolves the marker to seconds.
|
|
15
|
+
- `audio.renderMidiToBuffer` renders a parsed MIDI file to stereo PCM frames, offline and
|
|
16
|
+
deterministic, voiced by an optional game-supplied `SoundFont` or the built-in waveform
|
|
17
|
+
synth, with the loop region attached.
|
|
18
|
+
- `audio.parseSoundFont` reads SoundFont 2 bytes into layered preset voices.
|
|
19
|
+
- `audio.Channel` (a seekable buffered WebAudio voice with volume, pitch, pan and a loop
|
|
20
|
+
region) and `audio.AudioBus` (independent BGM/BGS loops, ME jingles that suspend and resume
|
|
21
|
+
the BGM, overlapping SE one-shots, save and replay with playheads, plain-data snapshots).
|
|
8
22
|
## [0.17.1] - 2026-09-25
|
|
9
23
|
|
|
10
24
|
### Fixed
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Buffered WebAudio voices for music and jingles: a `Channel` plays one looping track
|
|
3
|
+
* with volume, pitch, pan, a loop region and a seekable playhead, and an `AudioBus`
|
|
4
|
+
* wires channels into the four lifetimes a role-playing game needs from its audio.
|
|
5
|
+
* BGM and BGS are two independent looping streams, ME is an interrupting jingle that
|
|
6
|
+
* pauses the BGM and resumes it where it left off, SE is overlapping one-shots, each
|
|
7
|
+
* with its own volume, pitch and pan per play.
|
|
8
|
+
*
|
|
9
|
+
* The browser owns the clock and the nodes, so both classes take the pieces they
|
|
10
|
+
* cannot build themselves as injectables: the `AudioContext` in the constructor and,
|
|
11
|
+
* for the bus, a `load` function turning a kind and a name into a decoded buffer.
|
|
12
|
+
* Tests hand in fakes for both, the same way `Sound` and `Music` take `create()`.
|
|
13
|
+
*/
|
|
14
|
+
/** seconds within a buffer: the region a looping track repeats */
|
|
15
|
+
export interface LoopRegion {
|
|
16
|
+
start: number;
|
|
17
|
+
end: number;
|
|
18
|
+
}
|
|
19
|
+
export interface ChannelPlayOptions {
|
|
20
|
+
/** level, 0 to 1 */
|
|
21
|
+
volume?: number;
|
|
22
|
+
/** playback ratio, 1 unchanged, above 1 higher and faster */
|
|
23
|
+
pitch?: number;
|
|
24
|
+
/** stereo position, -1 (left) to 1 (right) */
|
|
25
|
+
pan?: number;
|
|
26
|
+
/** start offset in seconds */
|
|
27
|
+
pos?: number;
|
|
28
|
+
/** region to repeat, null repeats the whole buffer */
|
|
29
|
+
loop?: LoopRegion | null;
|
|
30
|
+
/** false plays once through for jingles; true repeats */
|
|
31
|
+
repeat?: boolean;
|
|
32
|
+
}
|
|
33
|
+
/** plain-data playback state: parameters plus playhead, without the buffer */
|
|
34
|
+
export interface ChannelState {
|
|
35
|
+
volume: number;
|
|
36
|
+
pitch: number;
|
|
37
|
+
pan: number;
|
|
38
|
+
pos: number;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* One buffered voice: a decoded track with a loop region and a playhead the game can
|
|
42
|
+
* read and move. Replaying the same buffer restarts it; changing parameters mid-play
|
|
43
|
+
* never does.
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* ```ts
|
|
47
|
+
* import { Channel } from '@datamoc/mw_games/audio';
|
|
48
|
+
*
|
|
49
|
+
* declare const context: AudioContext;
|
|
50
|
+
* declare const buffer: AudioBuffer;
|
|
51
|
+
*
|
|
52
|
+
* const channel = new Channel(context);
|
|
53
|
+
* channel.play(buffer, { volume: 0.8, pitch: 1, pan: 0, loop: { start: 1, end: 4 } });
|
|
54
|
+
* console.log(channel.position());
|
|
55
|
+
* channel.stop();
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
export declare class Channel {
|
|
59
|
+
private readonly context;
|
|
60
|
+
private readonly destination;
|
|
61
|
+
private source;
|
|
62
|
+
private gainNode;
|
|
63
|
+
private pannerNode;
|
|
64
|
+
private buffer;
|
|
65
|
+
private params;
|
|
66
|
+
private loopRegion;
|
|
67
|
+
private repeat;
|
|
68
|
+
private startedAt;
|
|
69
|
+
private offset;
|
|
70
|
+
constructor(context: AudioContext, destination?: AudioNode);
|
|
71
|
+
get isPlaying(): boolean;
|
|
72
|
+
get duration(): number;
|
|
73
|
+
/** starts the buffer with these parameters, stopping whatever played before */
|
|
74
|
+
play(buffer: AudioBuffer, options?: ChannelPlayOptions): void;
|
|
75
|
+
/** retunes the playing voice in place: no restart, no seek */
|
|
76
|
+
update(params: {
|
|
77
|
+
volume?: number;
|
|
78
|
+
pitch?: number;
|
|
79
|
+
pan?: number;
|
|
80
|
+
}): void;
|
|
81
|
+
/** stops the voice, fading out over `fadeSeconds` first when positive */
|
|
82
|
+
stop(fadeSeconds?: number): void;
|
|
83
|
+
/** seconds into the buffer, wrapping inside the loop region while it repeats */
|
|
84
|
+
position(): number;
|
|
85
|
+
/** moves the playhead, restarting the voice when it plays */
|
|
86
|
+
seek(pos: number): void;
|
|
87
|
+
/** plain-data state for saves and transfers: parameters plus playhead */
|
|
88
|
+
state(): ChannelState;
|
|
89
|
+
/** applies plain-data state, restarting the voice at its playhead when it plays */
|
|
90
|
+
restore(state: ChannelState): void;
|
|
91
|
+
private clearNodes;
|
|
92
|
+
}
|
|
93
|
+
export type BusKind = 'bgm' | 'bgs' | 'me' | 'se';
|
|
94
|
+
/**
|
|
95
|
+
* A named track request in role-playing units: volume 0 to 100, pitch 50 to 150
|
|
96
|
+
* percent, pan -100 to 100, an optional resume position in seconds.
|
|
97
|
+
*/
|
|
98
|
+
export interface BusTrack {
|
|
99
|
+
name: string;
|
|
100
|
+
volume?: number;
|
|
101
|
+
pitch?: number;
|
|
102
|
+
pan?: number;
|
|
103
|
+
pos?: number;
|
|
104
|
+
}
|
|
105
|
+
/** a resolved track request: every parameter filled in, ready to store or restore */
|
|
106
|
+
export interface SavedBusTrack {
|
|
107
|
+
name: string;
|
|
108
|
+
volume: number;
|
|
109
|
+
pitch: number;
|
|
110
|
+
pan: number;
|
|
111
|
+
pos: number;
|
|
112
|
+
}
|
|
113
|
+
/** a decoded track: its buffer plus the region it repeats, if any */
|
|
114
|
+
export interface LoadedTrack {
|
|
115
|
+
buffer: AudioBuffer;
|
|
116
|
+
loop?: LoopRegion | null;
|
|
117
|
+
}
|
|
118
|
+
/** turns a kind and a name into a decoded buffer, or null when it cannot */
|
|
119
|
+
export type BusLoader = (kind: BusKind, name: string) => Promise<LoadedTrack | null>;
|
|
120
|
+
/** plain-data bus state for saves and transfers: tracks plus memorized ones */
|
|
121
|
+
export interface BusSnapshot {
|
|
122
|
+
bgm: SavedBusTrack | null;
|
|
123
|
+
bgs: SavedBusTrack | null;
|
|
124
|
+
savedBgm: SavedBusTrack | null;
|
|
125
|
+
savedBgs: SavedBusTrack | null;
|
|
126
|
+
}
|
|
127
|
+
export interface AudioBusOptions {
|
|
128
|
+
destination?: AudioNode;
|
|
129
|
+
load?: BusLoader;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* The four audio lifetimes of a role-playing game over two `Channel` voices: BGM and
|
|
133
|
+
* BGS loop independently, an ME jingle suspends the BGM and resumes it where it left
|
|
134
|
+
* off, SE plays overlapping one-shots. Replaying the current track only retunes it;
|
|
135
|
+
* save and replay memorize a track with its playhead for menus and transfers.
|
|
136
|
+
*
|
|
137
|
+
* @example
|
|
138
|
+
* ```ts
|
|
139
|
+
* import { AudioBus } from '@datamoc/mw_games/audio';
|
|
140
|
+
*
|
|
141
|
+
* declare const context: AudioContext;
|
|
142
|
+
*
|
|
143
|
+
* const bus = new AudioBus(context, { load: (_kind, _name) => Promise.resolve(null) });
|
|
144
|
+
* void bus.playBgm({ name: 'town', volume: 90, pitch: 100, pan: 0 });
|
|
145
|
+
* console.log(bus.snapshot());
|
|
146
|
+
* ```
|
|
147
|
+
*/
|
|
148
|
+
export declare class AudioBus {
|
|
149
|
+
private readonly context;
|
|
150
|
+
private readonly destination;
|
|
151
|
+
private readonly load;
|
|
152
|
+
private readonly bgmChannel;
|
|
153
|
+
private readonly bgsChannel;
|
|
154
|
+
private readonly tracks;
|
|
155
|
+
private readonly saved;
|
|
156
|
+
private readonly requests;
|
|
157
|
+
private me;
|
|
158
|
+
private meToken;
|
|
159
|
+
private suspendedBgm;
|
|
160
|
+
private readonly seNodes;
|
|
161
|
+
constructor(context: AudioContext, options?: AudioBusOptions);
|
|
162
|
+
/** loops this BGM, or retunes it when it already loops */
|
|
163
|
+
playBgm(track: BusTrack): Promise<void>;
|
|
164
|
+
/** loops this BGS, or retunes it when it already loops */
|
|
165
|
+
playBgs(track: BusTrack): Promise<void>;
|
|
166
|
+
stopBgm(): void;
|
|
167
|
+
stopBgs(): void;
|
|
168
|
+
fadeOutBgm(seconds?: number): void;
|
|
169
|
+
fadeOutBgs(seconds?: number): void;
|
|
170
|
+
/** the intended track with its live playhead, or the one an ME suspended */
|
|
171
|
+
currentTrack(kind: 'bgm' | 'bgs'): SavedBusTrack | null;
|
|
172
|
+
/** memorizes the playing BGM with its playhead; replay restores it */
|
|
173
|
+
saveBgm(): SavedBusTrack | null;
|
|
174
|
+
/** memorizes the playing BGS with its playhead; replay restores it */
|
|
175
|
+
saveBgs(): SavedBusTrack | null;
|
|
176
|
+
replayBgm(): Promise<void>;
|
|
177
|
+
replayBgs(): Promise<void>;
|
|
178
|
+
/**
|
|
179
|
+
* Plays a jingle once, suspending the BGM and resuming it where it left off when
|
|
180
|
+
* the jingle ends. A BGM started mid-jingle takes over instead of being resumed over.
|
|
181
|
+
*/
|
|
182
|
+
playMe(track: BusTrack): Promise<void>;
|
|
183
|
+
/** stops the jingle, resuming the suspended BGM unless told not to */
|
|
184
|
+
stopMe(resume?: boolean): void;
|
|
185
|
+
/** plays one overlapping sound effect */
|
|
186
|
+
playSe(track: BusTrack): Promise<void>;
|
|
187
|
+
stopSe(): void;
|
|
188
|
+
stopAll(): void;
|
|
189
|
+
/** plain-data bus state for saves and transfers */
|
|
190
|
+
snapshot(): BusSnapshot;
|
|
191
|
+
/** replays plain-data bus state, reloading every named track */
|
|
192
|
+
restore(state: BusSnapshot | null | undefined): void;
|
|
193
|
+
private static normalize;
|
|
194
|
+
private playLoop;
|
|
195
|
+
private stopMeNodes;
|
|
196
|
+
private finishMe;
|
|
197
|
+
}
|
|
198
|
+
//# sourceMappingURL=Channels.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Channels.d.ts","sourceRoot":"","sources":["../../src/audio/Channels.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Buffered WebAudio voices for music and jingles: a `Channel` plays one looping track
|
|
3
|
+
* with volume, pitch, pan, a loop region and a seekable playhead, and an `AudioBus`
|
|
4
|
+
* wires channels into the four lifetimes a role-playing game needs from its audio.
|
|
5
|
+
* BGM and BGS are two independent looping streams, ME is an interrupting jingle that
|
|
6
|
+
* pauses the BGM and resumes it where it left off, SE is overlapping one-shots, each
|
|
7
|
+
* with its own volume, pitch and pan per play.
|
|
8
|
+
*
|
|
9
|
+
* The browser owns the clock and the nodes, so both classes take the pieces they
|
|
10
|
+
* cannot build themselves as injectables: the `AudioContext` in the constructor and,
|
|
11
|
+
* for the bus, a `load` function turning a kind and a name into a decoded buffer.
|
|
12
|
+
* Tests hand in fakes for both, the same way `Sound` and `Music` take `create()`.
|
|
13
|
+
*/
|
|
14
|
+
/** one wired voice: a buffer source through a gain and an optional stereo panner */
|
|
15
|
+
function wireVoice(context, destination, buffer, volume, pitch, pan) {
|
|
16
|
+
const source = context.createBufferSource();
|
|
17
|
+
source.buffer = buffer;
|
|
18
|
+
source.playbackRate.value = pitch;
|
|
19
|
+
const gain = context.createGain();
|
|
20
|
+
gain.gain.value = volume;
|
|
21
|
+
let panner = null;
|
|
22
|
+
if (typeof context.createStereoPanner === 'function') {
|
|
23
|
+
panner = context.createStereoPanner();
|
|
24
|
+
panner.pan.value = pan;
|
|
25
|
+
source.connect(gain);
|
|
26
|
+
gain.connect(panner);
|
|
27
|
+
panner.connect(destination);
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
source.connect(gain);
|
|
31
|
+
gain.connect(destination);
|
|
32
|
+
}
|
|
33
|
+
return { source, gain, panner };
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* One buffered voice: a decoded track with a loop region and a playhead the game can
|
|
37
|
+
* read and move. Replaying the same buffer restarts it; changing parameters mid-play
|
|
38
|
+
* never does.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```ts
|
|
42
|
+
* import { Channel } from '@datamoc/mw_games/audio';
|
|
43
|
+
*
|
|
44
|
+
* declare const context: AudioContext;
|
|
45
|
+
* declare const buffer: AudioBuffer;
|
|
46
|
+
*
|
|
47
|
+
* const channel = new Channel(context);
|
|
48
|
+
* channel.play(buffer, { volume: 0.8, pitch: 1, pan: 0, loop: { start: 1, end: 4 } });
|
|
49
|
+
* console.log(channel.position());
|
|
50
|
+
* channel.stop();
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
export class Channel {
|
|
54
|
+
constructor(context, destination) {
|
|
55
|
+
this.source = null;
|
|
56
|
+
this.gainNode = null;
|
|
57
|
+
this.pannerNode = null;
|
|
58
|
+
this.buffer = null;
|
|
59
|
+
this.params = { volume: 1, pitch: 1, pan: 0 };
|
|
60
|
+
this.loopRegion = null;
|
|
61
|
+
this.repeat = true;
|
|
62
|
+
this.startedAt = 0;
|
|
63
|
+
this.offset = 0;
|
|
64
|
+
this.context = context;
|
|
65
|
+
this.destination = destination ?? context.destination;
|
|
66
|
+
}
|
|
67
|
+
get isPlaying() {
|
|
68
|
+
return this.source !== null;
|
|
69
|
+
}
|
|
70
|
+
get duration() {
|
|
71
|
+
return this.buffer?.duration ?? 0;
|
|
72
|
+
}
|
|
73
|
+
/** starts the buffer with these parameters, stopping whatever played before */
|
|
74
|
+
play(buffer, options = {}) {
|
|
75
|
+
this.stop();
|
|
76
|
+
const volume = options.volume ?? 1;
|
|
77
|
+
const pitch = options.pitch ?? 1;
|
|
78
|
+
const pan = options.pan ?? 0;
|
|
79
|
+
const { source, gain, panner } = wireVoice(this.context, this.destination, buffer, volume, pitch, pan);
|
|
80
|
+
const loop = options.loop ?? null;
|
|
81
|
+
const repeat = options.repeat ?? true;
|
|
82
|
+
source.loop = repeat;
|
|
83
|
+
if (loop) {
|
|
84
|
+
source.loopStart = loop.start;
|
|
85
|
+
source.loopEnd = loop.end;
|
|
86
|
+
}
|
|
87
|
+
if (!repeat) {
|
|
88
|
+
const started = source;
|
|
89
|
+
source.onended = () => {
|
|
90
|
+
if (this.source === started)
|
|
91
|
+
this.clearNodes();
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
this.source = source;
|
|
95
|
+
this.gainNode = gain;
|
|
96
|
+
this.pannerNode = panner;
|
|
97
|
+
this.buffer = buffer;
|
|
98
|
+
this.params = { volume, pitch, pan };
|
|
99
|
+
this.loopRegion = loop;
|
|
100
|
+
this.repeat = repeat;
|
|
101
|
+
this.offset = Math.min(Math.max(0, options.pos ?? 0), Math.max(0, buffer.duration - 0.001));
|
|
102
|
+
this.startedAt = this.context.currentTime;
|
|
103
|
+
source.start(0, this.offset);
|
|
104
|
+
}
|
|
105
|
+
/** retunes the playing voice in place: no restart, no seek */
|
|
106
|
+
update(params) {
|
|
107
|
+
if (params.volume !== undefined)
|
|
108
|
+
this.params.volume = params.volume;
|
|
109
|
+
if (params.pitch !== undefined)
|
|
110
|
+
this.params.pitch = params.pitch;
|
|
111
|
+
if (params.pan !== undefined)
|
|
112
|
+
this.params.pan = params.pan;
|
|
113
|
+
if (!this.source || !this.gainNode)
|
|
114
|
+
return;
|
|
115
|
+
const now = this.context.currentTime;
|
|
116
|
+
this.gainNode.gain.setTargetAtTime(this.params.volume, now, 0.02);
|
|
117
|
+
this.source.playbackRate.value = this.params.pitch;
|
|
118
|
+
if (this.pannerNode)
|
|
119
|
+
this.pannerNode.pan.value = this.params.pan;
|
|
120
|
+
}
|
|
121
|
+
/** stops the voice, fading out over `fadeSeconds` first when positive */
|
|
122
|
+
stop(fadeSeconds = 0) {
|
|
123
|
+
const resumeAt = this.position();
|
|
124
|
+
const source = this.source;
|
|
125
|
+
const gain = this.gainNode;
|
|
126
|
+
this.clearNodes();
|
|
127
|
+
this.offset = resumeAt;
|
|
128
|
+
if (!source)
|
|
129
|
+
return;
|
|
130
|
+
try {
|
|
131
|
+
if (fadeSeconds > 0 && gain) {
|
|
132
|
+
const now = this.context.currentTime;
|
|
133
|
+
gain.gain.cancelScheduledValues(now);
|
|
134
|
+
gain.gain.setValueAtTime(gain.gain.value, now);
|
|
135
|
+
gain.gain.linearRampToValueAtTime(0, now + fadeSeconds);
|
|
136
|
+
source.stop(now + fadeSeconds + 0.02);
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
source.stop();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
//already stopped
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/** seconds into the buffer, wrapping inside the loop region while it repeats */
|
|
147
|
+
position() {
|
|
148
|
+
if (!this.source || !this.buffer)
|
|
149
|
+
return this.offset;
|
|
150
|
+
const elapsed = Math.max(0, this.context.currentTime - this.startedAt) * this.params.pitch;
|
|
151
|
+
let pos = this.offset + elapsed;
|
|
152
|
+
if (this.repeat && this.duration > 0) {
|
|
153
|
+
const start = this.loopRegion?.start ?? 0;
|
|
154
|
+
const end = this.loopRegion?.end ?? this.buffer.duration;
|
|
155
|
+
if (end > start && pos >= end)
|
|
156
|
+
pos = start + ((pos - start) % (end - start));
|
|
157
|
+
}
|
|
158
|
+
return pos;
|
|
159
|
+
}
|
|
160
|
+
/** moves the playhead, restarting the voice when it plays */
|
|
161
|
+
seek(pos) {
|
|
162
|
+
this.restore({ ...this.state(), pos });
|
|
163
|
+
}
|
|
164
|
+
/** plain-data state for saves and transfers: parameters plus playhead */
|
|
165
|
+
state() {
|
|
166
|
+
return { ...this.params, pos: this.position() };
|
|
167
|
+
}
|
|
168
|
+
/** applies plain-data state, restarting the voice at its playhead when it plays */
|
|
169
|
+
restore(state) {
|
|
170
|
+
const buffer = this.buffer;
|
|
171
|
+
this.update({ volume: state.volume, pitch: state.pitch, pan: state.pan });
|
|
172
|
+
if (buffer && this.source) {
|
|
173
|
+
this.play(buffer, {
|
|
174
|
+
volume: state.volume,
|
|
175
|
+
pitch: state.pitch,
|
|
176
|
+
pan: state.pan,
|
|
177
|
+
pos: state.pos,
|
|
178
|
+
loop: this.loopRegion,
|
|
179
|
+
repeat: this.repeat,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
this.offset = Math.max(0, state.pos);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
clearNodes() {
|
|
187
|
+
if (this.source)
|
|
188
|
+
this.source.onended = null;
|
|
189
|
+
this.source = null;
|
|
190
|
+
this.gainNode = null;
|
|
191
|
+
this.pannerNode = null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function clampBus(value, low, high, fallback) {
|
|
195
|
+
const n = Number(value);
|
|
196
|
+
return Math.min(high, Math.max(low, Number.isFinite(n) ? n : fallback));
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* The four audio lifetimes of a role-playing game over two `Channel` voices: BGM and
|
|
200
|
+
* BGS loop independently, an ME jingle suspends the BGM and resumes it where it left
|
|
201
|
+
* off, SE plays overlapping one-shots. Replaying the current track only retunes it;
|
|
202
|
+
* save and replay memorize a track with its playhead for menus and transfers.
|
|
203
|
+
*
|
|
204
|
+
* @example
|
|
205
|
+
* ```ts
|
|
206
|
+
* import { AudioBus } from '@datamoc/mw_games/audio';
|
|
207
|
+
*
|
|
208
|
+
* declare const context: AudioContext;
|
|
209
|
+
*
|
|
210
|
+
* const bus = new AudioBus(context, { load: (_kind, _name) => Promise.resolve(null) });
|
|
211
|
+
* void bus.playBgm({ name: 'town', volume: 90, pitch: 100, pan: 0 });
|
|
212
|
+
* console.log(bus.snapshot());
|
|
213
|
+
* ```
|
|
214
|
+
*/
|
|
215
|
+
export class AudioBus {
|
|
216
|
+
constructor(context, options = {}) {
|
|
217
|
+
this.tracks = { bgm: null, bgs: null };
|
|
218
|
+
this.saved = { bgm: null, bgs: null };
|
|
219
|
+
this.requests = { bgm: 0, bgs: 0 };
|
|
220
|
+
this.me = null;
|
|
221
|
+
this.meToken = 0;
|
|
222
|
+
this.suspendedBgm = null;
|
|
223
|
+
this.seNodes = new Set();
|
|
224
|
+
this.context = context;
|
|
225
|
+
this.destination = options.destination ?? context.destination;
|
|
226
|
+
this.load = options.load ?? (async () => null);
|
|
227
|
+
this.bgmChannel = new Channel(context, this.destination);
|
|
228
|
+
this.bgsChannel = new Channel(context, this.destination);
|
|
229
|
+
}
|
|
230
|
+
/** loops this BGM, or retunes it when it already loops */
|
|
231
|
+
playBgm(track) {
|
|
232
|
+
return this.playLoop('bgm', track);
|
|
233
|
+
}
|
|
234
|
+
/** loops this BGS, or retunes it when it already loops */
|
|
235
|
+
playBgs(track) {
|
|
236
|
+
return this.playLoop('bgs', track);
|
|
237
|
+
}
|
|
238
|
+
stopBgm() {
|
|
239
|
+
this.requests.bgm++;
|
|
240
|
+
this.tracks.bgm = null;
|
|
241
|
+
this.suspendedBgm = null;
|
|
242
|
+
this.bgmChannel.stop();
|
|
243
|
+
}
|
|
244
|
+
stopBgs() {
|
|
245
|
+
this.requests.bgs++;
|
|
246
|
+
this.tracks.bgs = null;
|
|
247
|
+
this.bgsChannel.stop();
|
|
248
|
+
}
|
|
249
|
+
fadeOutBgm(seconds = 1) {
|
|
250
|
+
this.requests.bgm++;
|
|
251
|
+
this.tracks.bgm = null;
|
|
252
|
+
this.suspendedBgm = null;
|
|
253
|
+
this.bgmChannel.stop(Math.max(0, seconds));
|
|
254
|
+
}
|
|
255
|
+
fadeOutBgs(seconds = 1) {
|
|
256
|
+
this.requests.bgs++;
|
|
257
|
+
this.tracks.bgs = null;
|
|
258
|
+
this.bgsChannel.stop(Math.max(0, seconds));
|
|
259
|
+
}
|
|
260
|
+
/** the intended track with its live playhead, or the one an ME suspended */
|
|
261
|
+
currentTrack(kind) {
|
|
262
|
+
const track = this.tracks[kind];
|
|
263
|
+
if (track) {
|
|
264
|
+
const channel = kind === 'bgm' ? this.bgmChannel : this.bgsChannel;
|
|
265
|
+
return { ...track, pos: channel.isPlaying ? channel.position() : track.pos };
|
|
266
|
+
}
|
|
267
|
+
return kind === 'bgm' ? this.suspendedBgm : null;
|
|
268
|
+
}
|
|
269
|
+
/** memorizes the playing BGM with its playhead; replay restores it */
|
|
270
|
+
saveBgm() {
|
|
271
|
+
this.saved.bgm = this.currentTrack('bgm');
|
|
272
|
+
return this.saved.bgm;
|
|
273
|
+
}
|
|
274
|
+
/** memorizes the playing BGS with its playhead; replay restores it */
|
|
275
|
+
saveBgs() {
|
|
276
|
+
this.saved.bgs = this.currentTrack('bgs');
|
|
277
|
+
return this.saved.bgs;
|
|
278
|
+
}
|
|
279
|
+
replayBgm() {
|
|
280
|
+
if (!this.saved.bgm)
|
|
281
|
+
return Promise.resolve();
|
|
282
|
+
return this.playBgm(this.saved.bgm);
|
|
283
|
+
}
|
|
284
|
+
replayBgs() {
|
|
285
|
+
if (!this.saved.bgs)
|
|
286
|
+
return Promise.resolve();
|
|
287
|
+
return this.playBgs(this.saved.bgs);
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Plays a jingle once, suspending the BGM and resuming it where it left off when
|
|
291
|
+
* the jingle ends. A BGM started mid-jingle takes over instead of being resumed over.
|
|
292
|
+
*/
|
|
293
|
+
async playMe(track) {
|
|
294
|
+
const me = AudioBus.normalize(track);
|
|
295
|
+
const token = ++this.meToken;
|
|
296
|
+
this.stopMeNodes();
|
|
297
|
+
if (!me.name)
|
|
298
|
+
return;
|
|
299
|
+
const loaded = await this.load('me', me.name);
|
|
300
|
+
if (token !== this.meToken || !loaded)
|
|
301
|
+
return;
|
|
302
|
+
const remembered = this.currentTrack('bgm');
|
|
303
|
+
if (remembered) {
|
|
304
|
+
this.suspendedBgm = remembered;
|
|
305
|
+
this.requests.bgm++;
|
|
306
|
+
this.bgmChannel.stop();
|
|
307
|
+
this.tracks.bgm = null;
|
|
308
|
+
}
|
|
309
|
+
const { source } = wireVoice(this.context, this.destination, loaded.buffer, me.volume / 100, me.pitch / 100, me.pan / 100);
|
|
310
|
+
this.me = { source, token };
|
|
311
|
+
source.onended = () => {
|
|
312
|
+
if (this.me?.token === token)
|
|
313
|
+
this.finishMe();
|
|
314
|
+
};
|
|
315
|
+
source.start(0);
|
|
316
|
+
}
|
|
317
|
+
/** stops the jingle, resuming the suspended BGM unless told not to */
|
|
318
|
+
stopMe(resume = true) {
|
|
319
|
+
this.stopMeNodes();
|
|
320
|
+
if (resume)
|
|
321
|
+
this.finishMe();
|
|
322
|
+
}
|
|
323
|
+
/** plays one overlapping sound effect */
|
|
324
|
+
async playSe(track) {
|
|
325
|
+
const se = AudioBus.normalize(track);
|
|
326
|
+
if (!se.name)
|
|
327
|
+
return;
|
|
328
|
+
const loaded = await this.load('se', se.name);
|
|
329
|
+
if (!loaded)
|
|
330
|
+
return;
|
|
331
|
+
const { source } = wireVoice(this.context, this.destination, loaded.buffer, se.volume / 100, se.pitch / 100, se.pan / 100);
|
|
332
|
+
const node = { source };
|
|
333
|
+
this.seNodes.add(node);
|
|
334
|
+
source.onended = () => {
|
|
335
|
+
this.seNodes.delete(node);
|
|
336
|
+
};
|
|
337
|
+
source.start(0);
|
|
338
|
+
}
|
|
339
|
+
stopSe() {
|
|
340
|
+
for (const node of this.seNodes) {
|
|
341
|
+
node.source.onended = null;
|
|
342
|
+
try {
|
|
343
|
+
node.source.stop();
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
//already ended
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
this.seNodes.clear();
|
|
350
|
+
}
|
|
351
|
+
stopAll() {
|
|
352
|
+
this.requests.bgm++;
|
|
353
|
+
this.requests.bgs++;
|
|
354
|
+
this.tracks.bgm = null;
|
|
355
|
+
this.tracks.bgs = null;
|
|
356
|
+
this.suspendedBgm = null;
|
|
357
|
+
this.bgmChannel.stop();
|
|
358
|
+
this.bgsChannel.stop();
|
|
359
|
+
this.stopMeNodes();
|
|
360
|
+
this.stopSe();
|
|
361
|
+
}
|
|
362
|
+
/** plain-data bus state for saves and transfers */
|
|
363
|
+
snapshot() {
|
|
364
|
+
return {
|
|
365
|
+
bgm: this.currentTrack('bgm'),
|
|
366
|
+
bgs: this.currentTrack('bgs'),
|
|
367
|
+
savedBgm: this.saved.bgm,
|
|
368
|
+
savedBgs: this.saved.bgs,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
/** replays plain-data bus state, reloading every named track */
|
|
372
|
+
restore(state) {
|
|
373
|
+
if (!state || typeof state !== 'object')
|
|
374
|
+
return;
|
|
375
|
+
this.saved.bgm = state.savedBgm ?? null;
|
|
376
|
+
this.saved.bgs = state.savedBgs ?? null;
|
|
377
|
+
if (state.bgm)
|
|
378
|
+
void this.playBgm(state.bgm);
|
|
379
|
+
if (state.bgs)
|
|
380
|
+
void this.playBgs(state.bgs);
|
|
381
|
+
}
|
|
382
|
+
static normalize(track) {
|
|
383
|
+
return {
|
|
384
|
+
name: String(track?.name || ''),
|
|
385
|
+
volume: clampBus(track?.volume, 0, 100, 90),
|
|
386
|
+
pitch: clampBus(track?.pitch, 10, 400, 100),
|
|
387
|
+
pan: clampBus(track?.pan, -100, 100, 0),
|
|
388
|
+
pos: Math.max(0, Number(track?.pos) || 0),
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
async playLoop(kind, input) {
|
|
392
|
+
const channel = kind === 'bgm' ? this.bgmChannel : this.bgsChannel;
|
|
393
|
+
const wanted = AudioBus.normalize(input);
|
|
394
|
+
if (!wanted.name) {
|
|
395
|
+
this.requests[kind]++;
|
|
396
|
+
this.tracks[kind] = null;
|
|
397
|
+
if (kind === 'bgm')
|
|
398
|
+
this.suspendedBgm = null;
|
|
399
|
+
channel.stop();
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const current = this.tracks[kind];
|
|
403
|
+
//Replaying the current track only retunes it, even while it still loads.
|
|
404
|
+
if (current && current.name === wanted.name && !(wanted.pos > 0)) {
|
|
405
|
+
this.tracks[kind] = { ...wanted, pos: 0 };
|
|
406
|
+
channel.update({ volume: wanted.volume / 100, pitch: wanted.pitch / 100, pan: wanted.pan / 100 });
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
//A BGM requested mid-jingle replaces the suspended one instead of racing it.
|
|
410
|
+
if (kind === 'bgm' && this.me) {
|
|
411
|
+
this.requests.bgm++;
|
|
412
|
+
this.suspendedBgm = wanted;
|
|
413
|
+
this.tracks.bgm = null;
|
|
414
|
+
channel.stop();
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
const request = ++this.requests[kind];
|
|
418
|
+
channel.stop();
|
|
419
|
+
this.tracks[kind] = { ...wanted, pos: 0 };
|
|
420
|
+
const loaded = await this.load(kind, wanted.name);
|
|
421
|
+
if (request !== this.requests[kind])
|
|
422
|
+
return;
|
|
423
|
+
if (!loaded) {
|
|
424
|
+
this.tracks[kind] = null;
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
channel.play(loaded.buffer, {
|
|
428
|
+
volume: wanted.volume / 100,
|
|
429
|
+
pitch: wanted.pitch / 100,
|
|
430
|
+
pan: wanted.pan / 100,
|
|
431
|
+
pos: wanted.pos,
|
|
432
|
+
loop: loaded.loop ?? null,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
stopMeNodes() {
|
|
436
|
+
const me = this.me;
|
|
437
|
+
this.me = null;
|
|
438
|
+
if (!me)
|
|
439
|
+
return;
|
|
440
|
+
me.source.onended = null;
|
|
441
|
+
try {
|
|
442
|
+
me.source.stop();
|
|
443
|
+
}
|
|
444
|
+
catch {
|
|
445
|
+
//already ended
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
finishMe() {
|
|
449
|
+
this.me = null;
|
|
450
|
+
const resume = this.suspendedBgm;
|
|
451
|
+
this.suspendedBgm = null;
|
|
452
|
+
if (resume && !this.tracks.bgm)
|
|
453
|
+
void this.playBgm(resume);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
//# sourceMappingURL=Channels.js.map
|