utkucoban commited on
Commit
9bd3a64
·
verified ·
1 Parent(s): 8cc5eda

Upload 4 files

Browse files
Files changed (2) hide show
  1. app.js +94 -358
  2. worker.js +469 -0
app.js CHANGED
@@ -44,17 +44,55 @@ const canvasCtx = noteCanvas.getContext("2d");
44
 
45
  // Inference & Audio Variables
46
  let activeModelName = "";
47
- let activeTokenizer = "abc";
48
- let session;
49
- let stoi;
50
- let itos;
51
- let vocabSize = 0;
52
- let h;
53
- let c;
54
- let currentId;
55
  let running = false;
56
  let pumping = false;
57
  let schedulerId = 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  let nextNoteTime = 0;
59
  let generatedEvents = 0;
60
  let queue = [];
@@ -569,141 +607,6 @@ function updateReadouts(lastToken = null) {
569
  if (topKValue) topKValue.textContent = topKInput.value;
570
  }
571
 
572
- function tokenId(token) {
573
- return stoi?.[token] ?? null;
574
- }
575
-
576
- function makeTensorId(id) {
577
- return new ort.Tensor("int64", BigInt64Array.from([BigInt(id)]), [1, 1]);
578
- }
579
-
580
- function zeroState() {
581
- return new ort.Tensor("float32", new Float32Array(numLayers * hiddenSize), [numLayers, 1, hiddenSize]);
582
- }
583
-
584
- // Dimensions are updated explicitly on model load
585
-
586
- function promptIds() {
587
- if (midiSeedActive && midiTokens.length > 0) {
588
- const sliced = getTokensUpToBar(midiTokens, midiStartBar);
589
- return sliced.map(tokenId).filter((id) => id !== null);
590
- }
591
- const eventPrompt = ["BOS", "BPM_120", "GRID_64", "BAR", "POS_0"];
592
- const abcPrompt = ["X:", "1", "<NL>", "T:", "piece", "<NL>", "M:", "4/4", "<NL>", "L:", "1/8", "<NL>", "Q:", "1/4=120", "<NL>", "K:", "C", "<NL>"];
593
- const prompt = activeTokenizer === "giantmidi_event" ? eventPrompt : abcPrompt;
594
- const ids = prompt.map(tokenId).filter((id) => id !== null);
595
- return ids.length ? ids : [0];
596
- }
597
-
598
- async function warmPrompt() {
599
- h = zeroState();
600
- c = zeroState();
601
-
602
- const allTokensStr = midiSeedActive && midiTokens.length > 0
603
- ? getTokensUpToBar(midiTokens, midiStartBar)
604
- : (activeTokenizer === "giantmidi_event"
605
- ? ["BOS", "BPM_120", "GRID_64", "BAR", "POS_0"]
606
- : ["X:", "1", "<NL>", "T:", "piece", "<NL>", "M:", "4/4", "<NL>", "L:", "1/8", "<NL>", "Q:", "1/4=120", "<NL>", "K:", "C", "<NL>"]);
607
-
608
- isWarmingUp = true;
609
-
610
- // 1. Feed all prompt tokens to parser to advance its clock
611
- for (const token of allTokensStr) {
612
- if (parser) {
613
- parser.feed(token);
614
- }
615
- }
616
-
617
- // 2. Slice model warm-up tokens to last 256 (matching model's context window)
618
- const modelTokensStr = allTokensStr.slice(-256);
619
- const ids = modelTokensStr.map(tokenId).filter((id) => id !== null);
620
-
621
- for (const id of ids) {
622
- currentId = id;
623
- await stepModel(false);
624
- }
625
-
626
- isWarmingUp = false;
627
- }
628
-
629
- function sampleFromLogits(logits) {
630
- const temperature = Math.max(0.05, Number(tempInput.value));
631
- const topK = Math.max(1, Number(topKInput.value));
632
- const scored = [];
633
- for (let i = 0; i < logits.length; i += 1) {
634
- const token = itos[i];
635
- if (token === undefined) continue;
636
- let score = logits[i] / temperature;
637
- if (token === "<EOP>" || token === "EOS") score -= 1.0;
638
- scored.push([i, score]);
639
- }
640
- scored.sort((a, b) => b[1] - a[1]);
641
- const picked = scored.slice(0, Math.min(topK, scored.length));
642
- const maxScore = picked[0]?.[1] ?? 0;
643
- let sum = 0;
644
- for (const item of picked) {
645
- item[2] = Math.exp(item[1] - maxScore);
646
- sum += item[2];
647
- }
648
- let r = Math.random() * sum;
649
- for (const item of picked) {
650
- r -= item[2];
651
- if (r <= 0) return item[0];
652
- }
653
- return picked[picked.length - 1][0];
654
- }
655
-
656
- async function stepModel(record = true) {
657
- const output = await session.run({ input: makeTensorId(currentId), h, c });
658
- h = output.h_out;
659
- c = output.c_out;
660
- currentId = sampleFromLogits(output.logits.data);
661
- const token = itos[currentId] ?? "?";
662
- if (record) {
663
- updateReadouts(token);
664
- parser.feed(token);
665
- }
666
- return token;
667
- }
668
-
669
- function durationToSecondsFromAbc(duration) {
670
- let eighths = 1;
671
- if (duration) {
672
- if (duration === "/") eighths = 0.5;
673
- else if (duration.startsWith("/")) eighths = 1 / Number(duration.slice(1));
674
- else if (duration.includes("/")) {
675
- const [a, b] = duration.split("/").map(Number);
676
- eighths = a / b;
677
- } else {
678
- eighths = Number(duration);
679
- }
680
- }
681
- const quarterSeconds = 60 / Number(tempoEl.textContent || 120);
682
- return Math.max(0.08, eighths * quarterSeconds * 0.5);
683
- }
684
-
685
- function durationToSecondsFromEventSteps(steps, grid) {
686
- const quarterSeconds = 60 / Number(tempoEl.textContent || 120);
687
- return Math.max(0.035, (Math.max(1, steps) * 4 * quarterSeconds) / Math.max(1, grid));
688
- }
689
-
690
- function midiToTonePitch(midi) {
691
- const names = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
692
- return `${names[((midi % 12) + 12) % 12]}${Math.floor(midi / 12) - 1}`;
693
- }
694
-
695
- function abcToTonePitch(token) {
696
- const match = token.match(/^(\^\^|__|\^|_|=)?([A-Ga-g])([,']*)$/);
697
- if (!match) return null;
698
- const accidental = match[1] || "";
699
- const step = match[2];
700
- const marks = match[3] || "";
701
- let octave = step === step.toLowerCase() ? 5 : 4;
702
- for (const mark of marks) octave += mark === "'" ? 1 : -1;
703
- const accidentalText = accidental === "^^" ? "##" : accidental === "__" ? "bb" : accidental === "^" ? "#" : accidental === "_" ? "b" : "";
704
- return `${step.toUpperCase()}${accidentalText}${octave}`;
705
- }
706
-
707
  function midiFromPitchName(note) {
708
  const match = String(note).match(/^([A-G])([#b]{0,2})(-?\d+)$/);
709
  if (!match) return null;
@@ -732,200 +635,20 @@ function recordVisualEvent(event, startTime) {
732
  }
733
 
734
  function pushEvent(event) {
735
- if (isWarmingUp) return; // Discard prompt history events
736
  if (event.duration < 0 || queue.length > TARGET_QUEUE_EVENTS * 3) return;
737
  queue.push(event);
738
  generatedEvents += event.type === "note" ? 1 : 0;
739
  updateReadouts();
740
  }
741
 
742
- function makeAbcParser() {
743
- return {
744
- pending: null,
745
- chord: null,
746
- reset() {
747
- this.pending = null;
748
- this.chord = null;
749
- },
750
- feed(token) {
751
- if (token === "Q:") {
752
- this.pending = { type: "tempoHeader" };
753
- return;
754
- }
755
- if (this.pending?.type === "tempoHeader") {
756
- const bpm = Number(String(token).split("=").pop());
757
- if (Number.isFinite(bpm) && bpm >= 40 && bpm <= 220) {
758
- if (!midiSeedActive || isWarmingUp) {
759
- if (tempoEl) tempoEl.textContent = String(Math.round(bpm));
760
- }
761
- }
762
- this.pending = null;
763
- return;
764
- }
765
- if (token === "[") {
766
- this.chord = [];
767
- this.pending = null;
768
- return;
769
- }
770
- if (this.chord) {
771
- if (token === "]") {
772
- this.pending = { type: "chord", notes: this.chord.map(abcToTonePitch).filter(Boolean) };
773
- this.chord = null;
774
- return;
775
- }
776
- if (abcNotePattern.test(token)) this.chord.push(token);
777
- return;
778
- }
779
- if (token === "z") {
780
- this.pending = { type: "rest" };
781
- return;
782
- }
783
- if (abcNotePattern.test(token)) {
784
- this.pending = { type: "note", note: abcToTonePitch(token) };
785
- return;
786
- }
787
- if (token.startsWith("DUR:") && this.pending) {
788
- this.emitPending(token.slice(4));
789
- return;
790
- }
791
- if (this.pending && (token === "|" || token === "<NL>" || token === "<EOP>")) this.emitPending(null);
792
- },
793
- emitPending(durationToken) {
794
- const duration = durationToSecondsFromAbc(durationToken);
795
- if (this.pending.type === "rest") pushEvent({ type: "rest", duration });
796
- if (this.pending.type === "note" && this.pending.note) pushEvent({ type: "note", notes: [this.pending.note], duration, advance: duration });
797
- if (this.pending.type === "chord" && this.pending.notes.length) pushEvent({ type: "note", notes: this.pending.notes.slice(0, 8), duration, advance: duration });
798
- this.pending = null;
799
- },
800
- };
801
- }
802
-
803
- function makeEventParser() {
804
- return {
805
- grid: 64,
806
- bar: -1,
807
- pos: 0,
808
- pendingPosition: null,
809
- pendingNotes: [],
810
- pendingNote: null,
811
- lastQ: 0,
812
- reset() {
813
- this.grid = tokenId("GRID_64") !== null ? 64 : 64;
814
- this.bar = -1;
815
- this.pos = 0;
816
- this.pendingPosition = null;
817
- this.pendingNotes = [];
818
- this.pendingNote = null;
819
- this.lastQ = 0;
820
- },
821
- feed(token) {
822
- if (token.startsWith("BPM_")) {
823
- const bpm = Number(token.slice(4));
824
- if (Number.isFinite(bpm) && bpm >= 40 && bpm <= 220) {
825
- if (!midiSeedActive || isWarmingUp) {
826
- if (tempoEl) tempoEl.textContent = String(Math.round(bpm));
827
- }
828
- }
829
- return;
830
- }
831
- if (token.startsWith("GRID_")) {
832
- const grid = Number(token.slice(5));
833
- if (Number.isFinite(grid) && grid > 0) this.grid = grid;
834
- return;
835
- }
836
- if (token === "BAR") {
837
- this.flushTo(this.absoluteQFor(this.bar + 1, 0));
838
- this.bar += 1;
839
- this.pos = 0;
840
- return;
841
- }
842
- if (token.startsWith("POS_")) {
843
- const pos = Number(token.slice(4));
844
- if (!Number.isFinite(pos)) return;
845
- this.flushTo(this.absoluteQFor(this.bar, pos));
846
- this.pos = pos;
847
- return;
848
- }
849
- if (token.startsWith("NOTE_")) {
850
- const midi = Number(token.slice(5));
851
- if (Number.isFinite(midi) && midi >= 0 && midi <= 127) {
852
- this.pendingNote = { midi, durationSteps: 1, velocity: 0.72 };
853
- }
854
- return;
855
- }
856
- if (token.startsWith("DUR_") && this.pendingNote) {
857
- const steps = Number(token.slice(4));
858
- if (Number.isFinite(steps)) this.pendingNote.durationSteps = Math.max(1, steps);
859
- return;
860
- }
861
- if (token.startsWith("VEL_") && this.pendingNote) {
862
- const bucket = Number(token.slice(4));
863
- if (Number.isFinite(bucket)) this.pendingNote.velocity = Math.max(0.2, Math.min(0.95, bucket / 8));
864
- const q = this.absoluteQFor(this.bar, this.pos);
865
- this.pendingPosition ??= q;
866
- this.pendingNotes.push(this.pendingNote);
867
- this.pendingNote = null;
868
- }
869
- },
870
- absoluteQFor(bar, pos) {
871
- return Math.max(0, bar) * 4 + (Math.max(0, pos) * 4) / Math.max(1, this.grid);
872
- },
873
- flushTo(nextQ) {
874
- if (this.pendingNote) {
875
- const q = this.absoluteQFor(this.bar, this.pos);
876
- this.pendingPosition ??= q;
877
- this.pendingNotes.push(this.pendingNote);
878
- this.pendingNote = null;
879
- }
880
- if (this.pendingNotes.length && this.pendingPosition !== null) {
881
- const gap = Math.max(0, this.pendingPosition - this.lastQ);
882
- if (gap > 0) pushEvent({ type: "rest", duration: this.quartersToSeconds(gap) });
883
- pushEvent({
884
- type: "note",
885
- notes: this.pendingNotes.map((note) => midiToTonePitch(note.midi)),
886
- perNoteDurations: this.pendingNotes.map((note) => durationToSecondsFromEventSteps(note.durationSteps, this.grid)),
887
- velocities: this.pendingNotes.map((note) => note.velocity),
888
- duration: 0,
889
- advance: 0,
890
- });
891
- this.lastQ = this.pendingPosition;
892
- }
893
- const finalGap = Math.max(0, nextQ - this.lastQ);
894
- if (finalGap > 0) pushEvent({ type: "rest", duration: this.quartersToSeconds(finalGap) });
895
- this.lastQ = Math.max(this.lastQ, nextQ);
896
- this.pendingPosition = null;
897
- this.pendingNotes = [];
898
- },
899
- quartersToSeconds(quarters) {
900
- return (quarters * 60) / Number(tempoEl.textContent || 120);
901
- },
902
- };
903
- }
904
-
905
- function makeParser() {
906
- return activeTokenizer === "giantmidi_event" ? makeEventParser() : makeAbcParser();
907
- }
908
-
909
- async function pumpTokens() {
910
  if (!running || pumping || queue.length >= TARGET_QUEUE_EVENTS) return;
911
  pumping = true;
912
- setStatus("Generating...", "buffering");
913
- try {
914
- let steps = 0;
915
- while (running && queue.length < TARGET_QUEUE_EVENTS && steps < STEPS_PER_PUMP) {
916
- await stepModel(true);
917
- steps += 1;
918
- }
919
- if (running) {
920
- setStatus(`Playing ${activeModelName}`, "playing");
921
- }
922
- } catch (error) {
923
- console.error(error);
924
- setStatus(`Generation error: ${error.message}`, "idle");
925
- stop();
926
- } finally {
927
- pumping = false;
928
- }
929
  }
930
 
931
  function scheduleAudio() {
@@ -1030,7 +753,7 @@ async function ensurePiano() {
1030
  }
1031
 
1032
  async function start() {
1033
- if (running || !session) return;
1034
  startBtn.disabled = true;
1035
  setStatus("Starting audio...", "loading");
1036
 
@@ -1042,11 +765,21 @@ async function start() {
1042
  visualNotes = [];
1043
  if (tempoEl) tempoEl.textContent = midiSeedActive ? String(Math.round(midiBpm)) : "120";
1044
 
1045
- parser = makeParser();
1046
- parser.reset();
1047
-
1048
  setStatus("Warming AI model prompt...", "buffering");
1049
- await warmPrompt();
 
 
 
 
 
 
 
 
 
 
 
 
 
1050
 
1051
  running = true;
1052
  stopBtn.disabled = false;
@@ -1059,7 +792,6 @@ async function start() {
1059
  midiStartBarInput.disabled = true;
1060
 
1061
  setStatus(`Playing ${activeModelName}`, "playing");
1062
- await pumpTokens();
1063
 
1064
  nextNoteTime = Tone.now() + 0.12;
1065
  startVisualizer();
@@ -1073,7 +805,10 @@ function stop() {
1073
  running = false;
1074
  window.clearTimeout(schedulerId);
1075
  schedulerId = 0;
1076
- startBtn.disabled = !session;
 
 
 
1077
  stopBtn.disabled = true;
1078
  if (newSongBtn) newSongBtn.disabled = true;
1079
  if (recordBtn) recordBtn.disabled = true;
@@ -1096,7 +831,7 @@ function stop() {
1096
  }
1097
  if (visualFrame) cancelAnimationFrame(visualFrame);
1098
  visualFrame = 0;
1099
- setStatus(session ? `Ready: ${activeModelName}` : "Stopped", "idle");
1100
  }
1101
 
1102
  // MIDI Recording functions
@@ -1245,7 +980,7 @@ function downloadMidi(fileBytes, filename = "nanomaestro_performance.mid") {
1245
 
1246
  async function loadSelectedModel() {
1247
  stop();
1248
- session = null;
1249
  startBtn.disabled = true;
1250
  if (!modelFileInput || !vocabFileInput) {
1251
  setStatus("Select model_int8.onnx and matching vocab.json in Advanced settings", "idle");
@@ -1261,18 +996,21 @@ async function loadSelectedModel() {
1261
  activeModelName = modelFile.name;
1262
  setStatus(`Reading ${vocabFile.name}...`, "loading");
1263
  const vocab = JSON.parse(await vocabFile.text());
1264
- stoi = vocab.stoi;
1265
- itos = Object.fromEntries(Object.entries(vocab.itos).map(([key, value]) => [Number(key), value]));
1266
- vocabSize = vocab.vocab_size;
1267
- activeTokenizer = vocab.tokenizer || "abc";
1268
 
1269
  setStatus(`Loading ${modelFile.name}...`, "loading");
1270
  const modelBuffer = await modelFile.arrayBuffer();
1271
- session = await ort.InferenceSession.create(modelBuffer, {
1272
- executionProviders: ["wasm"],
1273
- graphOptimizationLevel: "all",
 
 
 
 
 
 
 
1274
  });
1275
- updateModelDimensions(session);
1276
  setStatus(`Ready: ${activeModelName}`, "idle");
1277
  startBtn.disabled = false;
1278
  }
@@ -1595,10 +1333,6 @@ async function init() {
1595
  // Step 1. Load Vocabulary
1596
  updateProgress(5, "Downloading vocabulary metadata...");
1597
  const vocab = await fetchWithCache("https://huggingface.co/utkucoban/NanoMaestro-Realtime/resolve/main/NanoMaestro-Realtime/NM2.4%20ONNX%20int8/vocab.json", null, true);
1598
- stoi = vocab.stoi;
1599
- itos = Object.fromEntries(Object.entries(vocab.itos).map(([key, value]) => [Number(key), value]));
1600
- vocabSize = vocab.vocab_size;
1601
- activeTokenizer = vocab.tokenizer || "abc";
1602
 
1603
  // Step 2. Download ONNX Model
1604
  updateProgress(15, "Downloading AI model...");
@@ -1610,18 +1344,20 @@ async function init() {
1610
  updateProgress(pct, `Downloading AI model (${loadedMB}MB / ${totalMB}MB)...`);
1611
  }, false);
1612
 
1613
- // Step 3. Initialize inference session
1614
  updateProgress(82, "Initializing neural network engine...");
1615
- ort.env.wasm.wasmPaths = "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.20.1/dist/";
1616
- ort.env.wasm.numThreads = Math.min(4, navigator.hardwareConcurrency || 1);
1617
 
1618
  const modelBuffer = await modelBlob.arrayBuffer();
1619
- session = await ort.InferenceSession.create(modelBuffer, {
1620
- executionProviders: ["wasm"],
1621
- graphOptimizationLevel: "all",
 
 
 
 
 
 
1622
  });
1623
- numLayers = 2;
1624
- hiddenSize = 1024;
1625
  activeModelName = "model_int8.onnx (v2.4)";
1626
 
1627
  // Step 4. Loading Piano Samples
 
44
 
45
  // Inference & Audio Variables
46
  let activeModelName = "";
47
+ let isModelReady = false;
 
 
 
 
 
 
 
48
  let running = false;
49
  let pumping = false;
50
  let schedulerId = 0;
51
+
52
+ // Web Worker instance & routing
53
+ const worker = new Worker("./worker.js");
54
+ let modelInitResolve = null;
55
+ let modelInitReject = null;
56
+ let startResolve = null;
57
+ let startReject = null;
58
+
59
+ worker.onmessage = function (e) {
60
+ const data = e.data;
61
+ switch (data.action) {
62
+ case "initialized":
63
+ isModelReady = true;
64
+ if (modelInitResolve) modelInitResolve();
65
+ break;
66
+ case "started":
67
+ if (data.events) {
68
+ data.events.forEach(event => pushEvent(event));
69
+ }
70
+ if (data.lastToken) {
71
+ updateReadouts(data.lastToken);
72
+ }
73
+ if (startResolve) startResolve();
74
+ break;
75
+ case "events":
76
+ if (data.events) {
77
+ data.events.forEach(event => pushEvent(event));
78
+ }
79
+ if (data.lastToken) {
80
+ updateReadouts(data.lastToken);
81
+ }
82
+ pumping = false;
83
+ break;
84
+ case "tempo":
85
+ if (tempoEl) tempoEl.textContent = String(Math.round(data.bpm));
86
+ break;
87
+ case "error":
88
+ console.error("Worker error:", data.message);
89
+ setStatus(data.message, "idle");
90
+ stop();
91
+ if (modelInitReject) modelInitReject(new Error(data.message));
92
+ if (startReject) startReject(new Error(data.message));
93
+ break;
94
+ }
95
+ };
96
  let nextNoteTime = 0;
97
  let generatedEvents = 0;
98
  let queue = [];
 
607
  if (topKValue) topKValue.textContent = topKInput.value;
608
  }
609
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
610
  function midiFromPitchName(note) {
611
  const match = String(note).match(/^([A-G])([#b]{0,2})(-?\d+)$/);
612
  if (!match) return null;
 
635
  }
636
 
637
  function pushEvent(event) {
 
638
  if (event.duration < 0 || queue.length > TARGET_QUEUE_EVENTS * 3) return;
639
  queue.push(event);
640
  generatedEvents += event.type === "note" ? 1 : 0;
641
  updateReadouts();
642
  }
643
 
644
+ function pumpTokens() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
645
  if (!running || pumping || queue.length >= TARGET_QUEUE_EVENTS) return;
646
  pumping = true;
647
+ worker.postMessage({
648
+ action: "pump",
649
+ temperature: Math.max(0.05, Number(tempInput.value)),
650
+ topK: Math.max(1, Number(topKInput.value))
651
+ });
 
 
 
 
 
 
 
 
 
 
 
 
652
  }
653
 
654
  function scheduleAudio() {
 
753
  }
754
 
755
  async function start() {
756
+ if (running || !isModelReady) return;
757
  startBtn.disabled = true;
758
  setStatus("Starting audio...", "loading");
759
 
 
765
  visualNotes = [];
766
  if (tempoEl) tempoEl.textContent = midiSeedActive ? String(Math.round(midiBpm)) : "120";
767
 
 
 
 
768
  setStatus("Warming AI model prompt...", "buffering");
769
+
770
+ await new Promise((resolve, reject) => {
771
+ startResolve = resolve;
772
+ startReject = reject;
773
+ worker.postMessage({
774
+ action: "start",
775
+ temperature: Math.max(0.05, Number(tempInput.value)),
776
+ topK: Math.max(1, Number(topKInput.value)),
777
+ bpm: Number(tempoEl.textContent || 120),
778
+ midiSeedActive: midiSeedActive,
779
+ midiTokens: midiSeedActive ? midiTokens : [],
780
+ midiStartBar: midiStartBar
781
+ });
782
+ });
783
 
784
  running = true;
785
  stopBtn.disabled = false;
 
792
  midiStartBarInput.disabled = true;
793
 
794
  setStatus(`Playing ${activeModelName}`, "playing");
 
795
 
796
  nextNoteTime = Tone.now() + 0.12;
797
  startVisualizer();
 
805
  running = false;
806
  window.clearTimeout(schedulerId);
807
  schedulerId = 0;
808
+
809
+ worker.postMessage({ action: "stop" });
810
+
811
+ startBtn.disabled = !isModelReady;
812
  stopBtn.disabled = true;
813
  if (newSongBtn) newSongBtn.disabled = true;
814
  if (recordBtn) recordBtn.disabled = true;
 
831
  }
832
  if (visualFrame) cancelAnimationFrame(visualFrame);
833
  visualFrame = 0;
834
+ setStatus(isModelReady ? `Ready: ${activeModelName}` : "Stopped", "idle");
835
  }
836
 
837
  // MIDI Recording functions
 
980
 
981
  async function loadSelectedModel() {
982
  stop();
983
+ isModelReady = false;
984
  startBtn.disabled = true;
985
  if (!modelFileInput || !vocabFileInput) {
986
  setStatus("Select model_int8.onnx and matching vocab.json in Advanced settings", "idle");
 
996
  activeModelName = modelFile.name;
997
  setStatus(`Reading ${vocabFile.name}...`, "loading");
998
  const vocab = JSON.parse(await vocabFile.text());
 
 
 
 
999
 
1000
  setStatus(`Loading ${modelFile.name}...`, "loading");
1001
  const modelBuffer = await modelFile.arrayBuffer();
1002
+
1003
+ await new Promise((resolve, reject) => {
1004
+ modelInitResolve = resolve;
1005
+ modelInitReject = reject;
1006
+ worker.postMessage({
1007
+ action: "init",
1008
+ activeModelName: modelFile.name,
1009
+ vocab: vocab,
1010
+ modelBuffer: modelBuffer
1011
+ }, [modelBuffer]);
1012
  });
1013
+
1014
  setStatus(`Ready: ${activeModelName}`, "idle");
1015
  startBtn.disabled = false;
1016
  }
 
1333
  // Step 1. Load Vocabulary
1334
  updateProgress(5, "Downloading vocabulary metadata...");
1335
  const vocab = await fetchWithCache("https://huggingface.co/utkucoban/NanoMaestro-Realtime/resolve/main/NanoMaestro-Realtime/NM2.4%20ONNX%20int8/vocab.json", null, true);
 
 
 
 
1336
 
1337
  // Step 2. Download ONNX Model
1338
  updateProgress(15, "Downloading AI model...");
 
1344
  updateProgress(pct, `Downloading AI model (${loadedMB}MB / ${totalMB}MB)...`);
1345
  }, false);
1346
 
1347
+ // Step 3. Initialize inference session inside worker
1348
  updateProgress(82, "Initializing neural network engine...");
 
 
1349
 
1350
  const modelBuffer = await modelBlob.arrayBuffer();
1351
+ await new Promise((resolve, reject) => {
1352
+ modelInitResolve = resolve;
1353
+ modelInitReject = reject;
1354
+ worker.postMessage({
1355
+ action: "init",
1356
+ activeModelName: "model_int8.onnx (v2.4)",
1357
+ vocab: vocab,
1358
+ modelBuffer: modelBuffer
1359
+ }, [modelBuffer]);
1360
  });
 
 
1361
  activeModelName = "model_int8.onnx (v2.4)";
1362
 
1363
  // Step 4. Loading Piano Samples
worker.js ADDED
@@ -0,0 +1,469 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // NanoMaestro Web Worker for isolated model inference and parsing
2
+ importScripts("https://cdn.jsdelivr.net/npm/onnxruntime-web@1.20.1/dist/ort.min.js");
3
+
4
+ // ONNX runtime configuration
5
+ ort.env.wasm.wasmPaths = "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.20.1/dist/";
6
+ ort.env.wasm.numThreads = Math.min(4, navigator.hardwareConcurrency || 1);
7
+
8
+ // State variables
9
+ let session = null;
10
+ let stoi = null;
11
+ let itos = null;
12
+ let vocabSize = 0;
13
+ let activeTokenizer = "abc";
14
+
15
+ let h = null;
16
+ let c = null;
17
+ let currentId = 0;
18
+ let activeModelName = "";
19
+
20
+ // Model parameters
21
+ let hiddenSize = 1024;
22
+ let numLayers = 2;
23
+
24
+ // Playback settings
25
+ let temperature = 0.85;
26
+ let topK = 40;
27
+ let currentBpm = 120;
28
+
29
+ // Seeding settings
30
+ let midiSeedActive = false;
31
+ let midiTokens = [];
32
+ let midiStartBar = 0;
33
+ let isWarmingUp = false;
34
+
35
+ // Temporal queues for gathering parsed events
36
+ let tempQueue = [];
37
+ let parser = null;
38
+
39
+ // MIDI to Pitch Name helpers
40
+ const abcNotePattern = /^(?:\^\^|__|\^|_|=)?[A-Ga-g][,']*$/;
41
+
42
+ function tokenId(token) {
43
+ return stoi?.[token] ?? null;
44
+ }
45
+
46
+ function makeTensorId(id) {
47
+ return new ort.Tensor("int64", BigInt64Array.from([BigInt(id)]), [1, 1]);
48
+ }
49
+
50
+ function zeroState() {
51
+ return new ort.Tensor("float32", new Float32Array(numLayers * hiddenSize), [numLayers, 1, hiddenSize]);
52
+ }
53
+
54
+ function updateModelDimensions(session) {
55
+ try {
56
+ const inputNames = session.inputNames;
57
+ if (inputNames.includes("h") && session.handler && session.handler._model) {
58
+ const inputs = session.handler._model.graph.inputs;
59
+ const hInput = inputs.find(i => i.name === "h");
60
+ if (hInput && hInput.type && hInput.type.tensorType && hInput.type.tensorType.shape) {
61
+ const shape = hInput.type.tensorType.shape.dim;
62
+ const layers = Number(shape[0].dimValue);
63
+ const hidden = Number(shape[2].dimValue);
64
+ if (layers > 0 && hidden > 0) {
65
+ numLayers = layers;
66
+ hiddenSize = hidden;
67
+ console.log(`Worker set model dimensions: layers=${numLayers}, hiddenSize=${hiddenSize}`);
68
+ }
69
+ }
70
+ }
71
+ } catch (e) {
72
+ console.error("Worker failed to detect model dimensions:", e);
73
+ // Fall back to default
74
+ numLayers = 2;
75
+ hiddenSize = 1024;
76
+ }
77
+ }
78
+
79
+ // Model Step Execution
80
+ async function stepModel(record = true) {
81
+ if (!session) return "?";
82
+ const output = await session.run({ input: makeTensorId(currentId), h, c });
83
+ h = output.h_out;
84
+ c = output.c_out;
85
+ currentId = sampleFromLogits(output.logits.data);
86
+ const token = itos[currentId] ?? "?";
87
+ if (record && parser) {
88
+ parser.feed(token);
89
+ }
90
+ return token;
91
+ }
92
+
93
+ function sampleFromLogits(logits) {
94
+ const temp = Math.max(0.05, temperature);
95
+ const k = Math.max(1, topK);
96
+ const scored = [];
97
+ for (let i = 0; i < logits.length; i += 1) {
98
+ const token = itos[i];
99
+ if (token === undefined) continue;
100
+ let score = logits[i] / temp;
101
+ if (token === "<EOP>" || token === "EOS") score -= 1.0;
102
+ scored.push([i, score]);
103
+ }
104
+ scored.sort((a, b) => b[1] - a[1]);
105
+ const picked = scored.slice(0, Math.min(k, scored.length));
106
+ const maxScore = picked[0]?.[1] ?? 0;
107
+ let sum = 0;
108
+ for (const item of picked) {
109
+ item[2] = Math.exp(item[1] - maxScore);
110
+ sum += item[2];
111
+ }
112
+ let r = Math.random() * sum;
113
+ for (const item of picked) {
114
+ r -= item[2];
115
+ if (r <= 0) return item[0];
116
+ }
117
+ return picked[picked.length - 1][0];
118
+ }
119
+
120
+ // Duration converters
121
+ function durationToSecondsFromAbc(duration) {
122
+ let eighths = 1;
123
+ if (duration) {
124
+ if (duration === "/") eighths = 0.5;
125
+ else if (duration.startsWith("/")) eighths = 1 / Number(duration.slice(1));
126
+ else if (duration.includes("/")) {
127
+ const [a, b] = duration.split("/").map(Number);
128
+ eighths = a / b;
129
+ } else {
130
+ eighths = Number(duration);
131
+ }
132
+ }
133
+ const quarterSeconds = 60 / currentBpm;
134
+ return Math.max(0.08, eighths * quarterSeconds * 0.5);
135
+ }
136
+
137
+ function durationToSecondsFromEventSteps(steps, grid) {
138
+ const quarterSeconds = 60 / currentBpm;
139
+ return Math.max(0.035, (Math.max(1, steps) * 4 * quarterSeconds) / Math.max(1, grid));
140
+ }
141
+
142
+ function midiToTonePitch(midi) {
143
+ const names = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
144
+ return `${names[((midi % 12) + 12) % 12]}${Math.floor(midi / 12) - 1}`;
145
+ }
146
+
147
+ function abcToTonePitch(token) {
148
+ const match = token.match(/^(\^\^|__|\^|_|=)?([A-Ga-g])([,']*)$/);
149
+ if (!match) return null;
150
+ const accidental = match[1] || "";
151
+ const step = match[2];
152
+ const marks = match[3] || "";
153
+ let octave = step === step.toLowerCase() ? 5 : 4;
154
+ for (const mark of marks) octave += mark === "'" ? 1 : -1;
155
+ const accidentalText = accidental === "^^" ? "##" : accidental === "__" ? "bb" : accidental === "^" ? "#" : accidental === "_" ? "b" : "";
156
+ return `${step.toUpperCase()}${accidentalText}${octave}`;
157
+ }
158
+
159
+ function pushEvent(event) {
160
+ if (isWarmingUp) return; // Discard prompt history events
161
+ tempQueue.push(event);
162
+ }
163
+
164
+ // Token Slicers
165
+ function getTokensUpToBar(tokens, targetBar) {
166
+ let barCount = 0;
167
+ const sliced = [];
168
+ for (const t of tokens) {
169
+ if (t === "BAR") {
170
+ barCount += 1;
171
+ if (barCount > targetBar) {
172
+ break;
173
+ }
174
+ }
175
+ sliced.push(t);
176
+ }
177
+ return sliced;
178
+ }
179
+
180
+ // ABC Parser
181
+ function makeAbcParser() {
182
+ return {
183
+ pending: null,
184
+ chord: null,
185
+ reset() {
186
+ this.pending = null;
187
+ this.chord = null;
188
+ },
189
+ feed(token) {
190
+ if (token === "Q:") {
191
+ this.pending = { type: "tempoHeader" };
192
+ return;
193
+ }
194
+ if (this.pending?.type === "tempoHeader") {
195
+ const bpm = Number(String(token).split("=").pop());
196
+ if (Number.isFinite(bpm) && bpm >= 40 && bpm <= 220) {
197
+ if (!midiSeedActive || isWarmingUp) {
198
+ currentBpm = bpm;
199
+ self.postMessage({ action: "tempo", bpm: bpm });
200
+ }
201
+ }
202
+ this.pending = null;
203
+ return;
204
+ }
205
+ if (token === "[") {
206
+ this.chord = [];
207
+ this.pending = null;
208
+ return;
209
+ }
210
+ if (this.chord) {
211
+ if (token === "]") {
212
+ this.pending = { type: "chord", notes: this.chord.map(abcToTonePitch).filter(Boolean) };
213
+ this.chord = null;
214
+ return;
215
+ }
216
+ if (abcNotePattern.test(token)) this.chord.push(token);
217
+ return;
218
+ }
219
+ if (token === "z") {
220
+ this.pending = { type: "rest" };
221
+ return;
222
+ }
223
+ if (abcNotePattern.test(token)) {
224
+ this.pending = { type: "note", note: abcToTonePitch(token) };
225
+ return;
226
+ }
227
+ if (token.startsWith("DUR:") && this.pending) {
228
+ this.emitPending(token.slice(4));
229
+ return;
230
+ }
231
+ if (this.pending && (token === "|" || token === "<NL>" || token === "<EOP>")) this.emitPending(null);
232
+ },
233
+ emitPending(durationToken) {
234
+ const duration = durationToSecondsFromAbc(durationToken);
235
+ if (this.pending.type === "rest") pushEvent({ type: "rest", duration });
236
+ if (this.pending.type === "note" && this.pending.note) pushEvent({ type: "note", notes: [this.pending.note], duration, advance: duration });
237
+ if (this.pending.type === "chord" && this.pending.notes.length) pushEvent({ type: "note", notes: this.pending.notes.slice(0, 8), duration, advance: duration });
238
+ this.pending = null;
239
+ },
240
+ };
241
+ }
242
+
243
+ // Event Parser
244
+ function makeEventParser() {
245
+ return {
246
+ grid: 64,
247
+ bar: -1,
248
+ pos: 0,
249
+ pendingPosition: null,
250
+ pendingNotes: [],
251
+ pendingNote: null,
252
+ lastQ: 0,
253
+ reset() {
254
+ this.grid = 64;
255
+ this.bar = -1;
256
+ this.pos = 0;
257
+ this.pendingPosition = null;
258
+ this.pendingNotes = [];
259
+ this.pendingNote = null;
260
+ this.lastQ = 0;
261
+ },
262
+ feed(token) {
263
+ if (token.startsWith("BPM_")) {
264
+ const bpm = Number(token.slice(4));
265
+ if (Number.isFinite(bpm) && bpm >= 40 && bpm <= 220) {
266
+ if (!midiSeedActive || isWarmingUp) {
267
+ currentBpm = bpm;
268
+ self.postMessage({ action: "tempo", bpm: bpm });
269
+ }
270
+ }
271
+ return;
272
+ }
273
+ if (token.startsWith("GRID_")) {
274
+ const grid = Number(token.slice(5));
275
+ if (Number.isFinite(grid) && grid > 0) this.grid = grid;
276
+ return;
277
+ }
278
+ if (token === "BAR") {
279
+ this.flushTo(this.absoluteQFor(this.bar + 1, 0));
280
+ this.bar += 1;
281
+ this.pos = 0;
282
+ return;
283
+ }
284
+ if (token.startsWith("POS_")) {
285
+ const pos = Number(token.slice(4));
286
+ if (!Number.isFinite(pos)) return;
287
+ this.flushTo(this.absoluteQFor(this.bar, pos));
288
+ this.pos = pos;
289
+ return;
290
+ }
291
+ if (token.startsWith("NOTE_")) {
292
+ const midi = Number(token.slice(5));
293
+ if (Number.isFinite(midi) && midi >= 0 && midi <= 127) {
294
+ this.pendingNote = { midi, durationSteps: 1, velocity: 0.72 };
295
+ }
296
+ return;
297
+ }
298
+ if (token.startsWith("DUR_") && this.pendingNote) {
299
+ const steps = Number(token.slice(4));
300
+ if (Number.isFinite(steps)) this.pendingNote.durationSteps = Math.max(1, steps);
301
+ return;
302
+ }
303
+ if (token.startsWith("VEL_") && this.pendingNote) {
304
+ const bucket = Number(token.slice(4));
305
+ if (Number.isFinite(bucket)) this.pendingNote.velocity = Math.max(0.2, Math.min(0.95, bucket / 8));
306
+ const q = this.absoluteQFor(this.bar, this.pos);
307
+ this.pendingPosition ??= q;
308
+ this.pendingNotes.push(this.pendingNote);
309
+ this.pendingNote = null;
310
+ }
311
+ },
312
+ absoluteQFor(bar, pos) {
313
+ return Math.max(0, bar) * 4 + (Math.max(0, pos) * 4) / Math.max(1, this.grid);
314
+ },
315
+ flushTo(nextQ) {
316
+ if (this.pendingNote) {
317
+ const q = this.absoluteQFor(this.bar, this.pos);
318
+ this.pendingPosition ??= q;
319
+ this.pendingNotes.push(this.pendingNote);
320
+ this.pendingNote = null;
321
+ }
322
+ if (this.pendingNotes.length && this.pendingPosition !== null) {
323
+ const gap = Math.max(0, this.pendingPosition - this.lastQ);
324
+ if (gap > 0) pushEvent({ type: "rest", duration: this.quartersToSeconds(gap) });
325
+ pushEvent({
326
+ type: "note",
327
+ notes: this.pendingNotes.map((note) => midiToTonePitch(note.midi)),
328
+ perNoteDurations: this.pendingNotes.map((note) => durationToSecondsFromEventSteps(note.durationSteps, this.grid)),
329
+ velocities: this.pendingNotes.map((note) => note.velocity),
330
+ duration: 0,
331
+ advance: 0,
332
+ });
333
+ this.lastQ = this.pendingPosition;
334
+ }
335
+ const finalGap = Math.max(0, nextQ - this.lastQ);
336
+ if (finalGap > 0) pushEvent({ type: "rest", duration: this.quartersToSeconds(finalGap) });
337
+ this.lastQ = Math.max(this.lastQ, nextQ);
338
+ this.pendingPosition = null;
339
+ this.pendingNotes = [];
340
+ },
341
+ quartersToSeconds(quarters) {
342
+ return (quarters * 60) / currentBpm;
343
+ },
344
+ };
345
+ }
346
+
347
+ function makeParser() {
348
+ return activeTokenizer === "giantmidi_event" ? makeEventParser() : makeAbcParser();
349
+ }
350
+
351
+ async function warmPrompt() {
352
+ h = zeroState();
353
+ c = zeroState();
354
+
355
+ const allTokensStr = midiSeedActive && midiTokens.length > 0
356
+ ? getTokensUpToBar(midiTokens, midiStartBar)
357
+ : (activeTokenizer === "giantmidi_event"
358
+ ? ["BOS", "BPM_120", "GRID_64", "BAR", "POS_0"]
359
+ : ["X:", "1", "<NL>", "T:", "piece", "<NL>", "M:", "4/4", "<NL>", "L:", "1/8", "<NL>", "Q:", "1/4=120", "<NL>", "K:", "C", "<NL>"]);
360
+
361
+ isWarmingUp = true;
362
+
363
+ // 1. Feed all prompt tokens to parser to advance its clock
364
+ if (parser) {
365
+ for (const token of allTokensStr) {
366
+ parser.feed(token);
367
+ }
368
+ }
369
+
370
+ // 2. Slice model warm-up tokens to last 256 (matching model's context window)
371
+ const modelTokensStr = allTokensStr.slice(-256);
372
+ const ids = modelTokensStr.map(tokenId).filter((id) => id !== null);
373
+
374
+ for (const id of ids) {
375
+ currentId = id;
376
+ await stepModel(false);
377
+ }
378
+
379
+ isWarmingUp = false;
380
+ }
381
+
382
+ async function pumpTokens(targetCount = 96) {
383
+ tempQueue = [];
384
+ let steps = 0;
385
+ // Keep stepping the model until we collect enough parsed events or hit loop bounds
386
+ while (tempQueue.length < targetCount && steps < 300) {
387
+ await stepModel(true);
388
+ steps += 1;
389
+ }
390
+ return {
391
+ events: tempQueue,
392
+ lastToken: itos[currentId] ?? "?"
393
+ };
394
+ }
395
+
396
+ // Message Router
397
+ self.onmessage = async function (e) {
398
+ const data = e.data;
399
+
400
+ switch (data.action) {
401
+ case "init":
402
+ try {
403
+ console.log(`Worker loading model: ${data.activeModelName}`);
404
+ activeModelName = data.activeModelName;
405
+ stoi = data.vocab.stoi;
406
+ itos = Object.fromEntries(Object.entries(data.vocab.itos).map(([key, value]) => [Number(key), value]));
407
+ vocabSize = data.vocab.vocab_size;
408
+ activeTokenizer = data.vocab.tokenizer || "abc";
409
+
410
+ session = await ort.InferenceSession.create(data.modelBuffer, {
411
+ executionProviders: ["wasm"],
412
+ graphOptimizationLevel: "all",
413
+ });
414
+
415
+ updateModelDimensions(session);
416
+ self.postMessage({ action: "initialized" });
417
+ } catch (err) {
418
+ console.error("Worker initialization failed:", err);
419
+ self.postMessage({ action: "error", message: `Init failed: ${err.message}` });
420
+ }
421
+ break;
422
+
423
+ case "start":
424
+ try {
425
+ temperature = data.temperature;
426
+ topK = data.topK;
427
+ currentBpm = data.bpm;
428
+ midiSeedActive = data.midiSeedActive;
429
+ midiTokens = data.midiTokens;
430
+ midiStartBar = data.midiStartBar;
431
+
432
+ parser = makeParser();
433
+ parser.reset();
434
+
435
+ await warmPrompt();
436
+
437
+ const initialBatch = await pumpTokens(96);
438
+ self.postMessage({
439
+ action: "started",
440
+ events: initialBatch.events,
441
+ lastToken: initialBatch.lastToken
442
+ });
443
+ } catch (err) {
444
+ console.error("Worker start failed:", err);
445
+ self.postMessage({ action: "error", message: `Start failed: ${err.message}` });
446
+ }
447
+ break;
448
+
449
+ case "pump":
450
+ try {
451
+ temperature = data.temperature;
452
+ topK = data.topK;
453
+ const batch = await pumpTokens(96);
454
+ self.postMessage({
455
+ action: "events",
456
+ events: batch.events,
457
+ lastToken: batch.lastToken
458
+ });
459
+ } catch (err) {
460
+ console.error("Worker pump failed:", err);
461
+ self.postMessage({ action: "error", message: `Pump failed: ${err.message}` });
462
+ }
463
+ break;
464
+
465
+ case "stop":
466
+ // Halts ongoing actions, resets states if needed
467
+ break;
468
+ }
469
+ };