@dawcore/components 0.0.23 → 0.0.24

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/index.mjs CHANGED
@@ -201,6 +201,68 @@ var DawTrackElement = class extends LitElement2 {
201
201
  createRenderRoot() {
202
202
  return this;
203
203
  }
204
+ // --- Effects API (delegates to the owning <daw-editor>) ---
205
+ addEffect(type, params) {
206
+ return this._effectsEditor()._trackAddEffect(this.trackId, this, type, params);
207
+ }
208
+ /** Load a WAM plugin (via the optional @dawcore/wam peer) into this track's chain. */
209
+ addWamPlugin(url, initialState) {
210
+ return this._effectsEditor()._trackAddWamPlugin(this.trackId, this, url, initialState);
211
+ }
212
+ /**
213
+ * Compile Faust DSP source in the browser (via the optional @dawcore/faust
214
+ * peer) and add the resulting WAM to this track's chain. Compile errors
215
+ * keep their Faust line/column diagnostics and leave the chain untouched.
216
+ */
217
+ addFaustEffect(dspCode, options) {
218
+ return this._effectsEditor()._trackAddFaustEffect(this.trackId, this, dspCode, options);
219
+ }
220
+ /** Snapshot this track's chain in its persisted form (see dawcore README). */
221
+ getEffectsState() {
222
+ const editor = this.closest("daw-editor");
223
+ return editor?._trackGetEffectsState(this.trackId) ?? Promise.resolve([]);
224
+ }
225
+ /** Replace this track's chain with a persisted snapshot. */
226
+ setEffectsState(entries) {
227
+ return this._effectsEditor()._trackSetEffectsState(this.trackId, this, entries);
228
+ }
229
+ /**
230
+ * Open (lazily creating) the GUI for one of this track's effects into a
231
+ * consumer-provided container. Closing hides without interrupting audio;
232
+ * the element is cached for reopen. See <daw-editor>.openEffectGui.
233
+ */
234
+ openEffectGui(effectId, container) {
235
+ return this._effectsEditor()._trackOpenEffectGui(this.trackId, this, effectId, container);
236
+ }
237
+ /** Hide an effect's GUI (cached for reopen — never destroys). */
238
+ closeEffectGui(effectId) {
239
+ this._effectsEditor()._trackCloseEffectGui(this.trackId, effectId);
240
+ }
241
+ removeEffect(effectId) {
242
+ this._effectsEditor()._trackEffectOp(this.trackId, this, "remove", effectId);
243
+ }
244
+ setEffectParams(effectId, params) {
245
+ this._effectsEditor()._trackEffectOp(this.trackId, this, "setParams", effectId, params);
246
+ }
247
+ setEffectBypassed(effectId, bypassed) {
248
+ this._effectsEditor()._trackEffectOp(this.trackId, this, "setBypassed", effectId, bypassed);
249
+ }
250
+ moveEffect(effectId, newIndex) {
251
+ this._effectsEditor()._trackEffectOp(this.trackId, this, "move", effectId, newIndex);
252
+ }
253
+ get effects() {
254
+ const editor = this.closest("daw-editor");
255
+ return editor?._trackEffects(this.trackId) ?? [];
256
+ }
257
+ _effectsEditor() {
258
+ const editor = this.closest("daw-editor");
259
+ if (!editor) {
260
+ throw new Error(
261
+ "[waveform-playlist] <daw-track> effects API requires the track to be inside a <daw-editor>"
262
+ );
263
+ }
264
+ return editor;
265
+ }
204
266
  connectedCallback() {
205
267
  super.connectedCallback();
206
268
  setTimeout(() => {
@@ -1386,7 +1448,7 @@ function createPeaksWorker() {
1386
1448
  }
1387
1449
  const pending = /* @__PURE__ */ new Map();
1388
1450
  let terminated = false;
1389
- let idCounter = 0;
1451
+ let idCounter2 = 0;
1390
1452
  worker.onmessage = (e) => {
1391
1453
  const msg = e.data;
1392
1454
  const entry = pending.get(msg.id);
@@ -1419,7 +1481,7 @@ function createPeaksWorker() {
1419
1481
  return {
1420
1482
  generate(params) {
1421
1483
  if (terminated) return Promise.reject(new Error("Worker terminated"));
1422
- const messageId = String(++idCounter);
1484
+ const messageId = String(++idCounter2);
1423
1485
  return new Promise((resolve, reject) => {
1424
1486
  pending.set(messageId, { resolve, reject });
1425
1487
  worker.postMessage(
@@ -4096,6 +4158,1088 @@ function findAudioBufferForClip(host, clip, track) {
4096
4158
  return null;
4097
4159
  }
4098
4160
 
4161
+ // src/effects/effects-chain-controller.ts
4162
+ var idCounter = 0;
4163
+ var EffectsChainController = class {
4164
+ constructor(audioContext) {
4165
+ this._entries = [];
4166
+ this._disposed = false;
4167
+ this._input = audioContext.createGain();
4168
+ this._output = audioContext.createGain();
4169
+ this._input.connect(this._output);
4170
+ }
4171
+ /** Connect the upstream source (track mute node, master gain) into this. */
4172
+ get input() {
4173
+ return this._input;
4174
+ }
4175
+ /** Route this onward (to the master bus / destination). */
4176
+ get output() {
4177
+ return this._output;
4178
+ }
4179
+ get entries() {
4180
+ return this._entries.map((entry) => ({
4181
+ id: entry.id,
4182
+ kind: entry.kind,
4183
+ type: entry.type,
4184
+ params: { ...entry.params },
4185
+ bypassed: entry.bypassed,
4186
+ ...entry.url !== void 0 ? { url: entry.url } : {},
4187
+ ...entry.source !== void 0 ? { source: { ...entry.source } } : {},
4188
+ ...entry.label !== void 0 ? { label: entry.label } : {},
4189
+ ...entry.error !== void 0 ? { error: entry.error } : {}
4190
+ }));
4191
+ }
4192
+ /** Snapshot the chain in its persisted form. WAM entries are asked for
4193
+ * their live state; placeholders re-emit the state they were saved with. */
4194
+ serialize() {
4195
+ return Promise.all(
4196
+ this._entries.map(async (entry) => {
4197
+ if (entry.kind === "wam") {
4198
+ const sourceFields = entry.source?.faust !== void 0 ? {
4199
+ faustDsp: entry.source.faust,
4200
+ ...entry.label !== void 0 ? { faustName: entry.label } : {}
4201
+ } : { url: entry.url ?? "" };
4202
+ if (entry.placeholder) {
4203
+ return {
4204
+ kind: "wam",
4205
+ ...sourceFields,
4206
+ bypassed: entry.placeholder.bypassed,
4207
+ ...entry.placeholder.state !== void 0 ? { state: entry.placeholder.state } : {}
4208
+ };
4209
+ }
4210
+ let state5;
4211
+ try {
4212
+ state5 = await entry.instance.getState?.();
4213
+ } catch (err) {
4214
+ console.warn(
4215
+ '[waveform-playlist] serialize: plugin "' + (entry.url ?? entry.label ?? entry.type) + '" getState failed: ' + String(err)
4216
+ );
4217
+ }
4218
+ return {
4219
+ kind: "wam",
4220
+ ...sourceFields,
4221
+ bypassed: entry.bypassed,
4222
+ ...state5 !== void 0 ? { state: state5 } : {}
4223
+ };
4224
+ }
4225
+ return {
4226
+ kind: "native",
4227
+ type: entry.type,
4228
+ params: { ...entry.params },
4229
+ bypassed: entry.bypassed
4230
+ };
4231
+ })
4232
+ );
4233
+ }
4234
+ get disposed() {
4235
+ return this._disposed;
4236
+ }
4237
+ /** Internal (manager-facing): the live entry — including its instance —
4238
+ * for GUI wiring. Returns undefined (no warning) when the id is unknown. */
4239
+ getEntry(effectId) {
4240
+ return this._entries.find((e) => e.id === effectId);
4241
+ }
4242
+ add(item, index) {
4243
+ if (this._disposed) {
4244
+ throw new Error(
4245
+ "[waveform-playlist] EffectsChainController.add: chain is disposed \u2014 entries cannot be added"
4246
+ );
4247
+ }
4248
+ const id = "effect-" + ++idCounter;
4249
+ const entry = { ...item, params: { ...item.params }, id, bypassed: false };
4250
+ const at = index === void 0 ? this._entries.length : Math.max(0, Math.min(index, this._entries.length));
4251
+ this._entries = [...this._entries.slice(0, at), entry, ...this._entries.slice(at)];
4252
+ this._rebuild();
4253
+ return id;
4254
+ }
4255
+ remove(effectId) {
4256
+ const entry = this._find("remove", effectId);
4257
+ if (!entry) return;
4258
+ this._entries = this._entries.filter((e) => e.id !== effectId);
4259
+ entry.instance.output.disconnect();
4260
+ entry.instance.dispose?.();
4261
+ this._rebuild();
4262
+ }
4263
+ move(effectId, newIndex) {
4264
+ const entry = this._find("move", effectId);
4265
+ if (!entry) return;
4266
+ const without = this._entries.filter((e) => e.id !== effectId);
4267
+ const at = Math.max(0, Math.min(newIndex, without.length));
4268
+ this._entries = [...without.slice(0, at), entry, ...without.slice(at)];
4269
+ this._rebuild();
4270
+ }
4271
+ setParams(effectId, params) {
4272
+ const entry = this._find("setParams", effectId);
4273
+ if (!entry) return;
4274
+ entry.params = { ...entry.params, ...params };
4275
+ if (entry.bypassed && entry.wetParam && entry.wetParam in params) {
4276
+ const { [entry.wetParam]: _stored, ...rest } = params;
4277
+ if (Object.keys(rest).length > 0) {
4278
+ entry.instance.applyParams(rest);
4279
+ }
4280
+ return;
4281
+ }
4282
+ entry.instance.applyParams(params);
4283
+ }
4284
+ setBypassed(effectId, bypassed) {
4285
+ const entry = this._find("setBypassed", effectId);
4286
+ if (!entry || entry.bypassed === bypassed) return;
4287
+ entry.bypassed = bypassed;
4288
+ if (entry.wetParam) {
4289
+ const wet = bypassed ? 0 : entry.params[entry.wetParam] ?? 0;
4290
+ entry.instance.applyParams({ [entry.wetParam]: wet });
4291
+ return;
4292
+ }
4293
+ this._rebuild();
4294
+ }
4295
+ dispose() {
4296
+ if (this._disposed) return;
4297
+ this._disposed = true;
4298
+ this._severOwnEdges();
4299
+ for (const entry of this._entries) {
4300
+ entry.instance.dispose?.();
4301
+ }
4302
+ this._entries = [];
4303
+ try {
4304
+ this._output.disconnect();
4305
+ } catch {
4306
+ }
4307
+ }
4308
+ _find(op, effectId) {
4309
+ const entry = this._entries.find((e) => e.id === effectId);
4310
+ if (!entry) {
4311
+ console.warn(
4312
+ "[waveform-playlist] EffectsChainController." + op + ': unknown effectId "' + effectId + '"'
4313
+ );
4314
+ }
4315
+ return entry;
4316
+ }
4317
+ /** Sever only this chain's outgoing edges — never the consumer's. */
4318
+ _severOwnEdges() {
4319
+ this._input.disconnect();
4320
+ for (const entry of this._entries) {
4321
+ entry.instance.output.disconnect();
4322
+ }
4323
+ }
4324
+ _rebuild() {
4325
+ this._severOwnEdges();
4326
+ let previous = this._input;
4327
+ for (const entry of this._entries) {
4328
+ if (entry.bypassed && !entry.wetParam) {
4329
+ continue;
4330
+ }
4331
+ previous.connect(entry.instance.input);
4332
+ previous = entry.instance.output;
4333
+ }
4334
+ previous.connect(this._output);
4335
+ }
4336
+ };
4337
+
4338
+ // src/effects/optional-modules.ts
4339
+ var PREFIX = "[waveform-playlist] ";
4340
+ async function loadOptionalModule(importer, packageName, feature) {
4341
+ try {
4342
+ return await importer();
4343
+ } catch (originalErr) {
4344
+ console.warn(PREFIX + packageName + " dynamic import failed: " + String(originalErr));
4345
+ throw new Error(
4346
+ PREFIX + packageName + " is required for " + feature + ". Install with: npm install " + packageName + " (import failed: " + String(originalErr) + ")"
4347
+ );
4348
+ }
4349
+ }
4350
+ function loadWamModule(feature) {
4351
+ return loadOptionalModule(() => import("@dawcore/wam"), "@dawcore/wam", feature);
4352
+ }
4353
+ function loadFaustModule(feature) {
4354
+ return loadOptionalModule(() => import("@dawcore/faust"), "@dawcore/faust", feature);
4355
+ }
4356
+
4357
+ // src/effects/effect-registry.ts
4358
+ var MAX_DELAY_SECONDS = 10;
4359
+ var registry = /* @__PURE__ */ new Map();
4360
+ function registerEffect(type, definition) {
4361
+ if (registry.has(type)) {
4362
+ console.warn(
4363
+ '[waveform-playlist] registerEffect: overwriting existing effect type "' + type + '"'
4364
+ );
4365
+ }
4366
+ registry.set(type, definition);
4367
+ }
4368
+ function getEffectDefinitions() {
4369
+ return new Map(registry);
4370
+ }
4371
+ function createEffectInstance(type, audioContext, params = {}) {
4372
+ const definition = registry.get(type);
4373
+ if (!definition) {
4374
+ throw new Error(
4375
+ '[waveform-playlist] createEffectInstance: unknown effect type "' + type + '". Available types: ' + [...registry.keys()].join(", ")
4376
+ );
4377
+ }
4378
+ const merged = { ...definition.defaults, ...params };
4379
+ const instance = definition.create(audioContext, merged);
4380
+ instance.applyParams(merged);
4381
+ return { instance, params: merged, wetParam: definition.wetParam };
4382
+ }
4383
+ function singleNode(node, applyParams) {
4384
+ return { input: node, output: node, applyParams };
4385
+ }
4386
+ function registerBuiltIns() {
4387
+ registry.set("native-gain", {
4388
+ label: "Gain",
4389
+ category: "dynamics",
4390
+ defaults: { gain: 1 },
4391
+ params: { gain: { min: 0, max: 2, step: 0.01 } },
4392
+ create: (ctx) => {
4393
+ const node = ctx.createGain();
4394
+ return singleNode(node, (p) => {
4395
+ if (p.gain !== void 0) node.gain.value = p.gain;
4396
+ });
4397
+ }
4398
+ });
4399
+ registry.set("native-filter", {
4400
+ label: "Lowpass Filter",
4401
+ category: "filter",
4402
+ defaults: { frequency: 1e3, q: 1 },
4403
+ params: {
4404
+ frequency: { min: 20, max: 2e4, step: 1, unit: "Hz" },
4405
+ q: { min: 0.1, max: 20, step: 0.1 }
4406
+ },
4407
+ create: (ctx) => {
4408
+ const node = ctx.createBiquadFilter();
4409
+ node.type = "lowpass";
4410
+ return singleNode(node, (p) => {
4411
+ if (p.frequency !== void 0) node.frequency.value = p.frequency;
4412
+ if (p.q !== void 0) node.Q.value = p.q;
4413
+ });
4414
+ }
4415
+ });
4416
+ registry.set("native-compressor", {
4417
+ label: "Compressor",
4418
+ category: "dynamics",
4419
+ defaults: { threshold: -24, knee: 30, ratio: 12, attack: 3e-3, release: 0.25 },
4420
+ params: {
4421
+ threshold: { min: -60, max: 0, step: 1, unit: "dB" },
4422
+ knee: { min: 0, max: 40, step: 1, unit: "dB" },
4423
+ ratio: { min: 1, max: 20, step: 0.5 },
4424
+ attack: { min: 0, max: 1, step: 1e-3, unit: "s" },
4425
+ release: { min: 0, max: 1, step: 0.01, unit: "s" }
4426
+ },
4427
+ create: (ctx) => {
4428
+ const node = ctx.createDynamicsCompressor();
4429
+ return singleNode(node, (p) => {
4430
+ if (p.threshold !== void 0) node.threshold.value = p.threshold;
4431
+ if (p.knee !== void 0) node.knee.value = p.knee;
4432
+ if (p.ratio !== void 0) node.ratio.value = p.ratio;
4433
+ if (p.attack !== void 0) node.attack.value = p.attack;
4434
+ if (p.release !== void 0) node.release.value = p.release;
4435
+ });
4436
+ }
4437
+ });
4438
+ registry.set("native-stereo-panner", {
4439
+ label: "Stereo Panner",
4440
+ category: "spatial",
4441
+ defaults: { pan: 0 },
4442
+ params: { pan: { min: -1, max: 1, step: 0.01 } },
4443
+ create: (ctx) => {
4444
+ const node = ctx.createStereoPanner();
4445
+ return singleNode(node, (p) => {
4446
+ if (p.pan !== void 0) node.pan.value = p.pan;
4447
+ });
4448
+ }
4449
+ });
4450
+ registry.set("native-delay", {
4451
+ label: "Delay",
4452
+ category: "delay",
4453
+ defaults: { delayTime: 0.25, feedback: 0.4, wet: 0.35 },
4454
+ params: {
4455
+ delayTime: { min: 0, max: MAX_DELAY_SECONDS, step: 0.01, unit: "s" },
4456
+ feedback: { min: 0, max: 0.95, step: 0.01 },
4457
+ wet: { min: 0, max: 1, step: 0.01 }
4458
+ },
4459
+ wetParam: "wet",
4460
+ create: (ctx) => {
4461
+ const input = ctx.createGain();
4462
+ const output = ctx.createGain();
4463
+ const delay = ctx.createDelay(MAX_DELAY_SECONDS);
4464
+ const feedback = ctx.createGain();
4465
+ const wet = ctx.createGain();
4466
+ const dry = ctx.createGain();
4467
+ input.connect(dry);
4468
+ dry.connect(output);
4469
+ input.connect(delay);
4470
+ delay.connect(feedback);
4471
+ feedback.connect(delay);
4472
+ delay.connect(wet);
4473
+ wet.connect(output);
4474
+ return {
4475
+ input,
4476
+ output,
4477
+ applyParams: (p) => {
4478
+ if (p.delayTime !== void 0) delay.delayTime.value = p.delayTime;
4479
+ if (p.feedback !== void 0) feedback.gain.value = p.feedback;
4480
+ if (p.wet !== void 0) {
4481
+ wet.gain.value = p.wet;
4482
+ dry.gain.value = 1 - p.wet;
4483
+ }
4484
+ }
4485
+ };
4486
+ }
4487
+ });
4488
+ }
4489
+ registerBuiltIns();
4490
+
4491
+ // src/effects/effects-manager.ts
4492
+ var PREFIX2 = "[waveform-playlist] ";
4493
+ function makeGuiRecord(element, destroyImpl) {
4494
+ let destroyed = false;
4495
+ return {
4496
+ element,
4497
+ destroy: () => {
4498
+ if (destroyed) return;
4499
+ destroyed = true;
4500
+ destroyImpl();
4501
+ }
4502
+ };
4503
+ }
4504
+ var EffectsManager = class {
4505
+ constructor(getAdapter, masterEventTarget) {
4506
+ this._masterChain = null;
4507
+ this._trackChains = /* @__PURE__ */ new Map();
4508
+ /** Per-chain restore ownership — a newer setEffectsState supersedes a stale in-flight one. */
4509
+ this._restoreTokens = /* @__PURE__ */ new WeakMap();
4510
+ /** Cached GUI elements by effectId — close hides, only removal destroys. */
4511
+ this._guis = /* @__PURE__ */ new Map();
4512
+ /** In-flight GUI creation by effectId — concurrent opens share one build. */
4513
+ this._guiPending = /* @__PURE__ */ new Map();
4514
+ /** Live WAM plugin nodes across all chains, fed to the wam-transport bridge. */
4515
+ this._wamNodes = /* @__PURE__ */ new Set();
4516
+ this._transportBridge = null;
4517
+ this._getAdapter = getAdapter;
4518
+ this._masterTarget = masterEventTarget;
4519
+ }
4520
+ // --- Master chain ---
4521
+ addMasterEffect(type, params) {
4522
+ const chain = this._ensureMasterChain();
4523
+ return this._addToChain(chain, this._masterTarget, type, params);
4524
+ }
4525
+ masterEffects() {
4526
+ return this._masterChain?.entries ?? [];
4527
+ }
4528
+ masterOp(op, effectId, arg) {
4529
+ this._runOp(this._masterChain, this._masterTarget, op, effectId, arg);
4530
+ }
4531
+ addMasterWamPlugin(url, initialState) {
4532
+ const chain = this._ensureMasterChain();
4533
+ return this._addWamToChain(chain, this._masterTarget, url, initialState);
4534
+ }
4535
+ addMasterFaustEffect(dspCode, options) {
4536
+ const chain = this._ensureMasterChain();
4537
+ return this._addFaustToChain(chain, this._masterTarget, dspCode, { name: options?.name });
4538
+ }
4539
+ getMasterEffectsState() {
4540
+ return this._masterChain?.serialize() ?? Promise.resolve([]);
4541
+ }
4542
+ async setMasterEffectsState(entries) {
4543
+ validateSerializedEntries(entries);
4544
+ await this._restoreChain(this._ensureMasterChain(), this._masterTarget, entries);
4545
+ }
4546
+ // --- Track chains ---
4547
+ addTrackEffect(trackId, target, type, params) {
4548
+ const chain = this._ensureTrackChain(trackId);
4549
+ return this._addToChain(chain, target, type, params);
4550
+ }
4551
+ addTrackWamPlugin(trackId, target, url, initialState) {
4552
+ const chain = this._ensureTrackChain(trackId);
4553
+ return this._addWamToChain(chain, target, url, initialState);
4554
+ }
4555
+ addTrackFaustEffect(trackId, target, dspCode, options) {
4556
+ const chain = this._ensureTrackChain(trackId);
4557
+ return this._addFaustToChain(chain, target, dspCode, { name: options?.name });
4558
+ }
4559
+ trackEffects(trackId) {
4560
+ return this._trackChains.get(trackId)?.entries ?? [];
4561
+ }
4562
+ getTrackEffectsState(trackId) {
4563
+ return this._trackChains.get(trackId)?.serialize() ?? Promise.resolve([]);
4564
+ }
4565
+ async setTrackEffectsState(trackId, target, entries) {
4566
+ validateSerializedEntries(entries);
4567
+ await this._restoreChain(this._ensureTrackChain(trackId), target, entries);
4568
+ }
4569
+ trackOp(trackId, target, op, effectId, arg) {
4570
+ this._runOp(this._trackChains.get(trackId) ?? null, target, op, effectId, arg);
4571
+ }
4572
+ // --- Effect GUIs ---
4573
+ /** Open (lazily creating) the GUI for a master-chain effect. */
4574
+ openMasterEffectGui(effectId, container) {
4575
+ return this._openGui(this._masterChain, this._masterTarget, effectId, container);
4576
+ }
4577
+ /** Open (lazily creating) the GUI for a track-chain effect. */
4578
+ openTrackEffectGui(trackId, target, effectId, container) {
4579
+ return this._openGui(this._trackChains.get(trackId) ?? null, target, effectId, container);
4580
+ }
4581
+ /** Hide an open GUI. The element stays cached so reopen is instant —
4582
+ * audio processing is never interrupted. */
4583
+ closeEffectGui(effectId) {
4584
+ const record = this._guis.get(effectId);
4585
+ if (!record) {
4586
+ console.warn(PREFIX2 + 'closeEffectGui: no open GUI for effectId "' + effectId + '"');
4587
+ return;
4588
+ }
4589
+ record.element.remove();
4590
+ }
4591
+ // --- Lifecycle ---
4592
+ /** Transport setTracks rebuilds TrackNodes, severing chain hookups — re-wire. */
4593
+ rewireTrackChains() {
4594
+ const transport = this._getAdapter()?.transport;
4595
+ if (!transport) return;
4596
+ for (const [trackId, chain] of this._trackChains) {
4597
+ transport.connectTrackOutput(trackId, chain.input);
4598
+ chain.output.connect(transport.masterOutputNode);
4599
+ }
4600
+ }
4601
+ disposeTrackChain(trackId) {
4602
+ const chain = this._trackChains.get(trackId);
4603
+ if (!chain) return;
4604
+ this._trackChains.delete(trackId);
4605
+ for (const entry of chain.entries) {
4606
+ this._destroyGui(entry.id);
4607
+ }
4608
+ try {
4609
+ this._getAdapter()?.transport?.disconnectTrackOutput(trackId);
4610
+ } catch (err) {
4611
+ console.warn(PREFIX2 + "EffectsManager: error disconnecting track output: " + String(err));
4612
+ }
4613
+ chain.dispose();
4614
+ }
4615
+ disposeAll() {
4616
+ this._transportBridge?.dispose();
4617
+ this._transportBridge = null;
4618
+ this._wamNodes.clear();
4619
+ for (const trackId of [...this._trackChains.keys()]) {
4620
+ this.disposeTrackChain(trackId);
4621
+ }
4622
+ if (this._masterChain) {
4623
+ for (const entry of this._masterChain.entries) {
4624
+ this._destroyGui(entry.id);
4625
+ }
4626
+ try {
4627
+ this._getAdapter()?.transport?.disconnectMasterOutput();
4628
+ } catch (err) {
4629
+ console.warn(PREFIX2 + "EffectsManager: error disconnecting master output: " + String(err));
4630
+ }
4631
+ this._masterChain.dispose();
4632
+ this._masterChain = null;
4633
+ }
4634
+ }
4635
+ // --- Private ---
4636
+ _requireWiring() {
4637
+ const adapter = this._getAdapter();
4638
+ if (!adapter) {
4639
+ throw new Error(
4640
+ PREFIX2 + "effects require an adapter \u2014 set editor.adapter before adding effects."
4641
+ );
4642
+ }
4643
+ const { audioContext, transport } = adapter;
4644
+ if (!audioContext || !transport || typeof transport.connectTrackOutput !== "function") {
4645
+ throw new Error(
4646
+ PREFIX2 + "the current adapter does not expose effects hooks (transport.connectTrackOutput / connectMasterOutput)."
4647
+ );
4648
+ }
4649
+ return { audioContext, transport };
4650
+ }
4651
+ /** Dynamic-import the optional @dawcore/wam peer with an actionable error. */
4652
+ _loadWamModule(feature) {
4653
+ return loadWamModule(feature);
4654
+ }
4655
+ /** Dynamic-import the optional @dawcore/faust peer with an actionable error. */
4656
+ _loadFaustModule(feature) {
4657
+ return loadFaustModule(feature);
4658
+ }
4659
+ async _openGui(chain, target, effectId, container) {
4660
+ if (!container || typeof container.appendChild !== "function") {
4661
+ throw new Error(PREFIX2 + "openEffectGui: container must be a DOM element");
4662
+ }
4663
+ const entry = chain?.getEntry(effectId);
4664
+ if (!chain || !entry) {
4665
+ throw new Error(PREFIX2 + 'openEffectGui: unknown effectId "' + effectId + '"');
4666
+ }
4667
+ if (entry.error !== void 0) {
4668
+ throw new Error(
4669
+ PREFIX2 + 'openEffectGui: effect "' + effectId + '" is a failed-plugin placeholder (' + entry.error + ") \u2014 no GUI is available. Remove it or retry the restore."
4670
+ );
4671
+ }
4672
+ const cached = this._guis.get(effectId);
4673
+ if (cached) {
4674
+ container.appendChild(cached.element);
4675
+ return cached.element;
4676
+ }
4677
+ let pending = this._guiPending.get(effectId);
4678
+ if (!pending) {
4679
+ pending = this._createGuiRecord(chain, target, entry, effectId).finally(() => {
4680
+ this._guiPending.delete(effectId);
4681
+ });
4682
+ this._guiPending.set(effectId, pending);
4683
+ }
4684
+ const record = await pending;
4685
+ if (chain.disposed || !chain.getEntry(effectId)) {
4686
+ this._guis.delete(effectId);
4687
+ record.element.remove();
4688
+ try {
4689
+ record.destroy();
4690
+ } catch (err) {
4691
+ console.warn(
4692
+ PREFIX2 + 'openEffectGui: destroying a late GUI for "' + effectId + '" failed: ' + String(err)
4693
+ );
4694
+ }
4695
+ throw new Error(
4696
+ PREFIX2 + 'openEffectGui: effect "' + effectId + '" was removed while its GUI was loading; the GUI was discarded.'
4697
+ );
4698
+ }
4699
+ this._guis.set(effectId, record);
4700
+ container.appendChild(record.element);
4701
+ return record.element;
4702
+ }
4703
+ /** Build a GUI record: the plugin's own GUI when available, otherwise the
4704
+ * generic parameter panel from @dawcore/wam. */
4705
+ async _createGuiRecord(chain, target, entry, effectId) {
4706
+ const { instance } = entry;
4707
+ if (typeof instance.createGui === "function") {
4708
+ try {
4709
+ const element2 = await instance.createGui();
4710
+ return makeGuiRecord(element2, () => instance.destroyGui?.(element2));
4711
+ } catch (err) {
4712
+ console.warn(
4713
+ PREFIX2 + 'openEffectGui: plugin createGui failed for "' + effectId + '" \u2014 falling back to the generic parameter panel: ' + String(err)
4714
+ );
4715
+ }
4716
+ }
4717
+ const element = await this._createFallbackPanel(chain, target, entry, effectId);
4718
+ return makeGuiRecord(element, () => {
4719
+ });
4720
+ }
4721
+ /** The generic parameter panel — one code path for "no custom GUI":
4722
+ * native entries render from the registry's params metadata, WAM entries
4723
+ * from getParameterInfo(). Edits route through the regular setParams op so
4724
+ * they hit the audio (applyParams → setParameterValues for WAM) AND
4725
+ * dispatch daw-effect-change like any other parameter edit. */
4726
+ async _createFallbackPanel(chain, target, entry, effectId) {
4727
+ const wamModule = await this._loadWamModule("openEffectGui() parameter panels");
4728
+ const onChange = (paramId, value) => {
4729
+ this._runOp(chain, target, "setParams", effectId, { [paramId]: value });
4730
+ };
4731
+ if (entry.kind === "native") {
4732
+ const definition = getEffectDefinitions().get(entry.type);
4733
+ if (!definition) {
4734
+ throw new Error(
4735
+ PREFIX2 + 'openEffectGui: no registry definition for effect type "' + entry.type + '"'
4736
+ );
4737
+ }
4738
+ const params = Object.entries(definition.params).map(([id, def]) => ({
4739
+ id,
4740
+ min: def.min,
4741
+ max: def.max,
4742
+ ...def.step !== void 0 ? { step: def.step } : {},
4743
+ ...def.unit !== void 0 ? { unit: def.unit } : {},
4744
+ value: entry.params[id] ?? definition.defaults[id]
4745
+ }));
4746
+ return wamModule.createParameterPanel(params, onChange);
4747
+ }
4748
+ if (typeof entry.instance.getParameterInfo !== "function") {
4749
+ throw new Error(
4750
+ PREFIX2 + 'openEffectGui: effect "' + effectId + '" has no GUI and exposes no parameter info \u2014 nothing to render.'
4751
+ );
4752
+ }
4753
+ return wamModule.createWamParameterPanel(
4754
+ { getParameterInfo: () => entry.instance.getParameterInfo() },
4755
+ { onParamChange: onChange }
4756
+ );
4757
+ }
4758
+ /** Detach + destroy a cached GUI. Called only from removal paths — close
4759
+ * never destroys. Safe when no GUI was ever opened. */
4760
+ _destroyGui(effectId) {
4761
+ const record = this._guis.get(effectId);
4762
+ if (!record) return;
4763
+ this._guis.delete(effectId);
4764
+ record.element.remove();
4765
+ try {
4766
+ record.destroy();
4767
+ } catch (err) {
4768
+ console.warn(PREFIX2 + 'destroyGui failed for effect "' + effectId + '": ' + String(err));
4769
+ }
4770
+ }
4771
+ _ensureMasterChain() {
4772
+ if (this._masterChain) return this._masterChain;
4773
+ const { audioContext, transport } = this._requireWiring();
4774
+ const chain = new EffectsChainController(audioContext);
4775
+ transport.connectMasterOutput(chain.input);
4776
+ chain.output.connect(audioContext.destination);
4777
+ this._masterChain = chain;
4778
+ return chain;
4779
+ }
4780
+ _ensureTrackChain(trackId) {
4781
+ const existing = this._trackChains.get(trackId);
4782
+ if (existing) return existing;
4783
+ const { audioContext, transport } = this._requireWiring();
4784
+ const chain = new EffectsChainController(audioContext);
4785
+ transport.connectTrackOutput(trackId, chain.input);
4786
+ chain.output.connect(transport.masterOutputNode);
4787
+ this._trackChains.set(trackId, chain);
4788
+ return chain;
4789
+ }
4790
+ _addToChain(chain, target, type, params) {
4791
+ const audioContext = this._requireWiring().audioContext;
4792
+ const created = createEffectInstance(type, audioContext, params);
4793
+ const effectId = chain.add({
4794
+ kind: "native",
4795
+ type,
4796
+ instance: created.instance,
4797
+ params: created.params,
4798
+ wetParam: created.wetParam
4799
+ });
4800
+ const index = chain.entries.findIndex((e) => e.id === effectId);
4801
+ this._dispatch(target, "daw-effect-add", {
4802
+ effectId,
4803
+ kind: "native",
4804
+ type,
4805
+ params: { ...created.params },
4806
+ index
4807
+ });
4808
+ return effectId;
4809
+ }
4810
+ /**
4811
+ * Load a WAM plugin (via the optional @dawcore/wam peer dep) and add it to
4812
+ * a chain as a kind:'wam' entry. WAM entries participate in every chain
4813
+ * operation with no special-casing: remove destroys the plugin (via the
4814
+ * entry's dispose), bypass uses disconnection semantics (no wet param),
4815
+ * and setParams maps onto the plugin's setParameterValues.
4816
+ */
4817
+ async _addWamToChain(chain, target, url, initialState) {
4818
+ const { audioContext } = this._requireWiring();
4819
+ const wamModule = await this._loadWamModule("addWamPlugin()");
4820
+ const { hostGroupId } = await wamModule.ensureWamHost(audioContext);
4821
+ const plugin = await wamModule.createWamInstance(url, audioContext, hostGroupId, {
4822
+ initialState
4823
+ });
4824
+ return this._insertWamPlugin(chain, target, wamModule, plugin, { url });
4825
+ }
4826
+ /**
4827
+ * Compile Faust DSP source in the browser (via the optional @dawcore/faust
4828
+ * peer) and add the resulting WAM to a chain. Compilation happens BEFORE
4829
+ * any chain work, so a Faust error (with its line/column diagnostics intact)
4830
+ * leaves the chain untouched. The entry lands as kind:'wam' with a
4831
+ * `source: { faust }` marker so persistence recompiles instead of fetching.
4832
+ */
4833
+ async _addFaustToChain(chain, target, dspCode, opts) {
4834
+ if (typeof dspCode !== "string" || dspCode.trim().length === 0) {
4835
+ throw new Error(PREFIX2 + "addFaustEffect: dspCode must be a non-empty string");
4836
+ }
4837
+ this._requireWiring();
4838
+ const faustModule = await this._loadFaustModule("addFaustEffect()");
4839
+ const compiled = await faustModule.compileFaustToWam(dspCode, { name: opts.name });
4840
+ const { audioContext } = this._requireWiring();
4841
+ const wamModule = await this._loadWamModule("addFaustEffect()");
4842
+ const { hostGroupId } = await wamModule.ensureWamHost(audioContext);
4843
+ const plugin = await wamModule.createWamInstanceFromFactory(
4844
+ compiled.factory,
4845
+ audioContext,
4846
+ hostGroupId,
4847
+ { initialState: opts.initialState, label: compiled.name }
4848
+ );
4849
+ return this._insertWamPlugin(chain, target, wamModule, plugin, {
4850
+ source: { faust: compiled.dspCode }
4851
+ });
4852
+ }
4853
+ /**
4854
+ * Wire a live WAM plugin instance into a chain as a kind:'wam' entry —
4855
+ * shared by the url path (addWamPlugin) and the Faust path (addFaustEffect).
4856
+ * WAM entries participate in every chain operation with no special-casing:
4857
+ * remove destroys the plugin (via the entry's dispose), bypass uses
4858
+ * disconnection semantics (no wet param), and setParams maps onto the
4859
+ * plugin's setParameterValues.
4860
+ */
4861
+ _insertWamPlugin(chain, target, wamModule, plugin, meta) {
4862
+ const node = plugin.audioNode;
4863
+ const label = plugin.descriptor.name;
4864
+ if (chain.disposed) {
4865
+ plugin.destroy();
4866
+ throw new Error(
4867
+ PREFIX2 + 'addWamPlugin: the effects chain was disposed while "' + (meta.url ?? label) + '" was loading; the plugin was discarded.'
4868
+ );
4869
+ }
4870
+ let effectId;
4871
+ try {
4872
+ effectId = chain.add({
4873
+ kind: "wam",
4874
+ type: "wam",
4875
+ ...meta.url !== void 0 ? { url: meta.url } : {},
4876
+ ...meta.source !== void 0 ? { source: meta.source } : {},
4877
+ label,
4878
+ instance: {
4879
+ input: node,
4880
+ output: node,
4881
+ applyParams: (params) => {
4882
+ node.setParameterValues?.(toWamParameterMap(params))?.catch((err) => {
4883
+ console.warn(PREFIX2 + "WAM setParameterValues failed: " + String(err));
4884
+ });
4885
+ },
4886
+ dispose: () => {
4887
+ this._wamNodes.delete(node);
4888
+ plugin.destroy();
4889
+ },
4890
+ getState: () => plugin.getState(),
4891
+ getParameterInfo: () => plugin.getParameterInfo(),
4892
+ ...plugin.createGui ? { createGui: () => plugin.createGui() } : {},
4893
+ ...plugin.destroyGui ? { destroyGui: (gui) => plugin.destroyGui(gui) } : {}
4894
+ },
4895
+ params: {}
4896
+ });
4897
+ } catch (err) {
4898
+ try {
4899
+ plugin.destroy();
4900
+ } catch (destroyErr) {
4901
+ console.warn(
4902
+ PREFIX2 + "addWamPlugin: cleanup after failed insertion also failed: " + String(destroyErr)
4903
+ );
4904
+ }
4905
+ throw err;
4906
+ }
4907
+ this._wamNodes.add(node);
4908
+ this._ensureTransportBridge(wamModule)?.notifyNodeAdded(node);
4909
+ const index = chain.entries.findIndex((e) => e.id === effectId);
4910
+ this._dispatch(target, "daw-effect-add", {
4911
+ effectId,
4912
+ kind: "wam",
4913
+ type: "wam",
4914
+ ...meta.url !== void 0 ? { url: meta.url } : {},
4915
+ ...meta.source !== void 0 ? { source: meta.source } : {},
4916
+ params: {},
4917
+ index
4918
+ });
4919
+ return effectId;
4920
+ }
4921
+ /**
4922
+ * Lazily create the wam-transport bridge so tempo-synced plugins lock to
4923
+ * the timeline. Skipped (not an error) when the adapter's transport lacks
4924
+ * the query/event surface — the bridge is an enhancement, not a
4925
+ * requirement for audio processing.
4926
+ */
4927
+ _ensureTransportBridge(wamModule) {
4928
+ if (this._transportBridge) return this._transportBridge;
4929
+ if (typeof wamModule.createWamTransportBridge !== "function") return null;
4930
+ const transport = this._getAdapter()?.transport;
4931
+ if (!transport || typeof transport.on !== "function" || typeof transport.getTempo !== "function" || typeof transport.tickToBar !== "function" || typeof transport.timeToTick !== "function") {
4932
+ return null;
4933
+ }
4934
+ this._transportBridge = wamModule.createWamTransportBridge(transport, () => [
4935
+ ...this._wamNodes
4936
+ ]);
4937
+ return this._transportBridge;
4938
+ }
4939
+ /**
4940
+ * Replace a chain's contents with a persisted snapshot. Entries restore
4941
+ * sequentially so chain order survives async WAM loads. A WAM url that
4942
+ * fails to load becomes a bypassed passthrough placeholder at its saved
4943
+ * position — the restore continues, a daw-effect-error fires, and the
4944
+ * saved state is retained so a later snapshot/retry round-trips it.
4945
+ */
4946
+ async _restoreChain(chain, target, entries) {
4947
+ const token = /* @__PURE__ */ Symbol("restore");
4948
+ this._restoreTokens.set(chain, token);
4949
+ const superseded = () => this._restoreTokens.get(chain) !== token;
4950
+ for (const existing of chain.entries) {
4951
+ this._runOp(chain, target, "remove", existing.id);
4952
+ }
4953
+ for (const entry of entries) {
4954
+ if (superseded()) return;
4955
+ if (entry.kind === "native") {
4956
+ const id = this._addToChain(chain, target, entry.type, entry.params);
4957
+ if (entry.bypassed) {
4958
+ this._runOp(chain, target, "setBypassed", id, true);
4959
+ }
4960
+ continue;
4961
+ }
4962
+ try {
4963
+ const id = entry.faustDsp !== void 0 ? await this._addFaustToChain(chain, target, entry.faustDsp, {
4964
+ name: entry.faustName,
4965
+ initialState: entry.state
4966
+ }) : await this._addWamToChain(chain, target, entry.url ?? "", entry.state);
4967
+ if (superseded()) {
4968
+ this._runOp(chain, target, "remove", id);
4969
+ return;
4970
+ }
4971
+ if (entry.bypassed) {
4972
+ this._runOp(chain, target, "setBypassed", id, true);
4973
+ }
4974
+ } catch (err) {
4975
+ if (superseded()) return;
4976
+ const message = err instanceof Error ? err.message : String(err);
4977
+ const sourceLabel = entry.url ?? entry.faustName ?? "Faust effect";
4978
+ console.warn(
4979
+ PREFIX2 + 'setEffectsState: plugin "' + sourceLabel + '" failed to restore: ' + message
4980
+ );
4981
+ const effectId = this._addWamPlaceholder(chain, entry, message);
4982
+ this._dispatch(target, "daw-effect-error", {
4983
+ effectId,
4984
+ ...entry.url !== void 0 ? { url: entry.url } : {},
4985
+ ...entry.faustDsp !== void 0 ? { source: { faust: entry.faustDsp } } : {},
4986
+ message
4987
+ });
4988
+ }
4989
+ }
4990
+ }
4991
+ /** A silent passthrough occupying the failed plugin's chain position. */
4992
+ _addWamPlaceholder(chain, entry, message) {
4993
+ const { audioContext } = this._requireWiring();
4994
+ const node = audioContext.createGain();
4995
+ const effectId = chain.add({
4996
+ kind: "wam",
4997
+ type: "wam",
4998
+ ...entry.url !== void 0 ? { url: entry.url } : {},
4999
+ ...entry.faustDsp !== void 0 ? { source: { faust: entry.faustDsp } } : {},
5000
+ label: entry.url ?? entry.faustName ?? "Faust effect",
5001
+ error: message,
5002
+ placeholder: { state: entry.state, bypassed: entry.bypassed },
5003
+ instance: { input: node, output: node, applyParams: () => {
5004
+ } },
5005
+ params: {}
5006
+ });
5007
+ chain.setBypassed(effectId, true);
5008
+ return effectId;
5009
+ }
5010
+ _runOp(chain, target, op, effectId, arg) {
5011
+ const entries = chain?.entries ?? [];
5012
+ const fromIndex = entries.findIndex((e) => e.id === effectId);
5013
+ if (!chain || fromIndex === -1) {
5014
+ console.warn(PREFIX2 + "effects." + op + ': unknown effectId "' + effectId + '"');
5015
+ return;
5016
+ }
5017
+ if (entries[fromIndex].error !== void 0 && (op === "setParams" || op === "setBypassed")) {
5018
+ console.warn(
5019
+ PREFIX2 + "effects." + op + ': effect "' + effectId + '" is a failed-plugin placeholder (' + entries[fromIndex].error + ") \u2014 edit ignored. Remove it or retry the restore."
5020
+ );
5021
+ return;
5022
+ }
5023
+ switch (op) {
5024
+ case "remove":
5025
+ this._destroyGui(effectId);
5026
+ chain.remove(effectId);
5027
+ this._dispatch(target, "daw-effect-remove", { effectId });
5028
+ break;
5029
+ case "setParams":
5030
+ chain.setParams(effectId, arg);
5031
+ this._dispatch(target, "daw-effect-change", { effectId, params: { ...arg } });
5032
+ break;
5033
+ case "setBypassed":
5034
+ chain.setBypassed(effectId, arg);
5035
+ this._dispatch(target, "daw-effect-bypass", { effectId, bypassed: arg });
5036
+ break;
5037
+ case "move":
5038
+ chain.move(effectId, arg);
5039
+ this._dispatch(target, "daw-effect-reorder", { effectId, fromIndex, toIndex: arg });
5040
+ break;
5041
+ }
5042
+ }
5043
+ _dispatch(target, name, detail) {
5044
+ target.dispatchEvent(new CustomEvent(name, { bubbles: true, composed: true, detail }));
5045
+ }
5046
+ };
5047
+ function validateSerializedEntries(entries) {
5048
+ if (!Array.isArray(entries)) {
5049
+ throw new Error(PREFIX2 + "setEffectsState: expected an array of serialized effect entries");
5050
+ }
5051
+ entries.forEach((entry, i) => {
5052
+ const at = " (entry " + i + ")";
5053
+ if (entry === null || typeof entry !== "object") {
5054
+ throw new Error(PREFIX2 + "setEffectsState: entry must be an object" + at);
5055
+ }
5056
+ const e = entry;
5057
+ if (e.kind === "native") {
5058
+ if (typeof e.type !== "string" || e.type.length === 0) {
5059
+ throw new Error(PREFIX2 + "setEffectsState: native entry requires a type string" + at);
5060
+ }
5061
+ if (e.params === null || typeof e.params !== "object") {
5062
+ throw new Error(PREFIX2 + "setEffectsState: native entry requires a params object" + at);
5063
+ }
5064
+ } else if (e.kind === "wam") {
5065
+ const hasUrl = typeof e.url === "string" && e.url.length > 0;
5066
+ const hasFaustDsp = typeof e.faustDsp === "string" && e.faustDsp.trim().length > 0;
5067
+ if (!hasUrl && !hasFaustDsp) {
5068
+ throw new Error(
5069
+ PREFIX2 + "setEffectsState: wam entry requires a url string or a faustDsp source string" + at
5070
+ );
5071
+ }
5072
+ if (e.faustName !== void 0 && typeof e.faustName !== "string") {
5073
+ throw new Error(PREFIX2 + "setEffectsState: faustName must be a string when provided" + at);
5074
+ }
5075
+ } else {
5076
+ throw new Error(PREFIX2 + 'setEffectsState: unknown entry kind "' + String(e.kind) + '"' + at);
5077
+ }
5078
+ if (typeof e.bypassed !== "boolean") {
5079
+ throw new Error(PREFIX2 + "setEffectsState: entry requires a boolean bypassed flag" + at);
5080
+ }
5081
+ });
5082
+ }
5083
+ function toWamParameterMap(params) {
5084
+ const map = {};
5085
+ for (const [id, value] of Object.entries(params)) {
5086
+ map[id] = { id, value, normalized: false };
5087
+ }
5088
+ return map;
5089
+ }
5090
+
5091
+ // src/interactions/export-audio.ts
5092
+ var PREFIX3 = "[waveform-playlist] ";
5093
+ async function exportAudioImpl(host, options = {}) {
5094
+ const sampleRate = options.sampleRate ?? host.effectiveSampleRate;
5095
+ const startTime = options.startTime ?? 0;
5096
+ const duration = options.duration ?? Math.max(0, host.duration - startTime);
5097
+ const channels = options.channels ?? 2;
5098
+ if (!Number.isFinite(sampleRate) || sampleRate <= 0) {
5099
+ throw new Error(PREFIX3 + "exportAudio: invalid sampleRate " + String(sampleRate));
5100
+ }
5101
+ if (!Number.isFinite(duration) || duration <= 0) {
5102
+ throw new Error(PREFIX3 + "exportAudio: nothing to render (duration " + String(duration) + ")");
5103
+ }
5104
+ const ctx = new OfflineAudioContext({
5105
+ numberOfChannels: channels,
5106
+ length: Math.round(duration * sampleRate),
5107
+ sampleRate
5108
+ });
5109
+ const cleanups = [];
5110
+ try {
5111
+ const masterChain = await buildOfflineChain(ctx, await host.getMasterEffectsState());
5112
+ cleanups.push(masterChain.dispose);
5113
+ masterChain.output.connect(ctx.destination);
5114
+ const anySoloed = host.tracks.some((t) => t.soloed);
5115
+ for (const track of host.tracks) {
5116
+ if (track.muted || anySoloed && !track.soloed) {
5117
+ continue;
5118
+ }
5119
+ const chain = await buildOfflineChain(ctx, await host.getTrackEffectsState(track.id));
5120
+ cleanups.push(chain.dispose);
5121
+ const volume = ctx.createGain();
5122
+ volume.gain.value = track.volume;
5123
+ const panner = ctx.createStereoPanner();
5124
+ panner.pan.value = track.pan;
5125
+ volume.connect(panner);
5126
+ panner.connect(chain.input);
5127
+ chain.output.connect(masterChain.input);
5128
+ for (const clip of track.clips) {
5129
+ scheduleClip(ctx, clip, startTime, duration, volume);
5130
+ }
5131
+ }
5132
+ return await ctx.startRendering();
5133
+ } finally {
5134
+ for (const dispose of cleanups) {
5135
+ try {
5136
+ dispose();
5137
+ } catch (err) {
5138
+ console.warn(PREFIX3 + "exportAudio: cleanup error: " + String(err));
5139
+ }
5140
+ }
5141
+ }
5142
+ }
5143
+ function scheduleClip(ctx, clip, windowStart, windowDuration, destination) {
5144
+ if (!clip.audioBuffer || clip.durationSamples <= 0) {
5145
+ return;
5146
+ }
5147
+ const clipRate = clip.sampleRate;
5148
+ const clipStart = clip.startSample / clipRate;
5149
+ const clipDuration = clip.durationSamples / clipRate;
5150
+ let offset = clip.offsetSamples / clipRate;
5151
+ let when = clipStart - windowStart;
5152
+ let remaining = clipDuration;
5153
+ if (when < 0) {
5154
+ offset += -when;
5155
+ remaining += when;
5156
+ when = 0;
5157
+ }
5158
+ remaining = Math.min(remaining, windowDuration - when);
5159
+ if (remaining <= 0) {
5160
+ return;
5161
+ }
5162
+ const source = ctx.createBufferSource();
5163
+ source.buffer = clip.audioBuffer;
5164
+ let out = source;
5165
+ if (clip.gain !== void 0 && clip.gain !== 1) {
5166
+ const gainNode = ctx.createGain();
5167
+ gainNode.gain.value = clip.gain;
5168
+ out.connect(gainNode);
5169
+ out = gainNode;
5170
+ }
5171
+ out.connect(destination);
5172
+ source.start(when, offset, remaining);
5173
+ }
5174
+ async function buildOfflineChain(ctx, entries) {
5175
+ const input = ctx.createGain();
5176
+ const output = ctx.createGain();
5177
+ const cleanups = [];
5178
+ const dispose = () => {
5179
+ for (const cleanup of cleanups) {
5180
+ try {
5181
+ cleanup();
5182
+ } catch (err) {
5183
+ console.warn(PREFIX3 + "exportAudio: chain cleanup error: " + String(err));
5184
+ }
5185
+ }
5186
+ };
5187
+ let previous = input;
5188
+ try {
5189
+ await wireEntries();
5190
+ } catch (err) {
5191
+ dispose();
5192
+ throw err;
5193
+ }
5194
+ previous.connect(output);
5195
+ return { input, output, dispose };
5196
+ async function wireEntries() {
5197
+ for (const entry of entries) {
5198
+ if (entry.kind === "native") {
5199
+ const created = createEffectInstance(entry.type, ctx, entry.params);
5200
+ if (entry.bypassed) {
5201
+ if (!created.wetParam) {
5202
+ created.instance.dispose?.();
5203
+ continue;
5204
+ }
5205
+ created.instance.applyParams({ [created.wetParam]: 0 });
5206
+ }
5207
+ if (created.instance.dispose) {
5208
+ cleanups.push(created.instance.dispose);
5209
+ }
5210
+ previous.connect(created.instance.input);
5211
+ previous = created.instance.output;
5212
+ continue;
5213
+ }
5214
+ if (entry.bypassed) {
5215
+ continue;
5216
+ }
5217
+ const wamModule = await loadWamModule("exportAudio() with WAM effects");
5218
+ const { hostGroupId } = await wamModule.ensureWamHost(ctx);
5219
+ let plugin;
5220
+ if (entry.faustDsp !== void 0) {
5221
+ const faustModule = await loadFaustModule("exportAudio() with Faust effects");
5222
+ const compiled = await faustModule.compileFaustToWam(entry.faustDsp, {
5223
+ name: entry.faustName
5224
+ });
5225
+ plugin = await wamModule.createWamInstanceFromFactory(
5226
+ compiled.factory,
5227
+ ctx,
5228
+ hostGroupId,
5229
+ { initialState: entry.state, label: compiled.name }
5230
+ );
5231
+ } else {
5232
+ plugin = await wamModule.createWamInstance(entry.url ?? "", ctx, hostGroupId, {
5233
+ initialState: entry.state
5234
+ });
5235
+ }
5236
+ cleanups.push(() => plugin.destroy());
5237
+ previous.connect(plugin.audioNode);
5238
+ previous = plugin.audioNode;
5239
+ }
5240
+ }
5241
+ }
5242
+
4099
5243
  // src/interactions/peaks-loader.ts
4100
5244
  import WaveformData2 from "waveform-data";
4101
5245
  async function loadWaveformDataFromUrl(src) {
@@ -4286,6 +5430,8 @@ var DawEditorElement = class extends LitElement11 {
4286
5430
  this._selectionEndTime = 0;
4287
5431
  this._currentTime = 0;
4288
5432
  this._externalAdapter = null;
5433
+ // --- Effects (master chain; per-track chains delegated from <daw-track>) ---
5434
+ this._effectsManager = null;
4289
5435
  this._engine = null;
4290
5436
  this._warnedMissingTicksToSeconds = false;
4291
5437
  this._warnedMissingSecondsToTicks = false;
@@ -4643,11 +5789,122 @@ var DawEditorElement = class extends LitElement11 {
4643
5789
  "[dawcore] adapter set after engine is built. The engine will continue using the previous adapter."
4644
5790
  );
4645
5791
  }
5792
+ if (this._effectsManager) {
5793
+ console.warn(
5794
+ "[dawcore] adapter replaced \u2014 existing effect chains were disposed. Re-add effects on the new adapter."
5795
+ );
5796
+ this._effectsManager.disposeAll();
5797
+ this._effectsManager = null;
5798
+ }
4646
5799
  this._externalAdapter = value;
4647
5800
  }
4648
5801
  get adapter() {
4649
5802
  return this._externalAdapter;
4650
5803
  }
5804
+ get _effects() {
5805
+ if (!this._effectsManager) {
5806
+ this._effectsManager = new EffectsManager(() => this._externalAdapter, this);
5807
+ }
5808
+ return this._effectsManager;
5809
+ }
5810
+ /** Master effects chain — inserted between the master bus and the destination. */
5811
+ addEffect(type, params) {
5812
+ return this._effects.addMasterEffect(type, params);
5813
+ }
5814
+ removeEffect(effectId) {
5815
+ this._effects.masterOp("remove", effectId);
5816
+ }
5817
+ setEffectParams(effectId, params) {
5818
+ this._effects.masterOp("setParams", effectId, params);
5819
+ }
5820
+ setEffectBypassed(effectId, bypassed) {
5821
+ this._effects.masterOp("setBypassed", effectId, bypassed);
5822
+ }
5823
+ moveEffect(effectId, newIndex) {
5824
+ this._effects.masterOp("move", effectId, newIndex);
5825
+ }
5826
+ get effects() {
5827
+ return this._effectsManager?.masterEffects() ?? [];
5828
+ }
5829
+ /** Load a WAM plugin (via the optional @dawcore/wam peer) into the master chain. */
5830
+ addWamPlugin(url, initialState) {
5831
+ return this._effects.addMasterWamPlugin(url, initialState);
5832
+ }
5833
+ /**
5834
+ * Compile Faust DSP source in the browser (via the optional @dawcore/faust
5835
+ * peer) and add the resulting WAM to the master chain. Compile errors keep
5836
+ * their Faust line/column diagnostics and leave the chain untouched.
5837
+ */
5838
+ addFaustEffect(dspCode, options) {
5839
+ return this._effects.addMasterFaustEffect(dspCode, options);
5840
+ }
5841
+ /**
5842
+ * Open (lazily creating) the GUI for a master-chain effect into a
5843
+ * consumer-provided container. WAM plugins mount their own GUI; plugins
5844
+ * without one — and native effects — get the generic parameter panel from
5845
+ * @dawcore/wam. The element is cached: closeEffectGui hides it without
5846
+ * interrupting audio, reopening remounts the same element.
5847
+ */
5848
+ openEffectGui(effectId, container) {
5849
+ return this._effects.openMasterEffectGui(effectId, container);
5850
+ }
5851
+ /** Hide a master-chain effect's GUI (cached for reopen — never destroys). */
5852
+ closeEffectGui(effectId) {
5853
+ this._effects.closeEffectGui(effectId);
5854
+ }
5855
+ /** Snapshot the master chain in its persisted form (see dawcore README). */
5856
+ getEffectsState() {
5857
+ return this._effectsManager?.getMasterEffectsState() ?? Promise.resolve([]);
5858
+ }
5859
+ /** Replace the master chain with a persisted snapshot. */
5860
+ setEffectsState(entries) {
5861
+ return this._effects.setMasterEffectsState(entries);
5862
+ }
5863
+ /**
5864
+ * Render the session offline through all effect chains (per-track +
5865
+ * master), including WAM plugins (re-instantiated on the offline context
5866
+ * with their live state). Returns the rendered AudioBuffer.
5867
+ */
5868
+ exportAudio(options) {
5869
+ return exportAudioImpl(
5870
+ {
5871
+ effectiveSampleRate: this.effectiveSampleRate,
5872
+ duration: this._duration,
5873
+ tracks: [...this._engineTracks.values()],
5874
+ getMasterEffectsState: () => this.getEffectsState(),
5875
+ getTrackEffectsState: (trackId) => this._trackGetEffectsState(trackId)
5876
+ },
5877
+ options
5878
+ );
5879
+ }
5880
+ /** Internal — <daw-track> effects API delegates here (dawcore-internal contract). */
5881
+ _trackAddEffect(trackId, target, type, params) {
5882
+ return this._effects.addTrackEffect(trackId, target, type, params);
5883
+ }
5884
+ _trackEffectOp(trackId, target, op, effectId, arg) {
5885
+ this._effects.trackOp(trackId, target, op, effectId, arg);
5886
+ }
5887
+ _trackEffects(trackId) {
5888
+ return this._effectsManager?.trackEffects(trackId) ?? [];
5889
+ }
5890
+ _trackAddWamPlugin(trackId, target, url, initialState) {
5891
+ return this._effects.addTrackWamPlugin(trackId, target, url, initialState);
5892
+ }
5893
+ _trackAddFaustEffect(trackId, target, dspCode, options) {
5894
+ return this._effects.addTrackFaustEffect(trackId, target, dspCode, options);
5895
+ }
5896
+ _trackOpenEffectGui(trackId, target, effectId, container) {
5897
+ return this._effects.openTrackEffectGui(trackId, target, effectId, container);
5898
+ }
5899
+ _trackCloseEffectGui(_trackId, effectId) {
5900
+ this._effects.closeEffectGui(effectId);
5901
+ }
5902
+ _trackGetEffectsState(trackId) {
5903
+ return this._effectsManager?.getTrackEffectsState(trackId) ?? Promise.resolve([]);
5904
+ }
5905
+ _trackSetEffectsState(trackId, target, entries) {
5906
+ return this._effects.setTrackEffectsState(trackId, target, entries);
5907
+ }
4651
5908
  get audioContext() {
4652
5909
  if (!this._externalAdapter) {
4653
5910
  throw new Error(NO_ADAPTER_ERROR);
@@ -4819,6 +6076,8 @@ var DawEditorElement = class extends LitElement11 {
4819
6076
  }
4820
6077
  disconnectedCallback() {
4821
6078
  super.disconnectedCallback();
6079
+ this._effectsManager?.disposeAll();
6080
+ this._effectsManager = null;
4822
6081
  this.removeEventListener("daw-track-connected", this._onTrackConnected);
4823
6082
  this.removeEventListener("daw-track-update", this._onTrackUpdate);
4824
6083
  this.removeEventListener("daw-track-control", this._onTrackControl);
@@ -4894,6 +6153,7 @@ var DawEditorElement = class extends LitElement11 {
4894
6153
  }
4895
6154
  _onTrackRemoved(trackId) {
4896
6155
  this._trackElements.delete(trackId);
6156
+ this._effectsManager?.disposeTrackChain(trackId);
4897
6157
  const removedTrack = this._engineTracks.get(trackId);
4898
6158
  if (removedTrack) {
4899
6159
  const nextPeaks = new Map(this._peaksData);
@@ -5544,6 +6804,7 @@ var DawEditorElement = class extends LitElement11 {
5544
6804
  nextTracks.set(track.id, track);
5545
6805
  }
5546
6806
  this._engineTracks = nextTracks;
6807
+ this._effectsManager?.rewireTrackChains();
5547
6808
  const audioTracks = engineState.tracks.filter((t) => {
5548
6809
  const desc = this._tracks.get(t.id);
5549
6810
  return desc?.renderMode !== "piano-roll";
@@ -7019,7 +8280,10 @@ export {
7019
8280
  DawWaveformElement,
7020
8281
  RecordingController,
7021
8282
  SpectrogramController,
8283
+ createEffectInstance,
8284
+ getEffectDefinitions,
7022
8285
  isDomClip,
8286
+ registerEffect,
7023
8287
  splitAtPlayhead
7024
8288
  };
7025
8289
  //# sourceMappingURL=index.mjs.map