PageSourceSearch

https://www.hamienet.com/assets/synth-BE1TSTD4.js

js hamienet.com collected 2026-09-24 18:59:01 UTC 62,224 bytes, 1,579 lines download raw bytes

1const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/vendor-audio-DH3w0BzW.js","assets/vendor-react-Bay9Y7Lb.js","assets/vendor-dVNaLJx_.js","assets/vendor-i18n-R-bMoYp_.js","assets/vendor-DqKRdbjz.css"])))=>i.map(i=>d[i]);
2import { _ as __vitePreload } from "./vendor-react-three-MDLA5jog.js";
3import { W as WasmSynthAdapter } from "./wasm-synth-adapter-BrqgBX1y.js";
4import { a as Midi } from "./vendor-audio-DH3w0BzW.js";
5class VocalDSP {
6  constructor(ctx) {
7    this.sourceNode = null;
8    this.stream = null;
9    this._autoVocalMode = false;
10    this._guideTrackMuted = false;
11    this._micThresholdDbfs = -28;
12    this._cooldownLatchTimeout = null;
13    this._animFrame = null;
14    this.ctx = ctx;
15    this.compressor = this.ctx.createDynamicsCompressor();
16    this.compressor.threshold.value = -30;
17    this.compressor.knee.value = 10;
18    this.compressor.ratio.value = 4;
19    this.compressor.attack.value = 0.05;
20    this.compressor.release.value = 0.25;
21    this.delay = this.ctx.createDelay(5);
22    this.delay.delayTime.value = 0;
23    this.delayGain = this.ctx.createGain();
24    this.delayGain.gain.value = 0;
25    const feedback = this.ctx.createGain();
26    feedback.gain.value = 0.3;
27    this.delay.connect(feedback);
28    feedback.connect(this.delay);
29    this.convolver = this.ctx.createConvolver();
30    this.convolver.buffer = this.generateSyntheticReverb(2);
31    this.convolverGain = this.ctx.createGain();
32    this.convolverGain.gain.value = 0;
33    this.dryGain = this.ctx.createGain();
34    this.dryGain.gain.value = 1;
35    this.masterGain = this.ctx.createGain();
36    this.masterGain.gain.value = 1;
37    this.analyser = this.ctx.createAnalyser();
38    this.analyser.fftSize = 2048;
39    this.setupRouting();
40  }
41  setupRouting() {
42    this.compressor.connect(this.dryGain);
43    this.compressor.connect(this.delay);
44    this.compressor.connect(this.convolver);
45    this.compressor.connect(this.analyser);
46    this.delay.connect(this.delayGain);
47    this.convolver.connect(this.convolverGain);
48    this.dryGain.connect(this.masterGain);
49    this.delayGain.connect(this.masterGain);
50    this.convolverGain.connect(this.masterGain);
51    this.masterGain.connect(this.ctx.destination);
52  }
53  generateSyntheticReverb(duration) {
54    const sampleRate = this.ctx.sampleRate;
55    const length = sampleRate * duration;
56    const impulse = this.ctx.createBuffer(2, length, sampleRate);
57    const left = impulse.getChannelData(0);
58    const right = impulse.getChannelData(1);
59    for (let i = 0; i < length; i++) {
60      const multiplier = Math.exp(-i / (sampleRate * (duration / 5)));
61      left[i] = (Math.random() * 2 - 1) * multiplier;
62      right[i] = (Math.random() * 2 - 1) * multiplier;
63    }
64    return impulse;
65  }
66  async startMic(deviceId) {
67    if (this.stream) {
68      this.stopMic();
69    }
70    try {
71      const constraints = {
72        audio: {
73          echoCancellation: false,
74          noiseSuppression: false,
75          autoGainControl: false,
76          ...deviceId ? { deviceId: { exact: deviceId } } : {}
77        }
78      };
79      this.stream = await navigator.mediaDevices.getUserMedia(constraints);
80      this.sourceNode = this.ctx.createMediaStreamSource(this.stream);
81      this.sourceNode.connect(this.compressor);
82      this.startAnalysisLoop();
83    } catch (e) {
84      console.error("Failed to access mic for VocalDSP:", e);
85    }
86  }
87  stopMic() {
88    if (this.stream) {
89      this.stream.getTracks().forEach((t) => t.stop());
90      this.stream = null;
91    }
92    if (this.sourceNode) {
93      this.sourceNode.disconnect();
94      this.sourceNode = null;
95    }
96    if (this._animFrame) {
97      cancelAnimationFrame(this._animFrame);
98      this._animFrame = null;
99    }
100  }
101  setEffects(reverbAmount, delayAmount, normalize) {
102    this.convolverGain.gain.value = reverbAmount;
103    this.delayGain.gain.value = delayAmount;
104    this.delay.delayTime.value = delayAmount > 0 ? 0.3 : 0;
105    this.dryGain.gain.value = 1 - Math.max(reverbAmount, delayAmount) * 0.5;
106    if (normalize) {
107      this.compressor.threshold.value = -30;
108      this.compressor.ratio.value = 4;
109    } else {
110      this.compressor.threshold.value = 0;
111      this.compressor.ratio.value = 1;
112    }
113  }
114  setAutoVocalMode(enabled) {
115    var _a;
116    this._autoVocalMode = enabled;
117    if (!enabled && this._guideTrackMuted) {
118      this._guideTrackMuted = false;
119      (_a = this.onMuteGuideTrack) == null ? void 0 : _a.call(this, false);
120      if (this._cooldownLatchTimeout) {
121        window.clearTimeout(this._cooldownLatchTimeout);
122        this._cooldownLatchTimeout = null;
123      }
124    }
125  }
126  startAnalysisLoop() {
127    const data = new Float32Array(this.analyser.fftSize);
128    const loop = () => {
129      if (!this._autoVocalMode || !this.onMuteGuideTrack) {
130        this._animFrame = requestAnimationFrame(loop);
131        return;
132      }
133      this.analyser.getFloatTimeDomainData(data);
134      let sum = 0;
135      for (let i = 0; i < data.length; i++) {
136        sum += data[i] * data[i];
137      }
138      const rms = Math.sqrt(sum / data.length);
139      const dbfs = 20 * Math.log10(rms || 1e-8);
140      if (dbfs > this._micThresholdDbfs) {
141        if (!this._guideTrackMuted) {
142          this._guideTrackMuted = true;
143          this.onMuteGuideTrack(true);
144        }
145        if (this._cooldownLatchTimeout) {
146          window.clearTimeout(this._cooldownLatchTimeout);
147        }
148        this._cooldownLatchTimeout = window.setTimeout(() => {
149          this._guideTrackMuted = false;
150          if (this.onMuteGuideTrack) this.onMuteGuideTrack(false);
151          this._cooldownLatchTimeout = null;
152        }, 1500);
153      }
154      this._animFrame = requestAnimationFrame(loop);
155    };
156    this._animFrame = requestAnimationFrame(loop);
157  }
158}
159class PitchTracker {
160  constructor(ctx) {
161    this.sourceNode = null;
162    this.stream = null;
163    this._animFrame = null;
164    this.ctx = ctx;
165    this.analyser = this.ctx.createAnalyser();
166    this.analyser.fftSize = 2048;
167    this.dataBuffer = new Float32Array(this.analyser.fftSize);
168  }
169  async start(deviceId) {
170    if (this.stream) return;
171    if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
172      console.error("navigator.mediaDevices.getUserMedia is not supported or requires HTTPS");
173      return;
174    }
175    try {
176      const constraints = {
177        audio: {
178          echoCancellation: false,
179          noiseSuppression: false,
180          autoGainControl: false
181        }
182      };
183      if (deviceId) {
184        constraints.audio.deviceId = { exact: deviceId };
185      }
186      this.stream = await navigator.mediaDevices.getUserMedia(constraints);
187      this.sourceNode = this.ctx.createMediaStreamSource(this.stream);
188      this.sourceNode.connect(this.analyser);
189      this.startLoop();
190    } catch (e) {
191      console.error("Failed to access mic for PitchTracker:", e);
192    }
193  }
194  stop() {
195    if (this.stream) {
196      this.stream.getTracks().forEach((t) => t.stop());
197      this.stream = null;
198    }
199    if (this.sourceNode) {
200      this.sourceNode.disconnect();
201      this.sourceNode = null;
202    }
203    if (this._animFrame) {
204      cancelAnimationFrame(this._animFrame);
205      this._animFrame = null;
206    }
207  }
208  startLoop() {
209    const loop = () => {
210      this.analyser.getFloatTimeDomainData(this.dataBuffer);
211      let sumSquares = 0;
212      for (let i = 0; i < this.dataBuffer.length; i++) {
213        sumSquares += this.dataBuffer[i] * this.dataBuffer[i];
214      }
215      const rms = Math.sqrt(sumSquares / this.dataBuffer.length);
216      if (this.onVolumeUpdate) {
217        this.onVolumeUpdate(rms);
218      }
219      const pitch = this.yinPitch(this.dataBuffer, this.ctx.sampleRate);
220      if (pitch > 0 && this.onPitchDetected) {
221        this.onPitchDetected(pitch);
222      }
223      this._animFrame = requestAnimationFrame(loop);
224    };
225    this._animFrame = requestAnimationFrame(loop);
226  }
227  // YIN Auto-Correlation Algorithm
228  yinPitch(data, sampleRate) {
229    const threshold = 0.1;
230    const windowSize = data.length / 2;
231    const yinBuffer = new Float32Array(windowSize);
232    let j, tau;
233    for (tau = 0; tau < windowSize; tau++) {
234      yinBuffer[tau] = 0;
235      for (j = 0; j < windowSize; j++) {
236        const delta = data[j] - data[j + tau];
237        yinBuffer[tau] += delta * delta;
238      }
239    }
240    yinBuffer[0] = 1;
241    yinBuffer[1] = 1;
242    let runningSum = 0;
243    for (tau = 1; tau < windowSize; tau++) {
244      runningSum += yinBuffer[tau];
245      yinBuffer[tau] *= tau / runningSum;
246    }
247    let tauEstimate = -1;
248    for (tau = 2; tau < windowSize; tau++) {
249      if (yinBuffer[tau] < threshold) {
250        while (tau + 1 < windowSize && yinBuffer[tau + 1] < yinBuffer[tau]) {
251          tau++;
252        }
253        tauEstimate = tau;
254        break;
255      }
256    }
257    if (tauEstimate === -1) {
258      for (tau = 2; tau < windowSize; tau++) {
259        if (yinBuffer[tau] < yinBuffer[tauEstimate === -1 ? 0 : tauEstimate] && yinBuffer[tau] < 0.2) {
260          tauEstimate = tau;
261        }
262      }
263    }
264    if (tauEstimate !== -1) {
265      const x0 = tauEstimate < 1 ? tauEstimate : tauEstimate - 1;
266      const x2 = tauEstimate + 1 < windowSize ? tauEstimate + 1 : tauEstimate;
267      let betterTau = tauEstimate;
268      if (x0 !== tauEstimate && x2 !== tauEstimate) {
269        const s0 = yinBuffer[x0];
270        const s1 = yinBuffer[tauEstimate];
271        const s2 = yinBuffer[x2];
272        betterTau = tauEstimate + (s2 - s0) / (2 * (2 * s1 - s2 - s0));
273      }
274      return sampleRate / betterTau;
275    }
276    return -1;
277  }
278}
279class SoundTouchSource {
280  constructor(ctx, SoundTouch, SimpleFilter) {
281    this.tracks = [];
282    this.isPlaying = false;
283    this.onended = null;
284    this.currentPosition = 0;
285    this.ctx = ctx;
286    this.node = ctx.createScriptProcessor(4096, 0, 2);
287    this.samplesBuffer = new Float32Array(4096 * 2);
288    this.st = new SoundTouch(ctx.sampleRate);
289    this.st.tempo = 1;
290    const source = {
291      extract: (target, numFrames, position) => {
292        this.currentPosition = position;
293        target.fill(0);
294        if (this.tracks.length === 0) return 0;
295        let maxFramesRead = 0;
296        for (const track of this.tracks) {
297          if (!track.enabled || track.volume <= 1e-4) continue;
298          const buffer = track.buffer;
299          const l = buffer.getChannelData(0);
300          const r = buffer.numberOfChannels > 1 ? buffer.getChannelData(1) : l;
301          const vol = track.volume;
302          let framesRead = 0;
303          for (let i = 0; i < numFrames; i++) {
304            const idx = position + i;
305            if (idx < buffer.length) {
306              target[i * 2] += l[idx] * vol;
307              target[i * 2 + 1] += r[idx] * vol;
308              framesRead++;
309            }
310          }
311          if (framesRead > maxFramesRead) maxFramesRead = framesRead;
312        }
313        return maxFramesRead;
314      }
315    };
316    this.filter = new SimpleFilter(source, this.st);
317    this.node.onaudioprocess = this.process.bind(this);
318  }
319  static async create(ctx) {
320    const { SoundTouch, SimpleFilter } = await __vitePreload(async () => {
321      const { SoundTouch: SoundTouch2, SimpleFilter: SimpleFilter2 } = await import("./vendor-audio-DH3w0BzW.js").then((n) => n.s);
322      return { SoundTouch: SoundTouch2, SimpleFilter: SimpleFilter2 };
323    }, true ? __vite__mapDeps([0,1,2,3,4]) : void 0);
324    return new SoundTouchSource(ctx, SoundTouch, SimpleFilter);
325  }
326  setTracks(tracks) {
327    this.tracks = tracks;
328  }
329  updateTrackVolume(index, volume, enabled) {
330    if (this.tracks[index]) {
331      this.tracks[index].volume = volume;
332      this.tracks[index].enabled = enabled;
333    }
334  }
335  setPlaybackRate(rate) {
336    if (this.st) {
337      this.st.tempo = rate;
338    }
339  }
340  setPitchSemitones(semitones) {
341    if (this.st) {
342      this.st.pitchSemitones = semitones;
343    }
344  }
345  connect(destination) {
346    this.node.connect(destination);
347  }
348  disconnect() {
349    this.node.disconnect();
350  }
351  start(offset = 0) {
352    if (!this.filter) return;
353    const sampleRate = this.tracks.length > 0 ? this.tracks[0].buffer.sampleRate : this.ctx.sampleRate;
354    this.filter.sourcePosition = Math.floor(offset * sampleRate);
355    this.isPlaying = true;
356    console.log(`[SoundTouchSource] Started at offset ${offset}s (pos: ${this.filter.sourcePosition})`);
357  }
358  stop() {
359    if (!this.isPlaying) return;
360    this.isPlaying = false;
361    console.log(`[SoundTouchSource] Stopped`);
362    if (this.onended) this.onended();
363  }
364  process(e) {
365    const outputBuffer = e.outputBuffer;
366    const numFrames = outputBuffer.length;
367    const outputL = outputBuffer.getChannelData(0);
368    const outputR = outputBuffer.getChannelData(1);
369    if (!this.isPlaying) {
370      outputL.fill(0);
371      outputR.fill(0);
372      return;
373    }
374    if (this.samplesBuffer.length < numFrames * 2) {
375      this.samplesBuffer = new Float32Array(numFrames * 2);
376    }
377    const framesExtracted = this.filter.extract(this.samplesBuffer, numFrames);
378    for (let i = 0; i < framesExtracted; i++) {
379      outputL[i] = this.samplesBuffer[i * 2];
380      outputR[i] = this.samplesBuffer[i * 2 + 1];
381    }
382    if (framesExtracted < numFrames) {
383      for (let i = framesExtracted; i < numFrames; i++) {
384        outputL[i] = 0;
385        outputR[i] = 0;
386      }
387      if (framesExtracted === 0 && this.isPlaying) {
388        this.stop();
389      }
390    }
391  }
392}
393function cleanMidiBuffer(buffer) {
394  const data = new Uint8Array(buffer);
395  const out = [];
396  let offset = 0;
397  while (offset < data.length) {
398    if (offset + 8 > data.length) break;
399    const id = String.fromCharCode(...data.slice(offset, offset + 4));
400    const length = data[offset + 4] << 24 | data[offset + 5] << 16 | data[offset + 6] << 8 | data[offset + 7];
401    if (id === "MThd" || id === "MTrk") {
402      out.push(data.slice(offset, offset + 8 + length));
403    }
404    offset += 8 + length;
405  }
406  const totalLen = out.reduce((sum, arr) => sum + arr.length, 0);
407  const cleaned = new Uint8Array(totalLen);
408  let curr = 0;
409  for (const arr of out) {
410    cleaned.set(arr, curr);
411    curr += arr.length;
412  }
413  return cleaned.buffer;
414}
415class HamieSynth {
416  constructor() {
417    this.reverbNode = null;
418    this.reverbWetGain = null;
419    this.currentReverbDecay = 1.8;
420    this.channels = [];
421    this.trackBlobs = /* @__PURE__ */ new Map();
422    this.abortController = null;
423    this.wasmSynth = null;
424    this.useSoundTouch = false;
425    this.isSoundfontNormalized = false;
426    this.limiter = null;
427    this.isPlaying = false;
428    this.startTime = 0;
429    this.startOffset = 0;
430    this.mutedChannels = /* @__PURE__ */ new Set();
431    this.hoveredChannels = /* @__PURE__ */ new Set();
432    this.soloChannel = null;
433    this.tickDiv = 480;
434    this.tempo = 120;
435    this.playbackRate = 1;
436    this.pitchSemitones = 0;
437    this.soundfont = "GeneralUser-GS";
438    this.defaultWasmSoundfont = "SoundBlasterOld.sf2";
439    this.defaultJsSoundfont = "MusyngKite";
440    this.isLiveMode = false;
441    this.eventsJson = null;
442    this.scheduledNotes = [];
443    this.audioMode = "wasmsynth";
444    this.midiAccess = null;
445    this.midiAccessStatus = "uninitialized";
446    this.activeMidiOutput = null;
447    this.midiOutputId = null;
448    this.schedulerTimer = null;
449    this.nextScheduledIndex = 0;
450    this.LOOKAHEAD_MS = 50;
451    this.SCHEDULE_AHEAD_SEC = 0.15;
452    const isMobile = typeof window !== "undefined" && (/Mobi|Android|iPhone|iPad/i.test(navigator.userAgent) || /Macintosh/i.test(navigator.userAgent) && navigator.maxTouchPoints > 0 || "ontouchstart" in window);
453    this.ctx = new (window.AudioContext || window.webkitAudioContext)(isMobile ? { latencyHint: "playback" } : void 0);
454    this.masterGain = this.ctx.createGain();
455    this.masterGain.gain.value = 1;
456    try {
457      this.limiter = this.ctx.createDynamicsCompressor();
458      this.limiter.threshold.setValueAtTime(-1, this.ctx.currentTime);
459      this.limiter.knee.setValueAtTime(0, this.ctx.currentTime);
460      this.limiter.ratio.setValueAtTime(20, this.ctx.currentTime);
461      this.limiter.attack.setValueAtTime(3e-3, this.ctx.currentTime);
462      this.limiter.release.setValueAtTime(0.1, this.ctx.currentTime);
463      this.masterGain.connect(this.limiter);
464      this.limiter.connect(this.ctx.destination);
465    } catch (e) {
466      console.warn("Failed to initialize master limiter:", e);
467      this.masterGain.connect(this.ctx.destination);
468    }
469    try {
470      this.reverbNode = this.ctx.createConvolver();
471      this.reverbWetGain = this.ctx.createGain();
472      this.reverbWetGain.gain.value = 0;
473      this.createReverbImpulseResponse(this.currentReverbDecay);
474      this.masterGain.connect(this.reverbNode);
475      this.reverbNode.connect(this.reverbWetGain);
476      if (this.limiter) {
477        this.reverbWetGain.connect(this.limiter);
478      } else {
479        this.reverbWetGain.connect(this.ctx.destination);
480      }
481    } catch (e) {
482      console.warn("Failed to initialize master reverb convolver:", e);
483    }
484    this.vocalDSP = new VocalDSP(this.ctx);
485    this.pitchTracker = new PitchTracker(this.ctx);
486  }
487  getMidiOutputs() {
488    if (!this.midiAccess) return [];
489    return Array.from(this.midiAccess.outputs.values());
490  }
491  setMidiOutputById(id) {
492    if (!this.midiAccess) return;
493    const outputs = this.getMidiOutputs();
494    const selected = outputs.find((o) => o.id === id);
495    if (selected) {
496      this.activeMidiOutput = selected;
497      this.midiOutputId = id;
498      console.log(`[HamieSynth] Selected MIDI output: ${selected.name}`);
499    }
500  }
501  getActiveMidiOutputId() {
502    return this.midiOutputId;
503  }
504  async initMidiOutput() {
505    if (typeof navigator === "undefined" || !navigator.requestMIDIAccess) {
506      console.warn("[HamieSynth] Web MIDI API is not supported in this browser.");
507      return;
508    }
509    if (this.midiAccess) return;
510    try {
511      this.midiAccess = await navigator.requestMIDIAccess();
512      this.midiAccessStatus = "granted";
513      const outputs = this.getMidiOutputs();
514      if (outputs.length > 0) {
515        if (this.midiOutputId) {
516          const selected = outputs.find((o) => o.id === this.midiOutputId);
517          if (selected) {
518            this.activeMidiOutput = selected;
519            return;
520          }
521        }
522        this.activeMidiOutput = outputs[0];
523        this.midiOutputId = outputs[0].id;
524        console.log(`[HamieSynth] Web MIDI initialized. Default output: ${outputs[0].name}`);
525      } else {
526        console.log("[HamieSynth] Web MIDI initialized, but no MIDI output ports found.");
527      }
528    } catch (e) {
529      console.error("[HamieSynth] Failed to request MIDI access:", e);
530      this.midiAccessStatus = "denied";
531    }
532  }
533  muteTrackByIndex(index, mute) {
534    if (this.channels[index] && this.channels[index].gainNode) {
535      this.channels[index].gainNode.gain.value = mute ? 0 : this.channels[index].userVolume;
536    }
537  }
538  setPan(index, pan) {
539    this.setTrackPan(index, pan);
540  }
541  async startVocalMic(deviceId) {
542    if (this.vocalDSP) {
543      await this.vocalDSP.startMic(deviceId);
544    }
545  }
546  setGuideTrackIndex(index) {
547    this.vocalDSP.onMuteGuideTrack = (mute) => {
548      this.muteTrackByIndex(index, mute);
549    };
550  }
551  getAudioContext() {
552    return this.ctx;
553  }
554  getMasterGain() {
555    return this.masterGain;
556  }
557  getChannels() {
558    return this.channels.map((ch, idx) => ({
559      index: idx,
560      name: ch.track.name,
561      gainNode: ch.gainNode
562    }));
563  }
564  getChannelBuffers() {
565    return this.channels.map((ch) => ({
566      track: ch.track,
567      buffer: ch.buffer,
568      userVolume: ch.userVolume
569    }));
570  }
571  getTrackBlob(index) {
572    return this.trackBlobs.get(index) || null;
573  }
574  async loadTracks(instruments, tickDiv, tempo, tier, soundfont, eventsPath = null, onProgress, forceLive = false, defaultSoundfont = "MusyngKite", isAdmin = false) {
575    var _a, _b;
576    const dbg = (...args) => {
577      if (isAdmin) console.log(...args);
578    };
579    if (this.abortController) this.abortController.abort();
580    this.abortController = new AbortController();
581    const signal = this.abortController.signal;
582    if (this.ctx.state === "suspended") {
583      try {
584        await this.ctx.resume();
585      } catch {
586      }
587    }
588    this.stop();
589    this.tickDiv = tickDiv;
590    this.tempo = tempo;
591    let finalSoundfont = soundfont;
592    const sfLower = soundfont.toLowerCase();
593    let isDirectSf2 = sfLower.endsWith(".sf2") || sfLower.endsWith(".sf3") || sfLower.endsWith(".hsf3");
594    let isHsf3Bank = sfLower.endsWith("_hsf3bank") || sfLower.includes("_hsf3bank/");
595    if (this.audioMode === "wasmsynth") {
596      const isExplicitSf = sfLower.endsWith(".sf2") || sfLower.endsWith(".sf3") || sfLower.endsWith(".hsf3") || sfLower.endsWith("_hsf3bank") || sfLower.includes("_hsf3bank/");
597      if (isExplicitSf) {
598        finalSoundfont = soundfont;
599      } else {
600        finalSoundfont = this.defaultWasmSoundfont || "SoundBlasterOld.sf2";
601      }
602      const fLower = finalSoundfont.toLowerCase();
603      isHsf3Bank = fLower.endsWith("_hsf3bank") || fLower.includes("_hsf3bank/");
604      if (!fLower.endsWith(".sf2") && !fLower.endsWith(".sf3") && !fLower.endsWith(".hsf3") && !isHsf3Bank) {
605        finalSoundfont += ".sf2";
606      }
607      isDirectSf2 = true;
608    } else if (this.audioMode === "chiptune" || this.audioMode === "fmsynth") {
609      isDirectSf2 = false;
610    }
611    if (isDirectSf2 && typeof AudioWorkletNode === "undefined") {
612      console.warn(`[HamieSynth] AudioWorkletNode is not supported in this environment (non-secure context). Falling back from direct SF2 to JS soundfont.`);
613      finalSoundfont = finalSoundfont.replace(/\.h?sf[23]$/i, "").replace(/\.hsf3$/i, "").replace(/_hsf3bank$/i, "");
614      isDirectSf2 = false;
615      isHsf3Bank = false;
616    }
617    this.soundfont = finalSoundfont;
618    this.isLiveMode = forceLive || this.audioMode === "chiptune" || this.audioMode === "fmsynth" || this.audioMode === "wasmsynth";
619    this.isSoundfontNormalized = false;
620    if (this.audioMode === "singleopus") {
621      console.log("[HamieSynth] Single Opus mode - skipping loading of stems/MIDI, marking all ready.");
622      this.isLiveMode = false;
623      this.channels = [];
624      if (onProgress) {
625        instruments.forEach((_, idx) => onProgress(idx, { percent: 100, ready: true }));
626      }
627      return;
628    }
629    this.channels = [];
630    this.trackBlobs.clear();
631    this.eventsJson = null;
632    if (this.wasmSynth) {
633      this.wasmSynth.destroy();
634      this.wasmSynth = null;
635    }
636    console.log(`[HamieSynth] Loading ${instruments.length} tracks (Tier: ${tier}, SF: ${finalSoundfont}, Live: ${this.isLiveMode}, Direct: ${isDirectSf2})...`);
637    dbg("%c[HamieSynth] loadTracks() called", "color:#60a5fa;font-weight:bold");
638    dbg("  instruments   :", instruments.map((i, n) => `[${n}] ${i.name} prog=${i.program} stemPath=${i.stemPath || "(none)"}`).join("\n                "));
639    dbg("  eventsPath    :", eventsPath || "(none)");
640    dbg("  tickDiv/tempo :", tickDiv, "/", tempo);
641    dbg("  tier/sf       :", tier, "/", finalSoundfont);
642    dbg("  forceLive     :", forceLive, "| isDirectSf2:", isDirectSf2);
643    let eventsJsonLoaded = false;
644    if (this.isLiveMode && eventsPath) {
645      const pathsToTry = Array.isArray(eventsPath) ? eventsPath : [eventsPath];
646      for (const path of pathsToTry) {
647        dbg(`[HamieSynth] Attempting to load events from: ${path}`);
648        try {
649          const res = await fetch(path, { signal });
650          dbg(`  fetch status for ${path}:`, res.status, res.ok ? "OK" : "FAILED");
651          if (res.ok) {
652            const isMidi = path.toLowerCase().includes(".mid");
653            let rawEvents = [];
654            if (isMidi) {
655              dbg(`  [HamieSynth] Parsing binary MIDI file...`);
656              let arrayBuffer = await res.arrayBuffer();
657              arrayBuffer = cleanMidiBuffer(arrayBuffer);
658              const midi = new Midi.Midi(arrayBuffer);
659              dbg(`  [HamieSynth] MIDI parsed successfully. Tracks: ${midi.tracks.length}`);
660              midi.tracks.forEach((track, trackIdx) => {
661                const trackName = track.name || `Track ${trackIdx + 1}`;
662                const midiCh = track.channel ?? 0;
663                track.notes.forEach((note) => {
664                  rawEvents.push({
665                    t: note.time,
666                    d: note.duration,
667                    v: Math.round(note.velocity * 127),
668                    n: note.midi,
669                    i: trackName,
670                    trackIdx,
671                    // raw track index (kept for legacy JSON paths)
672                    midiCh
673                    // actual MIDI channel (used for channel-keyed instruments)
674                  });
675                });
676              });
677            } else {
678              dbg(`  [HamieSynth] Parsing JSON events file...`);
679              rawEvents = await res.json();
680            }
681            if (!rawEvents || rawEvents.length === 0) {
682              throw new Error(`Events source ${path} yielded 0 notes, trying next source if available.`);
683            }
684            const musicalTrackIndices = [];
685            instruments.forEach((inst, idx) => {
686              if (inst.name !== "Informational Track") {
687                musicalTrackIndices.push(idx);
688              }
689            });
690            this.eventsJson = rawEvents.map((ev) => {
691              var _a2, _b2;
692              let mappedIdx = ev.trackIdx;
693              if (ev.midiCh !== void 0) {
694                const byChannel = instruments.findIndex((inst) => inst.midiChannel === ev.midiCh);
695                if (byChannel !== -1) return { ...ev, trackIdx: byChannel };
696              }
697              let targetIdx = -1;
698              const match = (_a2 = ev.i) == null ? void 0 : _a2.match(/^(.*) \((\d+)\)$/);
699              if (match) {
700                targetIdx = parseInt(match[2]) - 1;
701              } else {
702                const trackMatch = (_b2 = ev.i) == null ? void 0 : _b2.match(/^Track\s+(\d+)$/i);
703                if (trackMatch) {
704                  targetIdx = parseInt(trackMatch[1]) - 1;
705                } else if (!isNaN(parseInt(ev.i))) {
706                  targetIdx = parseInt(ev.i);
707                }
708              }
709              if (targetIdx >= 0) {
710                const foundByTrackId = instruments.findIndex((inst) => inst.trackId === targetIdx);
711                const foundInst = foundByTrackId !== -1 ? instruments[foundByTrackId] : null;
712                const nameMatches = foundInst && (!ev.i || ev.i.startsWith(foundInst.name || "") || foundInst.name && foundInst.name.startsWith(ev.i.split(" (")[0]));
713                if (foundByTrackId !== -1 && nameMatches) {
714                  mappedIdx = foundByTrackId;
715                } else if (targetIdx < musicalTrackIndices.length) {
716                  mappedIdx = musicalTrackIndices[targetIdx];
717                } else {
718                  mappedIdx = musicalTrackIndices[musicalTrackIndices.length - 1] || 0;
719                }
720              } else {
721                const instIdx = instruments.findIndex((inst) => inst.name === ev.i);
722                if (instIdx !== -1) {
723                  mappedIdx = instIdx;
724                } else if (ev.trackIdx < musicalTrackIndices.length) {
725                  mappedIdx = musicalTrackIndices[ev.trackIdx];
726                }
727              }
728              return {
729                ...ev,
730                trackIdx: mappedIdx
731              };
732            });
733            this.eventsJson.sort((a, b) => a.t - b.t);
734            console.log(`[HamieSynth] Successfully loaded, mapped and sorted ${this.eventsJson.length} live synthesis events from: ${path}`);
735            dbg("  eventsJson mapped. Sample[0]:", (_a = this.eventsJson) == null ? void 0 : _a[0]);
736            eventsJsonLoaded = true;
737            break;
738          }
739        } catch (e) {
740          console.error(`Failed to load/parse events from ${path}`, e);
741          dbg(`  [HamieSynth] Error fetching or parsing path ${path}:`, e);
742        }
743      }
744    }
745    if (this.isLiveMode && !eventsJsonLoaded) {
746      console.warn("[HamieSynth] Live mode requested but no events source could be loaded or parsed — no notes will play!");
747      dbg("%c[HamieSynth] ❌ Live mode active but no valid events loaded!", "color:red;font-weight:bold");
748      throw new Error("No valid events source could be loaded or parsed (possibly 404). Live synthesis aborted.");
749    }
750    const promises = instruments.map(async (inst, idx) => {
751      if (onProgress) onProgress(idx, { percent: 0, ready: false });
752      const gainNode = this.ctx.createGain();
753      const pannerNode = this.ctx.createStereoPanner();
754      gainNode.connect(pannerNode);
755      pannerNode.connect(this.masterGain);
756      const channel = {
757        track: inst,
758        buffer: null,
759        source: null,
760        stSource: null,
761        gainNode,
762        pannerNode,
763        userPan: 0,
764        userVolume: 1,
765        // Default volume
766        sfInstrument: null,
767        notes: null
768      };
769      this.channels.push(channel);
770      if (!inst.stemPath) {
771        if (!this.isLiveMode) {
772          dbg(`  [track ${idx}] No stemPath in stem mode — marking ready, skipping load.`);
773          if (onProgress) onProgress(idx, { percent: 100, ready: true });
774          return;
775        }
776        dbg(`  [track ${idx}] No stemPath, but live mode — continuing to load soundfont.`);
777      }
778      try {
779        if (this.isLiveMode) {
780          try {
781            if (this.audioMode === "chiptune" || this.audioMode === "fmsynth") {
782              dbg(`  [track ${idx}] ${this.audioMode} mode — skipping soundfont load, marking ready.`);
783              if (onProgress) onProgress(idx, { percent: 100, ready: true });
784            } else if (isDirectSf2) {
785              dbg(`  [track ${idx}] Direct SF2 mode — deferring to WASM load below.`);
786              if (onProgress) onProgress(idx, { percent: 10, ready: false });
787            } else {
788              const { loadSoundfontData, decodeSoundfontNotes, createBufferPlayer } = await __vitePreload(async () => {
789                const { loadSoundfontData: loadSoundfontData2, decodeSoundfontNotes: decodeSoundfontNotes2, createBufferPlayer: createBufferPlayer2 } = await import("./soundfont-utils-ieqHyFXd.js");
790                return { loadSoundfontData: loadSoundfontData2, decodeSoundfontNotes: decodeSoundfontNotes2, createBufferPlayer: createBufferPlayer2 };
791              }, true ? [] : void 0);
792              const program = inst.program || 0;
793              const baseUrl = `/storage/soundfonts/${this.soundfont}/`;
794              const sfUrl = `${baseUrl}${program}-opus.js`;
795              const fallbackUrl = `${baseUrl}0-opus.js`;
796              const defaultJsBaseUrl = `/storage/soundfonts/${this.defaultJsSoundfont}/`;
797              const defaultJsSfUrl = `${defaultJsBaseUrl}${program}-opus.js`;
798              const defaultJsFallbackUrl = `${defaultJsBaseUrl}0-opus.js`;
799              dbg(`  [track ${idx}] Loading JS soundfont program=${program} from: ${sfUrl}`);
800              try {
801                let noteData;
802                try {
803                  noteData = await loadSoundfontData(sfUrl);
804                  dbg(`  [track ${idx}] Soundfont loaded OK: ${sfUrl}`);
805                } catch (e1) {
806                  console.warn(`[HamieSynth] Failed to load program ${program} from ${this.soundfont}, trying 0-opus.js fallback...`);
807                  try {
808                    noteData = await loadSoundfontData(fallbackUrl);
809                  } catch (e2) {
810                    console.warn(`[HamieSynth] Failed to load 0-opus from ${this.soundfont}, trying global JS fallback: ${this.defaultJsSoundfont}...`);
811                    try {
812                      noteData = await loadSoundfontData(defaultJsSfUrl);
813                    } catch (e3) {
814                      console.warn(`[HamieSynth] Failed to load global JS fallback program ${program}, trying 0-opus.js...`);
815                      noteData = await loadSoundfontData(defaultJsFallbackUrl);
816                    }
817                  }
818                }
819                const buffers = await decodeSoundfontNotes(this.ctx, noteData);
820                if (noteData && noteData._normalized) {
821                  this.isSoundfontNormalized = true;
822                }
823                channel.sfInstrument = createBufferPlayer(this.ctx, buffers, channel.gainNode);
824                dbg(`  [track ${idx}] sfInstrument created. Keys available: ${Object.keys(buffers).length}`);
825                if (onProgress) onProgress(idx, { percent: 100, ready: true });
826              } catch (sfErr) {
827                console.error(`[HamieSynth] Total failure to load soundfont ${this.soundfont}`, sfErr);
828                dbg(`%c[HamieSynth] ❌ Soundfont load total failure for track ${idx}`, "color:red;font-weight:bold", sfErr);
829                this.isLiveMode = false;
830                if (onProgress) onProgress(idx, { percent: 0, ready: false, error: true });
831              }
832            }
833          } catch (err) {
834            console.error(`[HamieSynth] Live mode error for track ${idx}:`, err);
835          }
836        }
837        if (!this.isLiveMode) {
838          const stemUrl = inst.stemPathHigh || inst.stemPath;
839          const mimeType = "audio/opus";
840          if (!stemUrl) throw new Error(`No stem path found for track ${inst.name || idx}`);
841          const timeoutSignal = AbortSignal.timeout ? AbortSignal.timeout(9e4) : null;
842          const fetchSignal = timeoutSignal ? AbortSignal.any ? AbortSignal.any([signal, timeoutSignal]) : signal : signal;
843          let response = await fetch(stemUrl, { signal: fetchSignal });
844          if (!response.ok && inst.stemPath && stemUrl !== inst.stemPath) {
845            console.warn(`[HamieSynth] Stem fetch failed for ${stemUrl} (Status: ${response.status}), trying fallback ${inst.stemPath}`);
846            response = await fetch(inst.stemPath, { signal: fetchSignal });
847          }
848          if (!response.ok) throw new Error(`Failed to fetch stem: ${stemUrl} (Status: ${response.status})`);
849          const contentLength = response.headers.get("content-length");
850          const total = parseInt(contentLength || "0", 10);
851          const chunks = [];
852          let loaded = 0;
853          if (response.body) {
854            const reader = response.body.getReader();
855            while (true) {
856              const { done, value } = await reader.read();
857              if (done) break;
858              if (value) {
859                chunks.push(value);
860                loaded += value.length;
861                if (onProgress && total > 0) {
862                  const pct = Math.min(99, loaded / total * 100);
863                  onProgress(idx, { percent: pct, ready: false });
864                }
865              }
866            }
867            this.trackBlobs.set(idx, new Blob(chunks, { type: mimeType }));
868          } else {
869            const blob = await response.blob();
870            this.trackBlobs.set(idx, blob);
871            chunks.push(new Uint8Array(await blob.arrayBuffer()));
872            loaded = blob.size;
873          }
874          const combined = new Uint8Array(loaded);
875          let offset = 0;
876          for (const chunk of chunks) {
877            combined.set(chunk, offset);
878            offset += chunk.length;
879          }
880          const audioBuffer = await this.ctx.decodeAudioData(combined.buffer);
881          channel.buffer = audioBuffer;
882          channel.stSource = await SoundTouchSource.create(this.ctx);
883          channel.stSource.setTracks([{
884            buffer: audioBuffer,
885            volume: 1,
886            enabled: true
887          }]);
888          channel.stSource.connect(channel.gainNode);
889          if (onProgress) onProgress(idx, { percent: 100, ready: true });
890        }
891      } catch (e) {
892        if (e instanceof Error && e.name === "AbortError") {
893          console.log(`[HamieSynth] Loading aborted for ${inst.name}`);
894        } else {
895          console.error(`[HamieSynth] Error loading track ${inst.name}:`, e);
896          if (onProgress) onProgress(idx, { percent: 0, ready: false, error: true });
897        }
898      }
899    });
900    await Promise.all(promises);
901    if (isDirectSf2 && this.isLiveMode) {
902      try {
903        this.wasmSynth = new WasmSynthAdapter(this.ctx, this.masterGain);
904        let isBank = finalSoundfont.toLowerCase().endsWith("_hsf3bank") || finalSoundfont.toLowerCase().includes("_hsf3bank/");
905        if (isBank) {
906          const uniqueInsts = /* @__PURE__ */ new Map();
907          this.channels.forEach((ch, idx) => {
908            if (ch.track.program !== void 0) {
909              const midiChannel = ch.track.midiChannel !== void 0 && ch.track.midiChannel >= 0 ? ch.track.midiChannel : idx % 16;
910              const bank = midiChannel % 16 === 9 ? 128 : 0;
911              const program = ch.track.program;
912              uniqueInsts.set(`${bank}_${program}`, { bank, program });
913            }
914          });
915          if (uniqueInsts.size === 0) {
916            uniqueInsts.set("0_0", { bank: 0, program: 0 });
917          }
918          const sfProgress = (pct) => {
919            this.channels.forEach((ch, idx) => {
920              const mappedPct = 10 + pct * 85;
921              if (onProgress) onProgress(idx, { percent: mappedPct, ready: false });
922            });
923          };
924          await this.wasmSynth.loadHsf3Bank(finalSoundfont, Array.from(uniqueInsts.values()), sfProgress);
925        } else {
926          const sfProgress = (pct) => {
927            this.channels.forEach((ch, idx) => {
928              const mappedPct = 10 + pct * 85;
929              if (onProgress) onProgress(idx, { percent: mappedPct, ready: false });
930            });
931          };
932          await this.wasmSynth.load(`/storage/soundfonts/${finalSoundfont}`, sfProgress);
933        }
934        this.channels.forEach((ch, idx) => {
935          if (this.wasmSynth && ch.track.program !== void 0) {
936            const midiChannel = ch.track.midiChannel !== void 0 && ch.track.midiChannel >= 0 ? ch.track.midiChannel : idx % 16;
937            const bank = midiChannel % 16 === 9 ? 128 : 0;
938            this.wasmSynth.setProgram(midiChannel % 16, ch.track.program, bank);
939          }
940          if (onProgress) onProgress(idx, { percent: 100, ready: true });
941        });
942      } catch (err) {
943        console.error(`[HamieSynth] Failed to load direct SF2:`, err);
944        this.isLiveMode = false;
945        this.channels.forEach((ch, idx) => {
946          if (onProgress) onProgress(idx, { percent: 0, ready: false, error: true });
947        });
948      }
949    }
950    console.log(`[HamieSynth] Load finished. Mode: ${this.isLiveMode ? this.wasmSynth ? "Wasm-SF2" : "Live-JS" : "Stems"}`);
951    dbg("%c[HamieSynth] Load complete", "color:#34d399;font-weight:bold");
952    dbg("  final isLiveMode  :", this.isLiveMode);
953    dbg("  eventsJson count  :", ((_b = this.eventsJson) == null ? void 0 : _b.length) ?? "null");
954    dbg("  channels loaded   :", this.channels.length);
955    dbg("  wasmSynth         :", !!this.wasmSynth);
956    this.channels.forEach((ch, i) => {
957      var _a2;
958      dbg(`  [ch ${i}] ${ch.track.name} | buffer=${!!ch.buffer} | sfInstrument=${!!ch.sfInstrument} | notes=${((_a2 = ch.notes) == null ? void 0 : _a2.length) ?? "null"}`);
959    });
960    this.updateGains();
961  }
962  async play(offset = 0) {
963    if (this.ctx.state === "suspended") {
964      try {
965        await this.ctx.resume();
966      } catch {
967      }
968    }
969    if (this.isPlaying) this.stop();
970    this.isPlaying = true;
971    this.startOffset = offset;
972    this.startTime = this.ctx.currentTime;
973    this.useSoundTouch = Math.abs(this.playbackRate - 1) > 0.01 || this.pitchSemitones !== 0;
974    if (this.audioMode === "webmidi" && this.eventsJson) {
975      console.log(`[HamieSynth] Playing Web MIDI (Offset: ${offset}s)`);
976      this.scheduledNotes = [];
977      if (this.schedulerTimer) {
978        clearInterval(this.schedulerTimer);
979        this.schedulerTimer = null;
980      }
981      this.nextScheduledIndex = 0;
982      for (let i = 0; i < this.eventsJson.length; i++) {
983        if (this.eventsJson[i].t >= offset) {
984          this.nextScheduledIndex = i;
985          break;
986        }
987      }
988      this.initMidiOutput().then(() => {
989        if (this.activeMidiOutput) {
990          this.channels.forEach((ch, idx) => {
991            const channel = ch.track.midiChannel !== void 0 ? ch.track.midiChannel : idx % 16;
992            const program = ch.track.program || 0;
993            try {
994              this.activeMidiOutput.send([192 | channel, program]);
995            } catch (e) {
996              console.warn(`[HamieSynth] Failed to send MIDI Program Change for ch ${channel}`, e);
997            }
998          });
999        }
1000      });
1001      this.scheduleNotes();
1002      this.schedulerTimer = setInterval(() => {
1003        this.scheduleNotes();
1004      }, this.LOOKAHEAD_MS);
1005    } else if (this.isLiveMode && this.eventsJson) {
1006      console.log(`[HamieSynth] Playing Live SF (Mode: ${this.wasmSynth ? "Wasm" : "JS"}, Offset: ${offset}s)`);
1007      this.scheduledNotes.forEach((item) => {
1008        const n = (item == null ? void 0 : item.node) || item;
1009        if (n && typeof n.stop === "function") {
1010          try {
1011            n.stop();
1012          } catch {
1013          }
1014        }
1015      });
1016      this.scheduledNotes = [];
1017      if (this.schedulerTimer) {
1018        clearInterval(this.schedulerTimer);
1019        this.schedulerTimer = null;
1020      }
1021      this.nextScheduledIndex = 0;
1022      for (let i = 0; i < this.eventsJson.length; i++) {
1023        if (this.eventsJson[i].t >= offset) {
1024          this.nextScheduledIndex = i;
1025          break;
1026        }
1027      }
1028      this.scheduleNotes();
1029      this.schedulerTimer = setInterval(() => {
1030        this.scheduleNotes();
1031      }, this.LOOKAHEAD_MS);
1032    } else if (this.useSoundTouch) {
1033      console.log(`[HamieSynth] Playing with SoundTouch (Rate: ${this.playbackRate}, Pitch: ${this.pitchSemitones})`);
1034      this.channels.forEach((ch) => {
1035        if (ch.stSource && ch.buffer) {
1036          ch.stSource.setPlaybackRate(this.playbackRate);
1037          ch.stSource.setPitchSemitones(this.pitchSemitones);
1038          ch.stSource.start(offset);
1039        }
1040      });
1041    } else {
1042      console.log(`[HamieSynth] Playing Native (Rate: ${this.playbackRate})`);
1043      this.channels.forEach((ch) => {
1044        if (ch.buffer) {
1045          if (ch.source) {
1046            try {
1047              ch.source.stop();
1048              ch.source.disconnect();
1049            } catch {
1050            }
1051          }
1052          const source = this.ctx.createBufferSource();
1053          source.buffer = ch.buffer;
1054          source.connect(ch.gainNode);
1055          if (offset < ch.buffer.duration) {
1056            source.playbackRate.value = this.playbackRate;
1057            source.start(0, offset);
1058          }
1059          ch.source = source;
1060        }
1061      });
1062    }
1063    this.updateGains();
1064    console.log(`[HamieSynth] Playing from ${offset.toFixed(2)}s`);
1065  }
1066  stop() {
1067    this.isPlaying = false;
1068    if (this.schedulerTimer) {
1069      clearInterval(this.schedulerTimer);
1070      this.schedulerTimer = null;
1071    }
1072    this.scheduledNotes.forEach((item) => {
1073      const n = (item == null ? void 0 : item.node) || item;
1074      if (n && typeof n.stop === "function") {
1075        try {
1076          n.stop();
1077        } catch {
1078        }
1079      }
1080    });
1081    this.scheduledNotes = [];
1082    if (this.activeMidiOutput) {
1083      for (let c = 0; c < 16; c++) {
1084        try {
1085          this.activeMidiOutput.send([176 | c, 123, 0]);
1086          this.activeMidiOutput.send([176 | c, 120, 0]);
1087        } catch (e) {
1088        }
1089      }
1090    }
1091    if (this.wasmSynth) {
1092      this.wasmSynth.stopAll();
1093    }
1094    this.channels.forEach((ch) => {
1095      if (ch.source) {
1096        try {
1097          ch.source.stop();
1098          ch.source.disconnect();
1099        } catch {
1100        }
1101        ch.source = null;
1102      }
1103      if (ch.stSource) {
1104        ch.stSource.stop();
1105      }
1106    });
1107  }
1108  seek(time) {
1109    if (this.isPlaying) {
1110      this.play(time);
1111    } else {
1112      this.startOffset = time;
1113    }
1114  }
1115  scheduleNotes() {
1116    if (!this.isPlaying || !this.eventsJson) return;
1117    const now = this.ctx.currentTime;
1118    const currentSongTime = this.getCurrentTime();
1119    const scheduleLimitTime = currentSongTime + this.SCHEDULE_AHEAD_SEC * this.playbackRate;
1120    this.scheduledNotes = this.scheduledNotes.filter((item) => {
1121      if (item && item.endTime && item.endTime < now - 1) {
1122        return false;
1123      }
1124      return true;
1125    });
1126    const isLowPerf = typeof window !== "undefined" && localStorage.getItem("low_performance") === "true";
1127    const polyphonyLimit = isLowPerf ? 32 : 96;
1128    while (this.nextScheduledIndex < this.eventsJson.length) {
1129      const ev = this.eventsJson[this.nextScheduledIndex];
1130      if (ev.t > scheduleLimitTime) {
1131        break;
1132      }
1133      if (ev.t >= this.startOffset) {
1134        const ch = this.channels[ev.trackIdx];
1135        if (ch) {
1136          const activeVoices = this.scheduledNotes.filter((item) => item.endTime > now).length;
1137          if (activeVoices < polyphonyLimit) {
1138            const playTime = this.startTime + (ev.t - this.startOffset) / this.playbackRate;
1139            const duration = ev.d / this.playbackRate;
1140            const velocity = Math.floor(ev.v * ch.userVolume);
1141            if (this.audioMode === "webmidi" && this.activeMidiOutput) {
1142              const performanceTime = performance.now() + (playTime - now) * 1e3;
1143              const channel = ch.track.midiChannel !== void 0 ? ch.track.midiChannel : ev.trackIdx % 16;
1144              try {
1145                this.activeMidiOutput.send([144 | channel, ev.n, velocity], performanceTime);
1146                this.activeMidiOutput.send([128 | channel, ev.n, 0], performanceTime + duration * 1e3);
1147              } catch (e) {
1148                console.warn("[HamieSynth] Web MIDI send error", e);
1149              }
1150              this.scheduledNotes.push({ node: null, endTime: playTime + duration });
1151            } else if (this.audioMode === "chiptune") {
1152              try {
1153                const osc = this.ctx.createOscillator();
1154                const gainNode = this.ctx.createGain();
1155                osc.connect(gainNode);
1156                gainNode.connect(this.masterGain);
1157                const channel = ch.track.midiChannel !== void 0 ? ch.track.midiChannel : ev.trackIdx % 16;
1158                if (channel === 9) {
1159                  osc.type = "triangle";
1160                  const freq2 = 120;
1161                  osc.frequency.setValueAtTime(freq2, playTime);
1162                  osc.frequency.exponentialRampToValueAtTime(30, playTime + 0.08);
1163                } else if (channel === 0 || channel === 1 || channel === 2) {
1164                  osc.type = "square";
1165                } else if (channel === 3 || channel === 4) {
1166                  osc.type = "sawtooth";
1167                } else {
1168                  osc.type = ev.n < 48 ? "triangle" : "sine";
1169                }
1170                const freq = 440 * Math.pow(2, (ev.n - 69) / 12);
1171                osc.frequency.setValueAtTime(freq, playTime);
1172                let waveVolume = 0.28;
1173                if (osc.type === "square" || osc.type === "sawtooth") waveVolume = 0.12;
1174                if (osc.type === "sine") waveVolume = 0.38;
1175                const gainVal = waveVolume * (velocity / 127) * ch.userVolume;
1176                gainNode.gain.setValueAtTime(gainVal, playTime);
1177                gainNode.gain.exponentialRampToValueAtTime(1e-4, playTime + duration);
1178                osc.start(playTime);
1179                osc.stop(playTime + duration);
1180                this.scheduledNotes.push({
1181                  node: {
1182                    stop: () => {
1183                      try {
1184                        osc.stop();
1185                      } catch {
1186                      }
1187                    }
1188                  },
1189                  endTime: playTime + duration
1190                });
1191              } catch (e) {
1192                console.warn("[HamieSynth] Failed to play chiptune oscillator", e);
1193              }
1194            } else if (this.audioMode === "fmsynth") {
1195              try {
1196                const carrier = this.ctx.createOscillator();
1197                const modulator = this.ctx.createOscillator();
1198                const modGain = this.ctx.createGain();
1199                const carrierGain = this.ctx.createGain();
1200                carrier.connect(carrierGain);
1201                carrierGain.connect(this.masterGain);
1202                modulator.connect(modGain);
1203                modGain.connect(carrier.frequency);
1204                const channelNum = ch.track.midiChannel !== void 0 ? ch.track.midiChannel : ev.trackIdx % 16;
1205                const freq = 440 * Math.pow(2, (ev.n - 69) / 12);
1206                let ratio = 1;
1207                let modIndex = 2;
1208                let modDecay = duration;
1209                let carrierType = "sine";
1210                let modulatorType = "sine";
1211                let voiceVolume = 0.55;
1212                if (channelNum === 9) {
1213                  carrierType = "triangle";
1214                  modulatorType = "sawtooth";
1215                  ratio = 5.25;
1216                  modIndex = 30;
1217                  modDecay = Math.min(0.08, duration);
1218                  voiceVolume = 0.33;
1219                } else if (channelNum === 0 || channelNum === 1 || channelNum === 2) {
1220                  carrierType = "sawtooth";
1221                  modulatorType = "sine";
1222                  ratio = 0.5;
1223                  modIndex = 4;
1224                  modDecay = duration * 0.5;
1225                  voiceVolume = 0.18;
1226                } else if (channelNum === 3 || channelNum === 4) {
1227                  carrierType = "sine";
1228                  modulatorType = "sine";
1229                  ratio = 1;
1230                  modIndex = 2.5;
1231                  modDecay = duration;
1232                  voiceVolume = 0.44;
1233                } else {
1234                  carrierType = "sine";
1235                  modulatorType = "sine";
1236                  if (ev.n > 72) {
1237                    ratio = 3.5;
1238                    modIndex = 8;
1239                    modDecay = duration * 0.3;
1240                    voiceVolume = 0.4;
1241                  } else {
1242                    ratio = 1;
1243                    modIndex = 3;
1244                    modDecay = duration * 0.4;
1245                    voiceVolume = 0.48;
1246                  }
1247                }
1248                carrier.type = carrierType;
1249                carrier.frequency.setValueAtTime(freq, playTime);
1250                modulator.type = modulatorType;
1251                modulator.frequency.setValueAtTime(freq * ratio, playTime);
1252                const startMod = freq * modIndex;
1253                modGain.gain.setValueAtTime(startMod, playTime);
1254                modGain.gain.exponentialRampToValueAtTime(1e-4, playTime + modDecay);
1255                const gainVal = voiceVolume * (velocity / 127) * ch.userVolume;
1256                carrierGain.gain.setValueAtTime(gainVal, playTime);
1257                carrierGain.gain.exponentialRampToValueAtTime(1e-4, playTime + duration);
1258                modulator.start(playTime);
1259                modulator.stop(playTime + duration);
1260                carrier.start(playTime);
1261                carrier.stop(playTime + duration);
1262                this.scheduledNotes.push({
1263                  node: {
1264                    stop: () => {
1265                      try {
1266                        carrier.stop();
1267                        modulator.stop();
1268                      } catch {
1269                      }
1270                    }
1271                  },
1272                  endTime: playTime + duration
1273                });
1274              } catch (e) {
1275                console.warn("[HamieSynth] Failed to play FM oscillator", e);
1276              }
1277            } else if (this.wasmSynth) {
1278              const midiChannel = ev.midiCh !== void 0 ? ev.midiCh : ev.trackIdx % 16;
1279              this.wasmSynth.noteOn(midiChannel % 16, ev.n, velocity, playTime);
1280              this.wasmSynth.noteOff(midiChannel % 16, ev.n, playTime + duration);
1281              this.scheduledNotes.push({ node: null, endTime: playTime + duration });
1282            } else if (ch.sfInstrument) {
1283              const node = ch.sfInstrument.play(ev.n, playTime, {
1284                duration,
1285                gain: ev.v / 127
1286              });
1287              this.scheduledNotes.push({ node, endTime: playTime + duration });
1288            }
1289          }
1290        }
1291      }
1292      this.nextScheduledIndex++;
1293    }
1294  }
1295  getCurrentTime() {
1296    if (!this.isPlaying) return this.startOffset;
1297    return this.startOffset + (this.ctx.currentTime - this.startTime) * this.playbackRate;
1298  }
1299  /** Returns the max duration (in seconds) across all loaded stem AudioBuffers. */
1300  getDuration() {
1301    let maxDuration = 0;
1302    for (const ch of this.channels) {
1303      if (ch.buffer && ch.buffer.duration > maxDuration) {
1304        maxDuration = ch.buffer.duration;
1305      }
1306    }
1307    return maxDuration;
1308  }
1309  // --- Control Methods ---
1310  setPitchSemitones(semitones) {
1311    if (this.pitchSemitones === semitones) return;
1312    if (this.isPlaying) {
1313      const elapsed = this.ctx.currentTime - this.startTime;
1314      this.startOffset += elapsed * this.playbackRate;
1315      this.startTime = this.ctx.currentTime;
1316    }
1317    this.pitchSemitones = semitones;
1318    if (!this.isPlaying) return;
1319    const wasSoundTouch = this.useSoundTouch;
1320    const needsSoundTouch = Math.abs(this.playbackRate - 1) > 0.01 || semitones !== 0;
1321    this.useSoundTouch = needsSoundTouch;
1322    if (wasSoundTouch !== needsSoundTouch) {
1323      this.stop();
1324      this.play(this.startOffset);
1325    } else {
1326      if (this.useSoundTouch) {
1327        this.channels.forEach((ch) => {
1328          if (ch.stSource) ch.stSource.setPitchSemitones(semitones);
1329        });
1330      }
1331    }
1332  }
1333  setPlaybackRate(rate) {
1334    const oldRate = this.playbackRate;
1335    if (Math.abs(oldRate - rate) < 1e-3) return;
1336    if (this.isPlaying) {
1337      const elapsed = this.ctx.currentTime - this.startTime;
1338      this.startOffset += elapsed * oldRate;
1339      this.startTime = this.ctx.currentTime;
1340    }
1341    this.playbackRate = rate;
1342    if (!this.isPlaying) return;
1343    const wasSoundTouch = this.useSoundTouch;
1344    const needsSoundTouch = Math.abs(rate - 1) > 0.01 || this.pitchSemitones !== 0;
1345    this.useSoundTouch = needsSoundTouch;
1346    if (wasSoundTouch !== needsSoundTouch) {
1347      this.stop();
1348      this.play(this.startOffset);
1349    } else {
1350      if (this.useSoundTouch) {
1351        this.channels.forEach((ch) => {
1352          if (ch.stSource) ch.stSource.setPlaybackRate(rate);
1353        });
1354      } else {
1355        this.channels.forEach((ch) => {
1356          if (ch.source) {
1357            try {
1358              ch.source.playbackRate.value = rate;
1359            } catch {
1360            }
1361          }
1362        });
1363      }
1364    }
1365  }
1366  setMasterVolume(volume) {
1367    let multiplier = 6;
1368    try {
1369      if (typeof window !== "undefined") {
1370        const saved = localStorage.getItem("hamie_settings");
1371        if (saved) {
1372          const parsed = JSON.parse(saved);
1373          if (typeof parsed.soundfontVolume === "number") {
1374            multiplier = parsed.soundfontVolume === 4 ? 6 : parsed.soundfontVolume;
1375          }
1376        }
1377      }
1378    } catch (e) {
1379      console.error("Failed to read soundfontVolume from local storage", e);
1380    }
1381    const needsBoost = this.isLiveMode && this.audioMode === "synth" && !this.wasmSynth && !this.isSoundfontNormalized;
1382    const finalMultiplier = needsBoost ? multiplier : 1;
1383    const boostedVolume = volume * finalMultiplier;
1384    this.masterGain.gain.setTargetAtTime(boostedVolume, this.ctx.currentTime, 0.05);
1385  }
1386  setMute(channelIndex, isMuted) {
1387    if (isMuted) this.mutedChannels.add(channelIndex);
1388    else this.mutedChannels.delete(channelIndex);
1389    this.updateGains();
1390  }
1391  isChannelMuted(channelIndex) {
1392    if (this.soloChannel !== null) {
1393      return channelIndex !== this.soloChannel;
1394    }
1395    return this.mutedChannels.has(channelIndex);
1396  }
1397  setTrackInstrument(channelIndex, program) {
1398    if (channelIndex < 0 || channelIndex >= this.channels.length) return;
1399    const ch = this.channels[channelIndex];
1400    ch.track.program = program;
1401    const midiChannel = ch.track.midiChannel !== void 0 && ch.track.midiChannel >= 0 ? ch.track.midiChannel : channelIndex % 16;
1402    const bank = midiChannel % 16 === 9 ? 128 : 0;
1403    if (this.wasmSynth) {
1404      try {
1405        this.wasmSynth.setProgram(midiChannel % 16, program, bank);
1406      } catch (e) {
1407        console.warn(`[HamieSynth] Failed to change SpessaSynth program to ${program} for channel ${midiChannel}`, e);
1408      }
1409    }
1410    if (this.activeMidiOutput) {
1411      try {
1412        this.activeMidiOutput.send([192 | midiChannel, program]);
1413      } catch (e) {
1414        console.warn(`[HamieSynth] Failed to send MIDI Program Change to ${program} for channel ${midiChannel}`, e);
1415      }
1416    }
1417  }
1418  setHoverBoost(channelIndex, isBoosted) {
1419    if (isBoosted) this.hoveredChannels.add(channelIndex);
1420    else this.hoveredChannels.delete(channelIndex);
1421    this.updateGains();
1422  }
1423  setSolo(channelIndex) {
1424    this.soloChannel = channelIndex;
1425    this.updateGains();
1426  }
1427  setTrackVolume(channelIndex, volume) {
1428    if (channelIndex >= 0 && channelIndex < this.channels.length) {
1429      const ch = this.channels[channelIndex];
1430      ch.userVolume = volume;
1431      this.updateGains();
1432    }
1433  }
1434  setTrackPan(channelIndex, pan) {
1435    if (channelIndex >= 0 && channelIndex < this.channels.length) {
1436      const ch = this.channels[channelIndex];
1437      ch.userPan = Math.max(-1, Math.min(1, pan));
1438      if (ch.pannerNode) {
1439        ch.pannerNode.pan.setValueAtTime(ch.userPan, this.ctx.currentTime);
1440      }
1441      if (this.wasmSynth) {
1442        const midiChannel = ch.track.midiChannel !== void 0 && ch.track.midiChannel >= 0 ? ch.track.midiChannel : channelIndex % 16;
1443        this.wasmSynth.setChannelPan(midiChannel % 16, ch.userPan);
1444      }
1445    }
1446  }
1447  resetMixer() {
1448    this.mutedChannels.clear();
1449    this.soloChannel = null;
1450    this.channels.forEach((ch) => {
1451      ch.userVolume = 1;
1452      ch.userPan = 0;
1453      if (ch.pannerNode) {
1454        ch.pannerNode.pan.setValueAtTime(0, this.ctx.currentTime);
1455      }
1456    });
1457    if (this.wasmSynth) {
1458      this.channels.forEach((_, idx) => {
1459        this.wasmSynth.setChannelPan(idx % 16, 0);
1460      });
1461    }
1462    this.updateGains();
1463  }
1464  updateGains() {
1465    this.channels.forEach((ch, idx) => {
1466      let gain = ch.userVolume;
1467      let isMuted = false;
1468      if (this.soloChannel !== null) {
1469        if (idx !== this.soloChannel) {
1470          gain = 0;
1471          isMuted = true;
1472        }
1473      } else {
1474        if (this.mutedChannels.has(idx)) {
1475          gain = 0;
1476          isMuted = true;
1477        }
1478      }
1479      if (gain > 0 && this.hoveredChannels.has(idx)) {
1480        gain *= 1.5;
1481      }
1482      ch.gainNode.gain.cancelScheduledValues(this.ctx.currentTime);
1483      ch.gainNode.gain.setValueAtTime(gain, this.ctx.currentTime);
1484      if (this.wasmSynth) {
1485        const midiChannel = ch.track.midiChannel !== void 0 && ch.track.midiChannel >= 0 ? ch.track.midiChannel : idx % 16;
1486        this.wasmSynth.muteChannel(midiChannel % 16, isMuted);
1487        this.wasmSynth.setChannelVolume(midiChannel % 16, gain);
1488      }
1489    });
1490  }
1491  createReverbImpulseResponse(decayTime) {
1492    if (!this.reverbNode) return;
1493    try {
1494      const rate = this.ctx.sampleRate;
1495      const length = Math.max(1, rate * decayTime);
1496      const impulse = this.ctx.createBuffer(2, length, rate);
1497      const left = impulse.getChannelData(0);
1498      const right = impulse.getChannelData(1);
1499      for (let i = 0; i < length; i++) {
1500        const percent = i / length;
1501        const decay = Math.exp(-percent * 6);
1502        left[i] = (Math.random() * 2 - 1) * decay;
1503        right[i] = (Math.random() * 2 - 1) * decay;
1504      }
1505      this.reverbNode.buffer = impulse;
1506    } catch (e) {
1507      console.warn("Failed to create reverb impulse response:", e);
1508    }
1509  }
1510  setMasterReverbLevel(level) {
1511    if (this.reverbWetGain) {
1512      this.reverbWetGain.gain.setTargetAtTime(level, this.ctx.currentTime, 0.05);
1513    }
1514  }
1515  setMasterReverbRoomSize(size) {
1516    let decay = 1.8;
1517    if (size === "Small Room") decay = 0.8;
1518    else if (size === "Medium Hall") decay = 1.8;
1519    else if (size === "Large Hall") decay = 3;
1520    else if (size === "Cathedral") decay = 6;
1521    else if (size === "Plate") decay = 2.2;
1522    this.currentReverbDecay = decay;
1523    this.createReverbImpulseResponse(decay);
1524  }
1525}
1526const synth = new HamieSynth();
1527const getAudioContext = () => synth.getAudioContext();
1528async function parseMidiInstruments(urls) {
1529  for (const url of urls) {
1530    try {
1531      const res = await fetch(url);
1532      if (!res.ok) continue;
1533      let buf = await res.arrayBuffer();
1534      if (url.toLowerCase().includes(".mid")) {
1535        buf = cleanMidiBuffer(buf);
1536      }
1537      const midi = new Midi.Midi(buf);
1538      const tickDiv = midi.header.ppq || 480;
1539      const tempo = midi.header.tempos.length > 0 ? midi.header.tempos[0].bpm : 120;
1540      const channelMap = /* @__PURE__ */ new Map();
1541      midi.tracks.forEach((track) => {
1542        var _a, _b;
1543        if (track.notes.length === 0) return;
1544        const ch = track.channel ?? 0;
1545        if (!channelMap.has(ch)) {
1546          const program = ((_a = track.instrument) == null ? void 0 : _a.number) ?? 0;
1547          const name = track.name || ((_b = track.instrument) == null ? void 0 : _b.name) || `Channel ${ch + 1}`;
1548          channelMap.set(ch, { program, name });
1549        }
1550      });
1551      if (channelMap.size === 0) {
1552        return { tracks: [{ name: "Master", program: 0, midiChannel: 0, notesCount: 1, trackId: 0 }], duration: midi.duration, tempo, tickDiv };
1553      }
1554      const sorted = Array.from(channelMap.entries()).sort(([a], [b]) => a - b);
1555      return {
1556        tracks: sorted.map(([ch, info], idx) => ({
1557          name: info.name,
1558          program: info.program,
1559          midiChannel: ch,
1560          notesCount: 1,
1561          // placeholder – not needed for live mode
1562          trackId: idx,
1563          stemPath: void 0
1564        })),
1565        duration: midi.duration,
1566        tempo,
1567        tickDiv
1568      };
1569    } catch {
1570    }
1571  }
1572  console.warn("[parseMidiInstruments] Could not parse any MIDI URL — using single default track.");
1573  return { tracks: [{ name: "Master", program: 0, midiChannel: 0, notesCount: 1, trackId: 0 }], duration: 0, tempo: 120, tickDiv: 480 };
1574}
1575export {
1576  getAudioContext as g,
1577  parseMidiInstruments as p,
1578  synth as s
1579};

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.