@beatbax/engine 0.1.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/dist/.tsbuildinfo +1 -0
- package/dist/audio/bufferedRenderer.d.ts +32 -0
- package/dist/audio/bufferedRenderer.d.ts.map +1 -0
- package/dist/audio/bufferedRenderer.js +309 -0
- package/dist/audio/bufferedRenderer.js.map +1 -0
- package/dist/audio/pcmRenderer.d.ts +25 -0
- package/dist/audio/pcmRenderer.d.ts.map +1 -0
- package/dist/audio/pcmRenderer.js +1288 -0
- package/dist/audio/pcmRenderer.js.map +1 -0
- package/dist/audio/playback.d.ts +57 -0
- package/dist/audio/playback.d.ts.map +1 -0
- package/dist/audio/playback.js +794 -0
- package/dist/audio/playback.js.map +1 -0
- package/dist/chips/gameboy/apu.d.ts +9 -0
- package/dist/chips/gameboy/apu.d.ts.map +1 -0
- package/dist/chips/gameboy/apu.js +27 -0
- package/dist/chips/gameboy/apu.js.map +1 -0
- package/dist/chips/gameboy/noise.d.ts +6 -0
- package/dist/chips/gameboy/noise.d.ts.map +1 -0
- package/dist/chips/gameboy/noise.js +155 -0
- package/dist/chips/gameboy/noise.js.map +1 -0
- package/dist/chips/gameboy/packet.d.ts +3 -0
- package/dist/chips/gameboy/packet.d.ts.map +1 -0
- package/dist/chips/gameboy/packet.js +3 -0
- package/dist/chips/gameboy/packet.js.map +1 -0
- package/dist/chips/gameboy/periodTables.d.ts +16 -0
- package/dist/chips/gameboy/periodTables.d.ts.map +1 -0
- package/dist/chips/gameboy/periodTables.js +29 -0
- package/dist/chips/gameboy/periodTables.js.map +1 -0
- package/dist/chips/gameboy/pulse.d.ts +12 -0
- package/dist/chips/gameboy/pulse.d.ts.map +1 -0
- package/dist/chips/gameboy/pulse.js +275 -0
- package/dist/chips/gameboy/pulse.js.map +1 -0
- package/dist/chips/gameboy/wave.d.ts +8 -0
- package/dist/chips/gameboy/wave.d.ts.map +1 -0
- package/dist/chips/gameboy/wave.js +146 -0
- package/dist/chips/gameboy/wave.js.map +1 -0
- package/dist/effects/index.d.ts +7 -0
- package/dist/effects/index.d.ts.map +1 -0
- package/dist/effects/index.js +1028 -0
- package/dist/effects/index.js.map +1 -0
- package/dist/effects/types.d.ts +8 -0
- package/dist/effects/types.d.ts.map +1 -0
- package/dist/effects/types.js +2 -0
- package/dist/effects/types.js.map +1 -0
- package/dist/expand/refExpander.d.ts +14 -0
- package/dist/expand/refExpander.d.ts.map +1 -0
- package/dist/expand/refExpander.js +130 -0
- package/dist/expand/refExpander.js.map +1 -0
- package/dist/export/index.d.ts +5 -0
- package/dist/export/index.d.ts.map +1 -0
- package/dist/export/index.js +5 -0
- package/dist/export/index.js.map +1 -0
- package/dist/export/jsonExport.d.ts +9 -0
- package/dist/export/jsonExport.d.ts.map +1 -0
- package/dist/export/jsonExport.js +184 -0
- package/dist/export/jsonExport.js.map +1 -0
- package/dist/export/midiExport.d.ts +9 -0
- package/dist/export/midiExport.d.ts.map +1 -0
- package/dist/export/midiExport.js +390 -0
- package/dist/export/midiExport.js.map +1 -0
- package/dist/export/ugeWriter.d.ts +33 -0
- package/dist/export/ugeWriter.d.ts.map +1 -0
- package/dist/export/ugeWriter.js +1997 -0
- package/dist/export/ugeWriter.js.map +1 -0
- package/dist/export/wavWriter.d.ts +24 -0
- package/dist/export/wavWriter.d.ts.map +1 -0
- package/dist/export/wavWriter.js +126 -0
- package/dist/export/wavWriter.js.map +1 -0
- package/dist/import/index.d.ts +5 -0
- package/dist/import/index.d.ts.map +1 -0
- package/dist/import/index.js +8 -0
- package/dist/import/index.js.map +1 -0
- package/dist/import/remoteCache.d.ts +60 -0
- package/dist/import/remoteCache.d.ts.map +1 -0
- package/dist/import/remoteCache.js +194 -0
- package/dist/import/remoteCache.js.map +1 -0
- package/dist/import/uge/uge.reader.d.ts +27 -0
- package/dist/import/uge/uge.reader.d.ts.map +1 -0
- package/dist/import/uge/uge.reader.js +545 -0
- package/dist/import/uge/uge.reader.js.map +1 -0
- package/dist/import/urlUtils.d.ts +50 -0
- package/dist/import/urlUtils.d.ts.map +1 -0
- package/dist/import/urlUtils.js +87 -0
- package/dist/import/urlUtils.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +318 -0
- package/dist/index.js.map +1 -0
- package/dist/instruments/instrumentState.d.ts +15 -0
- package/dist/instruments/instrumentState.d.ts.map +1 -0
- package/dist/instruments/instrumentState.js +24 -0
- package/dist/instruments/instrumentState.js.map +1 -0
- package/dist/parser/ast.d.ts +22 -0
- package/dist/parser/ast.d.ts.map +1 -0
- package/dist/parser/ast.js +5 -0
- package/dist/parser/ast.js.map +1 -0
- package/dist/parser/index.d.ts +14 -0
- package/dist/parser/index.d.ts.map +1 -0
- package/dist/parser/index.js +10 -0
- package/dist/parser/index.js.map +1 -0
- package/dist/parser/peggy/generated/parser.d.ts +8 -0
- package/dist/parser/peggy/generated/parser.d.ts.map +1 -0
- package/dist/parser/peggy/generated/parser.js +6269 -0
- package/dist/parser/peggy/generated/parser.js.map +1 -0
- package/dist/parser/peggy/index.d.ts +3 -0
- package/dist/parser/peggy/index.d.ts.map +1 -0
- package/dist/parser/peggy/index.js +555 -0
- package/dist/parser/peggy/index.js.map +1 -0
- package/dist/parser/structured.d.ts +16 -0
- package/dist/parser/structured.d.ts.map +1 -0
- package/dist/parser/structured.js +232 -0
- package/dist/parser/structured.js.map +1 -0
- package/dist/parser/tokenizer.d.ts +12 -0
- package/dist/parser/tokenizer.d.ts.map +1 -0
- package/dist/parser/tokenizer.js +14 -0
- package/dist/parser/tokenizer.js.map +1 -0
- package/dist/patterns/expand.d.ts +32 -0
- package/dist/patterns/expand.d.ts.map +1 -0
- package/dist/patterns/expand.js +184 -0
- package/dist/patterns/expand.js.map +1 -0
- package/dist/patterns/index.d.ts +2 -0
- package/dist/patterns/index.d.ts.map +1 -0
- package/dist/patterns/index.js +2 -0
- package/dist/patterns/index.js.map +1 -0
- package/dist/scheduler/index.d.ts +6 -0
- package/dist/scheduler/index.d.ts.map +1 -0
- package/dist/scheduler/index.js +9 -0
- package/dist/scheduler/index.js.map +1 -0
- package/dist/scheduler/tickScheduler.d.ts +27 -0
- package/dist/scheduler/tickScheduler.d.ts.map +1 -0
- package/dist/scheduler/tickScheduler.js +74 -0
- package/dist/scheduler/tickScheduler.js.map +1 -0
- package/dist/sequences/expand.d.ts +14 -0
- package/dist/sequences/expand.d.ts.map +1 -0
- package/dist/sequences/expand.js +137 -0
- package/dist/sequences/expand.js.map +1 -0
- package/dist/song/importResolver.browser.d.ts +29 -0
- package/dist/song/importResolver.browser.d.ts.map +1 -0
- package/dist/song/importResolver.browser.js +168 -0
- package/dist/song/importResolver.browser.js.map +1 -0
- package/dist/song/importResolver.d.ts +40 -0
- package/dist/song/importResolver.d.ts.map +1 -0
- package/dist/song/importResolver.js +445 -0
- package/dist/song/importResolver.js.map +1 -0
- package/dist/song/index.browser.d.ts +9 -0
- package/dist/song/index.browser.d.ts.map +1 -0
- package/dist/song/index.browser.js +7 -0
- package/dist/song/index.browser.js.map +1 -0
- package/dist/song/index.d.ts +8 -0
- package/dist/song/index.d.ts.map +1 -0
- package/dist/song/index.js +6 -0
- package/dist/song/index.js.map +1 -0
- package/dist/song/resolver.browser.d.ts +50 -0
- package/dist/song/resolver.browser.d.ts.map +1 -0
- package/dist/song/resolver.browser.js +536 -0
- package/dist/song/resolver.browser.js.map +1 -0
- package/dist/song/resolver.d.ts +20 -0
- package/dist/song/resolver.d.ts.map +1 -0
- package/dist/song/resolver.js +540 -0
- package/dist/song/resolver.js.map +1 -0
- package/dist/song/songModel.d.ts +34 -0
- package/dist/song/songModel.d.ts.map +1 -0
- package/dist/song/songModel.js +2 -0
- package/dist/song/songModel.js.map +1 -0
- package/dist/tests/refExpander.test.d.ts +2 -0
- package/dist/tests/refExpander.test.d.ts.map +1 -0
- package/dist/tests/refExpander.test.js +37 -0
- package/dist/tests/refExpander.test.js.map +1 -0
- package/dist/util/diag.d.ts +16 -0
- package/dist/util/diag.d.ts.map +1 -0
- package/dist/util/diag.js +29 -0
- package/dist/util/diag.js.map +1 -0
- package/package.json +84 -0
|
@@ -0,0 +1,794 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebAudio-based playback for BeatBax (engine package).
|
|
3
|
+
*/
|
|
4
|
+
import { playPulse as playPulseImpl, parseEnvelope as pulseParseEnvelope } from '../chips/gameboy/pulse.js';
|
|
5
|
+
import { playWavetable as playWavetableImpl, parseWaveTable } from '../chips/gameboy/wave.js';
|
|
6
|
+
import { playNoise as playNoiseImpl } from '../chips/gameboy/noise.js';
|
|
7
|
+
import { noteNameToMidi, midiToFreq } from '../chips/gameboy/apu.js';
|
|
8
|
+
import { error } from '../util/diag.js';
|
|
9
|
+
import createScheduler from '../scheduler/index.js';
|
|
10
|
+
import BufferedRenderer from './bufferedRenderer.js';
|
|
11
|
+
import { get as getEffect, clearEffectState } from '../effects/index.js';
|
|
12
|
+
export { midiToFreq, noteNameToMidi };
|
|
13
|
+
export { parseWaveTable };
|
|
14
|
+
export const parseEnvelope = pulseParseEnvelope;
|
|
15
|
+
export async function createAudioContext(opts = {}) {
|
|
16
|
+
const backend = opts.backend ?? 'auto';
|
|
17
|
+
// Try browser if requested and available
|
|
18
|
+
if (backend !== 'node-webaudio' && typeof window !== 'undefined' && globalThis.AudioContext) {
|
|
19
|
+
const Ctor = globalThis.AudioContext || globalThis.webkitAudioContext;
|
|
20
|
+
if (opts.offline && opts.duration) {
|
|
21
|
+
const OfflineAudioContextCtor = globalThis.OfflineAudioContext || globalThis.webkitOfflineAudioContext;
|
|
22
|
+
const sampleRate = opts.sampleRate ?? 44100;
|
|
23
|
+
const lengthInSamples = Math.ceil(opts.duration * sampleRate);
|
|
24
|
+
return new OfflineAudioContextCtor(2, lengthInSamples, sampleRate);
|
|
25
|
+
}
|
|
26
|
+
return new Ctor({ sampleRate: opts.sampleRate });
|
|
27
|
+
}
|
|
28
|
+
// Fallback to Node polyfill
|
|
29
|
+
if (backend !== 'browser') {
|
|
30
|
+
try {
|
|
31
|
+
const mod = await import('standardized-audio-context');
|
|
32
|
+
const { AudioContext, OfflineAudioContext } = mod;
|
|
33
|
+
if (opts.offline && opts.duration) {
|
|
34
|
+
const sampleRate = opts.sampleRate ?? 44100;
|
|
35
|
+
const lengthInSamples = Math.ceil(opts.duration * sampleRate);
|
|
36
|
+
return new OfflineAudioContext({ numberOfChannels: 2, length: lengthInSamples, sampleRate });
|
|
37
|
+
}
|
|
38
|
+
return new AudioContext({ sampleRate: opts.sampleRate ?? 44100 });
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (backend === 'node-webaudio') {
|
|
42
|
+
throw new Error(`Failed to load 'standardized-audio-context'. Is it installed? (${error.message})`);
|
|
43
|
+
}
|
|
44
|
+
// If auto, we might just fail later if no context is found
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
throw new Error(`No compatible AudioContext found for backend: ${backend}`);
|
|
48
|
+
}
|
|
49
|
+
function playPulse(ctx, freq, duty, start, dur, inst, scheduler, destination) {
|
|
50
|
+
return playPulseImpl(ctx, freq, duty, start, dur, inst, scheduler, destination);
|
|
51
|
+
}
|
|
52
|
+
function playWavetable(ctx, freq, table, start, dur, inst, scheduler, destination) {
|
|
53
|
+
return playWavetableImpl(ctx, freq, table, start, dur, inst, scheduler, destination);
|
|
54
|
+
}
|
|
55
|
+
function playNoise(ctx, start, dur, inst, scheduler, destination) {
|
|
56
|
+
return playNoiseImpl(ctx, start, dur, inst, scheduler, destination);
|
|
57
|
+
}
|
|
58
|
+
export class Player {
|
|
59
|
+
ctx;
|
|
60
|
+
scheduler;
|
|
61
|
+
bpmDefault = 128;
|
|
62
|
+
masterGain = null;
|
|
63
|
+
activeNodes = [];
|
|
64
|
+
muted = new Set();
|
|
65
|
+
solo = null;
|
|
66
|
+
onSchedule;
|
|
67
|
+
_repeatTimer = null;
|
|
68
|
+
constructor(ctx, opts = {}) {
|
|
69
|
+
if (!ctx) {
|
|
70
|
+
const Ctor = (typeof window !== 'undefined' && window.AudioContext) ? window.AudioContext : globalThis.AudioContext;
|
|
71
|
+
if (!Ctor) {
|
|
72
|
+
throw new Error('No AudioContext constructor found. Please provide an AudioContext to the Player constructor or ensure one is available globally.');
|
|
73
|
+
}
|
|
74
|
+
this.ctx = new Ctor();
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
this.ctx = ctx;
|
|
78
|
+
}
|
|
79
|
+
this.scheduler = createScheduler(this.ctx);
|
|
80
|
+
if (opts.buffered) {
|
|
81
|
+
this._buffered = new BufferedRenderer(this.ctx, this.scheduler, { segmentDuration: opts.segmentDuration, lookahead: opts.bufferedLookahead, maxPreRenderSegments: opts.maxPreRenderSegments });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async playAST(ast) {
|
|
85
|
+
try {
|
|
86
|
+
if (this.ctx && typeof this.ctx.resume === 'function') {
|
|
87
|
+
try {
|
|
88
|
+
const st = this.ctx.state;
|
|
89
|
+
if (st === 'suspended')
|
|
90
|
+
await this.ctx.resume();
|
|
91
|
+
}
|
|
92
|
+
catch (e) { }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch (e) { }
|
|
96
|
+
// ensure a clean slate for each playback run
|
|
97
|
+
try {
|
|
98
|
+
this.stop();
|
|
99
|
+
}
|
|
100
|
+
catch (e) { }
|
|
101
|
+
// Create or update master gain node
|
|
102
|
+
// Default to 1.0 (matches hUGETracker behavior - no attenuation)
|
|
103
|
+
const masterVolume = ast.volume !== undefined ? ast.volume : 1.0;
|
|
104
|
+
if (!this.masterGain) {
|
|
105
|
+
this.masterGain = this.ctx.createGain();
|
|
106
|
+
this.masterGain.connect(this.ctx.destination);
|
|
107
|
+
}
|
|
108
|
+
this.masterGain.gain.setValueAtTime(masterVolume, this.ctx.currentTime);
|
|
109
|
+
const chip = ast.chip || 'gameboy';
|
|
110
|
+
if (chip !== 'gameboy') {
|
|
111
|
+
throw new Error(`Unsupported chip: ${chip}. Only 'gameboy' is supported at this time.`);
|
|
112
|
+
}
|
|
113
|
+
// Track estimated playback duration (seconds) across channels for repeat scheduling
|
|
114
|
+
let globalDurationSec = 0;
|
|
115
|
+
// Clone the instrument table to avoid in-place mutations during scheduling/playback
|
|
116
|
+
// Use structuredClone when available for correctness and performance, fallback to JSON clone.
|
|
117
|
+
const rootInsts = ast.insts || {};
|
|
118
|
+
const instsRootClone = (typeof globalThis.structuredClone === 'function')
|
|
119
|
+
? globalThis.structuredClone(rootInsts)
|
|
120
|
+
: JSON.parse(JSON.stringify(rootInsts));
|
|
121
|
+
// Store chip info in context for effects to access (e.g., for chip-specific frame rates)
|
|
122
|
+
this.ctx._chipType = ast.chip || 'gameboy';
|
|
123
|
+
for (const ch of ast.channels || []) {
|
|
124
|
+
const instsMap = instsRootClone;
|
|
125
|
+
let currentInst = instsMap[ch.inst || ''];
|
|
126
|
+
const tokens = Array.isArray(ch.events) ? ch.events : (Array.isArray(ch.pat) ? ch.pat : ['.']);
|
|
127
|
+
let tempInst = null;
|
|
128
|
+
let tempRemaining = 0;
|
|
129
|
+
let bpm;
|
|
130
|
+
if (typeof ch.speed === 'number' && ast && typeof ast.bpm === 'number')
|
|
131
|
+
bpm = ast.bpm * ch.speed;
|
|
132
|
+
else
|
|
133
|
+
bpm = (ast && typeof ast.bpm === 'number') ? ast.bpm : this.bpmDefault;
|
|
134
|
+
const secondsPerBeat = 60 / bpm;
|
|
135
|
+
const tickSeconds = secondsPerBeat / 4;
|
|
136
|
+
const startTime = this.ctx.currentTime + 0.1;
|
|
137
|
+
// estimate channel duration in seconds from token count and ticks
|
|
138
|
+
let lastEndTimeForThisChannel = 0;
|
|
139
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
140
|
+
const token = tokens[i];
|
|
141
|
+
const t = startTime + i * tickSeconds;
|
|
142
|
+
if (token && typeof token === 'object' && token.type) {
|
|
143
|
+
if (token.type === 'rest' || token.type === 'sustain') {
|
|
144
|
+
// ignore explicit rest/sustain objects here
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
let sustainCount = 0;
|
|
148
|
+
for (let j = i + 1; j < tokens.length; j++) {
|
|
149
|
+
const next = tokens[j];
|
|
150
|
+
if (next && typeof next === 'object' && next.type === 'sustain')
|
|
151
|
+
sustainCount++;
|
|
152
|
+
else if (next === '_' || next === '-')
|
|
153
|
+
sustainCount++;
|
|
154
|
+
else
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
const dur = tickSeconds * (1 + sustainCount);
|
|
158
|
+
if (token.type === 'named') {
|
|
159
|
+
const instProps = token.instProps || instsMap[token.instrument] || null;
|
|
160
|
+
// For noise instruments, always use instrument name for lookup
|
|
161
|
+
// For pulse/wave with defaultNote, use the specified note
|
|
162
|
+
const isNoise = instProps && instProps.type && String(instProps.type).toLowerCase().includes('noise');
|
|
163
|
+
const tokenToPlay = (isNoise || !token.defaultNote)
|
|
164
|
+
? (token.token || token.instrument)
|
|
165
|
+
: token.defaultNote;
|
|
166
|
+
this.scheduleToken(ch.id, instProps, instsMap, tokenToPlay, t, dur, tickSeconds);
|
|
167
|
+
}
|
|
168
|
+
else if (token.type === 'note') {
|
|
169
|
+
const instProps = token.instProps || (tempRemaining > 0 && tempInst ? tempInst : currentInst);
|
|
170
|
+
// Pass the full token object so scheduleToken can honour inline pan/effects
|
|
171
|
+
this.scheduleToken(ch.id, instProps, instsMap, token, t, dur, tickSeconds);
|
|
172
|
+
if (tempRemaining > 0) {
|
|
173
|
+
tempRemaining -= 1;
|
|
174
|
+
if (tempRemaining <= 0) {
|
|
175
|
+
tempInst = null;
|
|
176
|
+
tempRemaining = 0;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
lastEndTimeForThisChannel = Math.max(lastEndTimeForThisChannel, t + dur);
|
|
181
|
+
}
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (token === '_' || token === '-')
|
|
185
|
+
continue;
|
|
186
|
+
const mInstInline = typeof token === 'string' && token.match(/^inst\(([^,()\s]+)(?:,(\d+))?\)$/i);
|
|
187
|
+
if (mInstInline) {
|
|
188
|
+
const name = mInstInline[1];
|
|
189
|
+
const count = mInstInline[2] ? parseInt(mInstInline[2], 10) : null;
|
|
190
|
+
const resolved = instsMap[name];
|
|
191
|
+
if (count && resolved) {
|
|
192
|
+
tempInst = resolved;
|
|
193
|
+
tempRemaining = count;
|
|
194
|
+
}
|
|
195
|
+
else if (resolved) {
|
|
196
|
+
currentInst = resolved;
|
|
197
|
+
}
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const useInst = tempRemaining > 0 && tempInst ? tempInst : currentInst;
|
|
201
|
+
// Calculate duration by looking ahead for sustains
|
|
202
|
+
let sustainCount = 0;
|
|
203
|
+
for (let j = i + 1; j < tokens.length; j++) {
|
|
204
|
+
const next = tokens[j];
|
|
205
|
+
if (next && typeof next === 'object' && next.type === 'sustain')
|
|
206
|
+
sustainCount++;
|
|
207
|
+
else if (next === '_' || next === '-')
|
|
208
|
+
sustainCount++;
|
|
209
|
+
else
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
const dur = tickSeconds * (1 + sustainCount);
|
|
213
|
+
this.scheduleToken(ch.id, useInst, instsMap, token, t, dur, tickSeconds);
|
|
214
|
+
lastEndTimeForThisChannel = Math.max(lastEndTimeForThisChannel, t + dur);
|
|
215
|
+
if (tempRemaining > 0 && token !== '.') {
|
|
216
|
+
tempRemaining -= 1;
|
|
217
|
+
if (tempRemaining <= 0) {
|
|
218
|
+
tempInst = null;
|
|
219
|
+
tempRemaining = 0;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// convert channel end (absolute) into a channel duration relative to startTime
|
|
224
|
+
const channelDuration = lastEndTimeForThisChannel > 0 ? (lastEndTimeForThisChannel - (this.ctx.currentTime + 0.1)) : (tokens.length * tickSeconds);
|
|
225
|
+
globalDurationSec = Math.max(globalDurationSec, channelDuration);
|
|
226
|
+
}
|
|
227
|
+
// start the scheduler to begin firing scheduled events
|
|
228
|
+
this.scheduler.start();
|
|
229
|
+
// debug: show estimated global duration for repeat scheduling
|
|
230
|
+
try {
|
|
231
|
+
console.debug('[player] estimated globalDurationSec=', globalDurationSec);
|
|
232
|
+
}
|
|
233
|
+
catch (e) { }
|
|
234
|
+
// If AST requests repeat/looping, schedule a restart when playback ends
|
|
235
|
+
try {
|
|
236
|
+
if (ast.play?.repeat) {
|
|
237
|
+
const delayMs = Math.max(10, Math.round(globalDurationSec * 1000) + 50);
|
|
238
|
+
try {
|
|
239
|
+
console.debug('[player] scheduling repeat in ms=', delayMs);
|
|
240
|
+
}
|
|
241
|
+
catch (e) { }
|
|
242
|
+
if (this._repeatTimer)
|
|
243
|
+
clearTimeout(this._repeatTimer);
|
|
244
|
+
this._repeatTimer = setTimeout(() => {
|
|
245
|
+
try {
|
|
246
|
+
try {
|
|
247
|
+
console.debug('[player] repeat timer fired - restarting playback');
|
|
248
|
+
}
|
|
249
|
+
catch (e) { }
|
|
250
|
+
this.stop();
|
|
251
|
+
// replay AST (fire-and-forget)
|
|
252
|
+
this.playAST(ast).catch((e) => { error('player', 'Repeat playback failed: ' + (e && e.message ? e.message : String(e))); });
|
|
253
|
+
}
|
|
254
|
+
catch (e) { }
|
|
255
|
+
}, delayMs);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
catch (e) { }
|
|
259
|
+
}
|
|
260
|
+
scheduleToken(chId, inst, instsMap, token, time, dur, tickSeconds) {
|
|
261
|
+
if (token === '.')
|
|
262
|
+
return;
|
|
263
|
+
if (instsMap && typeof token === 'string' && instsMap[token]) {
|
|
264
|
+
const alt = instsMap[token];
|
|
265
|
+
if (alt.type && String(alt.type).toLowerCase().includes('noise')) {
|
|
266
|
+
try {
|
|
267
|
+
if (typeof this.onSchedule === 'function') {
|
|
268
|
+
this.onSchedule({ chId, inst: alt, token, time, dur });
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
catch (e) { }
|
|
272
|
+
this.scheduler.schedule(time, () => {
|
|
273
|
+
if (this.solo !== null && this.solo !== chId)
|
|
274
|
+
return;
|
|
275
|
+
if (this.muted.has(chId))
|
|
276
|
+
return;
|
|
277
|
+
const nodes = playNoise(this.ctx, time, dur, alt, this.scheduler, this.masterGain || undefined);
|
|
278
|
+
for (const n of nodes)
|
|
279
|
+
this.activeNodes.push({ node: n, chId });
|
|
280
|
+
});
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
inst = alt;
|
|
284
|
+
}
|
|
285
|
+
if (!inst)
|
|
286
|
+
return;
|
|
287
|
+
try {
|
|
288
|
+
if (typeof this.onSchedule === 'function') {
|
|
289
|
+
this.onSchedule({ chId, inst, token, time, dur });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
catch (e) { }
|
|
293
|
+
// token may be a string like "C4" or an object with { type: 'note', token: 'C4', pan, effects }
|
|
294
|
+
let tokenStr = typeof token === 'string' ? token : (token && token.token ? token.token : '');
|
|
295
|
+
// compute pan if present: inline token pan takes precedence; inst pan as fallback
|
|
296
|
+
const panVal = (token && token.pan) ? token.pan : (inst && (inst['gb:pan'] || inst['pan']) ? inst['gb:pan'] || inst['pan'] : undefined);
|
|
297
|
+
const m = (typeof tokenStr === 'string' && tokenStr.match(/^([A-G][#B]?)(-?\d+)$/i)) || null;
|
|
298
|
+
if (m) {
|
|
299
|
+
const note = m[1].toUpperCase();
|
|
300
|
+
const octave = parseInt(m[2], 10);
|
|
301
|
+
const midi = noteNameToMidi(note, octave);
|
|
302
|
+
if (midi === null)
|
|
303
|
+
return;
|
|
304
|
+
const freq = midiToFreq(midi);
|
|
305
|
+
if (inst.type && inst.type.toLowerCase().includes('pulse')) {
|
|
306
|
+
const duty = inst.duty ? parseFloat(inst.duty) / 100 : 0.5;
|
|
307
|
+
const buffered = this._buffered;
|
|
308
|
+
if (buffered) {
|
|
309
|
+
// For buffered rendering, attach pan info into queued item for later panning processing
|
|
310
|
+
buffered.enqueuePulse(time, freq, duty, dur, inst, chId, panVal);
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
const capturedInst = inst;
|
|
314
|
+
this.scheduler.schedule(time, () => {
|
|
315
|
+
if (this.solo !== null && this.solo !== chId)
|
|
316
|
+
return;
|
|
317
|
+
if (this.muted.has(chId))
|
|
318
|
+
return;
|
|
319
|
+
const nodes = playPulse(this.ctx, freq, duty, time, dur, capturedInst, this.scheduler, this.masterGain || undefined);
|
|
320
|
+
// apply inline token.effects first (e.g. C4<pan:-1>) then fallback to inline pan/inst pan
|
|
321
|
+
this.tryApplyEffects(this.ctx, nodes, token && token.effects ? token.effects : [], time, dur, chId, tickSeconds, capturedInst);
|
|
322
|
+
// Apply panning first, before echo/retrigger, so panner is inserted before echo routing
|
|
323
|
+
this.tryApplyPan(this.ctx, nodes, panVal);
|
|
324
|
+
this.tryScheduleEcho(nodes);
|
|
325
|
+
this.tryScheduleRetriggers(nodes, freq, capturedInst, chId, token, tickSeconds, panVal);
|
|
326
|
+
for (const n of nodes)
|
|
327
|
+
this.activeNodes.push({ node: n, chId });
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
else if (inst.type && inst.type.toLowerCase().includes('wave')) {
|
|
332
|
+
const wav = parseWaveTable(inst.wave);
|
|
333
|
+
const buffered = this._buffered;
|
|
334
|
+
if (buffered) {
|
|
335
|
+
buffered.enqueueWavetable(time, freq, wav, dur, inst, chId, panVal);
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
const capturedInst = inst;
|
|
339
|
+
this.scheduler.schedule(time, () => {
|
|
340
|
+
if (this.solo !== null && this.solo !== chId)
|
|
341
|
+
return;
|
|
342
|
+
if (this.muted.has(chId))
|
|
343
|
+
return;
|
|
344
|
+
const nodes = playWavetable(this.ctx, freq, wav, time, dur, capturedInst, this.scheduler, this.masterGain || undefined);
|
|
345
|
+
this.tryApplyEffects(this.ctx, nodes, token && token.effects ? token.effects : [], time, dur, chId, tickSeconds, capturedInst);
|
|
346
|
+
// Apply panning first, before echo/retrigger, so panner is inserted before echo routing
|
|
347
|
+
this.tryApplyPan(this.ctx, nodes, panVal);
|
|
348
|
+
this.tryScheduleEcho(nodes);
|
|
349
|
+
this.tryScheduleRetriggers(nodes, freq, capturedInst, chId, token, tickSeconds, panVal);
|
|
350
|
+
for (const n of nodes)
|
|
351
|
+
this.activeNodes.push({ node: n, chId });
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
else if (inst.type && inst.type.toLowerCase().includes('noise')) {
|
|
356
|
+
const buffered = this._buffered;
|
|
357
|
+
if (buffered) {
|
|
358
|
+
buffered.enqueueNoise(time, dur, inst, chId, panVal);
|
|
359
|
+
}
|
|
360
|
+
else {
|
|
361
|
+
this.scheduler.schedule(time, () => {
|
|
362
|
+
if (this.solo !== null && this.solo !== chId)
|
|
363
|
+
return;
|
|
364
|
+
if (this.muted.has(chId))
|
|
365
|
+
return;
|
|
366
|
+
const nodes = playNoise(this.ctx, time, dur, inst, this.scheduler, this.masterGain || undefined);
|
|
367
|
+
this.tryApplyEffects(this.ctx, nodes, token && token.effects ? token.effects : [], time, dur, chId, tickSeconds);
|
|
368
|
+
// Apply panning first, before echo/retrigger, so panner is inserted before echo routing
|
|
369
|
+
this.tryApplyPan(this.ctx, nodes, panVal);
|
|
370
|
+
this.tryScheduleEcho(nodes);
|
|
371
|
+
this.tryScheduleRetriggers(nodes, 0, inst, chId, token, tickSeconds, panVal);
|
|
372
|
+
for (const n of nodes)
|
|
373
|
+
this.activeNodes.push({ node: n, chId });
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
if (inst.type && inst.type.toLowerCase().includes('noise')) {
|
|
380
|
+
this.scheduler.schedule(time, () => {
|
|
381
|
+
if (this.solo !== null && this.solo !== chId)
|
|
382
|
+
return;
|
|
383
|
+
if (this.muted.has(chId))
|
|
384
|
+
return;
|
|
385
|
+
const nodes = playNoise(this.ctx, time, dur, inst, this.scheduler, this.masterGain || undefined);
|
|
386
|
+
this.tryApplyPan(this.ctx, nodes, panVal);
|
|
387
|
+
for (const n of nodes)
|
|
388
|
+
this.activeNodes.push({ node: n, chId });
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
// Apply registered effects for a scheduled note. `effectsArr` may be an array of
|
|
394
|
+
// objects { type, params } produced by the parser (or legacy arrays). This will
|
|
395
|
+
// look up handlers in the effects registry and invoke them.
|
|
396
|
+
tryApplyEffects(ctx, nodes, effectsArr, start, dur, chId, tickSeconds, inst) {
|
|
397
|
+
if (!Array.isArray(effectsArr) || effectsArr.length === 0)
|
|
398
|
+
return;
|
|
399
|
+
for (const fx of effectsArr) {
|
|
400
|
+
try {
|
|
401
|
+
const name = fx && fx.type ? fx.type : fx;
|
|
402
|
+
// Prefer resolver-provided durationSec when available; inject into params[3]
|
|
403
|
+
let params = fx && fx.params ? fx.params : (Array.isArray(fx) ? fx : []);
|
|
404
|
+
if (fx && typeof fx.durationSec === 'number') {
|
|
405
|
+
const pcopy = Array.isArray(params) ? params.slice() : [];
|
|
406
|
+
pcopy[3] = fx.durationSec;
|
|
407
|
+
params = pcopy;
|
|
408
|
+
}
|
|
409
|
+
const handler = getEffect(name);
|
|
410
|
+
if (handler) {
|
|
411
|
+
try {
|
|
412
|
+
handler(ctx, nodes, params, start, dur, chId, tickSeconds, inst);
|
|
413
|
+
}
|
|
414
|
+
catch (e) { }
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
catch (e) { }
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
// Try to apply per-note panning. `nodes` is the array returned by play* functions
|
|
421
|
+
// which typically is [oscillatorNode, gainNode]. We attempt to insert a StereoPannerNode
|
|
422
|
+
// between the gain and the destination when available. `panSpec` may be:
|
|
423
|
+
// - an object { enum: 'L'|'R'|'C' } or { value: number }
|
|
424
|
+
// - a raw number or string
|
|
425
|
+
// Schedule retriggered notes if retrigger effect was applied.
|
|
426
|
+
// The retrigger effect handler stores metadata on the nodes array that we read here.
|
|
427
|
+
tryScheduleRetriggers(nodes, freq, inst, chId, token, tickSeconds, panVal) {
|
|
428
|
+
const retrigMeta = nodes.__retrigger;
|
|
429
|
+
if (!retrigMeta)
|
|
430
|
+
return;
|
|
431
|
+
const { interval, volumeDelta, tickDuration, start, dur } = retrigMeta;
|
|
432
|
+
const intervalSec = interval * tickDuration;
|
|
433
|
+
// Schedule retriggered notes at each interval
|
|
434
|
+
let retrigTime = start + intervalSec;
|
|
435
|
+
let volMultiplier = 1.0;
|
|
436
|
+
while (retrigTime < start + dur) {
|
|
437
|
+
// Apply volume delta for fadeout/fadein effect
|
|
438
|
+
// volumeDelta is in Game Boy envelope units (-15 to +15, typically -2 to -5 for fadeout)
|
|
439
|
+
// Normalized to 0-1 range by dividing by 15, so -2 = -0.133 per retrigger
|
|
440
|
+
// Example: -2 delta over 8 retrigs = 8 × -0.133 = -1.064 total (full fadeout)
|
|
441
|
+
if (volumeDelta !== 0) {
|
|
442
|
+
volMultiplier = Math.max(0, Math.min(1, volMultiplier + (volumeDelta / 15)));
|
|
443
|
+
}
|
|
444
|
+
// Create modified instrument with adjusted envelope/volume
|
|
445
|
+
const retrigInst = { ...inst };
|
|
446
|
+
if (retrigInst.env) {
|
|
447
|
+
const envParts = String(retrigInst.env).split(',');
|
|
448
|
+
if (envParts.length > 0) {
|
|
449
|
+
const envLevel = Math.max(0, Math.min(15, Math.round(parseFloat(envParts[0]) * volMultiplier)));
|
|
450
|
+
retrigInst.env = `${envLevel},${envParts.slice(1).join(',')}`;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
// Calculate remaining duration for this retrig
|
|
454
|
+
const retrigDur = Math.min(intervalSec, start + dur - retrigTime);
|
|
455
|
+
const capturedTime = retrigTime;
|
|
456
|
+
const capturedInst = retrigInst;
|
|
457
|
+
const capturedToken = token;
|
|
458
|
+
// Schedule the retriggered note
|
|
459
|
+
if (inst.type && inst.type.toLowerCase().includes('pulse')) {
|
|
460
|
+
const duty = inst.duty ? parseFloat(inst.duty) / 100 : 0.5;
|
|
461
|
+
this.scheduler.schedule(capturedTime, () => {
|
|
462
|
+
if (this.solo !== null && this.solo !== chId)
|
|
463
|
+
return;
|
|
464
|
+
if (this.muted.has(chId))
|
|
465
|
+
return;
|
|
466
|
+
const retrigNodes = playPulse(this.ctx, freq, duty, capturedTime, retrigDur, capturedInst, this.scheduler, this.masterGain || undefined);
|
|
467
|
+
// Don't apply retrigger effect recursively, but apply other effects
|
|
468
|
+
const effectsWithoutRetrig = (capturedToken && capturedToken.effects ? capturedToken.effects : []).filter((fx) => {
|
|
469
|
+
const fxType = fx && fx.type ? fx.type : fx;
|
|
470
|
+
return fxType !== 'retrig';
|
|
471
|
+
});
|
|
472
|
+
this.tryApplyEffects(this.ctx, retrigNodes, effectsWithoutRetrig, capturedTime, retrigDur, chId, tickSeconds, capturedInst);
|
|
473
|
+
this.tryApplyPan(this.ctx, retrigNodes, panVal);
|
|
474
|
+
for (const n of retrigNodes)
|
|
475
|
+
this.activeNodes.push({ node: n, chId });
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
else if (inst.type && inst.type.toLowerCase().includes('wave')) {
|
|
479
|
+
const wav = parseWaveTable(capturedInst.wave);
|
|
480
|
+
this.scheduler.schedule(capturedTime, () => {
|
|
481
|
+
if (this.solo !== null && this.solo !== chId)
|
|
482
|
+
return;
|
|
483
|
+
if (this.muted.has(chId))
|
|
484
|
+
return;
|
|
485
|
+
const retrigNodes = playWavetable(this.ctx, freq, wav, capturedTime, retrigDur, capturedInst, this.scheduler, this.masterGain || undefined);
|
|
486
|
+
const effectsWithoutRetrig = (capturedToken && capturedToken.effects ? capturedToken.effects : []).filter((fx) => {
|
|
487
|
+
const fxType = fx && fx.type ? fx.type : fx;
|
|
488
|
+
return fxType !== 'retrig';
|
|
489
|
+
});
|
|
490
|
+
this.tryApplyEffects(this.ctx, retrigNodes, effectsWithoutRetrig, capturedTime, retrigDur, chId, tickSeconds, capturedInst);
|
|
491
|
+
this.tryApplyPan(this.ctx, retrigNodes, panVal);
|
|
492
|
+
for (const n of retrigNodes)
|
|
493
|
+
this.activeNodes.push({ node: n, chId });
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
else if (inst.type && inst.type.toLowerCase().includes('noise')) {
|
|
497
|
+
this.scheduler.schedule(capturedTime, () => {
|
|
498
|
+
if (this.solo !== null && this.solo !== chId)
|
|
499
|
+
return;
|
|
500
|
+
if (this.muted.has(chId))
|
|
501
|
+
return;
|
|
502
|
+
const retrigNodes = playNoise(this.ctx, capturedTime, retrigDur, capturedInst, this.scheduler, this.masterGain || undefined);
|
|
503
|
+
const effectsWithoutRetrig = (capturedToken && capturedToken.effects ? capturedToken.effects : []).filter((fx) => {
|
|
504
|
+
const fxType = fx && fx.type ? fx.type : fx;
|
|
505
|
+
return fxType !== 'retrig';
|
|
506
|
+
});
|
|
507
|
+
this.tryApplyEffects(this.ctx, retrigNodes, effectsWithoutRetrig, capturedTime, retrigDur, chId, tickSeconds, capturedInst);
|
|
508
|
+
this.tryApplyPan(this.ctx, retrigNodes, panVal);
|
|
509
|
+
for (const n of retrigNodes)
|
|
510
|
+
this.activeNodes.push({ node: n, chId });
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
retrigTime += intervalSec;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
// Schedule echo/delay effect if echo metadata was stored on the nodes array.
|
|
517
|
+
// The echo effect handler stores metadata that we use here to create the delay routing.
|
|
518
|
+
tryScheduleEcho(nodes) {
|
|
519
|
+
const echoMeta = nodes.__echo;
|
|
520
|
+
if (!echoMeta)
|
|
521
|
+
return;
|
|
522
|
+
const { delayTime, feedback, mix, start, dur } = echoMeta;
|
|
523
|
+
try {
|
|
524
|
+
// Find the gain node (typically nodes[1])
|
|
525
|
+
const gainNode = nodes.length > 1 ? nodes[1] : nodes[0];
|
|
526
|
+
if (!gainNode || !gainNode.connect)
|
|
527
|
+
return;
|
|
528
|
+
// Create delay effect nodes
|
|
529
|
+
const delayNode = this.ctx.createDelay(Math.max(5.0, delayTime * 4));
|
|
530
|
+
const feedbackGain = this.ctx.createGain();
|
|
531
|
+
const wetGain = this.ctx.createGain();
|
|
532
|
+
const dryGain = this.ctx.createGain();
|
|
533
|
+
// Set parameters
|
|
534
|
+
// mix controls wet/dry balance: mix=0 (all dry), mix=1 (all wet)
|
|
535
|
+
const wetLevel = mix;
|
|
536
|
+
const dryLevel = 1 - mix;
|
|
537
|
+
try {
|
|
538
|
+
delayNode.delayTime.setValueAtTime(delayTime, start);
|
|
539
|
+
feedbackGain.gain.setValueAtTime(feedback, start);
|
|
540
|
+
wetGain.gain.setValueAtTime(wetLevel, start);
|
|
541
|
+
dryGain.gain.setValueAtTime(dryLevel, start);
|
|
542
|
+
}
|
|
543
|
+
catch (_) {
|
|
544
|
+
delayNode.delayTime.value = delayTime;
|
|
545
|
+
feedbackGain.gain.value = feedback;
|
|
546
|
+
wetGain.gain.value = wetLevel;
|
|
547
|
+
dryGain.gain.value = dryLevel;
|
|
548
|
+
}
|
|
549
|
+
// Find the destination (use masterGain if available)
|
|
550
|
+
const destination = this.masterGain || this.ctx.destination;
|
|
551
|
+
// Disconnect gainNode from its current destination to avoid double-routing
|
|
552
|
+
try {
|
|
553
|
+
gainNode.disconnect();
|
|
554
|
+
}
|
|
555
|
+
catch (_) {
|
|
556
|
+
// Already disconnected or no connections
|
|
557
|
+
}
|
|
558
|
+
// Create proper echo routing with separate dry/wet paths:
|
|
559
|
+
// Dry path: gainNode -> dryGain -> destination
|
|
560
|
+
// Wet path: gainNode -> delayNode -> wetGain -> destination
|
|
561
|
+
// Feedback loop: delayNode -> feedbackGain -> delayNode (internal)
|
|
562
|
+
// Connect dry path
|
|
563
|
+
gainNode.connect(dryGain);
|
|
564
|
+
dryGain.connect(destination);
|
|
565
|
+
// Connect to delay input
|
|
566
|
+
gainNode.connect(delayNode);
|
|
567
|
+
// Connect feedback loop: delay -> feedbackGain -> back to delay input
|
|
568
|
+
delayNode.connect(feedbackGain);
|
|
569
|
+
feedbackGain.connect(delayNode);
|
|
570
|
+
// Connect wet signal: delay -> wetGain -> destination
|
|
571
|
+
delayNode.connect(wetGain);
|
|
572
|
+
wetGain.connect(destination);
|
|
573
|
+
// Track all echo nodes for proper cleanup
|
|
574
|
+
this.activeNodes.push({ node: delayNode, chId: -1 });
|
|
575
|
+
this.activeNodes.push({ node: feedbackGain, chId: -1 });
|
|
576
|
+
this.activeNodes.push({ node: wetGain, chId: -1 });
|
|
577
|
+
this.activeNodes.push({ node: dryGain, chId: -1 });
|
|
578
|
+
// Schedule cleanup after the echo tail has died out
|
|
579
|
+
// Use logarithmic decay formula to calculate tail duration:
|
|
580
|
+
// Time for signal to decay to 1/1000 of original level (-60dB)
|
|
581
|
+
// For feedback close to 1.0, this prevents infinite/excessive durations
|
|
582
|
+
let tailDuration;
|
|
583
|
+
if (feedback < 0.001) {
|
|
584
|
+
// Very low feedback - tail dies out quickly (just one repeat)
|
|
585
|
+
tailDuration = delayTime * 2;
|
|
586
|
+
}
|
|
587
|
+
else if (feedback >= 0.999) {
|
|
588
|
+
// Very high feedback - cap to prevent excessive duration
|
|
589
|
+
tailDuration = Math.min(10.0, delayTime * 20);
|
|
590
|
+
}
|
|
591
|
+
else {
|
|
592
|
+
// Calculate decay time using logarithmic formula
|
|
593
|
+
// Math.log(1000) ≈ 6.9, which represents -60dB decay
|
|
594
|
+
const decayTime = (delayTime * Math.log(1000)) / Math.log(1 / feedback);
|
|
595
|
+
// Cap to reasonable maximum (10 seconds) to prevent excessive memory usage
|
|
596
|
+
tailDuration = Math.min(10.0, decayTime);
|
|
597
|
+
}
|
|
598
|
+
const cleanupTime = start + dur + tailDuration;
|
|
599
|
+
// Schedule proper cleanup: ramp gain to zero, then disconnect all nodes
|
|
600
|
+
this.scheduler.schedule(cleanupTime - 0.1, () => {
|
|
601
|
+
try {
|
|
602
|
+
// Ramp feedback to zero over 100ms to avoid clicks
|
|
603
|
+
feedbackGain.gain.setValueAtTime(feedback, this.ctx.currentTime);
|
|
604
|
+
feedbackGain.gain.linearRampToValueAtTime(0, this.ctx.currentTime + 0.1);
|
|
605
|
+
}
|
|
606
|
+
catch (_) {
|
|
607
|
+
// Scheduling failed, proceed to disconnect anyway
|
|
608
|
+
}
|
|
609
|
+
});
|
|
610
|
+
// Schedule node disconnection after fade-out completes
|
|
611
|
+
this.scheduler.schedule(cleanupTime, () => {
|
|
612
|
+
try {
|
|
613
|
+
delayNode.disconnect();
|
|
614
|
+
feedbackGain.disconnect();
|
|
615
|
+
wetGain.disconnect();
|
|
616
|
+
dryGain.disconnect();
|
|
617
|
+
}
|
|
618
|
+
catch (_) {
|
|
619
|
+
// Already disconnected or GC'd
|
|
620
|
+
}
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
catch (e) {
|
|
624
|
+
// Echo routing failed, skip silently
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
tryApplyPan(ctx, nodes, panSpec) {
|
|
628
|
+
if (!panSpec)
|
|
629
|
+
return;
|
|
630
|
+
let p = undefined;
|
|
631
|
+
if (typeof panSpec === 'number')
|
|
632
|
+
p = Math.max(-1, Math.min(1, panSpec));
|
|
633
|
+
else if (typeof panSpec === 'string') {
|
|
634
|
+
const s = panSpec.toUpperCase();
|
|
635
|
+
if (s === 'L')
|
|
636
|
+
p = -1;
|
|
637
|
+
else if (s === 'R')
|
|
638
|
+
p = 1;
|
|
639
|
+
else if (s === 'C')
|
|
640
|
+
p = 0;
|
|
641
|
+
else {
|
|
642
|
+
const n = Number(panSpec);
|
|
643
|
+
if (!Number.isNaN(n))
|
|
644
|
+
p = Math.max(-1, Math.min(1, n));
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
else if (typeof panSpec === 'object') {
|
|
648
|
+
if (panSpec.value !== undefined)
|
|
649
|
+
p = Math.max(-1, Math.min(1, Number(panSpec.value)));
|
|
650
|
+
else if (panSpec.enum) {
|
|
651
|
+
const s = String(panSpec.enum).toUpperCase();
|
|
652
|
+
if (s === 'L')
|
|
653
|
+
p = -1;
|
|
654
|
+
else if (s === 'R')
|
|
655
|
+
p = 1;
|
|
656
|
+
else
|
|
657
|
+
p = 0;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
if (p === undefined)
|
|
661
|
+
return;
|
|
662
|
+
try {
|
|
663
|
+
const gain = nodes && nodes.length >= 2 ? nodes[1] : null;
|
|
664
|
+
if (!gain || typeof gain.connect !== 'function')
|
|
665
|
+
return;
|
|
666
|
+
// Determine the actual destination (masterGain if available, otherwise ctx.destination)
|
|
667
|
+
const dest = this.masterGain || ctx.destination;
|
|
668
|
+
// create StereoPannerNode if available
|
|
669
|
+
const createPanner = ctx.createStereoPanner;
|
|
670
|
+
if (typeof createPanner === 'function') {
|
|
671
|
+
const panner = ctx.createStereoPanner();
|
|
672
|
+
try {
|
|
673
|
+
panner.pan.setValueAtTime(p, ctx.currentTime);
|
|
674
|
+
}
|
|
675
|
+
catch (e) {
|
|
676
|
+
try {
|
|
677
|
+
panner.pan.value = p;
|
|
678
|
+
}
|
|
679
|
+
catch (e2) { }
|
|
680
|
+
}
|
|
681
|
+
// Disconnect from all destinations (handles both masterGain and ctx.destination cases)
|
|
682
|
+
try {
|
|
683
|
+
gain.disconnect();
|
|
684
|
+
}
|
|
685
|
+
catch (e) { }
|
|
686
|
+
gain.connect(panner);
|
|
687
|
+
panner.connect(dest);
|
|
688
|
+
// also track panner node so stop/cleanup will disconnect it
|
|
689
|
+
this.activeNodes.push({ node: panner, chId: -1 });
|
|
690
|
+
}
|
|
691
|
+
else {
|
|
692
|
+
// StereoPanner not available — best-effort: do nothing or optionally implement left/right gains
|
|
693
|
+
// For now, we silently skip (no pan) to avoid complex signal routing.
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
catch (e) {
|
|
697
|
+
// swallow errors — panning is best-effort
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
stop() {
|
|
701
|
+
if (this._repeatTimer) {
|
|
702
|
+
try {
|
|
703
|
+
clearTimeout(this._repeatTimer);
|
|
704
|
+
}
|
|
705
|
+
catch (e) { }
|
|
706
|
+
this._repeatTimer = null;
|
|
707
|
+
}
|
|
708
|
+
if (this.scheduler) {
|
|
709
|
+
this.scheduler.clear();
|
|
710
|
+
this.scheduler.stop();
|
|
711
|
+
}
|
|
712
|
+
// Clear effect state (e.g., portamento frequency tracking)
|
|
713
|
+
clearEffectState();
|
|
714
|
+
for (const entry of this.activeNodes) {
|
|
715
|
+
try {
|
|
716
|
+
if (entry.node && typeof entry.node.stop === 'function')
|
|
717
|
+
entry.node.stop();
|
|
718
|
+
}
|
|
719
|
+
catch (e) { }
|
|
720
|
+
try {
|
|
721
|
+
if (entry.node && typeof entry.node.disconnect === 'function')
|
|
722
|
+
entry.node.disconnect();
|
|
723
|
+
}
|
|
724
|
+
catch (e) { }
|
|
725
|
+
}
|
|
726
|
+
this.activeNodes = [];
|
|
727
|
+
try {
|
|
728
|
+
const buffered = this._buffered;
|
|
729
|
+
if (buffered && typeof buffered.drainScheduledNodes === 'function') {
|
|
730
|
+
const nodes = buffered.drainScheduledNodes();
|
|
731
|
+
for (const n of nodes) {
|
|
732
|
+
try {
|
|
733
|
+
if (n.src && typeof n.src.stop === 'function')
|
|
734
|
+
n.src.stop();
|
|
735
|
+
}
|
|
736
|
+
catch (_) { }
|
|
737
|
+
try {
|
|
738
|
+
if (n.src && typeof n.src.disconnect === 'function')
|
|
739
|
+
n.src.disconnect();
|
|
740
|
+
}
|
|
741
|
+
catch (_) { }
|
|
742
|
+
try {
|
|
743
|
+
if (n.gain && typeof n.gain.disconnect === 'function')
|
|
744
|
+
n.gain.disconnect();
|
|
745
|
+
}
|
|
746
|
+
catch (_) { }
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
catch (e) { }
|
|
751
|
+
}
|
|
752
|
+
toggleChannelMute(chId) {
|
|
753
|
+
if (this.muted.has(chId))
|
|
754
|
+
this.muted.delete(chId);
|
|
755
|
+
else
|
|
756
|
+
this.muted.add(chId);
|
|
757
|
+
}
|
|
758
|
+
toggleChannelSolo(chId) {
|
|
759
|
+
if (this.solo === chId)
|
|
760
|
+
this.solo = null;
|
|
761
|
+
else
|
|
762
|
+
this.solo = chId;
|
|
763
|
+
}
|
|
764
|
+
stopChannel(chId) {
|
|
765
|
+
const keep = [];
|
|
766
|
+
for (const entry of this.activeNodes) {
|
|
767
|
+
if (entry.chId === chId) {
|
|
768
|
+
try {
|
|
769
|
+
if (entry.node && typeof entry.node.stop === 'function')
|
|
770
|
+
entry.node.stop();
|
|
771
|
+
}
|
|
772
|
+
catch (e) { }
|
|
773
|
+
try {
|
|
774
|
+
if (entry.node && typeof entry.node.disconnect === 'function')
|
|
775
|
+
entry.node.disconnect();
|
|
776
|
+
}
|
|
777
|
+
catch (e) { }
|
|
778
|
+
}
|
|
779
|
+
else {
|
|
780
|
+
keep.push(entry);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
this.activeNodes = keep;
|
|
784
|
+
try {
|
|
785
|
+
const buffered = this._buffered;
|
|
786
|
+
if (buffered && typeof buffered.stop === 'function') {
|
|
787
|
+
buffered.stop(chId);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
catch (e) { }
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
export default Player;
|
|
794
|
+
//# sourceMappingURL=playback.js.map
|