@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.js CHANGED
@@ -60,7 +60,10 @@ __export(index_exports, {
60
60
  DawWaveformElement: () => DawWaveformElement,
61
61
  RecordingController: () => RecordingController,
62
62
  SpectrogramController: () => SpectrogramController,
63
+ createEffectInstance: () => createEffectInstance,
64
+ getEffectDefinitions: () => getEffectDefinitions,
63
65
  isDomClip: () => isDomClip,
66
+ registerEffect: () => registerEffect,
64
67
  splitAtPlayhead: () => splitAtPlayhead
65
68
  });
66
69
  module.exports = __toCommonJS(index_exports);
@@ -257,6 +260,68 @@ var DawTrackElement = class extends import_lit2.LitElement {
257
260
  createRenderRoot() {
258
261
  return this;
259
262
  }
263
+ // --- Effects API (delegates to the owning <daw-editor>) ---
264
+ addEffect(type, params) {
265
+ return this._effectsEditor()._trackAddEffect(this.trackId, this, type, params);
266
+ }
267
+ /** Load a WAM plugin (via the optional @dawcore/wam peer) into this track's chain. */
268
+ addWamPlugin(url, initialState) {
269
+ return this._effectsEditor()._trackAddWamPlugin(this.trackId, this, url, initialState);
270
+ }
271
+ /**
272
+ * Compile Faust DSP source in the browser (via the optional @dawcore/faust
273
+ * peer) and add the resulting WAM to this track's chain. Compile errors
274
+ * keep their Faust line/column diagnostics and leave the chain untouched.
275
+ */
276
+ addFaustEffect(dspCode, options) {
277
+ return this._effectsEditor()._trackAddFaustEffect(this.trackId, this, dspCode, options);
278
+ }
279
+ /** Snapshot this track's chain in its persisted form (see dawcore README). */
280
+ getEffectsState() {
281
+ const editor = this.closest("daw-editor");
282
+ return editor?._trackGetEffectsState(this.trackId) ?? Promise.resolve([]);
283
+ }
284
+ /** Replace this track's chain with a persisted snapshot. */
285
+ setEffectsState(entries) {
286
+ return this._effectsEditor()._trackSetEffectsState(this.trackId, this, entries);
287
+ }
288
+ /**
289
+ * Open (lazily creating) the GUI for one of this track's effects into a
290
+ * consumer-provided container. Closing hides without interrupting audio;
291
+ * the element is cached for reopen. See <daw-editor>.openEffectGui.
292
+ */
293
+ openEffectGui(effectId, container) {
294
+ return this._effectsEditor()._trackOpenEffectGui(this.trackId, this, effectId, container);
295
+ }
296
+ /** Hide an effect's GUI (cached for reopen — never destroys). */
297
+ closeEffectGui(effectId) {
298
+ this._effectsEditor()._trackCloseEffectGui(this.trackId, effectId);
299
+ }
300
+ removeEffect(effectId) {
301
+ this._effectsEditor()._trackEffectOp(this.trackId, this, "remove", effectId);
302
+ }
303
+ setEffectParams(effectId, params) {
304
+ this._effectsEditor()._trackEffectOp(this.trackId, this, "setParams", effectId, params);
305
+ }
306
+ setEffectBypassed(effectId, bypassed) {
307
+ this._effectsEditor()._trackEffectOp(this.trackId, this, "setBypassed", effectId, bypassed);
308
+ }
309
+ moveEffect(effectId, newIndex) {
310
+ this._effectsEditor()._trackEffectOp(this.trackId, this, "move", effectId, newIndex);
311
+ }
312
+ get effects() {
313
+ const editor = this.closest("daw-editor");
314
+ return editor?._trackEffects(this.trackId) ?? [];
315
+ }
316
+ _effectsEditor() {
317
+ const editor = this.closest("daw-editor");
318
+ if (!editor) {
319
+ throw new Error(
320
+ "[waveform-playlist] <daw-track> effects API requires the track to be inside a <daw-editor>"
321
+ );
322
+ }
323
+ return editor;
324
+ }
260
325
  connectedCallback() {
261
326
  super.connectedCallback();
262
327
  setTimeout(() => {
@@ -1437,7 +1502,7 @@ function createPeaksWorker() {
1437
1502
  }
1438
1503
  const pending = /* @__PURE__ */ new Map();
1439
1504
  let terminated = false;
1440
- let idCounter = 0;
1505
+ let idCounter2 = 0;
1441
1506
  worker.onmessage = (e) => {
1442
1507
  const msg = e.data;
1443
1508
  const entry = pending.get(msg.id);
@@ -1470,7 +1535,7 @@ function createPeaksWorker() {
1470
1535
  return {
1471
1536
  generate(params) {
1472
1537
  if (terminated) return Promise.reject(new Error("Worker terminated"));
1473
- const messageId = String(++idCounter);
1538
+ const messageId = String(++idCounter2);
1474
1539
  return new Promise((resolve, reject) => {
1475
1540
  pending.set(messageId, { resolve, reject });
1476
1541
  worker.postMessage(
@@ -4142,6 +4207,1088 @@ function findAudioBufferForClip(host, clip, track) {
4142
4207
  return null;
4143
4208
  }
4144
4209
 
4210
+ // src/effects/effects-chain-controller.ts
4211
+ var idCounter = 0;
4212
+ var EffectsChainController = class {
4213
+ constructor(audioContext) {
4214
+ this._entries = [];
4215
+ this._disposed = false;
4216
+ this._input = audioContext.createGain();
4217
+ this._output = audioContext.createGain();
4218
+ this._input.connect(this._output);
4219
+ }
4220
+ /** Connect the upstream source (track mute node, master gain) into this. */
4221
+ get input() {
4222
+ return this._input;
4223
+ }
4224
+ /** Route this onward (to the master bus / destination). */
4225
+ get output() {
4226
+ return this._output;
4227
+ }
4228
+ get entries() {
4229
+ return this._entries.map((entry) => ({
4230
+ id: entry.id,
4231
+ kind: entry.kind,
4232
+ type: entry.type,
4233
+ params: { ...entry.params },
4234
+ bypassed: entry.bypassed,
4235
+ ...entry.url !== void 0 ? { url: entry.url } : {},
4236
+ ...entry.source !== void 0 ? { source: { ...entry.source } } : {},
4237
+ ...entry.label !== void 0 ? { label: entry.label } : {},
4238
+ ...entry.error !== void 0 ? { error: entry.error } : {}
4239
+ }));
4240
+ }
4241
+ /** Snapshot the chain in its persisted form. WAM entries are asked for
4242
+ * their live state; placeholders re-emit the state they were saved with. */
4243
+ serialize() {
4244
+ return Promise.all(
4245
+ this._entries.map(async (entry) => {
4246
+ if (entry.kind === "wam") {
4247
+ const sourceFields = entry.source?.faust !== void 0 ? {
4248
+ faustDsp: entry.source.faust,
4249
+ ...entry.label !== void 0 ? { faustName: entry.label } : {}
4250
+ } : { url: entry.url ?? "" };
4251
+ if (entry.placeholder) {
4252
+ return {
4253
+ kind: "wam",
4254
+ ...sourceFields,
4255
+ bypassed: entry.placeholder.bypassed,
4256
+ ...entry.placeholder.state !== void 0 ? { state: entry.placeholder.state } : {}
4257
+ };
4258
+ }
4259
+ let state5;
4260
+ try {
4261
+ state5 = await entry.instance.getState?.();
4262
+ } catch (err) {
4263
+ console.warn(
4264
+ '[waveform-playlist] serialize: plugin "' + (entry.url ?? entry.label ?? entry.type) + '" getState failed: ' + String(err)
4265
+ );
4266
+ }
4267
+ return {
4268
+ kind: "wam",
4269
+ ...sourceFields,
4270
+ bypassed: entry.bypassed,
4271
+ ...state5 !== void 0 ? { state: state5 } : {}
4272
+ };
4273
+ }
4274
+ return {
4275
+ kind: "native",
4276
+ type: entry.type,
4277
+ params: { ...entry.params },
4278
+ bypassed: entry.bypassed
4279
+ };
4280
+ })
4281
+ );
4282
+ }
4283
+ get disposed() {
4284
+ return this._disposed;
4285
+ }
4286
+ /** Internal (manager-facing): the live entry — including its instance —
4287
+ * for GUI wiring. Returns undefined (no warning) when the id is unknown. */
4288
+ getEntry(effectId) {
4289
+ return this._entries.find((e) => e.id === effectId);
4290
+ }
4291
+ add(item, index) {
4292
+ if (this._disposed) {
4293
+ throw new Error(
4294
+ "[waveform-playlist] EffectsChainController.add: chain is disposed \u2014 entries cannot be added"
4295
+ );
4296
+ }
4297
+ const id = "effect-" + ++idCounter;
4298
+ const entry = { ...item, params: { ...item.params }, id, bypassed: false };
4299
+ const at = index === void 0 ? this._entries.length : Math.max(0, Math.min(index, this._entries.length));
4300
+ this._entries = [...this._entries.slice(0, at), entry, ...this._entries.slice(at)];
4301
+ this._rebuild();
4302
+ return id;
4303
+ }
4304
+ remove(effectId) {
4305
+ const entry = this._find("remove", effectId);
4306
+ if (!entry) return;
4307
+ this._entries = this._entries.filter((e) => e.id !== effectId);
4308
+ entry.instance.output.disconnect();
4309
+ entry.instance.dispose?.();
4310
+ this._rebuild();
4311
+ }
4312
+ move(effectId, newIndex) {
4313
+ const entry = this._find("move", effectId);
4314
+ if (!entry) return;
4315
+ const without = this._entries.filter((e) => e.id !== effectId);
4316
+ const at = Math.max(0, Math.min(newIndex, without.length));
4317
+ this._entries = [...without.slice(0, at), entry, ...without.slice(at)];
4318
+ this._rebuild();
4319
+ }
4320
+ setParams(effectId, params) {
4321
+ const entry = this._find("setParams", effectId);
4322
+ if (!entry) return;
4323
+ entry.params = { ...entry.params, ...params };
4324
+ if (entry.bypassed && entry.wetParam && entry.wetParam in params) {
4325
+ const { [entry.wetParam]: _stored, ...rest } = params;
4326
+ if (Object.keys(rest).length > 0) {
4327
+ entry.instance.applyParams(rest);
4328
+ }
4329
+ return;
4330
+ }
4331
+ entry.instance.applyParams(params);
4332
+ }
4333
+ setBypassed(effectId, bypassed) {
4334
+ const entry = this._find("setBypassed", effectId);
4335
+ if (!entry || entry.bypassed === bypassed) return;
4336
+ entry.bypassed = bypassed;
4337
+ if (entry.wetParam) {
4338
+ const wet = bypassed ? 0 : entry.params[entry.wetParam] ?? 0;
4339
+ entry.instance.applyParams({ [entry.wetParam]: wet });
4340
+ return;
4341
+ }
4342
+ this._rebuild();
4343
+ }
4344
+ dispose() {
4345
+ if (this._disposed) return;
4346
+ this._disposed = true;
4347
+ this._severOwnEdges();
4348
+ for (const entry of this._entries) {
4349
+ entry.instance.dispose?.();
4350
+ }
4351
+ this._entries = [];
4352
+ try {
4353
+ this._output.disconnect();
4354
+ } catch {
4355
+ }
4356
+ }
4357
+ _find(op, effectId) {
4358
+ const entry = this._entries.find((e) => e.id === effectId);
4359
+ if (!entry) {
4360
+ console.warn(
4361
+ "[waveform-playlist] EffectsChainController." + op + ': unknown effectId "' + effectId + '"'
4362
+ );
4363
+ }
4364
+ return entry;
4365
+ }
4366
+ /** Sever only this chain's outgoing edges — never the consumer's. */
4367
+ _severOwnEdges() {
4368
+ this._input.disconnect();
4369
+ for (const entry of this._entries) {
4370
+ entry.instance.output.disconnect();
4371
+ }
4372
+ }
4373
+ _rebuild() {
4374
+ this._severOwnEdges();
4375
+ let previous = this._input;
4376
+ for (const entry of this._entries) {
4377
+ if (entry.bypassed && !entry.wetParam) {
4378
+ continue;
4379
+ }
4380
+ previous.connect(entry.instance.input);
4381
+ previous = entry.instance.output;
4382
+ }
4383
+ previous.connect(this._output);
4384
+ }
4385
+ };
4386
+
4387
+ // src/effects/optional-modules.ts
4388
+ var PREFIX = "[waveform-playlist] ";
4389
+ async function loadOptionalModule(importer, packageName, feature) {
4390
+ try {
4391
+ return await importer();
4392
+ } catch (originalErr) {
4393
+ console.warn(PREFIX + packageName + " dynamic import failed: " + String(originalErr));
4394
+ throw new Error(
4395
+ PREFIX + packageName + " is required for " + feature + ". Install with: npm install " + packageName + " (import failed: " + String(originalErr) + ")"
4396
+ );
4397
+ }
4398
+ }
4399
+ function loadWamModule(feature) {
4400
+ return loadOptionalModule(() => import("@dawcore/wam"), "@dawcore/wam", feature);
4401
+ }
4402
+ function loadFaustModule(feature) {
4403
+ return loadOptionalModule(() => import("@dawcore/faust"), "@dawcore/faust", feature);
4404
+ }
4405
+
4406
+ // src/effects/effect-registry.ts
4407
+ var MAX_DELAY_SECONDS = 10;
4408
+ var registry = /* @__PURE__ */ new Map();
4409
+ function registerEffect(type, definition) {
4410
+ if (registry.has(type)) {
4411
+ console.warn(
4412
+ '[waveform-playlist] registerEffect: overwriting existing effect type "' + type + '"'
4413
+ );
4414
+ }
4415
+ registry.set(type, definition);
4416
+ }
4417
+ function getEffectDefinitions() {
4418
+ return new Map(registry);
4419
+ }
4420
+ function createEffectInstance(type, audioContext, params = {}) {
4421
+ const definition = registry.get(type);
4422
+ if (!definition) {
4423
+ throw new Error(
4424
+ '[waveform-playlist] createEffectInstance: unknown effect type "' + type + '". Available types: ' + [...registry.keys()].join(", ")
4425
+ );
4426
+ }
4427
+ const merged = { ...definition.defaults, ...params };
4428
+ const instance = definition.create(audioContext, merged);
4429
+ instance.applyParams(merged);
4430
+ return { instance, params: merged, wetParam: definition.wetParam };
4431
+ }
4432
+ function singleNode(node, applyParams) {
4433
+ return { input: node, output: node, applyParams };
4434
+ }
4435
+ function registerBuiltIns() {
4436
+ registry.set("native-gain", {
4437
+ label: "Gain",
4438
+ category: "dynamics",
4439
+ defaults: { gain: 1 },
4440
+ params: { gain: { min: 0, max: 2, step: 0.01 } },
4441
+ create: (ctx) => {
4442
+ const node = ctx.createGain();
4443
+ return singleNode(node, (p) => {
4444
+ if (p.gain !== void 0) node.gain.value = p.gain;
4445
+ });
4446
+ }
4447
+ });
4448
+ registry.set("native-filter", {
4449
+ label: "Lowpass Filter",
4450
+ category: "filter",
4451
+ defaults: { frequency: 1e3, q: 1 },
4452
+ params: {
4453
+ frequency: { min: 20, max: 2e4, step: 1, unit: "Hz" },
4454
+ q: { min: 0.1, max: 20, step: 0.1 }
4455
+ },
4456
+ create: (ctx) => {
4457
+ const node = ctx.createBiquadFilter();
4458
+ node.type = "lowpass";
4459
+ return singleNode(node, (p) => {
4460
+ if (p.frequency !== void 0) node.frequency.value = p.frequency;
4461
+ if (p.q !== void 0) node.Q.value = p.q;
4462
+ });
4463
+ }
4464
+ });
4465
+ registry.set("native-compressor", {
4466
+ label: "Compressor",
4467
+ category: "dynamics",
4468
+ defaults: { threshold: -24, knee: 30, ratio: 12, attack: 3e-3, release: 0.25 },
4469
+ params: {
4470
+ threshold: { min: -60, max: 0, step: 1, unit: "dB" },
4471
+ knee: { min: 0, max: 40, step: 1, unit: "dB" },
4472
+ ratio: { min: 1, max: 20, step: 0.5 },
4473
+ attack: { min: 0, max: 1, step: 1e-3, unit: "s" },
4474
+ release: { min: 0, max: 1, step: 0.01, unit: "s" }
4475
+ },
4476
+ create: (ctx) => {
4477
+ const node = ctx.createDynamicsCompressor();
4478
+ return singleNode(node, (p) => {
4479
+ if (p.threshold !== void 0) node.threshold.value = p.threshold;
4480
+ if (p.knee !== void 0) node.knee.value = p.knee;
4481
+ if (p.ratio !== void 0) node.ratio.value = p.ratio;
4482
+ if (p.attack !== void 0) node.attack.value = p.attack;
4483
+ if (p.release !== void 0) node.release.value = p.release;
4484
+ });
4485
+ }
4486
+ });
4487
+ registry.set("native-stereo-panner", {
4488
+ label: "Stereo Panner",
4489
+ category: "spatial",
4490
+ defaults: { pan: 0 },
4491
+ params: { pan: { min: -1, max: 1, step: 0.01 } },
4492
+ create: (ctx) => {
4493
+ const node = ctx.createStereoPanner();
4494
+ return singleNode(node, (p) => {
4495
+ if (p.pan !== void 0) node.pan.value = p.pan;
4496
+ });
4497
+ }
4498
+ });
4499
+ registry.set("native-delay", {
4500
+ label: "Delay",
4501
+ category: "delay",
4502
+ defaults: { delayTime: 0.25, feedback: 0.4, wet: 0.35 },
4503
+ params: {
4504
+ delayTime: { min: 0, max: MAX_DELAY_SECONDS, step: 0.01, unit: "s" },
4505
+ feedback: { min: 0, max: 0.95, step: 0.01 },
4506
+ wet: { min: 0, max: 1, step: 0.01 }
4507
+ },
4508
+ wetParam: "wet",
4509
+ create: (ctx) => {
4510
+ const input = ctx.createGain();
4511
+ const output = ctx.createGain();
4512
+ const delay = ctx.createDelay(MAX_DELAY_SECONDS);
4513
+ const feedback = ctx.createGain();
4514
+ const wet = ctx.createGain();
4515
+ const dry = ctx.createGain();
4516
+ input.connect(dry);
4517
+ dry.connect(output);
4518
+ input.connect(delay);
4519
+ delay.connect(feedback);
4520
+ feedback.connect(delay);
4521
+ delay.connect(wet);
4522
+ wet.connect(output);
4523
+ return {
4524
+ input,
4525
+ output,
4526
+ applyParams: (p) => {
4527
+ if (p.delayTime !== void 0) delay.delayTime.value = p.delayTime;
4528
+ if (p.feedback !== void 0) feedback.gain.value = p.feedback;
4529
+ if (p.wet !== void 0) {
4530
+ wet.gain.value = p.wet;
4531
+ dry.gain.value = 1 - p.wet;
4532
+ }
4533
+ }
4534
+ };
4535
+ }
4536
+ });
4537
+ }
4538
+ registerBuiltIns();
4539
+
4540
+ // src/effects/effects-manager.ts
4541
+ var PREFIX2 = "[waveform-playlist] ";
4542
+ function makeGuiRecord(element, destroyImpl) {
4543
+ let destroyed = false;
4544
+ return {
4545
+ element,
4546
+ destroy: () => {
4547
+ if (destroyed) return;
4548
+ destroyed = true;
4549
+ destroyImpl();
4550
+ }
4551
+ };
4552
+ }
4553
+ var EffectsManager = class {
4554
+ constructor(getAdapter, masterEventTarget) {
4555
+ this._masterChain = null;
4556
+ this._trackChains = /* @__PURE__ */ new Map();
4557
+ /** Per-chain restore ownership — a newer setEffectsState supersedes a stale in-flight one. */
4558
+ this._restoreTokens = /* @__PURE__ */ new WeakMap();
4559
+ /** Cached GUI elements by effectId — close hides, only removal destroys. */
4560
+ this._guis = /* @__PURE__ */ new Map();
4561
+ /** In-flight GUI creation by effectId — concurrent opens share one build. */
4562
+ this._guiPending = /* @__PURE__ */ new Map();
4563
+ /** Live WAM plugin nodes across all chains, fed to the wam-transport bridge. */
4564
+ this._wamNodes = /* @__PURE__ */ new Set();
4565
+ this._transportBridge = null;
4566
+ this._getAdapter = getAdapter;
4567
+ this._masterTarget = masterEventTarget;
4568
+ }
4569
+ // --- Master chain ---
4570
+ addMasterEffect(type, params) {
4571
+ const chain = this._ensureMasterChain();
4572
+ return this._addToChain(chain, this._masterTarget, type, params);
4573
+ }
4574
+ masterEffects() {
4575
+ return this._masterChain?.entries ?? [];
4576
+ }
4577
+ masterOp(op, effectId, arg) {
4578
+ this._runOp(this._masterChain, this._masterTarget, op, effectId, arg);
4579
+ }
4580
+ addMasterWamPlugin(url, initialState) {
4581
+ const chain = this._ensureMasterChain();
4582
+ return this._addWamToChain(chain, this._masterTarget, url, initialState);
4583
+ }
4584
+ addMasterFaustEffect(dspCode, options) {
4585
+ const chain = this._ensureMasterChain();
4586
+ return this._addFaustToChain(chain, this._masterTarget, dspCode, { name: options?.name });
4587
+ }
4588
+ getMasterEffectsState() {
4589
+ return this._masterChain?.serialize() ?? Promise.resolve([]);
4590
+ }
4591
+ async setMasterEffectsState(entries) {
4592
+ validateSerializedEntries(entries);
4593
+ await this._restoreChain(this._ensureMasterChain(), this._masterTarget, entries);
4594
+ }
4595
+ // --- Track chains ---
4596
+ addTrackEffect(trackId, target, type, params) {
4597
+ const chain = this._ensureTrackChain(trackId);
4598
+ return this._addToChain(chain, target, type, params);
4599
+ }
4600
+ addTrackWamPlugin(trackId, target, url, initialState) {
4601
+ const chain = this._ensureTrackChain(trackId);
4602
+ return this._addWamToChain(chain, target, url, initialState);
4603
+ }
4604
+ addTrackFaustEffect(trackId, target, dspCode, options) {
4605
+ const chain = this._ensureTrackChain(trackId);
4606
+ return this._addFaustToChain(chain, target, dspCode, { name: options?.name });
4607
+ }
4608
+ trackEffects(trackId) {
4609
+ return this._trackChains.get(trackId)?.entries ?? [];
4610
+ }
4611
+ getTrackEffectsState(trackId) {
4612
+ return this._trackChains.get(trackId)?.serialize() ?? Promise.resolve([]);
4613
+ }
4614
+ async setTrackEffectsState(trackId, target, entries) {
4615
+ validateSerializedEntries(entries);
4616
+ await this._restoreChain(this._ensureTrackChain(trackId), target, entries);
4617
+ }
4618
+ trackOp(trackId, target, op, effectId, arg) {
4619
+ this._runOp(this._trackChains.get(trackId) ?? null, target, op, effectId, arg);
4620
+ }
4621
+ // --- Effect GUIs ---
4622
+ /** Open (lazily creating) the GUI for a master-chain effect. */
4623
+ openMasterEffectGui(effectId, container) {
4624
+ return this._openGui(this._masterChain, this._masterTarget, effectId, container);
4625
+ }
4626
+ /** Open (lazily creating) the GUI for a track-chain effect. */
4627
+ openTrackEffectGui(trackId, target, effectId, container) {
4628
+ return this._openGui(this._trackChains.get(trackId) ?? null, target, effectId, container);
4629
+ }
4630
+ /** Hide an open GUI. The element stays cached so reopen is instant —
4631
+ * audio processing is never interrupted. */
4632
+ closeEffectGui(effectId) {
4633
+ const record = this._guis.get(effectId);
4634
+ if (!record) {
4635
+ console.warn(PREFIX2 + 'closeEffectGui: no open GUI for effectId "' + effectId + '"');
4636
+ return;
4637
+ }
4638
+ record.element.remove();
4639
+ }
4640
+ // --- Lifecycle ---
4641
+ /** Transport setTracks rebuilds TrackNodes, severing chain hookups — re-wire. */
4642
+ rewireTrackChains() {
4643
+ const transport = this._getAdapter()?.transport;
4644
+ if (!transport) return;
4645
+ for (const [trackId, chain] of this._trackChains) {
4646
+ transport.connectTrackOutput(trackId, chain.input);
4647
+ chain.output.connect(transport.masterOutputNode);
4648
+ }
4649
+ }
4650
+ disposeTrackChain(trackId) {
4651
+ const chain = this._trackChains.get(trackId);
4652
+ if (!chain) return;
4653
+ this._trackChains.delete(trackId);
4654
+ for (const entry of chain.entries) {
4655
+ this._destroyGui(entry.id);
4656
+ }
4657
+ try {
4658
+ this._getAdapter()?.transport?.disconnectTrackOutput(trackId);
4659
+ } catch (err) {
4660
+ console.warn(PREFIX2 + "EffectsManager: error disconnecting track output: " + String(err));
4661
+ }
4662
+ chain.dispose();
4663
+ }
4664
+ disposeAll() {
4665
+ this._transportBridge?.dispose();
4666
+ this._transportBridge = null;
4667
+ this._wamNodes.clear();
4668
+ for (const trackId of [...this._trackChains.keys()]) {
4669
+ this.disposeTrackChain(trackId);
4670
+ }
4671
+ if (this._masterChain) {
4672
+ for (const entry of this._masterChain.entries) {
4673
+ this._destroyGui(entry.id);
4674
+ }
4675
+ try {
4676
+ this._getAdapter()?.transport?.disconnectMasterOutput();
4677
+ } catch (err) {
4678
+ console.warn(PREFIX2 + "EffectsManager: error disconnecting master output: " + String(err));
4679
+ }
4680
+ this._masterChain.dispose();
4681
+ this._masterChain = null;
4682
+ }
4683
+ }
4684
+ // --- Private ---
4685
+ _requireWiring() {
4686
+ const adapter = this._getAdapter();
4687
+ if (!adapter) {
4688
+ throw new Error(
4689
+ PREFIX2 + "effects require an adapter \u2014 set editor.adapter before adding effects."
4690
+ );
4691
+ }
4692
+ const { audioContext, transport } = adapter;
4693
+ if (!audioContext || !transport || typeof transport.connectTrackOutput !== "function") {
4694
+ throw new Error(
4695
+ PREFIX2 + "the current adapter does not expose effects hooks (transport.connectTrackOutput / connectMasterOutput)."
4696
+ );
4697
+ }
4698
+ return { audioContext, transport };
4699
+ }
4700
+ /** Dynamic-import the optional @dawcore/wam peer with an actionable error. */
4701
+ _loadWamModule(feature) {
4702
+ return loadWamModule(feature);
4703
+ }
4704
+ /** Dynamic-import the optional @dawcore/faust peer with an actionable error. */
4705
+ _loadFaustModule(feature) {
4706
+ return loadFaustModule(feature);
4707
+ }
4708
+ async _openGui(chain, target, effectId, container) {
4709
+ if (!container || typeof container.appendChild !== "function") {
4710
+ throw new Error(PREFIX2 + "openEffectGui: container must be a DOM element");
4711
+ }
4712
+ const entry = chain?.getEntry(effectId);
4713
+ if (!chain || !entry) {
4714
+ throw new Error(PREFIX2 + 'openEffectGui: unknown effectId "' + effectId + '"');
4715
+ }
4716
+ if (entry.error !== void 0) {
4717
+ throw new Error(
4718
+ PREFIX2 + 'openEffectGui: effect "' + effectId + '" is a failed-plugin placeholder (' + entry.error + ") \u2014 no GUI is available. Remove it or retry the restore."
4719
+ );
4720
+ }
4721
+ const cached = this._guis.get(effectId);
4722
+ if (cached) {
4723
+ container.appendChild(cached.element);
4724
+ return cached.element;
4725
+ }
4726
+ let pending = this._guiPending.get(effectId);
4727
+ if (!pending) {
4728
+ pending = this._createGuiRecord(chain, target, entry, effectId).finally(() => {
4729
+ this._guiPending.delete(effectId);
4730
+ });
4731
+ this._guiPending.set(effectId, pending);
4732
+ }
4733
+ const record = await pending;
4734
+ if (chain.disposed || !chain.getEntry(effectId)) {
4735
+ this._guis.delete(effectId);
4736
+ record.element.remove();
4737
+ try {
4738
+ record.destroy();
4739
+ } catch (err) {
4740
+ console.warn(
4741
+ PREFIX2 + 'openEffectGui: destroying a late GUI for "' + effectId + '" failed: ' + String(err)
4742
+ );
4743
+ }
4744
+ throw new Error(
4745
+ PREFIX2 + 'openEffectGui: effect "' + effectId + '" was removed while its GUI was loading; the GUI was discarded.'
4746
+ );
4747
+ }
4748
+ this._guis.set(effectId, record);
4749
+ container.appendChild(record.element);
4750
+ return record.element;
4751
+ }
4752
+ /** Build a GUI record: the plugin's own GUI when available, otherwise the
4753
+ * generic parameter panel from @dawcore/wam. */
4754
+ async _createGuiRecord(chain, target, entry, effectId) {
4755
+ const { instance } = entry;
4756
+ if (typeof instance.createGui === "function") {
4757
+ try {
4758
+ const element2 = await instance.createGui();
4759
+ return makeGuiRecord(element2, () => instance.destroyGui?.(element2));
4760
+ } catch (err) {
4761
+ console.warn(
4762
+ PREFIX2 + 'openEffectGui: plugin createGui failed for "' + effectId + '" \u2014 falling back to the generic parameter panel: ' + String(err)
4763
+ );
4764
+ }
4765
+ }
4766
+ const element = await this._createFallbackPanel(chain, target, entry, effectId);
4767
+ return makeGuiRecord(element, () => {
4768
+ });
4769
+ }
4770
+ /** The generic parameter panel — one code path for "no custom GUI":
4771
+ * native entries render from the registry's params metadata, WAM entries
4772
+ * from getParameterInfo(). Edits route through the regular setParams op so
4773
+ * they hit the audio (applyParams → setParameterValues for WAM) AND
4774
+ * dispatch daw-effect-change like any other parameter edit. */
4775
+ async _createFallbackPanel(chain, target, entry, effectId) {
4776
+ const wamModule = await this._loadWamModule("openEffectGui() parameter panels");
4777
+ const onChange = (paramId, value) => {
4778
+ this._runOp(chain, target, "setParams", effectId, { [paramId]: value });
4779
+ };
4780
+ if (entry.kind === "native") {
4781
+ const definition = getEffectDefinitions().get(entry.type);
4782
+ if (!definition) {
4783
+ throw new Error(
4784
+ PREFIX2 + 'openEffectGui: no registry definition for effect type "' + entry.type + '"'
4785
+ );
4786
+ }
4787
+ const params = Object.entries(definition.params).map(([id, def]) => ({
4788
+ id,
4789
+ min: def.min,
4790
+ max: def.max,
4791
+ ...def.step !== void 0 ? { step: def.step } : {},
4792
+ ...def.unit !== void 0 ? { unit: def.unit } : {},
4793
+ value: entry.params[id] ?? definition.defaults[id]
4794
+ }));
4795
+ return wamModule.createParameterPanel(params, onChange);
4796
+ }
4797
+ if (typeof entry.instance.getParameterInfo !== "function") {
4798
+ throw new Error(
4799
+ PREFIX2 + 'openEffectGui: effect "' + effectId + '" has no GUI and exposes no parameter info \u2014 nothing to render.'
4800
+ );
4801
+ }
4802
+ return wamModule.createWamParameterPanel(
4803
+ { getParameterInfo: () => entry.instance.getParameterInfo() },
4804
+ { onParamChange: onChange }
4805
+ );
4806
+ }
4807
+ /** Detach + destroy a cached GUI. Called only from removal paths — close
4808
+ * never destroys. Safe when no GUI was ever opened. */
4809
+ _destroyGui(effectId) {
4810
+ const record = this._guis.get(effectId);
4811
+ if (!record) return;
4812
+ this._guis.delete(effectId);
4813
+ record.element.remove();
4814
+ try {
4815
+ record.destroy();
4816
+ } catch (err) {
4817
+ console.warn(PREFIX2 + 'destroyGui failed for effect "' + effectId + '": ' + String(err));
4818
+ }
4819
+ }
4820
+ _ensureMasterChain() {
4821
+ if (this._masterChain) return this._masterChain;
4822
+ const { audioContext, transport } = this._requireWiring();
4823
+ const chain = new EffectsChainController(audioContext);
4824
+ transport.connectMasterOutput(chain.input);
4825
+ chain.output.connect(audioContext.destination);
4826
+ this._masterChain = chain;
4827
+ return chain;
4828
+ }
4829
+ _ensureTrackChain(trackId) {
4830
+ const existing = this._trackChains.get(trackId);
4831
+ if (existing) return existing;
4832
+ const { audioContext, transport } = this._requireWiring();
4833
+ const chain = new EffectsChainController(audioContext);
4834
+ transport.connectTrackOutput(trackId, chain.input);
4835
+ chain.output.connect(transport.masterOutputNode);
4836
+ this._trackChains.set(trackId, chain);
4837
+ return chain;
4838
+ }
4839
+ _addToChain(chain, target, type, params) {
4840
+ const audioContext = this._requireWiring().audioContext;
4841
+ const created = createEffectInstance(type, audioContext, params);
4842
+ const effectId = chain.add({
4843
+ kind: "native",
4844
+ type,
4845
+ instance: created.instance,
4846
+ params: created.params,
4847
+ wetParam: created.wetParam
4848
+ });
4849
+ const index = chain.entries.findIndex((e) => e.id === effectId);
4850
+ this._dispatch(target, "daw-effect-add", {
4851
+ effectId,
4852
+ kind: "native",
4853
+ type,
4854
+ params: { ...created.params },
4855
+ index
4856
+ });
4857
+ return effectId;
4858
+ }
4859
+ /**
4860
+ * Load a WAM plugin (via the optional @dawcore/wam peer dep) and add it to
4861
+ * a chain as a kind:'wam' entry. WAM entries participate in every chain
4862
+ * operation with no special-casing: remove destroys the plugin (via the
4863
+ * entry's dispose), bypass uses disconnection semantics (no wet param),
4864
+ * and setParams maps onto the plugin's setParameterValues.
4865
+ */
4866
+ async _addWamToChain(chain, target, url, initialState) {
4867
+ const { audioContext } = this._requireWiring();
4868
+ const wamModule = await this._loadWamModule("addWamPlugin()");
4869
+ const { hostGroupId } = await wamModule.ensureWamHost(audioContext);
4870
+ const plugin = await wamModule.createWamInstance(url, audioContext, hostGroupId, {
4871
+ initialState
4872
+ });
4873
+ return this._insertWamPlugin(chain, target, wamModule, plugin, { url });
4874
+ }
4875
+ /**
4876
+ * Compile Faust DSP source in the browser (via the optional @dawcore/faust
4877
+ * peer) and add the resulting WAM to a chain. Compilation happens BEFORE
4878
+ * any chain work, so a Faust error (with its line/column diagnostics intact)
4879
+ * leaves the chain untouched. The entry lands as kind:'wam' with a
4880
+ * `source: { faust }` marker so persistence recompiles instead of fetching.
4881
+ */
4882
+ async _addFaustToChain(chain, target, dspCode, opts) {
4883
+ if (typeof dspCode !== "string" || dspCode.trim().length === 0) {
4884
+ throw new Error(PREFIX2 + "addFaustEffect: dspCode must be a non-empty string");
4885
+ }
4886
+ this._requireWiring();
4887
+ const faustModule = await this._loadFaustModule("addFaustEffect()");
4888
+ const compiled = await faustModule.compileFaustToWam(dspCode, { name: opts.name });
4889
+ const { audioContext } = this._requireWiring();
4890
+ const wamModule = await this._loadWamModule("addFaustEffect()");
4891
+ const { hostGroupId } = await wamModule.ensureWamHost(audioContext);
4892
+ const plugin = await wamModule.createWamInstanceFromFactory(
4893
+ compiled.factory,
4894
+ audioContext,
4895
+ hostGroupId,
4896
+ { initialState: opts.initialState, label: compiled.name }
4897
+ );
4898
+ return this._insertWamPlugin(chain, target, wamModule, plugin, {
4899
+ source: { faust: compiled.dspCode }
4900
+ });
4901
+ }
4902
+ /**
4903
+ * Wire a live WAM plugin instance into a chain as a kind:'wam' entry —
4904
+ * shared by the url path (addWamPlugin) and the Faust path (addFaustEffect).
4905
+ * WAM entries participate in every chain operation with no special-casing:
4906
+ * remove destroys the plugin (via the entry's dispose), bypass uses
4907
+ * disconnection semantics (no wet param), and setParams maps onto the
4908
+ * plugin's setParameterValues.
4909
+ */
4910
+ _insertWamPlugin(chain, target, wamModule, plugin, meta) {
4911
+ const node = plugin.audioNode;
4912
+ const label = plugin.descriptor.name;
4913
+ if (chain.disposed) {
4914
+ plugin.destroy();
4915
+ throw new Error(
4916
+ PREFIX2 + 'addWamPlugin: the effects chain was disposed while "' + (meta.url ?? label) + '" was loading; the plugin was discarded.'
4917
+ );
4918
+ }
4919
+ let effectId;
4920
+ try {
4921
+ effectId = chain.add({
4922
+ kind: "wam",
4923
+ type: "wam",
4924
+ ...meta.url !== void 0 ? { url: meta.url } : {},
4925
+ ...meta.source !== void 0 ? { source: meta.source } : {},
4926
+ label,
4927
+ instance: {
4928
+ input: node,
4929
+ output: node,
4930
+ applyParams: (params) => {
4931
+ node.setParameterValues?.(toWamParameterMap(params))?.catch((err) => {
4932
+ console.warn(PREFIX2 + "WAM setParameterValues failed: " + String(err));
4933
+ });
4934
+ },
4935
+ dispose: () => {
4936
+ this._wamNodes.delete(node);
4937
+ plugin.destroy();
4938
+ },
4939
+ getState: () => plugin.getState(),
4940
+ getParameterInfo: () => plugin.getParameterInfo(),
4941
+ ...plugin.createGui ? { createGui: () => plugin.createGui() } : {},
4942
+ ...plugin.destroyGui ? { destroyGui: (gui) => plugin.destroyGui(gui) } : {}
4943
+ },
4944
+ params: {}
4945
+ });
4946
+ } catch (err) {
4947
+ try {
4948
+ plugin.destroy();
4949
+ } catch (destroyErr) {
4950
+ console.warn(
4951
+ PREFIX2 + "addWamPlugin: cleanup after failed insertion also failed: " + String(destroyErr)
4952
+ );
4953
+ }
4954
+ throw err;
4955
+ }
4956
+ this._wamNodes.add(node);
4957
+ this._ensureTransportBridge(wamModule)?.notifyNodeAdded(node);
4958
+ const index = chain.entries.findIndex((e) => e.id === effectId);
4959
+ this._dispatch(target, "daw-effect-add", {
4960
+ effectId,
4961
+ kind: "wam",
4962
+ type: "wam",
4963
+ ...meta.url !== void 0 ? { url: meta.url } : {},
4964
+ ...meta.source !== void 0 ? { source: meta.source } : {},
4965
+ params: {},
4966
+ index
4967
+ });
4968
+ return effectId;
4969
+ }
4970
+ /**
4971
+ * Lazily create the wam-transport bridge so tempo-synced plugins lock to
4972
+ * the timeline. Skipped (not an error) when the adapter's transport lacks
4973
+ * the query/event surface — the bridge is an enhancement, not a
4974
+ * requirement for audio processing.
4975
+ */
4976
+ _ensureTransportBridge(wamModule) {
4977
+ if (this._transportBridge) return this._transportBridge;
4978
+ if (typeof wamModule.createWamTransportBridge !== "function") return null;
4979
+ const transport = this._getAdapter()?.transport;
4980
+ if (!transport || typeof transport.on !== "function" || typeof transport.getTempo !== "function" || typeof transport.tickToBar !== "function" || typeof transport.timeToTick !== "function") {
4981
+ return null;
4982
+ }
4983
+ this._transportBridge = wamModule.createWamTransportBridge(transport, () => [
4984
+ ...this._wamNodes
4985
+ ]);
4986
+ return this._transportBridge;
4987
+ }
4988
+ /**
4989
+ * Replace a chain's contents with a persisted snapshot. Entries restore
4990
+ * sequentially so chain order survives async WAM loads. A WAM url that
4991
+ * fails to load becomes a bypassed passthrough placeholder at its saved
4992
+ * position — the restore continues, a daw-effect-error fires, and the
4993
+ * saved state is retained so a later snapshot/retry round-trips it.
4994
+ */
4995
+ async _restoreChain(chain, target, entries) {
4996
+ const token = /* @__PURE__ */ Symbol("restore");
4997
+ this._restoreTokens.set(chain, token);
4998
+ const superseded = () => this._restoreTokens.get(chain) !== token;
4999
+ for (const existing of chain.entries) {
5000
+ this._runOp(chain, target, "remove", existing.id);
5001
+ }
5002
+ for (const entry of entries) {
5003
+ if (superseded()) return;
5004
+ if (entry.kind === "native") {
5005
+ const id = this._addToChain(chain, target, entry.type, entry.params);
5006
+ if (entry.bypassed) {
5007
+ this._runOp(chain, target, "setBypassed", id, true);
5008
+ }
5009
+ continue;
5010
+ }
5011
+ try {
5012
+ const id = entry.faustDsp !== void 0 ? await this._addFaustToChain(chain, target, entry.faustDsp, {
5013
+ name: entry.faustName,
5014
+ initialState: entry.state
5015
+ }) : await this._addWamToChain(chain, target, entry.url ?? "", entry.state);
5016
+ if (superseded()) {
5017
+ this._runOp(chain, target, "remove", id);
5018
+ return;
5019
+ }
5020
+ if (entry.bypassed) {
5021
+ this._runOp(chain, target, "setBypassed", id, true);
5022
+ }
5023
+ } catch (err) {
5024
+ if (superseded()) return;
5025
+ const message = err instanceof Error ? err.message : String(err);
5026
+ const sourceLabel = entry.url ?? entry.faustName ?? "Faust effect";
5027
+ console.warn(
5028
+ PREFIX2 + 'setEffectsState: plugin "' + sourceLabel + '" failed to restore: ' + message
5029
+ );
5030
+ const effectId = this._addWamPlaceholder(chain, entry, message);
5031
+ this._dispatch(target, "daw-effect-error", {
5032
+ effectId,
5033
+ ...entry.url !== void 0 ? { url: entry.url } : {},
5034
+ ...entry.faustDsp !== void 0 ? { source: { faust: entry.faustDsp } } : {},
5035
+ message
5036
+ });
5037
+ }
5038
+ }
5039
+ }
5040
+ /** A silent passthrough occupying the failed plugin's chain position. */
5041
+ _addWamPlaceholder(chain, entry, message) {
5042
+ const { audioContext } = this._requireWiring();
5043
+ const node = audioContext.createGain();
5044
+ const effectId = chain.add({
5045
+ kind: "wam",
5046
+ type: "wam",
5047
+ ...entry.url !== void 0 ? { url: entry.url } : {},
5048
+ ...entry.faustDsp !== void 0 ? { source: { faust: entry.faustDsp } } : {},
5049
+ label: entry.url ?? entry.faustName ?? "Faust effect",
5050
+ error: message,
5051
+ placeholder: { state: entry.state, bypassed: entry.bypassed },
5052
+ instance: { input: node, output: node, applyParams: () => {
5053
+ } },
5054
+ params: {}
5055
+ });
5056
+ chain.setBypassed(effectId, true);
5057
+ return effectId;
5058
+ }
5059
+ _runOp(chain, target, op, effectId, arg) {
5060
+ const entries = chain?.entries ?? [];
5061
+ const fromIndex = entries.findIndex((e) => e.id === effectId);
5062
+ if (!chain || fromIndex === -1) {
5063
+ console.warn(PREFIX2 + "effects." + op + ': unknown effectId "' + effectId + '"');
5064
+ return;
5065
+ }
5066
+ if (entries[fromIndex].error !== void 0 && (op === "setParams" || op === "setBypassed")) {
5067
+ console.warn(
5068
+ PREFIX2 + "effects." + op + ': effect "' + effectId + '" is a failed-plugin placeholder (' + entries[fromIndex].error + ") \u2014 edit ignored. Remove it or retry the restore."
5069
+ );
5070
+ return;
5071
+ }
5072
+ switch (op) {
5073
+ case "remove":
5074
+ this._destroyGui(effectId);
5075
+ chain.remove(effectId);
5076
+ this._dispatch(target, "daw-effect-remove", { effectId });
5077
+ break;
5078
+ case "setParams":
5079
+ chain.setParams(effectId, arg);
5080
+ this._dispatch(target, "daw-effect-change", { effectId, params: { ...arg } });
5081
+ break;
5082
+ case "setBypassed":
5083
+ chain.setBypassed(effectId, arg);
5084
+ this._dispatch(target, "daw-effect-bypass", { effectId, bypassed: arg });
5085
+ break;
5086
+ case "move":
5087
+ chain.move(effectId, arg);
5088
+ this._dispatch(target, "daw-effect-reorder", { effectId, fromIndex, toIndex: arg });
5089
+ break;
5090
+ }
5091
+ }
5092
+ _dispatch(target, name, detail) {
5093
+ target.dispatchEvent(new CustomEvent(name, { bubbles: true, composed: true, detail }));
5094
+ }
5095
+ };
5096
+ function validateSerializedEntries(entries) {
5097
+ if (!Array.isArray(entries)) {
5098
+ throw new Error(PREFIX2 + "setEffectsState: expected an array of serialized effect entries");
5099
+ }
5100
+ entries.forEach((entry, i) => {
5101
+ const at = " (entry " + i + ")";
5102
+ if (entry === null || typeof entry !== "object") {
5103
+ throw new Error(PREFIX2 + "setEffectsState: entry must be an object" + at);
5104
+ }
5105
+ const e = entry;
5106
+ if (e.kind === "native") {
5107
+ if (typeof e.type !== "string" || e.type.length === 0) {
5108
+ throw new Error(PREFIX2 + "setEffectsState: native entry requires a type string" + at);
5109
+ }
5110
+ if (e.params === null || typeof e.params !== "object") {
5111
+ throw new Error(PREFIX2 + "setEffectsState: native entry requires a params object" + at);
5112
+ }
5113
+ } else if (e.kind === "wam") {
5114
+ const hasUrl = typeof e.url === "string" && e.url.length > 0;
5115
+ const hasFaustDsp = typeof e.faustDsp === "string" && e.faustDsp.trim().length > 0;
5116
+ if (!hasUrl && !hasFaustDsp) {
5117
+ throw new Error(
5118
+ PREFIX2 + "setEffectsState: wam entry requires a url string or a faustDsp source string" + at
5119
+ );
5120
+ }
5121
+ if (e.faustName !== void 0 && typeof e.faustName !== "string") {
5122
+ throw new Error(PREFIX2 + "setEffectsState: faustName must be a string when provided" + at);
5123
+ }
5124
+ } else {
5125
+ throw new Error(PREFIX2 + 'setEffectsState: unknown entry kind "' + String(e.kind) + '"' + at);
5126
+ }
5127
+ if (typeof e.bypassed !== "boolean") {
5128
+ throw new Error(PREFIX2 + "setEffectsState: entry requires a boolean bypassed flag" + at);
5129
+ }
5130
+ });
5131
+ }
5132
+ function toWamParameterMap(params) {
5133
+ const map = {};
5134
+ for (const [id, value] of Object.entries(params)) {
5135
+ map[id] = { id, value, normalized: false };
5136
+ }
5137
+ return map;
5138
+ }
5139
+
5140
+ // src/interactions/export-audio.ts
5141
+ var PREFIX3 = "[waveform-playlist] ";
5142
+ async function exportAudioImpl(host, options = {}) {
5143
+ const sampleRate = options.sampleRate ?? host.effectiveSampleRate;
5144
+ const startTime = options.startTime ?? 0;
5145
+ const duration = options.duration ?? Math.max(0, host.duration - startTime);
5146
+ const channels = options.channels ?? 2;
5147
+ if (!Number.isFinite(sampleRate) || sampleRate <= 0) {
5148
+ throw new Error(PREFIX3 + "exportAudio: invalid sampleRate " + String(sampleRate));
5149
+ }
5150
+ if (!Number.isFinite(duration) || duration <= 0) {
5151
+ throw new Error(PREFIX3 + "exportAudio: nothing to render (duration " + String(duration) + ")");
5152
+ }
5153
+ const ctx = new OfflineAudioContext({
5154
+ numberOfChannels: channels,
5155
+ length: Math.round(duration * sampleRate),
5156
+ sampleRate
5157
+ });
5158
+ const cleanups = [];
5159
+ try {
5160
+ const masterChain = await buildOfflineChain(ctx, await host.getMasterEffectsState());
5161
+ cleanups.push(masterChain.dispose);
5162
+ masterChain.output.connect(ctx.destination);
5163
+ const anySoloed = host.tracks.some((t) => t.soloed);
5164
+ for (const track of host.tracks) {
5165
+ if (track.muted || anySoloed && !track.soloed) {
5166
+ continue;
5167
+ }
5168
+ const chain = await buildOfflineChain(ctx, await host.getTrackEffectsState(track.id));
5169
+ cleanups.push(chain.dispose);
5170
+ const volume = ctx.createGain();
5171
+ volume.gain.value = track.volume;
5172
+ const panner = ctx.createStereoPanner();
5173
+ panner.pan.value = track.pan;
5174
+ volume.connect(panner);
5175
+ panner.connect(chain.input);
5176
+ chain.output.connect(masterChain.input);
5177
+ for (const clip of track.clips) {
5178
+ scheduleClip(ctx, clip, startTime, duration, volume);
5179
+ }
5180
+ }
5181
+ return await ctx.startRendering();
5182
+ } finally {
5183
+ for (const dispose of cleanups) {
5184
+ try {
5185
+ dispose();
5186
+ } catch (err) {
5187
+ console.warn(PREFIX3 + "exportAudio: cleanup error: " + String(err));
5188
+ }
5189
+ }
5190
+ }
5191
+ }
5192
+ function scheduleClip(ctx, clip, windowStart, windowDuration, destination) {
5193
+ if (!clip.audioBuffer || clip.durationSamples <= 0) {
5194
+ return;
5195
+ }
5196
+ const clipRate = clip.sampleRate;
5197
+ const clipStart = clip.startSample / clipRate;
5198
+ const clipDuration = clip.durationSamples / clipRate;
5199
+ let offset = clip.offsetSamples / clipRate;
5200
+ let when = clipStart - windowStart;
5201
+ let remaining = clipDuration;
5202
+ if (when < 0) {
5203
+ offset += -when;
5204
+ remaining += when;
5205
+ when = 0;
5206
+ }
5207
+ remaining = Math.min(remaining, windowDuration - when);
5208
+ if (remaining <= 0) {
5209
+ return;
5210
+ }
5211
+ const source = ctx.createBufferSource();
5212
+ source.buffer = clip.audioBuffer;
5213
+ let out = source;
5214
+ if (clip.gain !== void 0 && clip.gain !== 1) {
5215
+ const gainNode = ctx.createGain();
5216
+ gainNode.gain.value = clip.gain;
5217
+ out.connect(gainNode);
5218
+ out = gainNode;
5219
+ }
5220
+ out.connect(destination);
5221
+ source.start(when, offset, remaining);
5222
+ }
5223
+ async function buildOfflineChain(ctx, entries) {
5224
+ const input = ctx.createGain();
5225
+ const output = ctx.createGain();
5226
+ const cleanups = [];
5227
+ const dispose = () => {
5228
+ for (const cleanup of cleanups) {
5229
+ try {
5230
+ cleanup();
5231
+ } catch (err) {
5232
+ console.warn(PREFIX3 + "exportAudio: chain cleanup error: " + String(err));
5233
+ }
5234
+ }
5235
+ };
5236
+ let previous = input;
5237
+ try {
5238
+ await wireEntries();
5239
+ } catch (err) {
5240
+ dispose();
5241
+ throw err;
5242
+ }
5243
+ previous.connect(output);
5244
+ return { input, output, dispose };
5245
+ async function wireEntries() {
5246
+ for (const entry of entries) {
5247
+ if (entry.kind === "native") {
5248
+ const created = createEffectInstance(entry.type, ctx, entry.params);
5249
+ if (entry.bypassed) {
5250
+ if (!created.wetParam) {
5251
+ created.instance.dispose?.();
5252
+ continue;
5253
+ }
5254
+ created.instance.applyParams({ [created.wetParam]: 0 });
5255
+ }
5256
+ if (created.instance.dispose) {
5257
+ cleanups.push(created.instance.dispose);
5258
+ }
5259
+ previous.connect(created.instance.input);
5260
+ previous = created.instance.output;
5261
+ continue;
5262
+ }
5263
+ if (entry.bypassed) {
5264
+ continue;
5265
+ }
5266
+ const wamModule = await loadWamModule("exportAudio() with WAM effects");
5267
+ const { hostGroupId } = await wamModule.ensureWamHost(ctx);
5268
+ let plugin;
5269
+ if (entry.faustDsp !== void 0) {
5270
+ const faustModule = await loadFaustModule("exportAudio() with Faust effects");
5271
+ const compiled = await faustModule.compileFaustToWam(entry.faustDsp, {
5272
+ name: entry.faustName
5273
+ });
5274
+ plugin = await wamModule.createWamInstanceFromFactory(
5275
+ compiled.factory,
5276
+ ctx,
5277
+ hostGroupId,
5278
+ { initialState: entry.state, label: compiled.name }
5279
+ );
5280
+ } else {
5281
+ plugin = await wamModule.createWamInstance(entry.url ?? "", ctx, hostGroupId, {
5282
+ initialState: entry.state
5283
+ });
5284
+ }
5285
+ cleanups.push(() => plugin.destroy());
5286
+ previous.connect(plugin.audioNode);
5287
+ previous = plugin.audioNode;
5288
+ }
5289
+ }
5290
+ }
5291
+
4145
5292
  // src/interactions/peaks-loader.ts
4146
5293
  var import_waveform_data2 = __toESM(require("waveform-data"));
4147
5294
  async function loadWaveformDataFromUrl(src) {
@@ -4333,6 +5480,8 @@ var DawEditorElement = class extends import_lit15.LitElement {
4333
5480
  this._selectionEndTime = 0;
4334
5481
  this._currentTime = 0;
4335
5482
  this._externalAdapter = null;
5483
+ // --- Effects (master chain; per-track chains delegated from <daw-track>) ---
5484
+ this._effectsManager = null;
4336
5485
  this._engine = null;
4337
5486
  this._warnedMissingTicksToSeconds = false;
4338
5487
  this._warnedMissingSecondsToTicks = false;
@@ -4690,11 +5839,122 @@ var DawEditorElement = class extends import_lit15.LitElement {
4690
5839
  "[dawcore] adapter set after engine is built. The engine will continue using the previous adapter."
4691
5840
  );
4692
5841
  }
5842
+ if (this._effectsManager) {
5843
+ console.warn(
5844
+ "[dawcore] adapter replaced \u2014 existing effect chains were disposed. Re-add effects on the new adapter."
5845
+ );
5846
+ this._effectsManager.disposeAll();
5847
+ this._effectsManager = null;
5848
+ }
4693
5849
  this._externalAdapter = value;
4694
5850
  }
4695
5851
  get adapter() {
4696
5852
  return this._externalAdapter;
4697
5853
  }
5854
+ get _effects() {
5855
+ if (!this._effectsManager) {
5856
+ this._effectsManager = new EffectsManager(() => this._externalAdapter, this);
5857
+ }
5858
+ return this._effectsManager;
5859
+ }
5860
+ /** Master effects chain — inserted between the master bus and the destination. */
5861
+ addEffect(type, params) {
5862
+ return this._effects.addMasterEffect(type, params);
5863
+ }
5864
+ removeEffect(effectId) {
5865
+ this._effects.masterOp("remove", effectId);
5866
+ }
5867
+ setEffectParams(effectId, params) {
5868
+ this._effects.masterOp("setParams", effectId, params);
5869
+ }
5870
+ setEffectBypassed(effectId, bypassed) {
5871
+ this._effects.masterOp("setBypassed", effectId, bypassed);
5872
+ }
5873
+ moveEffect(effectId, newIndex) {
5874
+ this._effects.masterOp("move", effectId, newIndex);
5875
+ }
5876
+ get effects() {
5877
+ return this._effectsManager?.masterEffects() ?? [];
5878
+ }
5879
+ /** Load a WAM plugin (via the optional @dawcore/wam peer) into the master chain. */
5880
+ addWamPlugin(url, initialState) {
5881
+ return this._effects.addMasterWamPlugin(url, initialState);
5882
+ }
5883
+ /**
5884
+ * Compile Faust DSP source in the browser (via the optional @dawcore/faust
5885
+ * peer) and add the resulting WAM to the master chain. Compile errors keep
5886
+ * their Faust line/column diagnostics and leave the chain untouched.
5887
+ */
5888
+ addFaustEffect(dspCode, options) {
5889
+ return this._effects.addMasterFaustEffect(dspCode, options);
5890
+ }
5891
+ /**
5892
+ * Open (lazily creating) the GUI for a master-chain effect into a
5893
+ * consumer-provided container. WAM plugins mount their own GUI; plugins
5894
+ * without one — and native effects — get the generic parameter panel from
5895
+ * @dawcore/wam. The element is cached: closeEffectGui hides it without
5896
+ * interrupting audio, reopening remounts the same element.
5897
+ */
5898
+ openEffectGui(effectId, container) {
5899
+ return this._effects.openMasterEffectGui(effectId, container);
5900
+ }
5901
+ /** Hide a master-chain effect's GUI (cached for reopen — never destroys). */
5902
+ closeEffectGui(effectId) {
5903
+ this._effects.closeEffectGui(effectId);
5904
+ }
5905
+ /** Snapshot the master chain in its persisted form (see dawcore README). */
5906
+ getEffectsState() {
5907
+ return this._effectsManager?.getMasterEffectsState() ?? Promise.resolve([]);
5908
+ }
5909
+ /** Replace the master chain with a persisted snapshot. */
5910
+ setEffectsState(entries) {
5911
+ return this._effects.setMasterEffectsState(entries);
5912
+ }
5913
+ /**
5914
+ * Render the session offline through all effect chains (per-track +
5915
+ * master), including WAM plugins (re-instantiated on the offline context
5916
+ * with their live state). Returns the rendered AudioBuffer.
5917
+ */
5918
+ exportAudio(options) {
5919
+ return exportAudioImpl(
5920
+ {
5921
+ effectiveSampleRate: this.effectiveSampleRate,
5922
+ duration: this._duration,
5923
+ tracks: [...this._engineTracks.values()],
5924
+ getMasterEffectsState: () => this.getEffectsState(),
5925
+ getTrackEffectsState: (trackId) => this._trackGetEffectsState(trackId)
5926
+ },
5927
+ options
5928
+ );
5929
+ }
5930
+ /** Internal — <daw-track> effects API delegates here (dawcore-internal contract). */
5931
+ _trackAddEffect(trackId, target, type, params) {
5932
+ return this._effects.addTrackEffect(trackId, target, type, params);
5933
+ }
5934
+ _trackEffectOp(trackId, target, op, effectId, arg) {
5935
+ this._effects.trackOp(trackId, target, op, effectId, arg);
5936
+ }
5937
+ _trackEffects(trackId) {
5938
+ return this._effectsManager?.trackEffects(trackId) ?? [];
5939
+ }
5940
+ _trackAddWamPlugin(trackId, target, url, initialState) {
5941
+ return this._effects.addTrackWamPlugin(trackId, target, url, initialState);
5942
+ }
5943
+ _trackAddFaustEffect(trackId, target, dspCode, options) {
5944
+ return this._effects.addTrackFaustEffect(trackId, target, dspCode, options);
5945
+ }
5946
+ _trackOpenEffectGui(trackId, target, effectId, container) {
5947
+ return this._effects.openTrackEffectGui(trackId, target, effectId, container);
5948
+ }
5949
+ _trackCloseEffectGui(_trackId, effectId) {
5950
+ this._effects.closeEffectGui(effectId);
5951
+ }
5952
+ _trackGetEffectsState(trackId) {
5953
+ return this._effectsManager?.getTrackEffectsState(trackId) ?? Promise.resolve([]);
5954
+ }
5955
+ _trackSetEffectsState(trackId, target, entries) {
5956
+ return this._effects.setTrackEffectsState(trackId, target, entries);
5957
+ }
4698
5958
  get audioContext() {
4699
5959
  if (!this._externalAdapter) {
4700
5960
  throw new Error(NO_ADAPTER_ERROR);
@@ -4866,6 +6126,8 @@ var DawEditorElement = class extends import_lit15.LitElement {
4866
6126
  }
4867
6127
  disconnectedCallback() {
4868
6128
  super.disconnectedCallback();
6129
+ this._effectsManager?.disposeAll();
6130
+ this._effectsManager = null;
4869
6131
  this.removeEventListener("daw-track-connected", this._onTrackConnected);
4870
6132
  this.removeEventListener("daw-track-update", this._onTrackUpdate);
4871
6133
  this.removeEventListener("daw-track-control", this._onTrackControl);
@@ -4941,6 +6203,7 @@ var DawEditorElement = class extends import_lit15.LitElement {
4941
6203
  }
4942
6204
  _onTrackRemoved(trackId) {
4943
6205
  this._trackElements.delete(trackId);
6206
+ this._effectsManager?.disposeTrackChain(trackId);
4944
6207
  const removedTrack = this._engineTracks.get(trackId);
4945
6208
  if (removedTrack) {
4946
6209
  const nextPeaks = new Map(this._peaksData);
@@ -5591,6 +6854,7 @@ var DawEditorElement = class extends import_lit15.LitElement {
5591
6854
  nextTracks.set(track.id, track);
5592
6855
  }
5593
6856
  this._engineTracks = nextTracks;
6857
+ this._effectsManager?.rewireTrackChains();
5594
6858
  const audioTracks = engineState.tracks.filter((t) => {
5595
6859
  const desc = this._tracks.get(t.id);
5596
6860
  return desc?.renderMode !== "piano-roll";
@@ -7067,7 +8331,10 @@ DawSpectrogramElement = __decorateClass([
7067
8331
  DawWaveformElement,
7068
8332
  RecordingController,
7069
8333
  SpectrogramController,
8334
+ createEffectInstance,
8335
+ getEffectDefinitions,
7070
8336
  isDomClip,
8337
+ registerEffect,
7071
8338
  splitAtPlayhead
7072
8339
  });
7073
8340
  //# sourceMappingURL=index.js.map