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.