so the caller can append it to the modal.
@@ -24,11 +39,43 @@ export function createFileList(
// Section wrapper
const filesSection = document.createElement("div");
+ // Header row with title and uniform track color toggle
+ const headerRow = document.createElement("div");
+ headerRow.style.cssText =
+ "display:flex;align-items:center;justify-content:space-between;margin:0 0 12px;";
+
// Header
const filesHeader = document.createElement("h3");
filesHeader.textContent = "MIDI Files";
- filesHeader.style.cssText = "margin:0 0 12px;font-size:16px;font-weight:600;";
- filesSection.appendChild(filesHeader);
+ filesHeader.style.cssText = "margin:0;font-size:16px;font-weight:600;";
+ headerRow.appendChild(filesHeader);
+
+ // Uniform Track Color toggle
+ const uniformColorToggle = document.createElement("label");
+ uniformColorToggle.style.cssText =
+ "display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-muted);cursor:pointer;";
+
+ const uniformColorCheckbox = document.createElement("input");
+ uniformColorCheckbox.type = "checkbox";
+ uniformColorCheckbox.checked =
+ dependencies.stateManager.getState().visual.uniformTrackColor ?? false;
+ uniformColorCheckbox.style.cssText = "cursor:pointer;";
+ uniformColorCheckbox.onchange = () => {
+ dependencies.stateManager.updateVisualState({
+ uniformTrackColor: uniformColorCheckbox.checked,
+ });
+ // Refresh the file list to update track color dots
+ refreshFileList();
+ };
+
+ const uniformColorLabel = document.createElement("span");
+ uniformColorLabel.textContent = "Uniform Track Color";
+
+ uniformColorToggle.appendChild(uniformColorCheckbox);
+ uniformColorToggle.appendChild(uniformColorLabel);
+ headerRow.appendChild(uniformColorToggle);
+
+ filesSection.appendChild(headerRow);
// List container
const fileList = document.createElement("div");
@@ -44,11 +91,20 @@ export function createFileList(
// ---- Enable container-level dragover / drop so that
// users can drop _below_ the last item (e.g. when only 2 files)
+ // Only for internal reorder - external file drops go to the drop zone
const containerDragOver = (e: DragEvent) => {
+ // Ignore external file drops
+ if (e.dataTransfer?.types.includes("Files")) {
+ return;
+ }
e.preventDefault();
};
const containerDrop = (e: DragEvent) => {
+ // Ignore external file drops
+ if (e.dataTransfer?.types.includes("Files")) {
+ return;
+ }
e.preventDefault();
const sourceIndex = parseInt(
(e.dataTransfer as DataTransfer).getData("text/plain"),
@@ -110,8 +166,13 @@ export function createFileList(
const shapeHost = document.createElement("div");
shapeHost.style.cssText = `width:18px;height:18px;display:flex;align-items:center;justify-content:center;`;
// Unified onset marker SVG renderer for both swatch and marker picker
- const renderOnsetSvg = (style: import("@/types").OnsetMarkerStyle, color: string) => renderOnsetSVG(style, color, 16);
- const ensuredStyle = dependencies.stateManager.ensureOnsetMarkerForFile(file.id);
+ const renderOnsetSvg = (
+ style: import("@/types").OnsetMarkerStyle,
+ color: string
+ ) => renderOnsetSVG(style, color, 16);
+ const ensuredStyle = dependencies.stateManager.ensureOnsetMarkerForFile(
+ file.id
+ );
shapeHost.innerHTML = renderOnsetSvg(ensuredStyle, initialHex);
swatchBtn.appendChild(shapeHost);
@@ -124,14 +185,9 @@ export function createFileList(
// Unified picker trigger (clicking the swatch opens the combined picker)
swatchBtn.onclick = (e) => {
- openOnsetPicker(
- dependencies,
- file.id,
- swatchBtn,
- (style, hex) => {
- shapeHost.innerHTML = renderOnsetSvg(style, hex);
- }
- );
+ openOnsetPicker(dependencies, file.id, swatchBtn, (style, hex) => {
+ shapeHost.innerHTML = renderOnsetSvg(style, hex);
+ });
};
// Handle color change from native picker
@@ -145,7 +201,9 @@ export function createFileList(
);
// Update swatch shape color
- const styleNow = dependencies.stateManager.getOnsetMarkerForFile(file.id) || ensuredStyle;
+ const styleNow =
+ dependencies.stateManager.getOnsetMarkerForFile(file.id) ||
+ ensuredStyle;
shapeHost.innerHTML = renderOnsetSvg(styleNow, hex);
};
@@ -176,7 +234,9 @@ export function createFileList(
delBtn.style.cssText =
"border:none;background:transparent;cursor:pointer;width:24px;height:24px;display:flex;align-items:center;justify-content:center;color:var(--text-muted);";
delBtn.onclick = () => {
- if (!canRemove) { return; }
+ if (!canRemove) {
+ return;
+ }
if (confirm(`Delete ${file.name}?`)) {
dependencies.midiManager.removeMidiFile(file.id);
refreshFileList();
@@ -221,12 +281,16 @@ export function createFileList(
element.addEventListener("touchstart", preventDrag);
});
- // Handle drag over (row)
+ // Handle drag over (row) - only for internal reorder, not external file drops
row.addEventListener("dragover", (e) => {
+ // Ignore external file drops - those go to the drop zone only
+ if (e.dataTransfer?.types.includes("Files")) {
+ return;
+ }
e.preventDefault();
e.stopPropagation();
- // Highlight potential drop target for live feedback
- row.style.outline = "2px dashed var(--focus-ring)"; // focus color
+ // Highlight potential drop target for live feedback (internal reorder only)
+ row.style.outline = "2px dashed var(--focus-ring)";
});
// Clear highlight when leaving the row
@@ -234,15 +298,23 @@ export function createFileList(
row.style.outline = "none";
});
- // Handle drag over (handle) - allows dropping directly on the handle area
+ // Handle drag over (handle) - only for internal reorder
handle.addEventListener("dragover", (e) => {
+ // Ignore external file drops
+ if (e.dataTransfer?.types.includes("Files")) {
+ return;
+ }
e.preventDefault();
e.stopPropagation();
row.style.outline = "2px dashed var(--focus-ring)";
});
- // Unified drop handler
+ // Unified drop handler - only for internal reorder
const onDrop = (targetIndex: number) => (e: DragEvent) => {
+ // Ignore external file drops - those go to the drop zone only
+ if (e.dataTransfer?.types.includes("Files")) {
+ return;
+ }
e.preventDefault();
e.stopPropagation();
const sourceIndex = parseInt(
@@ -261,55 +333,271 @@ export function createFileList(
row.addEventListener("drop", onDrop(idx));
handle.addEventListener("drop", onDrop(idx));
+ // Check if this file has multiple tracks for accordion toggle
+ const tracks = file.parsedData?.tracks;
+ const hasMultipleTracks = tracks && tracks.length > 1;
+
+ // Always create chevron space for alignment (empty for single-track files)
+ let trackListEl: HTMLElement | null = null;
+ let isExpanded = accordionExpandedState.get(file.id) ?? false;
+
+ const chevronSpan = document.createElement("span");
+ chevronSpan.style.cssText =
+ "display:flex;align-items:center;width:16px;min-width:16px;";
+
+ if (hasMultipleTracks) {
+ chevronSpan.innerHTML = isExpanded ? CHEVRON_DOWN : CHEVRON_RIGHT;
+ chevronSpan.style.cursor = "pointer";
+ chevronSpan.style.color = "var(--text-muted)";
+ chevronSpan.style.transition = "transform 0.2s";
+ chevronSpan.title = `${tracks.length} tracks`;
+ }
+
row.appendChild(handle);
+ row.appendChild(chevronSpan);
row.appendChild(colorPickerContainer);
row.appendChild(nameInput);
if (canRemove) {
row.appendChild(delBtn);
}
fileList.appendChild(row);
+
+ // ---- Track Accordion (for multi-track MIDI files) ----
+ if (hasMultipleTracks) {
+ // Track list container - aligned with file row (handle + chevron + colorPicker width)
+ trackListEl = document.createElement("div");
+ trackListEl.style.cssText = `display:${isExpanded ? "flex" : "none"};flex-direction:column;gap:1px;padding:4px 8px;background:var(--surface);border-radius:4px;margin-left:60px;margin-top:2px;`;
+
+ // Sort tracks: drums at the bottom, others by MIDI program number (ascending)
+ const sortedTracks = [...tracks].sort((a, b) => {
+ // Drums go to the bottom
+ if (a.isDrum && !b.isDrum) return 1;
+ if (!a.isDrum && b.isDrum) return -1;
+ // Both drums or both non-drums: sort by program number
+ return (a.program ?? 0) - (b.program ?? 0);
+ });
+
+ // Populate track items
+ sortedTracks.forEach((track: TrackInfo) => {
+ const trackRow = document.createElement("div");
+ trackRow.style.cssText =
+ "display:flex;align-items:center;gap:16px;padding:2px 0;";
+
+ // Instrument icon (first)
+ const iconSpan = document.createElement("span");
+ iconSpan.innerHTML = getInstrumentIcon(track.instrumentFamily);
+ iconSpan.style.cssText =
+ "display:flex;align-items:center;justify-content:center;width:18px;height:18px;color:var(--text-muted);";
+ iconSpan.title = track.instrumentFamily;
+
+ // Track name (second)
+ const trackName = document.createElement("span");
+ trackName.textContent = track.name;
+ trackName.style.cssText =
+ "flex:1;font-size:12px;color:var(--text-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;";
+
+ // Note count badge (third/last)
+ const noteCount = document.createElement("span");
+ noteCount.textContent = `${track.noteCount} notes`;
+ noteCount.style.cssText =
+ "font-size:10px;color:var(--text-muted);padding:2px 6px;background:var(--surface-alt);border-radius:10px;text-align:right;";
+
+ // Append in new order: InstrumentIcon | TrackName | NoteCount
+ trackRow.appendChild(iconSpan);
+ trackRow.appendChild(trackName);
+ trackRow.appendChild(noteCount);
+ trackListEl!.appendChild(trackRow);
+ });
+
+ fileList.appendChild(trackListEl);
+
+ // Toggle accordion on chevron click
+ chevronSpan.onclick = (e: MouseEvent) => {
+ e.stopPropagation();
+ isExpanded = !isExpanded;
+ accordionExpandedState.set(file.id, isExpanded);
+ if (trackListEl) {
+ trackListEl.style.display = isExpanded ? "flex" : "none";
+ }
+ chevronSpan.innerHTML = isExpanded ? CHEVRON_DOWN : CHEVRON_RIGHT;
+ };
+ }
});
- /* ---------- Add MIDI button ---------- */
+ /* ---------- Unified Drop Zone ---------- */
const canAdd = dependencies.permissions?.canAddFiles !== false;
- const addBtn = document.createElement("button");
- addBtn.type = "button";
- addBtn.textContent = "Add MIDI Files";
- addBtn.style.cssText =
- "margin-top:12px;padding:8px;border:1px solid var(--ui-border);border-radius:6px;background:var(--surface);cursor:pointer;font-size:14px;color:var(--text-primary);";
-
+ const allowFileDrop = dependencies.allowFileDrop !== false;
+
+ // Drop zone container with dashed border
+ const dropZone = document.createElement("div");
+ dropZone.style.cssText = `
+ margin-top:12px;
+ padding:16px;
+ border:2px dashed var(--ui-border);
+ border-radius:8px;
+ background:var(--surface);
+ cursor:pointer;
+ text-align:center;
+ transition:all 0.2s ease;
+ `;
+
+ // Drop zone content
+ const dropZoneContent = document.createElement("div");
+ dropZoneContent.style.cssText = "pointer-events:none;";
+
+ const dropZoneTitle = document.createElement("div");
+ dropZoneTitle.textContent = "+ Add MIDI Files";
+ dropZoneTitle.style.cssText =
+ "font-size:14px;font-weight:500;color:var(--text-primary);margin-bottom:4px;";
+
+ const dropZoneHint = document.createElement("div");
+ dropZoneHint.textContent = allowFileDrop
+ ? "Click or drag & drop MIDI files"
+ : "Click to choose MIDI files";
+ dropZoneHint.style.cssText =
+ "font-size:12px;color:var(--text-muted);margin-bottom:2px;";
+
+ const dropZoneFormats = document.createElement("div");
+ dropZoneFormats.textContent = ".mid, .midi";
+ dropZoneFormats.style.cssText =
+ "font-size:10px;color:var(--text-muted);opacity:0.7;";
+
+ dropZoneContent.appendChild(dropZoneTitle);
+ dropZoneContent.appendChild(dropZoneHint);
+ dropZoneContent.appendChild(dropZoneFormats);
+ dropZone.appendChild(dropZoneContent);
+
+ // Hidden file input (MIDI files only)
const hiddenInput = document.createElement("input");
hiddenInput.type = "file";
hiddenInput.accept = ".mid,.midi";
hiddenInput.multiple = true;
hiddenInput.style.display = "none";
- addBtn.onclick = () => { if (canAdd) hiddenInput.click(); };
-
- hiddenInput.onchange = async (e) => {
- if (!canAdd) { return; }
- const files = Array.from((e.target as HTMLInputElement).files || []);
- if (files.length === 0) return;
+ /**
+ * Process dropped/selected MIDI files.
+ */
+ const processFiles = async (files: File[]) => {
+ if (!canAdd || files.length === 0) return;
for (const file of files) {
- try {
- const state = dependencies.stateManager?.getState();
- const pedalElongate = state?.visual.pedalElongate ?? true;
- const pedalThreshold = state?.visual.pedalThreshold ?? 64;
- const parsed = await parseMidi(file, {
- applyPedalElongate: pedalElongate,
- pedalThreshold: pedalThreshold
- });
- dependencies.midiManager.addMidiFile(file.name, parsed, undefined, file);
- } catch (err) {
- console.error("Failed to parse MIDI", err);
+ const ext = file.name.toLowerCase().match(/\.[^.]+$/)?.[0] || "";
+
+ if (MIDI_EXTENSIONS.includes(ext)) {
+ // Process MIDI file
+ try {
+ const state = dependencies.stateManager?.getState();
+ const pedalElongate = state?.visual.pedalElongate ?? true;
+ const pedalThreshold = state?.visual.pedalThreshold ?? 64;
+ const parsed = await parseMidi(file, {
+ applyPedalElongate: pedalElongate,
+ pedalThreshold: pedalThreshold,
+ });
+ dependencies.midiManager.addMidiFile(
+ file.name,
+ parsed,
+ undefined,
+ file
+ );
+ } catch (err) {
+ console.error("Failed to parse MIDI:", err);
+ }
}
+ // Non-MIDI files are silently ignored (audio files should be added via WAV File section)
}
+
refreshFileList();
+
+ // Update main screen file toggle section if available
+ const fileToggleContainer = document.querySelector(
+ '[data-role="file-toggle"]'
+ ) as HTMLElement | null;
+ if (fileToggleContainer) {
+ const FileToggleManager = (window as any).FileToggleManager;
+ if (FileToggleManager) {
+ FileToggleManager.updateFileToggleSection(
+ fileToggleContainer,
+ dependencies
+ );
+ }
+ }
+ };
+
+ // Click to open file picker (or trigger external callback)
+ dropZone.onclick = () => {
+ if (!canAdd) return;
+ // If external callback is registered (e.g., VS Code integration), use it
+ if (dependencies.onFileAddRequest) {
+ dependencies.onFileAddRequest();
+ } else {
+ hiddenInput.click();
+ }
+ };
+
+ // File input change handler
+ hiddenInput.onchange = async (e) => {
+ const files = Array.from((e.target as HTMLInputElement).files || []);
+ await processFiles(files);
hiddenInput.value = "";
};
+
+ if (allowFileDrop) {
+ // Drag & drop visual feedback
+ const setDropZoneHighlight = (active: boolean) => {
+ if (active) {
+ dropZone.style.borderColor = "var(--focus-ring)";
+ dropZone.style.background = "var(--surface-alt)";
+ dropZoneTitle.textContent = "Drop MIDI files here";
+ } else {
+ dropZone.style.borderColor = "var(--ui-border)";
+ dropZone.style.background = "var(--surface)";
+ dropZoneTitle.textContent = "+ Add MIDI Files";
+ }
+ };
+
+ // Drag enter/over - check if it's external files (not internal reorder)
+ dropZone.addEventListener("dragenter", (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ // Only highlight for external file drops
+ if (e.dataTransfer?.types.includes("Files")) {
+ setDropZoneHighlight(true);
+ }
+ });
+
+ dropZone.addEventListener("dragover", (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (e.dataTransfer?.types.includes("Files")) {
+ e.dataTransfer.dropEffect = "copy";
+ }
+ });
+
+ dropZone.addEventListener("dragleave", (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setDropZoneHighlight(false);
+ });
+
+ // Drop handler for external files
+ dropZone.addEventListener("drop", async (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setDropZoneHighlight(false);
+
+ // Only process if it's external files (not internal reorder)
+ if (!e.dataTransfer?.types.includes("Files")) {
+ return;
+ }
+
+ const files = Array.from(e.dataTransfer.files);
+ await processFiles(files);
+ });
+ }
+
if (canAdd) {
- fileList.appendChild(addBtn);
+ dropZone.appendChild(hiddenInput);
+ fileList.appendChild(dropZone);
}
};
diff --git a/src/lib/components/ui/settings/sections/solo-appearance.ts b/src/lib/components/ui/settings/sections/solo-appearance.ts
index 24823ac..3bfe22f 100644
--- a/src/lib/components/ui/settings/sections/solo-appearance.ts
+++ b/src/lib/components/ui/settings/sections/solo-appearance.ts
@@ -34,13 +34,15 @@ export function createSoloAppearanceSection(
// Section title
const title = document.createElement("h3");
title.textContent = "Note Appearance";
- title.style.cssText = "margin:0 0 12px;font-size:16px;font-weight:600;color:var(--text-primary);";
+ title.style.cssText =
+ "margin:0 0 12px;font-size:16px;font-weight:600;color:var(--text-primary);";
wrapper.appendChild(title);
// Current color and style (mutable)
let currentColorHex = toHexColor(file.color);
- let currentStyle = deps.stateManager.getOnsetMarkerForFile(fileId)
- || deps.stateManager.ensureOnsetMarkerForFile(fileId);
+ let currentStyle =
+ deps.stateManager.getOnsetMarkerForFile(fileId) ||
+ deps.stateManager.ensureOnsetMarkerForFile(fileId);
// Forward declarations for functions used in rebuildColorButtons
let updateMarkerPreviews: () => void = () => {};
@@ -52,7 +54,8 @@ export function createSoloAppearanceSection(
const colorLabel = document.createElement("div");
colorLabel.textContent = "Note Color";
- colorLabel.style.cssText = "font-size:13px;color:var(--text-muted);margin-bottom:8px;";
+ colorLabel.style.cssText =
+ "font-size:13px;color:var(--text-muted);margin-bottom:8px;";
colorSection.appendChild(colorLabel);
const colorsRow = document.createElement("div");
@@ -62,7 +65,8 @@ export function createSoloAppearanceSection(
const updateColorSelection = () => {
colorButtons.forEach((btn) => {
- const isSelected = (btn.dataset.hex || "").toLowerCase() === currentColorHex.toLowerCase();
+ const isSelected =
+ (btn.dataset.hex || "").toLowerCase() === currentColorHex.toLowerCase();
btn.style.outline = isSelected ? "2px solid var(--focus-ring)" : "none";
btn.style.outlineOffset = isSelected ? "2px" : "0";
});
@@ -75,7 +79,9 @@ export function createSoloAppearanceSection(
const rebuildColorButtons = () => {
const state = deps.midiManager.getState();
const allPalettes = [...DEFAULT_PALETTES, ...state.customPalettes];
- const palette = allPalettes.find((p) => p.id === state.activePaletteId) || DEFAULT_PALETTES[0];
+ const palette =
+ allPalettes.find((p) => p.id === state.activePaletteId) ||
+ DEFAULT_PALETTES[0];
// Sync current color from file state (palette switch may have changed it)
const currentFile = state.files.find((f) => f.id === fileId);
@@ -99,8 +105,12 @@ export function createSoloAppearanceSection(
border:1px solid var(--ui-border);background:${hex};
cursor:pointer;transition:transform 0.1s;
`;
- btn.onmouseenter = () => { btn.style.transform = "scale(1.1)"; };
- btn.onmouseleave = () => { btn.style.transform = "scale(1)"; };
+ btn.onmouseenter = () => {
+ btn.style.transform = "scale(1.1)";
+ };
+ btn.onmouseleave = () => {
+ btn.style.transform = "scale(1)";
+ };
btn.onclick = () => {
deps.midiManager.updateColor(fileId, color);
currentColorHex = hex;
@@ -124,7 +134,8 @@ export function createSoloAppearanceSection(
const markerLabel = document.createElement("div");
markerLabel.textContent = "Onset Marker";
- markerLabel.style.cssText = "font-size:13px;color:var(--text-muted);margin-bottom:8px;";
+ markerLabel.style.cssText =
+ "font-size:13px;color:var(--text-muted);margin-bottom:8px;";
markerSection.appendChild(markerLabel);
const markerWrapper = document.createElement("div");
@@ -136,7 +147,8 @@ export function createSoloAppearanceSection(
// Toggle button container
const toggleContainer = document.createElement("div");
- toggleContainer.style.cssText = "display:flex;gap:2px;background:var(--ui-border);border-radius:6px;padding:2px;";
+ toggleContainer.style.cssText =
+ "display:flex;gap:2px;background:var(--ui-border);border-radius:6px;padding:2px;";
const toggleButtons: HTMLButtonElement[] = [];
const updateToggleStyles = () => {
@@ -171,12 +183,13 @@ export function createSoloAppearanceSection(
// Single grid for active variant
const grid = document.createElement("div");
- grid.style.cssText = "display:grid;grid-template-columns:repeat(7,32px);gap:6px;";
+ grid.style.cssText =
+ "display:grid;grid-template-columns:repeat(7,32px);gap:6px;";
const updateMarkerSelection = () => {
markerButtons.forEach((btn) => {
- const isSelected =
- btn.dataset.shape === currentStyle.shape &&
+ const isSelected =
+ btn.dataset.shape === currentStyle.shape &&
btn.dataset.variant === currentStyle.variant;
btn.style.outline = isSelected ? "2px solid var(--focus-ring)" : "none";
btn.style.outlineOffset = isSelected ? "1px" : "0";
@@ -188,7 +201,12 @@ export function createSoloAppearanceSection(
markerButtons.forEach((btn) => {
const shape = btn.dataset.shape as OnsetMarkerShape;
const variant = btn.dataset.variant as OnsetMarkerStyle["variant"];
- const style: OnsetMarkerStyle = { shape, variant, size: 12, strokeWidth: 2 };
+ const style: OnsetMarkerStyle = {
+ shape,
+ variant,
+ size: 12,
+ strokeWidth: 2,
+ };
btn.innerHTML = renderOnsetSVG(style, currentColorHex, 18);
});
};
@@ -198,11 +216,11 @@ export function createSoloAppearanceSection(
markerButtons.length = 0;
ONSET_MARKER_SHAPES.forEach((shape) => {
- const style: OnsetMarkerStyle = {
- shape: shape as OnsetMarkerShape,
- variant: activeVariant,
- size: 12,
- strokeWidth: 2
+ const style: OnsetMarkerStyle = {
+ shape: shape as OnsetMarkerShape,
+ variant: activeVariant,
+ size: 12,
+ strokeWidth: 2,
};
const btn = document.createElement("button");
btn.type = "button";
@@ -217,8 +235,12 @@ export function createSoloAppearanceSection(
cursor:pointer;transition:background 0.1s;
`;
btn.innerHTML = renderOnsetSVG(style, currentColorHex, 18);
- btn.onmouseenter = () => { btn.style.background = "var(--hover-surface)"; };
- btn.onmouseleave = () => { btn.style.background = "var(--surface)"; };
+ btn.onmouseenter = () => {
+ btn.style.background = "var(--hover-surface)";
+ };
+ btn.onmouseleave = () => {
+ btn.style.background = "var(--surface)";
+ };
btn.onclick = () => {
deps.stateManager.setOnsetMarkerForFile(fileId, style);
currentStyle = style;
@@ -232,7 +254,7 @@ export function createSoloAppearanceSection(
updateMarkerSelection();
};
- // Dispatch custom event when settings change (for wave-roll-solo integration)
+ // Dispatch custom event when settings change (for wave-roll-studio integration)
dispatchSettingsChange = () => {
const state = deps.midiManager.getState();
const event = new CustomEvent("wr-appearance-change", {
@@ -271,7 +293,10 @@ export function createSoloAppearanceSection(
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.removedNodes) {
- if (node === wrapper || (node instanceof Element && node.contains(wrapper))) {
+ if (
+ node === wrapper ||
+ (node instanceof Element && node.contains(wrapper))
+ ) {
unsubscribe();
observer.disconnect();
return;
diff --git a/src/lib/components/ui/settings/sections/wave-list.ts b/src/lib/components/ui/settings/sections/wave-list.ts
index 82b0cf5..6b2785b 100644
--- a/src/lib/components/ui/settings/sections/wave-list.ts
+++ b/src/lib/components/ui/settings/sections/wave-list.ts
@@ -2,13 +2,17 @@ import { UIComponentDependencies } from "../../types";
import { addAudioFileFromUrl } from "@/lib/core/waveform/register";
import { PLAYER_ICONS } from "@/assets/player-icons";
+/** Supported audio file extensions */
+const AUDIO_EXTENSIONS = [".wav", ".mp3", ".m4a", ".ogg"];
+
export function createWaveListSection(
deps: UIComponentDependencies
): HTMLElement {
const section = document.createElement("div");
const header = document.createElement("h3");
header.textContent = "WAV File";
- header.style.cssText = "margin:0 0 12px;font-size:16px;font-weight:600;color:var(--text-primary);";
+ header.style.cssText =
+ "margin:0 0 12px;font-size:16px;font-weight:600;color:var(--text-primary);";
section.appendChild(header);
const list = document.createElement("div");
@@ -37,7 +41,8 @@ export function createWaveListSection(
}>;
files.forEach((a) => {
const row = document.createElement("div");
- row.style.cssText = "display:flex;align-items:center;gap:8px;background:var(--surface-alt);padding:8px;border-radius:6px;border:1px solid var(--ui-border);";
+ row.style.cssText =
+ "display:flex;align-items:center;gap:8px;background:var(--surface-alt);padding:8px;border-radius:6px;border:1px solid var(--ui-border);";
// color swatch (square)
const colorBtn = document.createElement("button");
@@ -47,7 +52,8 @@ export function createWaveListSection(
const input = document.createElement("input");
input.type = "color";
input.value = hex;
- input.style.cssText = "position:absolute;opacity:0;width:0;height:0;border:0;padding:0;";
+ input.style.cssText =
+ "position:absolute;opacity:0;width:0;height:0;border:0;padding:0;";
input.addEventListener("change", () => {
const newHex = input.value;
const num = parseInt(newHex.replace("#", ""), 16);
@@ -61,7 +67,8 @@ export function createWaveListSection(
const name = document.createElement("input");
name.type = "text";
name.value = a.name;
- name.style.cssText = "flex:1;padding:4px 6px;border:1px solid var(--ui-border);border-radius:4px;background:var(--surface);color:var(--text-primary);";
+ name.style.cssText =
+ "flex:1;padding:4px 6px;border:1px solid var(--ui-border);border-radius:4px;background:var(--surface);color:var(--text-primary);";
name.addEventListener("change", () => {
api?.updateName?.(a.id, name.value.trim());
});
@@ -75,9 +82,10 @@ export function createWaveListSection(
const delBtn = document.createElement("button");
delBtn.type = "button";
delBtn.innerHTML = PLAYER_ICONS.trash;
- delBtn.style.cssText = "width:24px;height:24px;padding:0;border:none;background:transparent;cursor:pointer;display:flex;align-items:center;justify-content:center;color:var(--text-muted);opacity:0.7;";
+ delBtn.style.cssText =
+ "width:24px;height:24px;padding:0;border:none;background:transparent;cursor:pointer;display:flex;align-items:center;justify-content:center;color:var(--text-muted);opacity:0.7;";
delBtn.title = "Remove audio file";
-
+
delBtn.addEventListener("mouseenter", () => {
delBtn.style.opacity = "1";
delBtn.style.color = "var(--danger, #dc3545)";
@@ -86,7 +94,7 @@ export function createWaveListSection(
delBtn.style.opacity = "0.7";
delBtn.style.color = "var(--text-muted)";
});
-
+
delBtn.addEventListener("click", () => {
api?.remove?.(a.id);
// Pause playback when removing audio file
@@ -94,68 +102,199 @@ export function createWaveListSection(
deps.audioPlayer?.pause?.();
} catch {}
refresh();
-
+
// Update main screen file toggle section
- const fileToggleContainer = document.querySelector('[data-role="file-toggle"]') as HTMLElement | null;
+ const fileToggleContainer = document.querySelector(
+ '[data-role="file-toggle"]'
+ ) as HTMLElement | null;
if (fileToggleContainer) {
const FileToggleManager = (window as any).FileToggleManager;
if (FileToggleManager) {
- FileToggleManager.updateFileToggleSection(fileToggleContainer, deps);
+ FileToggleManager.updateFileToggleSection(
+ fileToggleContainer,
+ deps
+ );
}
}
});
-
+
row.appendChild(delBtn);
}
list.appendChild(row);
});
- /* ---------- Add/Change WAV File button ---------- */
+ /* ---------- Audio File Drop Zone ---------- */
const canAdd = deps.permissions?.canAddFiles !== false;
- const addBtn = document.createElement("button");
- addBtn.type = "button";
- // Change button text based on whether a WAV file already exists
- addBtn.textContent = files.length > 0 ? "Change WAV File" : "Add WAV File";
- addBtn.style.cssText =
- "margin-top:12px;padding:8px;border:1px solid var(--ui-border);border-radius:6px;background:var(--surface);cursor:pointer;font-size:14px;color:var(--text-primary);";
+ const allowFileDrop = deps.allowFileDrop !== false;
+
+ // Drop zone container with dashed border
+ const dropZone = document.createElement("div");
+ dropZone.style.cssText = `
+ margin-top:12px;
+ padding:16px;
+ border:2px dashed var(--ui-border);
+ border-radius:8px;
+ background:var(--surface);
+ cursor:pointer;
+ text-align:center;
+ transition:all 0.2s ease;
+ `;
+
+ // Drop zone content
+ const dropZoneContent = document.createElement("div");
+ dropZoneContent.style.cssText = "pointer-events:none;";
+
+ const dropZoneTitle = document.createElement("div");
+ // Change text based on whether an audio file already exists
+ dropZoneTitle.textContent =
+ files.length > 0 ? "Change Audio File" : "+ Add Audio File";
+ dropZoneTitle.style.cssText =
+ "font-size:14px;font-weight:500;color:var(--text-primary);margin-bottom:4px;";
+
+ const dropZoneHint = document.createElement("div");
+ dropZoneHint.textContent = allowFileDrop
+ ? "Click or drag & drop audio file"
+ : "Click to choose an audio file";
+ dropZoneHint.style.cssText =
+ "font-size:12px;color:var(--text-muted);margin-bottom:2px;";
+
+ const dropZoneFormats = document.createElement("div");
+ dropZoneFormats.textContent = ".wav, .mp3, .m4a, .ogg";
+ dropZoneFormats.style.cssText =
+ "font-size:10px;color:var(--text-muted);opacity:0.7;";
+
+ dropZoneContent.appendChild(dropZoneTitle);
+ dropZoneContent.appendChild(dropZoneHint);
+ dropZoneContent.appendChild(dropZoneFormats);
+ dropZone.appendChild(dropZoneContent);
+ // Hidden file input (audio files only)
const hiddenInput = document.createElement("input");
hiddenInput.type = "file";
hiddenInput.accept = ".wav,.mp3,.m4a,.ogg";
hiddenInput.style.display = "none";
- addBtn.onclick = () => { if (canAdd) hiddenInput.click(); };
+ /**
+ * Process audio file (single file only - replaces existing).
+ */
+ const processAudioFile = async (file: File) => {
+ if (!canAdd) return;
- hiddenInput.onchange = async (e) => {
- if (!canAdd) { return; }
- const fileList = (e.target as HTMLInputElement).files;
- if (!fileList || fileList.length === 0) return;
+ const ext = file.name.toLowerCase().match(/\.[^.]+$/)?.[0] || "";
+ if (!AUDIO_EXTENSIONS.includes(ext)) {
+ console.warn(`Unsupported audio format: ${ext}`);
+ return;
+ }
- // Single file only (no 'multiple' attribute on input)
- const file = fileList[0];
try {
const url = URL.createObjectURL(file);
await addAudioFileFromUrl(null, url, file.name);
refresh();
-
+
// Update main screen file toggle section
- const fileToggleContainer = document.querySelector('[data-role="file-toggle"]') as HTMLElement | null;
+ const fileToggleContainer = document.querySelector(
+ '[data-role="file-toggle"]'
+ ) as HTMLElement | null;
if (fileToggleContainer) {
const FileToggleManager = (window as any).FileToggleManager;
if (FileToggleManager) {
- FileToggleManager.updateFileToggleSection(fileToggleContainer, deps);
+ FileToggleManager.updateFileToggleSection(
+ fileToggleContainer,
+ deps
+ );
}
}
} catch (err) {
- console.error("Failed to load audio file", err);
+ console.error("Failed to load audio file:", err);
}
+ };
+
+ // Click to open file picker (or trigger external callback)
+ dropZone.onclick = () => {
+ if (!canAdd) return;
+ // If external callback is registered (e.g., VS Code integration), use it
+ if (deps.onAudioFileAddRequest) {
+ deps.onAudioFileAddRequest();
+ } else {
+ hiddenInput.click();
+ }
+ };
+
+ // File input change handler
+ hiddenInput.onchange = async (e) => {
+ if (!canAdd) return;
+ const fileList = (e.target as HTMLInputElement).files;
+ if (!fileList || fileList.length === 0) return;
+
+ // Single file only
+ await processAudioFile(fileList[0]);
hiddenInput.value = "";
};
-
+
+ if (allowFileDrop) {
+ // Drag & drop visual feedback
+ const setDropZoneHighlight = (active: boolean) => {
+ if (active) {
+ dropZone.style.borderColor = "var(--focus-ring)";
+ dropZone.style.background = "var(--surface-alt)";
+ dropZoneTitle.textContent = "Drop audio file here";
+ } else {
+ dropZone.style.borderColor = "var(--ui-border)";
+ dropZone.style.background = "var(--surface)";
+ dropZoneTitle.textContent =
+ files.length > 0 ? "Change Audio File" : "+ Add Audio File";
+ }
+ };
+
+ // Drag enter/over
+ dropZone.addEventListener("dragenter", (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (e.dataTransfer?.types.includes("Files")) {
+ setDropZoneHighlight(true);
+ }
+ });
+
+ dropZone.addEventListener("dragover", (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (e.dataTransfer?.types.includes("Files")) {
+ e.dataTransfer.dropEffect = "copy";
+ }
+ });
+
+ dropZone.addEventListener("dragleave", (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setDropZoneHighlight(false);
+ });
+
+ // Drop handler for audio files
+ dropZone.addEventListener("drop", async (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setDropZoneHighlight(false);
+
+ if (!e.dataTransfer?.types.includes("Files")) {
+ return;
+ }
+
+ const droppedFiles = Array.from(e.dataTransfer.files);
+ // Only process the first valid audio file
+ for (const file of droppedFiles) {
+ const ext = file.name.toLowerCase().match(/\.[^.]+$/)?.[0] || "";
+ if (AUDIO_EXTENSIONS.includes(ext)) {
+ await processAudioFile(file);
+ break; // Only one audio file allowed
+ }
+ }
+ });
+ }
+
if (canAdd) {
- list.appendChild(addBtn);
- list.appendChild(hiddenInput);
+ dropZone.appendChild(hiddenInput);
+ list.appendChild(dropZone);
}
};
diff --git a/src/lib/components/ui/types.ts b/src/lib/components/ui/types.ts
index f6c4437..54053cb 100644
--- a/src/lib/components/ui/types.ts
+++ b/src/lib/components/ui/types.ts
@@ -35,7 +35,9 @@ export interface UIComponentDependencies {
* Callback used by visualisation engine to update the seek bar.
* Accepts an optional override payload when the caller has its own time values.
*/
- updateSeekBar: ((state?: { currentTime: number; duration: number }) => void) | null;
+ updateSeekBar:
+ | ((state?: { currentTime: number; duration: number }) => void)
+ | null;
/** Callback that toggles play β pause icon. */
updatePlayButton: (() => void) | null;
@@ -70,7 +72,7 @@ export interface UIComponentDependencies {
/** Toast tooltip options for highlight mode */
highlightToast?: {
/** Position: e.g., 'bottom', 'top' */
- position?: 'bottom' | 'top';
+ position?: "bottom" | "top";
/** Milliseconds to keep the toast visible */
durationMs?: number;
/** Inline CSS to override the toast container style */
@@ -78,11 +80,40 @@ export interface UIComponentDependencies {
};
};
+ /**
+ * When false, external drag & drop upload surfaces should be disabled.
+ * Click-to-open flows remain available.
+ */
+ allowFileDrop?: boolean;
+
/** Solo mode: hides evaluation UI, file sections, and waveform band */
soloMode?: boolean;
/** MIDI export options (mode and custom handler) */
midiExport?: MidiExportOptions;
+
+ /**
+ * Optional callback to trigger when user wants to add MIDI files.
+ * If provided, UI will call this instead of using the default file input.
+ * Used for VS Code integration where native dialogs are preferred.
+ */
+ onFileAddRequest?: () => void;
+
+ /**
+ * Optional callback to trigger when user wants to add audio files.
+ * If provided, UI will call this instead of using the default file input.
+ * Used for VS Code integration where native dialogs are preferred.
+ */
+ onAudioFileAddRequest?: () => void;
+
+ /**
+ * Optional function to add a file from raw data (ArrayBuffer or Base64).
+ * Used for VS Code integration where files are passed via postMessage.
+ */
+ addFileFromData?: (
+ data: ArrayBuffer | string,
+ filename: string
+ ) => Promise
;
}
export interface UIElements {
diff --git a/src/lib/core/audio/gm-instruments.ts b/src/lib/core/audio/gm-instruments.ts
new file mode 100644
index 0000000..c7473fa
--- /dev/null
+++ b/src/lib/core/audio/gm-instruments.ts
@@ -0,0 +1,225 @@
+/**
+ * General MIDI Instrument Names (Program 0-127)
+ * Source: https://gleitz.github.io/midi-js-soundfonts/FluidR3_GM/names.json
+ *
+ * Array index corresponds to MIDI Program Number (0-127)
+ */
+export const GM_INSTRUMENT_NAMES: readonly string[] = [
+ // Piano (0-7)
+ "acoustic_grand_piano", // 0
+ "bright_acoustic_piano", // 1
+ "electric_grand_piano", // 2
+ "honkytonk_piano", // 3
+ "electric_piano_1", // 4
+ "electric_piano_2", // 5
+ "harpsichord", // 6
+ "clavinet", // 7
+
+ // Chromatic Percussion (8-15)
+ "celesta", // 8
+ "glockenspiel", // 9
+ "music_box", // 10
+ "vibraphone", // 11
+ "marimba", // 12
+ "xylophone", // 13
+ "tubular_bells", // 14
+ "dulcimer", // 15
+
+ // Organ (16-23)
+ "drawbar_organ", // 16
+ "percussive_organ", // 17
+ "rock_organ", // 18
+ "church_organ", // 19
+ "reed_organ", // 20
+ "accordion", // 21
+ "harmonica", // 22
+ "tango_accordion", // 23
+
+ // Guitar (24-31)
+ "acoustic_guitar_nylon", // 24
+ "acoustic_guitar_steel", // 25
+ "electric_guitar_jazz", // 26
+ "electric_guitar_clean", // 27
+ "electric_guitar_muted", // 28
+ "overdriven_guitar", // 29
+ "distortion_guitar", // 30
+ "guitar_harmonics", // 31
+
+ // Bass (32-39)
+ "acoustic_bass", // 32
+ "electric_bass_finger", // 33
+ "electric_bass_pick", // 34
+ "fretless_bass", // 35
+ "slap_bass_1", // 36
+ "slap_bass_2", // 37
+ "synth_bass_1", // 38
+ "synth_bass_2", // 39
+
+ // Strings (40-47)
+ "violin", // 40
+ "viola", // 41
+ "cello", // 42
+ "contrabass", // 43
+ "tremolo_strings", // 44
+ "pizzicato_strings", // 45
+ "orchestral_harp", // 46
+ "timpani", // 47
+
+ // Ensemble (48-55)
+ "string_ensemble_1", // 48
+ "string_ensemble_2", // 49
+ "synth_strings_1", // 50
+ "synth_strings_2", // 51
+ "choir_aahs", // 52
+ "voice_oohs", // 53
+ "synth_choir", // 54
+ "orchestra_hit", // 55
+
+ // Brass (56-63)
+ "trumpet", // 56
+ "trombone", // 57
+ "tuba", // 58
+ "muted_trumpet", // 59
+ "french_horn", // 60
+ "brass_section", // 61
+ "synth_brass_1", // 62
+ "synth_brass_2", // 63
+
+ // Reed (64-71)
+ "soprano_sax", // 64
+ "alto_sax", // 65
+ "tenor_sax", // 66
+ "baritone_sax", // 67
+ "oboe", // 68
+ "english_horn", // 69
+ "bassoon", // 70
+ "clarinet", // 71
+
+ // Pipe (72-79)
+ "piccolo", // 72
+ "flute", // 73
+ "recorder", // 74
+ "pan_flute", // 75
+ "blown_bottle", // 76
+ "shakuhachi", // 77
+ "whistle", // 78
+ "ocarina", // 79
+
+ // Synth Lead (80-87)
+ "lead_1_square", // 80
+ "lead_2_sawtooth", // 81
+ "lead_3_calliope", // 82
+ "lead_4_chiff", // 83
+ "lead_5_charang", // 84
+ "lead_6_voice", // 85
+ "lead_7_fifths", // 86
+ "lead_8_bass__lead", // 87
+
+ // Synth Pad (88-95)
+ "pad_1_new_age", // 88
+ "pad_2_warm", // 89
+ "pad_3_polysynth", // 90
+ "pad_4_choir", // 91
+ "pad_5_bowed", // 92
+ "pad_6_metallic", // 93
+ "pad_7_halo", // 94
+ "pad_8_sweep", // 95
+
+ // Synth Effects (96-103)
+ "fx_1_rain", // 96
+ "fx_2_soundtrack", // 97
+ "fx_3_crystal", // 98
+ "fx_4_atmosphere", // 99
+ "fx_5_brightness", // 100
+ "fx_6_goblins", // 101
+ "fx_7_echoes", // 102
+ "fx_8_scifi", // 103
+
+ // Ethnic (104-111)
+ "sitar", // 104
+ "banjo", // 105
+ "shamisen", // 106
+ "koto", // 107
+ "kalimba", // 108
+ "bagpipe", // 109
+ "fiddle", // 110
+ "shanai", // 111
+
+ // Percussive (112-119)
+ "tinkle_bell", // 112
+ "agogo", // 113
+ "steel_drums", // 114
+ "woodblock", // 115
+ "taiko_drum", // 116
+ "melodic_tom", // 117
+ "synth_drum", // 118
+ "reverse_cymbal", // 119
+
+ // Sound Effects (120-127)
+ "guitar_fret_noise", // 120
+ "breath_noise", // 121
+ "seashore", // 122
+ "bird_tweet", // 123
+ "telephone_ring", // 124
+ "helicopter", // 125
+ "applause", // 126
+ "gunshot", // 127
+] as const;
+
+/**
+ * SoundFont base URL for loading instrument samples
+ */
+export const SOUNDFONT_BASE_URL =
+ "https://paulrosen.github.io/midi-js-soundfonts/FluidR3_GM/";
+
+/**
+ * Get the GM instrument name for a given MIDI program number.
+ * Returns "acoustic_grand_piano" for invalid program numbers.
+ * @param program - MIDI Program Number (0-127)
+ * @returns GM instrument name string
+ */
+export function getGMInstrumentName(program: number): string {
+ if (program < 0 || program > 127) return GM_INSTRUMENT_NAMES[0];
+ return GM_INSTRUMENT_NAMES[program];
+}
+
+/**
+ * Get a human-readable display name for a GM instrument.
+ * Converts underscore-separated names to Title Case.
+ * @param program - MIDI Program Number (0-127)
+ * @returns Human-readable instrument name (e.g., "Acoustic Grand Piano")
+ */
+export function getGMInstrumentDisplayName(program: number): string {
+ const instrumentName = getGMInstrumentName(program);
+ return instrumentName
+ .split("_")
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
+ .join(" ");
+}
+
+/**
+ * Get the SoundFont URL for a given MIDI program number.
+ * @param program - MIDI Program Number (0-127)
+ * @returns Full URL to the instrument's mp3 folder
+ */
+export function getGMInstrumentUrl(program: number): string {
+ const instrumentName = getGMInstrumentName(program);
+ return `${SOUNDFONT_BASE_URL}${instrumentName}-mp3/`;
+}
+
+/**
+ * Sample map for paulrosen's FluidR3_GM soundfonts.
+ * Uses flat notation (Eb, Gb) instead of sharp notation (Ds, Fs).
+ * This is different from Tone.js salamander which uses sharp notation.
+ */
+export const GM_SAMPLE_MAP: Record = {
+ C3: "C3.mp3",
+ "D#3": "Eb3.mp3", // flat notation for paulrosen soundfonts
+ "F#3": "Gb3.mp3",
+ A3: "A3.mp3",
+ C4: "C4.mp3",
+ "D#4": "Eb4.mp3",
+ "F#4": "Gb4.mp3",
+ A4: "A4.mp3",
+};
+
diff --git a/src/lib/core/audio/managers/midi-player-group.ts b/src/lib/core/audio/managers/midi-player-group.ts
index e9cdc67..f577a50 100644
--- a/src/lib/core/audio/managers/midi-player-group.ts
+++ b/src/lib/core/audio/managers/midi-player-group.ts
@@ -1,12 +1,14 @@
import * as Tone from 'tone';
import type { PlayerGroup, SynchronizationInfo } from '../master-clock';
import { DEFAULT_SAMPLE_MAP } from '../player-types';
+import { getGMInstrumentUrl, GM_SAMPLE_MAP } from '../gm-instruments';
/**
* MIDI note information
*/
interface MidiNote {
fileId?: string;
+ trackId?: number; // Track ID within the MIDI file (0-based index)
time: number;
duration: number;
pitch: number | string; // Support both numeric MIDI note and string note name
@@ -52,6 +54,21 @@ export class MidiPlayerGroup implements PlayerGroup {
// MIDI-related references
private midiManager: any = null;
+
+ // --- Program Number-based Sampler Cache (Lazy Loading) ---
+ /** Map of MIDI Program Number -> loaded Tone.Sampler for auto-instrument routing */
+ private programSamplers: Map = new Map();
+ /** Map of MIDI Program Number -> loading Promise to prevent duplicate loads */
+ private programSamplerLoading: Map> = new Map();
+
+ // --- Pending Player States (for settings applied before player creation) ---
+ /** Stores mute/volume/pan states set before the player is initialized */
+ private pendingPlayerStates = new Map();
+
// Error tracking and statistics
private errorStats = {
totalNoteAttempts: 0,
@@ -64,6 +81,74 @@ export class MidiPlayerGroup implements PlayerGroup {
constructor() {
// console.log('[MidiPlayerGroup] Initialized');
}
+
+ // --- Program Number-based Sampler Methods ---
+
+ /**
+ * Get or create a sampler for a specific MIDI Program Number (lazy loading).
+ */
+ public getOrCreateProgramSampler(program: number): Promise {
+ const existing = this.programSamplers.get(program);
+ if (existing) {
+ return Promise.resolve(existing);
+ }
+
+ const loadingPromise = this.programSamplerLoading.get(program);
+ if (loadingPromise) {
+ return loadingPromise;
+ }
+
+ const promise = new Promise((resolve, reject) => {
+ const soundFontUrl = getGMInstrumentUrl(program);
+ const sampler = new Tone.Sampler({
+ urls: GM_SAMPLE_MAP, // Use flat notation for paulrosen soundfonts
+ baseUrl: soundFontUrl,
+ onload: () => {
+ this.programSamplers.set(program, sampler);
+ this.programSamplerLoading.delete(program);
+ resolve(sampler);
+ },
+ onerror: (error: Error) => {
+ console.error(`[MidiPlayerGroup] Failed to load sampler for program ${program}:`, error);
+ this.programSamplerLoading.delete(program);
+ reject(error);
+ },
+ }).toDestination();
+ });
+
+ this.programSamplerLoading.set(program, promise);
+ return promise;
+ }
+
+ /**
+ * Get the program sampler synchronously if already loaded.
+ */
+ public getProgramSamplerSync(program: number): Tone.Sampler | null {
+ return this.programSamplers.get(program) ?? null;
+ }
+
+ /**
+ * Check if a program sampler is already loaded or currently loading.
+ */
+ public isProgramLoadedOrLoading(program: number): boolean {
+ return this.programSamplers.has(program) || this.programSamplerLoading.has(program);
+ }
+
+ /**
+ * Preload samplers for specific MIDI Program Numbers.
+ * Useful for loading all programs used in a MIDI file at initialization.
+ * @param programs - Array of MIDI Program Numbers to preload
+ */
+ public async preloadProgramSamplers(programs: number[]): Promise {
+ const uniquePrograms = [...new Set(programs)];
+ const loadPromises = uniquePrograms.map((program) =>
+ this.getOrCreateProgramSampler(program).catch((err) => {
+ console.warn(`[MidiPlayerGroup] Failed to preload sampler for program ${program}:`, err);
+ return null;
+ })
+ );
+ await Promise.allSettled(loadPromises);
+ }
/**
* Set MIDI manager (compatibility with existing code)
@@ -172,6 +257,23 @@ export class MidiPlayerGroup implements PlayerGroup {
};
this.players.set(fileId, playerInfo);
+
+ // Apply any pending states that were set before player creation
+ const pendingState = this.pendingPlayerStates.get(fileId);
+ if (pendingState) {
+ if (pendingState.volume !== undefined) {
+ this.setPlayerVolume(fileId, pendingState.volume);
+ }
+ if (pendingState.pan !== undefined) {
+ this.setPlayerPan(fileId, pendingState.pan);
+ }
+ if (pendingState.muted !== undefined) {
+ this.setPlayerMute(fileId, pendingState.muted);
+ }
+ this.pendingPlayerStates.delete(fileId);
+ // console.log('[MidiPlayerGroup] Applied pending states for file:', fileId, pendingState);
+ }
+
// console.log('[MidiPlayerGroup] Successfully created and loaded sampler for file:', fileId);
} catch (error) {
@@ -548,7 +650,7 @@ export class MidiPlayerGroup implements PlayerGroup {
// For seek/resume (no endTime specified): include sustaining notes that began before safeStart
// and are still active at safeStart by re-triggering them at t=0 with remaining duration.
const includeCarryOver = (endTime === undefined);
- let carryOverEvents: Array<{ time: number; note: string; velocity: number; duration: number; fileId?: string } > = [];
+ let carryOverEvents: Array<{ time: number; note: string; velocity: number; duration: number; fileId?: string; trackId?: number } > = [];
if (includeCarryOver) {
const sustaining = validNotes.filter(note => {
const noteEnd = note.time + note.duration;
@@ -562,7 +664,8 @@ export class MidiPlayerGroup implements PlayerGroup {
note: (note as any).name || this.normalizeNoteName(typeof note.pitch === 'number' ? note.pitch : Number(note.pitch) || 60),
velocity: note.velocity,
duration: scaledRemaining,
- fileId: note.fileId
+ fileId: note.fileId,
+ trackId: note.trackId
};
});
}
@@ -593,7 +696,8 @@ export class MidiPlayerGroup implements PlayerGroup {
note: (note as any).name || this.normalizeNoteName(typeof note.pitch === 'number' ? note.pitch : Number(note.pitch) || 60),
velocity: note.velocity,
duration: Math.max(0.01, note.duration * timeScale),
- fileId: note.fileId as string | undefined
+ fileId: note.fileId as string | undefined,
+ trackId: note.trackId
}));
// Prepend carry-over sustaining notes (if any)
@@ -604,7 +708,8 @@ export class MidiPlayerGroup implements PlayerGroup {
note: e.note,
velocity: e.velocity,
duration: Math.max(0.01, e.duration),
- fileId: (e.fileId as string | undefined)
+ fileId: (e.fileId as string | undefined),
+ trackId: e.trackId
}));
events = [...normalizedCarry, ...events];
}
@@ -637,6 +742,22 @@ export class MidiPlayerGroup implements PlayerGroup {
// console.debug('[MidiPlayerGroup] Player muted, skipping:', event.fileId);
return; // Don't play muted players (not counted as error)
}
+
+ // Check track-level mute via global midiManager reference
+ const midiMgr = (globalThis as unknown as { _waveRollMidiManager?: {
+ isTrackMuted?: (fileId: string, trackId: number) => boolean;
+ getTrackVolume?: (fileId: string, trackId: number) => number;
+ isTrackAutoInstrument?: (fileId: string, trackId: number) => boolean;
+ getTrackProgram?: (fileId: string, trackId: number) => number;
+ } })._waveRollMidiManager;
+
+ const fileId = (event.fileId ?? '') as string;
+ const trackId = event.trackId;
+
+ // Skip if track is muted
+ if (trackId !== undefined && midiMgr?.isTrackMuted?.(fileId, trackId)) {
+ return;
+ }
// console.log('[MidiPlayerGroup] Playing note:', event.note, 'at time:', time, 'transportSeconds', Tone.getTransport().seconds);
@@ -653,12 +774,36 @@ export class MidiPlayerGroup implements PlayerGroup {
console.warn('[MidiPlayerGroup] Runtime duration validation failed:', event);
return;
}
+
+ // Get track volume
+ let trackVolume = 1.0;
+ if (trackId !== undefined && midiMgr?.getTrackVolume) {
+ trackVolume = midiMgr.getTrackVolume(fileId, trackId);
+ }
+
+ // Apply volume: master * individual * track * velocity
+ const finalVolume = this.masterVolume * playerInfo.volume * trackVolume * event.velocity;
- // Apply volume: master * individual * velocity
- const finalVolume = this.masterVolume * playerInfo.volume * event.velocity;
+ // Determine which sampler to use: program-specific or default piano
+ let targetSampler: Tone.Sampler = playerInfo.sampler;
+ const useAutoInstrument =
+ trackId !== undefined &&
+ midiMgr?.isTrackAutoInstrument?.(fileId, trackId);
+
+ if (useAutoInstrument && midiMgr?.getTrackProgram) {
+ const program = midiMgr.getTrackProgram(fileId, trackId!);
+ const programSampler = this.getProgramSamplerSync(program);
+ if (programSampler?.loaded) {
+ targetSampler = programSampler;
+ } else {
+ // Lazy load the program sampler for future notes
+ this.getOrCreateProgramSampler(program).catch(() => {});
+ // Use default sampler for this note
+ }
+ }
- // Use Sampler's triggerAttackRelease with proper volume scaling
- playerInfo.sampler.triggerAttackRelease(
+ // Use selected Sampler's triggerAttackRelease with proper volume scaling
+ targetSampler.triggerAttackRelease(
event.note,
event.duration,
time,
@@ -922,7 +1067,10 @@ export class MidiPlayerGroup implements PlayerGroup {
setPlayerVolume(fileId: string, volume: number): void {
const playerInfo = this.players.get(fileId);
if (!playerInfo) {
- console.warn('[MidiPlayerGroup] Player not found:', fileId);
+ // Store pending volume state for later application when player is created
+ const pending = this.pendingPlayerStates.get(fileId) || {};
+ pending.volume = Math.max(0, Math.min(1, volume));
+ this.pendingPlayerStates.set(fileId, pending);
return;
}
@@ -941,7 +1089,10 @@ export class MidiPlayerGroup implements PlayerGroup {
setPlayerPan(fileId: string, pan: number): void {
const playerInfo = this.players.get(fileId);
if (!playerInfo) {
- console.warn('[MidiPlayerGroup] Player not found:', fileId);
+ // Store pending pan state for later application when player is created
+ const pending = this.pendingPlayerStates.get(fileId) || {};
+ pending.pan = Math.max(-1, Math.min(1, pan));
+ this.pendingPlayerStates.set(fileId, pending);
return;
}
@@ -957,7 +1108,10 @@ export class MidiPlayerGroup implements PlayerGroup {
setPlayerMute(fileId: string, muted: boolean): void {
const playerInfo = this.players.get(fileId);
if (!playerInfo) {
- console.warn('[MidiPlayerGroup] Player not found:', fileId);
+ // Store pending mute state for later application when player is created
+ const pending = this.pendingPlayerStates.get(fileId) || {};
+ pending.muted = muted;
+ this.pendingPlayerStates.set(fileId, pending);
return;
}
@@ -1015,5 +1169,6 @@ export class MidiPlayerGroup implements PlayerGroup {
}
this.players.clear();
+ this.pendingPlayerStates.clear();
}
}
diff --git a/src/lib/core/audio/managers/sampler-manager.ts b/src/lib/core/audio/managers/sampler-manager.ts
index fb27de7..1261353 100644
--- a/src/lib/core/audio/managers/sampler-manager.ts
+++ b/src/lib/core/audio/managers/sampler-manager.ts
@@ -1,13 +1,33 @@
/**
* Sampler Manager for MIDI playback
* Handles Tone.js sampler creation, loading, and playback
+ * Supports multi-instrument routing based on MIDI Program Number
*/
import * as Tone from "tone";
-import { NoteData } from "@/lib/midi/types";
-import { DEFAULT_SAMPLE_MAP, AUDIO_CONSTANTS, AudioPlayerState } from "../player-types";
+import { NoteData, InstrumentFamily } from "@/lib/midi/types";
+import {
+ DEFAULT_SAMPLE_MAP,
+ AUDIO_CONSTANTS,
+ AudioPlayerState,
+} from "../player-types";
import { clamp } from "../../utils";
import { toDb, fromDb, clamp01, isSilentDb, mixLinear } from "../utils";
+import {
+ getGMInstrumentUrl,
+ SOUNDFONT_BASE_URL,
+ GM_SAMPLE_MAP,
+} from "../gm-instruments";
+
+/**
+ * Loading state for an instrument family sampler.
+ */
+export interface InstrumentLoadState {
+ family: InstrumentFamily;
+ isLoading: boolean;
+ isLoaded: boolean;
+ error?: string;
+}
export interface SamplerTrack {
sampler: Tone.Sampler;
@@ -15,6 +35,10 @@ export interface SamplerTrack {
gate: Tone.Gain;
panner: Tone.Panner;
muted: boolean;
+ /** Instrument family for this track (used for multi-instrument routing) */
+ instrumentFamily?: InstrumentFamily;
+ /** Whether to use oscillator fallback (when sampler fails to load) */
+ useOscillatorFallback?: boolean;
}
export class SamplerManager {
@@ -34,7 +58,13 @@ export class SamplerManager {
private midiManager: any;
/** Alignment delay in seconds to compensate downstream (e.g., WAV PitchShift) latency */
private alignmentDelaySec: number = 0;
-
+
+ // --- Program Number-based Sampler Cache (Lazy Loading) ---
+ /** Map of MIDI Program Number -> loaded Tone.Sampler for auto-instrument routing */
+ private programSamplers: Map = new Map();
+ /** Map of MIDI Program Number -> loading Promise to prevent duplicate loads */
+ private programSamplerLoading: Map> = new Map();
+
constructor(notes: NoteData[], midiManager?: any) {
this.notes = notes;
this.midiManager = midiManager;
@@ -55,6 +85,80 @@ export class SamplerManager {
panner.pan.value = clamp(pan, -1, 1);
}
+ // --- Program Number-based Sampler Methods ---
+
+ /**
+ * Get or create a sampler for a specific MIDI Program Number (lazy loading).
+ * Returns the sampler immediately if already loaded, otherwise starts loading
+ * and returns a Promise that resolves when the sampler is ready.
+ * @param program - The MIDI Program Number (0-127) to load
+ * @returns Promise that resolves to the loaded Tone.Sampler
+ */
+ public getOrCreateProgramSampler(program: number): Promise {
+ // Return existing sampler if already loaded
+ const existing = this.programSamplers.get(program);
+ if (existing) {
+ return Promise.resolve(existing);
+ }
+
+ // Return existing loading promise if already loading
+ const loadingPromise = this.programSamplerLoading.get(program);
+ if (loadingPromise) {
+ return loadingPromise;
+ }
+
+ // Start loading the sampler
+ const promise = new Promise((resolve, reject) => {
+ const soundFontUrl = getGMInstrumentUrl(program);
+ const sampler = new Tone.Sampler({
+ urls: GM_SAMPLE_MAP, // Use flat notation for paulrosen soundfonts
+ baseUrl: soundFontUrl,
+ onload: () => {
+ this.programSamplers.set(program, sampler);
+ this.programSamplerLoading.delete(program);
+ resolve(sampler);
+ },
+ onerror: (error: Error) => {
+ console.error(
+ `Failed to load sampler for program ${program}:`,
+ error
+ );
+ this.programSamplerLoading.delete(program);
+ reject(error);
+ },
+ }).toDestination();
+ });
+
+ this.programSamplerLoading.set(program, promise);
+ return promise;
+ }
+
+ /**
+ * Get the program sampler synchronously if already loaded.
+ * Returns null if the sampler is not yet loaded.
+ * @param program - The MIDI Program Number (0-127)
+ * @returns The loaded sampler or null
+ */
+ public getProgramSamplerSync(program: number): Tone.Sampler | null {
+ return this.programSamplers.get(program) ?? null;
+ }
+
+ /**
+ * Preload samplers for specific MIDI Program Numbers.
+ * Useful for loading all programs used in a MIDI file at initialization.
+ * @param programs - Array of MIDI Program Numbers to preload
+ */
+ public async preloadProgramSamplers(programs: number[]): Promise {
+ const uniquePrograms = [...new Set(programs)];
+ const loadPromises = uniquePrograms.map((program) =>
+ this.getOrCreateProgramSampler(program).catch((err) => {
+ console.warn(`Failed to preload sampler for program ${program}:`, err);
+ return null;
+ })
+ );
+ await Promise.allSettled(loadPromises);
+ }
+
/** Return notes filtered to intersect the [loopStart, loopEnd) window, or all if window inactive. */
private filterNotesByLoopWindow(
notes: NoteData[],
@@ -109,7 +213,7 @@ export class SamplerManager {
});
}
- /** Map notes to Part events with fileId. */
+ /** Map notes to Part events with fileId and trackId. */
private notesToEventsWithFileId(
notes: NoteData[],
loopStartVisual: number | null | undefined,
@@ -120,6 +224,7 @@ export class SamplerManager {
duration: number;
velocity: number;
fileId: string;
+ trackId?: number;
}> {
return notes.map((note) => {
const onset = note.time;
@@ -138,6 +243,7 @@ export class SamplerManager {
duration: durationSafe,
velocity: note.velocity,
fileId: note.fileId || "__default",
+ trackId: note.trackId,
};
});
}
@@ -145,7 +251,14 @@ export class SamplerManager {
/** Build Tone.Part from events and callback, configure loop settings. */
private buildPart(
events: T[],
- options: { repeat?: boolean; duration?: number; tempo?: number; originalTempo?: number } | undefined,
+ options:
+ | {
+ repeat?: boolean;
+ duration?: number;
+ tempo?: number;
+ originalTempo?: number;
+ }
+ | undefined,
callback: (time: number, event: T) => void
): void {
// Dispose of existing part to prevent memory leak and double triggering
@@ -155,7 +268,7 @@ export class SamplerManager {
this.part.dispose();
this.part = null;
}
-
+
this.part = new Tone.Part(callback as any, events as any);
// Important: do NOT enable Part.loop. We explicitly handle Transport
// loop events in AudioPlayer.handleTransportLoop() by cancelling and
@@ -180,7 +293,10 @@ export class SamplerManager {
/**
* Initialize samplers - either multi-track or legacy single sampler
*/
- async initialize(options: { soundFont?: string; volume?: number }): Promise {
+ async initialize(options: {
+ soundFont?: string;
+ volume?: number;
+ }): Promise {
// Try multi-file setup first
if (!this.setupTrackSamplers(options)) {
// Fallback to legacy single sampler
@@ -209,7 +325,10 @@ export class SamplerManager {
* Set up per-track samplers for multi-file playback
* @returns true if multi-file setup succeeded, false for fallback
*/
- private setupTrackSamplers(options: { soundFont?: string; volume?: number }): boolean {
+ private setupTrackSamplers(options: {
+ soundFont?: string;
+ volume?: number;
+ }): boolean {
// Group notes by fileId
const fileNotes = new Map();
this.notes.forEach((note) => {
@@ -226,17 +345,23 @@ export class SamplerManager {
const panner = new Tone.Panner(0).toDestination();
const gate = new Tone.Gain(1).connect(panner);
// Optional alignment delay node between sampler and gate
- const delay = this.alignmentDelaySec > 0 ? new Tone.Delay(this.alignmentDelaySec) : null;
+ const delay =
+ this.alignmentDelaySec > 0
+ ? new Tone.Delay(this.alignmentDelaySec)
+ : null;
const sampler = new Tone.Sampler({
urls: DEFAULT_SAMPLE_MAP,
- baseUrl:
- options.soundFont ||
- "https://tonejs.github.io/audio/salamander/",
+ baseUrl: options.soundFont || SOUNDFONT_BASE_URL,
onload: () => {
// Sampler loaded for file
},
onerror: (error: Error) => {
console.error(`Failed to load sampler for file ${fid}:`, error);
+ // Mark track for oscillator fallback on next play attempt
+ const track = this.trackSamplers.get(fid);
+ if (track) {
+ track.useOscillatorFallback = true;
+ }
},
}).connect(delay ? delay : gate);
if (delay) delay.connect(gate);
@@ -255,7 +380,14 @@ export class SamplerManager {
}
}
- this.trackSamplers.set(fid, { sampler, delay, gate, panner, muted: initialMuted });
+ this.trackSamplers.set(fid, {
+ sampler,
+ delay,
+ gate,
+ panner,
+ muted: initialMuted,
+ useOscillatorFallback: false,
+ });
}
});
@@ -265,14 +397,19 @@ export class SamplerManager {
/**
* Fallback: Set up legacy single sampler
*/
- private async setupLegacySampler(options: { soundFont?: string; volume?: number }): Promise {
+ private async setupLegacySampler(options: {
+ soundFont?: string;
+ volume?: number;
+ }): Promise {
this.panner = new Tone.Panner(0).toDestination();
this.gate = new Tone.Gain(1).connect(this.panner);
- const delay = this.alignmentDelaySec > 0 ? new Tone.Delay(this.alignmentDelaySec) : null;
+ const delay =
+ this.alignmentDelaySec > 0
+ ? new Tone.Delay(this.alignmentDelaySec)
+ : null;
this.sampler = new Tone.Sampler({
urls: DEFAULT_SAMPLE_MAP,
- baseUrl:
- options.soundFont || "https://tonejs.github.io/audio/salamander/",
+ baseUrl: options.soundFont || SOUNDFONT_BASE_URL,
}).connect(delay ? delay : this.gate);
if (delay) delay.connect(this.gate);
@@ -290,7 +427,12 @@ export class SamplerManager {
setupNotePart(
loopStartVisual?: number | null,
loopEndVisual?: number | null,
- options?: { repeat?: boolean; duration?: number; tempo?: number; originalTempo?: number }
+ options?: {
+ repeat?: boolean;
+ duration?: number;
+ tempo?: number;
+ originalTempo?: number;
+ }
): void {
// Multi-file setup always takes precedence
if (this.trackSamplers.size > 0) {
@@ -306,7 +448,12 @@ export class SamplerManager {
private setupMultiTrackPart(
loopStartVisual?: number | null,
loopEndVisual?: number | null,
- options?: { repeat?: boolean; duration?: number; tempo?: number; originalTempo?: number }
+ options?: {
+ repeat?: boolean;
+ duration?: number;
+ tempo?: number;
+ originalTempo?: number;
+ }
): void {
const scaleToTransport = this.computeScaleToTransport(
options?.originalTempo,
@@ -323,6 +470,7 @@ export class SamplerManager {
duration: number;
velocity: number;
fileId: string;
+ trackId?: number;
};
const events: ScheduledNoteEvent[] = this.notesToEventsWithFileId(
filtered,
@@ -332,21 +480,73 @@ export class SamplerManager {
// Track if we've warned about each unloaded sampler
const warnedSamplers = new Set();
-
+
// Build events for Part
const callback = (time: number, event: ScheduledNoteEvent) => {
const fid = event.fileId || "__default";
const track = this.trackSamplers.get(fid);
if (track && !track.muted) {
- if (track.sampler.loaded) {
+ // Check track-level mute via global midiManager reference
+ const midiMgr = (
+ globalThis as unknown as {
+ _waveRollMidiManager?: {
+ isTrackMuted?: (fileId: string, trackId: number) => boolean;
+ getTrackVolume?: (fileId: string, trackId: number) => number;
+ isTrackAutoInstrument?: (
+ fileId: string,
+ trackId: number
+ ) => boolean;
+ getTrackProgram?: (fileId: string, trackId: number) => number;
+ };
+ }
+ )._waveRollMidiManager;
+
+ // Skip if track is muted
+ if (
+ event.trackId !== undefined &&
+ midiMgr?.isTrackMuted?.(fid, event.trackId)
+ ) {
+ return;
+ }
+
+ // Apply track volume
+ let velocity = event.velocity;
+ if (event.trackId !== undefined && midiMgr?.getTrackVolume) {
+ const trackVolume = midiMgr.getTrackVolume(fid, event.trackId);
+ velocity = velocity * trackVolume;
+ }
+
+ // Determine which sampler to use: program-specific or default piano
+ let targetSampler: Tone.Sampler | null = null;
+ const useAutoInstrument =
+ event.trackId !== undefined &&
+ midiMgr?.isTrackAutoInstrument?.(fid, event.trackId);
+
+ if (useAutoInstrument && midiMgr?.getTrackProgram) {
+ const program = midiMgr.getTrackProgram(fid, event.trackId!);
+ const programSampler = this.getProgramSamplerSync(program);
+ if (programSampler?.loaded) {
+ targetSampler = programSampler;
+ } else {
+ // Lazy load the program sampler for future notes
+ this.getOrCreateProgramSampler(program).catch(() => {});
+ // Fallback to default track sampler for this note
+ targetSampler = track.sampler.loaded ? track.sampler : null;
+ }
+ } else {
+ // Use default piano sampler
+ targetSampler = track.sampler.loaded ? track.sampler : null;
+ }
+
+ if (targetSampler) {
// Schedule note with slight lookahead for smoother playback
const scheduledTime = Math.max(time, Tone.now());
- track.sampler.triggerAttackRelease(
+ targetSampler.triggerAttackRelease(
event.note,
event.duration,
scheduledTime,
- event.velocity
+ velocity
);
} else if (!warnedSamplers.has(fid)) {
console.warn(
@@ -371,7 +571,12 @@ export class SamplerManager {
private setupLegacyPart(
loopStartVisual?: number | null,
loopEndVisual?: number | null,
- options?: { repeat?: boolean; duration?: number; tempo?: number; originalTempo?: number }
+ options?: {
+ repeat?: boolean;
+ duration?: number;
+ tempo?: number;
+ originalTempo?: number;
+ }
): void {
const scaleToTransport = this.computeScaleToTransport(
options?.originalTempo,
@@ -423,7 +628,7 @@ export class SamplerManager {
console.warn("[SamplerManager] No Part to start");
return;
}
-
+
// Check Part state to prevent duplicate starts
const partState = (this.part as any).state;
if (partState === "started") {
@@ -431,14 +636,17 @@ export class SamplerManager {
this.part.stop(0);
this.part.cancel(0);
}
-
+
// Ensure the part is completely stopped before starting
this.part.stop(0);
this.part.cancel(0);
-
+
// Start part with a safe non-negative offset (avoid tiny negative epsilons)
- const off = typeof offset === 'number' && Number.isFinite(offset) ? Math.max(0, offset) : 0;
-
+ const off =
+ typeof offset === "number" && Number.isFinite(offset)
+ ? Math.max(0, offset)
+ : 0;
+
try {
(this.part as Tone.Part).start(time, off);
// console.log("[SamplerManager] Part started", { time, offset: off });
@@ -458,7 +666,7 @@ export class SamplerManager {
// Don't dispose here - let buildPart handle disposal when creating a new one
}
}
-
+
/**
* Restart the part for seamless looping
* This avoids recreating the Part which can cause gaps
@@ -467,7 +675,7 @@ export class SamplerManager {
if (this.part) {
// Cancel any scheduled events to prevent overlap
this.part.cancel();
-
+
// Immediately restart the part from the beginning
// Use a very small offset to ensure smooth transition
const restartTime = Tone.now() + 0.001;
@@ -531,7 +739,9 @@ export class SamplerManager {
track.sampler.connect(track.panner);
} catch {}
} else if (track.delay) {
- try { track.delay.delayTime.value = d; } catch {}
+ try {
+ track.delay.delayTime.value = d;
+ } catch {}
}
});
// Legacy sampler path: cannot easily rewire if already connected; best-effort
@@ -627,7 +837,7 @@ export class SamplerManager {
*/
retriggerAllUnmutedHeldNotes(currentTime: number): void {
let totalRetriggered = 0;
-
+
// Retrigger for all unmuted tracks
this.trackSamplers.forEach((track, fileId) => {
if (!track.muted) {
@@ -636,7 +846,7 @@ export class SamplerManager {
// Note: retriggerHeldNotes doesn't return count, so we can't track exact numbers
}
});
-
+
// console.log("[SamplerManager] Retriggered held notes for all unmuted tracks at", currentTime);
}
@@ -691,7 +901,7 @@ export class SamplerManager {
return this.sampler ? this.sampler.volume.value <= SILENT_DB : true;
}
return !Array.from(this.trackSamplers.values()).some(
- track => !track.muted && track.sampler.volume.value > SILENT_DB
+ (track) => !track.muted && track.sampler.volume.value > SILENT_DB
);
}
@@ -700,19 +910,19 @@ export class SamplerManager {
*/
areAllTracksMuted(): boolean {
const SILENT_DB = AUDIO_CONSTANTS.SILENT_DB;
-
+
// Check multi-track samplers
if (this.trackSamplers.size > 0) {
return !Array.from(this.trackSamplers.values()).some(
(t) => !t.muted && t.sampler.volume.value > SILENT_DB
);
}
-
+
// Check legacy sampler
if (this.sampler) {
return this.sampler.volume.value <= SILENT_DB;
}
-
+
return true;
}
@@ -735,7 +945,9 @@ export class SamplerManager {
this.panner = null;
}
if (this.gate) {
- try { this.gate.dispose(); } catch {}
+ try {
+ this.gate.dispose();
+ } catch {}
this.gate = null;
}
@@ -758,7 +970,10 @@ export class SamplerManager {
this.trackSamplers.forEach(({ sampler }) => {
try {
// Try PolySynth-like API if available
- const anyS = sampler as unknown as { releaseAll?: (time?: number) => void; triggerRelease?: (notes?: any, time?: number) => void };
+ const anyS = sampler as unknown as {
+ releaseAll?: (time?: number) => void;
+ triggerRelease?: (notes?: any, time?: number) => void;
+ };
if (typeof anyS.releaseAll === "function") {
anyS.releaseAll(now);
return;
@@ -774,7 +989,10 @@ export class SamplerManager {
// Legacy single sampler
if (this.sampler) {
try {
- const anyS = this.sampler as unknown as { releaseAll?: (time?: number) => void; triggerRelease?: (notes?: any, time?: number) => void };
+ const anyS = this.sampler as unknown as {
+ releaseAll?: (time?: number) => void;
+ triggerRelease?: (notes?: any, time?: number) => void;
+ };
if (typeof anyS.releaseAll === "function") {
anyS.releaseAll(now);
} else if (typeof anyS.triggerRelease === "function") {
@@ -796,8 +1014,16 @@ export class SamplerManager {
*/
hardMuteAllGates(): void {
try {
- this.trackSamplers.forEach(({ gate }) => { try { gate.gain.value = 0; } catch {} });
- if (this.gate) { try { this.gate.gain.value = 0; } catch {} }
+ this.trackSamplers.forEach(({ gate }) => {
+ try {
+ gate.gain.value = 0;
+ } catch {}
+ });
+ if (this.gate) {
+ try {
+ this.gate.gain.value = 0;
+ } catch {}
+ }
} catch {}
}
@@ -806,8 +1032,16 @@ export class SamplerManager {
*/
hardUnmuteAllGates(): void {
try {
- this.trackSamplers.forEach(({ gate }) => { try { gate.gain.value = 1; } catch {} });
- if (this.gate) { try { this.gate.gain.value = 1; } catch {} }
+ this.trackSamplers.forEach(({ gate }) => {
+ try {
+ gate.gain.value = 1;
+ } catch {}
+ });
+ if (this.gate) {
+ try {
+ this.gate.gain.value = 1;
+ } catch {}
+ }
} catch {}
}
diff --git a/src/lib/core/audio/unified-audio-controller.ts b/src/lib/core/audio/unified-audio-controller.ts
index 81731f7..51c4b8b 100644
--- a/src/lib/core/audio/unified-audio-controller.ts
+++ b/src/lib/core/audio/unified-audio-controller.ts
@@ -1,29 +1,29 @@
-import * as Tone from 'tone';
-import { AudioMasterClock } from './master-clock';
-import { WavPlayerGroup } from './managers/wav-player-group';
-import { MidiPlayerGroup } from './managers/midi-player-group';
+import * as Tone from "tone";
+import { AudioMasterClock } from "./master-clock";
+import { WavPlayerGroup } from "./managers/wav-player-group";
+import { MidiPlayerGroup } from "./managers/midi-player-group";
/**
* Unified Audio Controller
- *
+ *
* Fully implements user requirements:
* - Upper-level object: unified management of nowTime, isPlaying, tempo, masterVolume, loopMode, markerA/B
* - Lower-level groups: per-player control of volume, pan, and mute
- *
+ *
* This class synchronizes WAV and MIDI player groups perfectly via AudioMasterClock.
*/
export class UnifiedAudioController {
// Master clock (single time source)
private masterClock: AudioMasterClock;
-
+
// Player groups
private wavPlayerGroup: WavPlayerGroup;
private midiPlayerGroup: MidiPlayerGroup;
-
+
// Initialization state
private isInitialized: boolean = false;
private initPromise: Promise | null = null;
-
+
// Visual update handling
private visualUpdateCallback?: (time: number) => void;
private visualUpdateLoop?: number;
@@ -32,7 +32,8 @@ export class UnifiedAudioController {
private _lastRepeatWrapAtGen: number = -1;
private lastJoinRequestTs = new Map();
private handleWavVisibilityChange = (e: Event) => {
- const detail = (e as CustomEvent<{ id: string; isVisible: boolean }>).detail;
+ const detail = (e as CustomEvent<{ id: string; isVisible: boolean }>)
+ .detail;
if (!detail) return;
if (detail.isVisible) {
this.alignWavJoin(detail.id);
@@ -50,30 +51,36 @@ export class UnifiedAudioController {
}
} catch {}
};
-
+
constructor() {
// console.log('[UnifiedAudioController] Initializing');
-
+
// Create master clock
this.masterClock = new AudioMasterClock();
-
+
// Create player groups
this.wavPlayerGroup = new WavPlayerGroup();
this.midiPlayerGroup = new MidiPlayerGroup();
-
+
// Register player groups to master clock
this.masterClock.registerPlayerGroup(this.wavPlayerGroup);
this.masterClock.registerPlayerGroup(this.midiPlayerGroup);
-
+
// console.log('[UnifiedAudioController] Created with master clock and player groups');
// Event-based WAV join alignment (avoid RAF-based rescheduling)
- if (typeof window !== 'undefined') {
- window.addEventListener('wr-wav-visibility-changed', this.handleWavVisibilityChange as EventListener);
- window.addEventListener('wr-wav-mute-changed', this.handleWavMuteChange as EventListener);
+ if (typeof window !== "undefined") {
+ window.addEventListener(
+ "wr-wav-visibility-changed",
+ this.handleWavVisibilityChange as EventListener
+ );
+ window.addEventListener(
+ "wr-wav-mute-changed",
+ this.handleWavMuteChange as EventListener
+ );
}
}
-
+
/**
* Compute effective total duration considering both MIDI and audible WAV sources.
* Falls back to master clock's totalTime when registry is unavailable.
@@ -81,13 +88,30 @@ export class UnifiedAudioController {
private getEffectiveTotalTime(): number {
let duration = this.masterClock.state.totalTime || 0;
try {
- const api = (globalThis as unknown as { _waveRollAudio?: { getFiles?: () => Array<{ isVisible?: boolean; isMuted?: boolean; volume?: number; audioBuffer?: { duration?: number } }> } })._waveRollAudio;
+ const api = (
+ globalThis as unknown as {
+ _waveRollAudio?: {
+ getFiles?: () => Array<{
+ isVisible?: boolean;
+ isMuted?: boolean;
+ volume?: number;
+ audioBuffer?: { duration?: number };
+ }>;
+ };
+ }
+ )._waveRollAudio;
const items = api?.getFiles?.();
if (items && Array.isArray(items)) {
const audioDurations = items
- .filter((i) => i && (i.isVisible !== false) && (i.isMuted !== true) && (i.volume === undefined || i.volume > 0))
- .map((i) => (i?.audioBuffer?.duration ?? 0))
- .filter((d) => typeof d === 'number' && d > 0);
+ .filter(
+ (i) =>
+ i &&
+ i.isVisible !== false &&
+ i.isMuted !== true &&
+ (i.volume === undefined || i.volume > 0)
+ )
+ .map((i) => i?.audioBuffer?.duration ?? 0)
+ .filter((d) => typeof d === "number" && d > 0);
if (audioDurations.length > 0) {
duration = Math.max(duration, ...audioDurations);
}
@@ -101,28 +125,30 @@ export class UnifiedAudioController {
*/
async initialize(): Promise {
if (this.isInitialized) return;
-
+
if (this.initPromise) {
return this.initPromise;
}
-
+
this.initPromise = this.performInitialization();
await this.initPromise;
}
-
+
private async performInitialization(): Promise {
try {
// console.log('[UnifiedAudioController] Starting initialization');
-
+
// Ensure Tone.js context is running
- if ((Tone as any).context && (Tone as any).context.state !== 'running') {
+ if ((Tone as any).context && (Tone as any).context.state !== "running") {
// console.log('[UnifiedAudioController] AudioContext state:', Tone.context.state);
await Tone.start();
// console.log('[UnifiedAudioController] Tone.js context started, new state:', Tone.context.state);
-
+
// Additional verification that context is truly running
- if (Tone.context.state !== 'running') {
- console.warn('[UnifiedAudioController] AudioContext still not running after Tone.start()');
+ if (Tone.context.state !== "running") {
+ console.warn(
+ "[UnifiedAudioController] AudioContext still not running after Tone.start()"
+ );
// Try direct resume as fallback
if ((Tone.context as any).resume) {
await Tone.context.resume();
@@ -130,65 +156,142 @@ export class UnifiedAudioController {
}
}
}
-
+
// Check WAV audio registry before initialization
- const api = (globalThis as unknown as { _waveRollAudio?: { getFiles?: () => any[] } })._waveRollAudio;
+ const api = (
+ globalThis as unknown as { _waveRollAudio?: { getFiles?: () => any[] } }
+ )._waveRollAudio;
if (api?.getFiles) {
const files = api.getFiles();
// console.log('[UnifiedAudioController] WAV registry found:', files.length, 'files:', files.map(f => f.displayName || f.id));
} else {
// console.log('[UnifiedAudioController] WAV registry not available');
}
-
+
// Initialize both player groups
await this.midiPlayerGroup.initialize();
await this.wavPlayerGroup.setupAudioPlayersFromRegistry();
-
+
this.isInitialized = true;
// console.log('[UnifiedAudioController] Initialization completed, AudioContext state:', Tone.context.state);
-
} catch (error) {
- console.error('[UnifiedAudioController] Initialization failed:', error);
+ console.error("[UnifiedAudioController] Initialization failed:", error);
throw error;
}
}
-
+
// === Upper-level unified control methods (user requirements) ===
-
+
/**
* Start unified playback
*/
async play(): Promise {
await this.initialize();
-
+
// console.log('[UnifiedAudioController] Starting unified playback');
-
+
try {
// Gate: ensure all audio backends are ready before first playback
await this.midiPlayerGroup.waitUntilReady();
await this.wavPlayerGroup.waitUntilReady();
+ // Preload soundfonts for auto-instrument tracks (AudioContext is now active after initialize())
+ await this.preloadAutoInstrumentSamplers();
+
// If the anchor is too close, refresh nowTime and let master clock compute a fresh anchor
const now = Tone.now();
const lastAnchor = (this as any)._lastPlayAnchor as number | undefined;
if (lastAnchor && now > lastAnchor - 0.01) {
this.masterClock.state.nowTime = this.masterClock.getCurrentTime();
}
-
+
// Perfectly synchronized playback via the master clock
await this.masterClock.startPlayback(this.masterClock.state.nowTime, 0.1);
(this as any)._lastPlayAnchor = Tone.now() + 0.1;
-
+
// Start visual update loop
this.startVisualUpdateLoop();
-
+
// console.log('[UnifiedAudioController] Unified playback started successfully');
} catch (error) {
- console.error('[UnifiedAudioController] Failed to start playback:', error);
+ console.error(
+ "[UnifiedAudioController] Failed to start playback:",
+ error
+ );
throw error;
}
}
-
+
+ /**
+ * Preload soundfonts for tracks with auto-instrument enabled.
+ * Called after AudioContext is active to ensure successful loading.
+ */
+ private async preloadAutoInstrumentSamplers(): Promise {
+ try {
+ // Access MIDI manager from global reference
+ const midiMgr = (
+ globalThis as unknown as {
+ _waveRollMidiManager?: {
+ getState?: () => {
+ files: Array<{
+ id: string;
+ parsedData?: {
+ tracks?: Array<{ id: number; program?: number }>;
+ };
+ }>;
+ };
+ isTrackAutoInstrument?: (
+ fileId: string,
+ trackId: number
+ ) => boolean;
+ getTrackProgram?: (fileId: string, trackId: number) => number;
+ };
+ }
+ )._waveRollMidiManager;
+
+ if (
+ !midiMgr?.getState ||
+ !midiMgr?.isTrackAutoInstrument ||
+ !midiMgr?.getTrackProgram
+ ) {
+ return;
+ }
+
+ const programs: number[] = [];
+ const state = midiMgr.getState();
+
+ for (const file of state.files) {
+ if (file.parsedData?.tracks) {
+ for (const track of file.parsedData.tracks) {
+ const isAutoInstrument = midiMgr.isTrackAutoInstrument(
+ file.id,
+ track.id
+ );
+ if (isAutoInstrument) {
+ // Use getTrackProgram to get correct program (handles drum tracks β 114)
+ const program = midiMgr.getTrackProgram(file.id, track.id);
+ programs.push(program);
+ }
+ }
+ }
+ }
+
+ const uniquePrograms = [...new Set(programs)];
+ const unloadedPrograms = uniquePrograms.filter(
+ (p) => !this.midiPlayerGroup.isProgramLoadedOrLoading(p)
+ );
+
+ if (unloadedPrograms.length > 0) {
+ await this.preloadProgramSamplers(unloadedPrograms);
+ }
+ } catch (err) {
+ console.warn(
+ "[UnifiedAudioController] Error during soundfont preload:",
+ err
+ );
+ }
+ }
+
/**
* Pause unified playback
*/
@@ -196,7 +299,7 @@ export class UnifiedAudioController {
this.masterClock.pausePlayback(); // Use pausePlayback instead of stopPlayback
this.stopVisualUpdateLoop();
}
-
+
/**
* Stop unified playback (rewind to start)
*/
@@ -204,14 +307,16 @@ export class UnifiedAudioController {
this.masterClock.stopPlayback();
this.stopVisualUpdateLoop();
}
-
+
/**
* Seek to a specific time
*/
seek(time: number): void {
// console.log('[UnifiedAudioController] seek called with', time);
// Ensure both groups realign at exactly the same master time.
- try { this.wavPlayerGroup.stopSynchronized(); } catch {}
+ try {
+ this.wavPlayerGroup.stopSynchronized();
+ } catch {}
this.masterClock.seekTo(time);
try {
const tr = Tone.getTransport();
@@ -223,56 +328,56 @@ export class UnifiedAudioController {
// });
} catch {}
}
-
+
/**
* Get current playback time
*/
getCurrentTime(): number {
return this.masterClock.getCurrentTime();
}
-
+
/**
* Check playing state
*/
get isPlaying(): boolean {
return this.masterClock.state.isPlaying;
}
-
+
/**
* Current nowTime (user requirements)
*/
get nowTime(): number {
return this.masterClock.state.nowTime;
}
-
+
set nowTime(time: number) {
this.masterClock.seekTo(time);
}
-
+
/**
* Set/get total time
*/
get totalTime(): number {
return this.masterClock.state.totalTime;
}
-
+
set totalTime(time: number) {
this.masterClock.state.totalTime = time;
}
-
+
/**
* Set/get tempo (user requirements)
*/
get tempo(): number {
return this.masterClock.state.tempo;
}
-
+
set tempo(bpm: number) {
const prevBpm = this.masterClock.state.tempo;
-
+
// Skip if same value to prevent duplicate calls
if (bpm === prevBpm) return;
-
+
const wasPlaying = this.masterClock.state.isPlaying;
const current = this.masterClock.getCurrentTime();
@@ -296,27 +401,31 @@ export class UnifiedAudioController {
this.wavPlayerGroup.setOriginalTempoBase(bpm);
this.midiPlayerGroup.setOriginalTempoBase(bpm);
}
-
+
/**
* Set/get master volume (user requirements)
*/
get masterVolume(): number {
return this.masterClock.state.masterVolume;
}
-
+
set masterVolume(volume: number) {
this.masterClock.setMasterVolume(volume);
}
-
+
/**
* Set/get loop mode (user requirements)
*/
- get loopMode(): 'off' | 'repeat' | 'ab' {
+ get loopMode(): "off" | "repeat" | "ab" {
return this.masterClock.state.loopMode;
}
-
- set loopMode(mode: 'off' | 'repeat' | 'ab') {
- this.masterClock.setLoopMode(mode, this.masterClock.state.markerA ?? undefined, this.masterClock.state.markerB ?? undefined);
+
+ set loopMode(mode: "off" | "repeat" | "ab") {
+ this.masterClock.setLoopMode(
+ mode,
+ this.masterClock.state.markerA ?? undefined,
+ this.masterClock.state.markerB ?? undefined
+ );
}
/**
@@ -329,42 +438,50 @@ export class UnifiedAudioController {
set isGlobalRepeat(enabled: boolean) {
(this.masterClock.state as any).globalRepeat = !!enabled;
}
-
+
/**
* Set/get marker A (user requirements)
*/
get markerA(): number | null {
return this.masterClock.state.markerA;
}
-
+
set markerA(time: number | null) {
this.masterClock.state.markerA = time;
- this.masterClock.setLoopMode(this.masterClock.state.loopMode, time ?? undefined, this.masterClock.state.markerB ?? undefined);
+ this.masterClock.setLoopMode(
+ this.masterClock.state.loopMode,
+ time ?? undefined,
+ this.masterClock.state.markerB ?? undefined
+ );
}
-
+
/**
* Set/get marker B (user requirements)
*/
get markerB(): number | null {
return this.masterClock.state.markerB;
}
-
+
set markerB(time: number | null) {
this.masterClock.state.markerB = time;
- this.masterClock.setLoopMode(this.masterClock.state.loopMode, this.masterClock.state.markerA ?? undefined, time ?? undefined);
+ this.masterClock.setLoopMode(
+ this.masterClock.state.loopMode,
+ this.masterClock.state.markerA ?? undefined,
+ time ?? undefined
+ );
}
-
+
/**
* Configure AβB loop
*/
setABLoop(markerA: number, markerB: number): void {
- this.masterClock.setLoopMode('ab', markerA, markerB);
+ this.masterClock.setLoopMode("ab", markerA, markerB);
}
-
+
// === Lower-level per-player control methods (user requirements) ===
-
+
// Per-WAV-player controls
-
+
/**
* Set WAV player volume
*/
@@ -386,67 +503,73 @@ export class UnifiedAudioController {
(this.wavPlayerGroup as any).setGroupMixGain?.(gain);
} catch {}
}
-
+
/**
* Set WAV player pan
*/
setWavPlayerPan(playerId: string, pan: number): void {
this.wavPlayerGroup.setPlayerPan(playerId, pan);
}
-
+
/**
* Set WAV player mute
*/
setWavPlayerMute(playerId: string, muted: boolean): void {
this.wavPlayerGroup.setPlayerMute(playerId, muted);
}
-
+
/**
* Get all WAV player states
*/
- getWavPlayerStates(): Record {
+ getWavPlayerStates(): Record<
+ string,
+ { volume: number; pan: number; muted: boolean }
+ > {
return this.wavPlayerGroup.getPlayerStates();
}
-
+
// Per-MIDI-player controls
-
+
/**
* Set MIDI player volume
*/
setMidiPlayerVolume(fileId: string, volume: number): void {
this.midiPlayerGroup.setPlayerVolume(fileId, volume);
}
-
+
/**
* Set MIDI player pan
*/
setMidiPlayerPan(fileId: string, pan: number): void {
this.midiPlayerGroup.setPlayerPan(fileId, pan);
}
-
+
/**
* Set MIDI player mute
*/
setMidiPlayerMute(fileId: string, muted: boolean): void {
this.midiPlayerGroup.setPlayerMute(fileId, muted);
}
-
+
/**
* Get all MIDI player states
*/
- getMidiPlayerStates(): Record {
+ getMidiPlayerStates(): Record<
+ string,
+ { volume: number; pan: number; muted: boolean }
+ > {
return this.midiPlayerGroup.getPlayerStates();
}
-
+
// === Compatibility methods with existing system ===
-
+
/**
* Set MIDI manager (compatibility with existing code)
*/
setMidiManager(midiManager: any): void {
this.midiPlayerGroup.setMidiManager(midiManager);
}
-
+
/**
* Return state object (compatibility with existing code)
*/
@@ -454,17 +577,17 @@ export class UnifiedAudioController {
const masterState = this.masterClock.state;
// Get real-time current time when playing
const currentTime = this.masterClock.getCurrentTime();
-
+
return {
...masterState,
- currentTime, // Use real-time current time instead of cached nowTime
- duration: masterState.totalTime, // Alias for legacy compatibility
+ currentTime, // Use real-time current time instead of cached nowTime
+ duration: masterState.totalTime, // Alias for legacy compatibility
// Include per-player states as well
wavPlayers: this.getWavPlayerStates(),
- midiPlayers: this.getMidiPlayerStates()
+ midiPlayers: this.getMidiPlayerStates(),
};
}
-
+
/**
* Set visual update callback (compatibility with existing code)
*/
@@ -479,27 +602,37 @@ export class UnifiedAudioController {
private startVisualUpdateLoop(): void {
this.stopVisualUpdateLoop();
-
+
const update = () => {
if (this.masterClock.state.isPlaying && this.visualUpdateCallback) {
try {
const currentTime = this.masterClock.getCurrentTime();
const st = this.masterClock.state;
const effectiveDuration = this.getEffectiveTotalTime();
- const globalRepeatOn = (this.masterClock.state as any).globalRepeat === true || st.loopMode === 'repeat';
+ const globalRepeatOn =
+ (this.masterClock.state as any).globalRepeat === true ||
+ st.loopMode === "repeat";
// End-of-track handling when no repeat is enabled: clamp to duration and auto-pause
- if (!globalRepeatOn && effectiveDuration > 0 && currentTime >= effectiveDuration) {
+ if (
+ !globalRepeatOn &&
+ effectiveDuration > 0 &&
+ currentTime >= effectiveDuration
+ ) {
const finalTime = effectiveDuration;
// Clamp visual and notify once at exact end
this.masterClock.state.nowTime = finalTime;
- try { this.visualUpdateCallback(finalTime); } catch {}
+ try {
+ this.visualUpdateCallback(finalTime);
+ } catch {}
// Pause and set exact position to duration to avoid > duration drift
this.masterClock.pausePlayback();
this.masterClock.seekTo(finalTime);
return; // Do not schedule further frames
}
// End-of-track handling when full repeat is ON (independent flag): wrap to start and continue
- const crossedEnd = this._prevTime < effectiveDuration && currentTime >= effectiveDuration;
+ const crossedEnd =
+ this._prevTime < effectiveDuration &&
+ currentTime >= effectiveDuration;
if (globalRepeatOn && effectiveDuration > 0 && crossedEnd) {
if (this._lastRepeatWrapAtGen !== st.generation) {
this._lastRepeatWrapAtGen = st.generation;
@@ -515,7 +648,11 @@ export class UnifiedAudioController {
// AB-loop handling: jump back to A at (or just after) B using the
// same robust atomic restart path as seek(). This prevents any
// overlapping audio because groups are stopped before restart.
- if (st.loopMode === 'ab' && st.markerA !== null && st.markerB !== null) {
+ if (
+ st.loopMode === "ab" &&
+ st.markerA !== null &&
+ st.markerB !== null
+ ) {
const a = Math.max(0, st.markerA);
const b = Math.max(a, st.markerB);
// Trigger only when we cross B (prev < B <= current)
@@ -532,68 +669,87 @@ export class UnifiedAudioController {
this._prevTime = currentTime;
} catch (error) {
- console.error('[UnifiedAudioController] Visual update error:', error);
+ console.error("[UnifiedAudioController] Visual update error:", error);
}
}
// Opportunistically start any pending WAV tracks that became visible/unmuted
try {
if (this.masterClock.state.isPlaying) {
- this.wavPlayerGroup.syncPendingPlayers(this.masterClock.getCurrentTime());
+ this.wavPlayerGroup.syncPendingPlayers(
+ this.masterClock.getCurrentTime()
+ );
}
} catch {}
-
+
if (this.masterClock.state.isPlaying) {
// Use RAF in browsers; fallback to setTimeout in non-DOM test envs
- const raf = typeof requestAnimationFrame !== 'undefined'
- ? requestAnimationFrame
- : ((cb: FrameRequestCallback) => setTimeout(() => cb(performance.now?.() ?? Date.now()), 16) as unknown as number);
+ const raf =
+ typeof requestAnimationFrame !== "undefined"
+ ? requestAnimationFrame
+ : (cb: FrameRequestCallback) =>
+ setTimeout(
+ () => cb(performance.now?.() ?? Date.now()),
+ 16
+ ) as unknown as number;
this.visualUpdateLoop = raf(update);
}
};
-
- const raf = typeof requestAnimationFrame !== 'undefined'
- ? requestAnimationFrame
- : ((cb: FrameRequestCallback) => setTimeout(() => cb(performance.now?.() ?? Date.now()), 16) as unknown as number);
+
+ const raf =
+ typeof requestAnimationFrame !== "undefined"
+ ? requestAnimationFrame
+ : (cb: FrameRequestCallback) =>
+ setTimeout(
+ () => cb(performance.now?.() ?? Date.now()),
+ 16
+ ) as unknown as number;
this.visualUpdateLoop = raf(update);
}
private stopVisualUpdateLoop(): void {
if (this.visualUpdateLoop) {
- const caf = typeof cancelAnimationFrame !== 'undefined'
- ? cancelAnimationFrame
- : ((id: number) => clearTimeout(id as unknown as any));
+ const caf =
+ typeof cancelAnimationFrame !== "undefined"
+ ? cancelAnimationFrame
+ : (id: number) => clearTimeout(id as unknown as any);
caf(this.visualUpdateLoop);
this.visualUpdateLoop = undefined;
}
}
-
+
/**
* Resource cleanup
*/
destroy(): void {
// console.log('[UnifiedAudioController] Destroying');
-
+
// Stop visual updates
this.stopVisualUpdateLoop();
-
+
// Stop playback
this.masterClock.stopPlayback();
-
+
// Destroy player groups
this.wavPlayerGroup.destroy();
this.midiPlayerGroup.destroy();
-
+
// Remove listeners
- if (typeof window !== 'undefined') {
- window.removeEventListener('wr-wav-visibility-changed', this.handleWavVisibilityChange as EventListener);
- window.removeEventListener('wr-wav-mute-changed', this.handleWavMuteChange as EventListener);
+ if (typeof window !== "undefined") {
+ window.removeEventListener(
+ "wr-wav-visibility-changed",
+ this.handleWavVisibilityChange as EventListener
+ );
+ window.removeEventListener(
+ "wr-wav-mute-changed",
+ this.handleWavMuteChange as EventListener
+ );
}
// Reset state
this.isInitialized = false;
this.initPromise = null;
this.visualUpdateCallback = undefined;
-
+
// console.log('[UnifiedAudioController] Destroyed');
}
@@ -613,4 +769,12 @@ export class UnifiedAudioController {
this.wavPlayerGroup.syncStartIfNeeded(fileId, masterTime, lookahead);
} catch {}
}
+
+ /**
+ * Preload samplers for specific MIDI Program Numbers.
+ * @param programs - Array of MIDI Program Numbers to preload
+ */
+ async preloadProgramSamplers(programs: number[]): Promise {
+ return this.midiPlayerGroup.preloadProgramSamplers(programs);
+ }
}
diff --git a/src/lib/core/controls/loop-controls.ts b/src/lib/core/controls/loop-controls.ts
index 27fd6b4..0f31c5f 100644
--- a/src/lib/core/controls/loop-controls.ts
+++ b/src/lib/core/controls/loop-controls.ts
@@ -89,7 +89,7 @@ export function createCoreLoopControls(
* Restart / Loop toggle button (icon)
* ------------------------------------------------------------------ */
const btnLoopRestart = document.createElement("button");
- btnLoopRestart.innerHTML = PLAYER_ICONS.loop_restart;
+ btnLoopRestart.innerHTML = PLAYER_ICONS.loop_start;
btnLoopRestart.title = "Toggle A-B Loop Mode";
btnLoopRestart.style.cssText = `
width: 32px;
@@ -171,7 +171,8 @@ export function createCoreLoopControls(
const state = audioPlayer?.getState();
if (!state) return;
pointA = state.currentTime;
- if (pointB !== null && pointA !== null && pointA > pointB) [pointA, pointB] = [pointB, pointA];
+ if (pointB !== null && pointA !== null && pointA > pointB)
+ [pointA, pointB] = [pointB, pointA];
// Debug log commented out by request
// try {
// const pr = state.playbackRate ?? 100;
@@ -188,14 +189,14 @@ export function createCoreLoopControls(
// Dynamic text color for contrast on sky/rose etc.
btnA.style.color = isHexColorLight(COLOR_A) ? "black" : "white";
btnA.style.fontWeight = "800";
- btnA.style.border = "none"; // Remove border when active
+ btnA.style.border = "none"; // Remove border when active
// Reset B if undefined
if (pointB === null) {
btnB.dataset.active = "";
btnB.setAttribute("aria-pressed", "false");
btnB.style.background = "transparent";
btnB.style.color = "var(--text-muted)";
- btnB.style.border = `2px solid ${COLOR_B}`; // Show B border when inactive
+ btnB.style.border = `2px solid ${COLOR_B}`; // Show B border when inactive
}
// Do not touch the engine when setting markers (no play/seek).
// Update only UI (overlay/markers).
@@ -212,13 +213,14 @@ export function createCoreLoopControls(
const state = audioPlayer?.getState();
if (!state) return;
pointB = state.currentTime;
- if (pointA !== null && pointB !== null && pointA > pointB) [pointA, pointB] = [pointB, pointA];
+ if (pointA !== null && pointB !== null && pointA > pointB)
+ [pointA, pointB] = [pointB, pointA];
btnB.dataset.active = "true";
btnB.setAttribute("aria-pressed", "true");
btnB.style.background = COLOR_B;
btnB.style.color = isHexColorLight(COLOR_B) ? "black" : "white";
btnB.style.fontWeight = "800";
- btnB.style.border = "none"; // Remove border when active
+ btnB.style.border = "none"; // Remove border when active
// Debug log commented out by request
// try {
// const pr = state.playbackRate ?? 100;
@@ -246,15 +248,17 @@ export function createCoreLoopControls(
btnB.setAttribute("aria-pressed", "false");
btnA.style.background = "transparent";
btnA.style.color = "var(--text-muted)";
- btnA.style.border = `2px solid ${COLOR_A}`; // Restore A border
+ btnA.style.border = `2px solid ${COLOR_A}`; // Restore A border
btnB.style.background = "transparent";
btnB.style.color = "var(--text-muted)";
- btnB.style.border = `2px solid ${COLOR_B}`; // Restore B border
+ btnB.style.border = `2px solid ${COLOR_B}`; // Restore B border
// If loop restart is currently active, turn it off and disable repeat
if (isLoopRestartActive) {
isLoopRestartActive = false;
setLoopRestartUI();
- try { audioPlayer?.toggleRepeat?.(false); } catch {}
+ try {
+ audioPlayer?.toggleRepeat?.(false);
+ } catch {}
}
// Always preserve current position when clearing loop
audioPlayer?.setLoopPoints(null, null, true);
@@ -283,13 +287,22 @@ export function createCoreLoopControls(
const midiDur = state.duration || 0;
let wavMax = 0;
try {
- const api = (globalThis as unknown as { _waveRollAudio?: { getFiles?: () => Array<{ audioBuffer?: AudioBuffer }> } })._waveRollAudio;
+ const api = (
+ globalThis as unknown as {
+ _waveRollAudio?: {
+ getFiles?: () => Array<{ audioBuffer?: AudioBuffer }>;
+ };
+ }
+ )._waveRollAudio;
const files = api?.getFiles?.() || [];
- const durations = files.map((f) => f.audioBuffer?.duration || 0).filter((d) => d > 0);
+ const durations = files
+ .map((f) => f.audioBuffer?.duration || 0)
+ .filter((d) => d > 0);
wavMax = durations.length > 0 ? Math.max(...durations) : 0;
} catch {}
const rawMax = Math.max(midiDur, wavMax);
- const effectiveDuration = speed > 0 ? (rawMax > 0 ? rawMax / speed : 0) : rawMax;
+ const effectiveDuration =
+ speed > 0 ? (rawMax > 0 ? rawMax / speed : 0) : rawMax;
if (effectiveDuration <= 0) return;
// percent positions or null
@@ -303,9 +316,11 @@ export function createCoreLoopControls(
let end = pointB;
if (end !== null && start > end) [start, end] = [end, start];
const clampedStart = Math.min(Math.max(0, start), effectiveDuration);
- const clampedEnd = end !== null ? Math.min(Math.max(0, end), effectiveDuration) : null;
+ const clampedEnd =
+ end !== null ? Math.min(Math.max(0, end), effectiveDuration) : null;
loopInfo.a = (clampedStart / effectiveDuration) * 100;
- loopInfo.b = clampedEnd !== null ? (clampedEnd / effectiveDuration) * 100 : null;
+ loopInfo.b =
+ clampedEnd !== null ? (clampedEnd / effectiveDuration) * 100 : null;
pianoRoll?.setLoopWindow?.(clampedStart, clampedEnd);
} else if (pointB !== null) {
const clampedB = Math.min(Math.max(0, pointB), effectiveDuration);
diff --git a/src/lib/core/midi/file-entry.ts b/src/lib/core/midi/file-entry.ts
index c194877..1e7d07e 100644
--- a/src/lib/core/midi/file-entry.ts
+++ b/src/lib/core/midi/file-entry.ts
@@ -18,9 +18,15 @@ export function createMidiFileEntry(
name?: string,
originalInput?: File | string
): MidiFileEntry {
+ const isVsCodeWebview =
+ typeof (globalThis as unknown as { acquireVsCodeApi?: unknown })
+ .acquireVsCodeApi === "function";
+
return {
id: generateMidiFileId(),
- name: name ?? fileName.replace(/\.mid$/i, ""),
+ // VS Code ν΅ν© μμλ νμ₯μλ₯Ό ν¬ν¨ν μλ³Έ νμΌλͺ
μ κ·Έλλ‘ μ¬μ©ν΄ νμλ₯Ό 보쑴νλ€.
+ name:
+ name ?? (isVsCodeWebview ? fileName : fileName.replace(/\.mid$/i, "")),
fileName,
parsedData,
isVisible: true,
diff --git a/src/lib/core/midi/multi-midi-manager.ts b/src/lib/core/midi/multi-midi-manager.ts
index 846bf70..5358135 100644
--- a/src/lib/core/midi/multi-midi-manager.ts
+++ b/src/lib/core/midi/multi-midi-manager.ts
@@ -1,4 +1,4 @@
-import { NoteData, ParsedMidi } from "@/lib/midi/types";
+import { NoteData, ParsedMidi, InstrumentFamily } from "@/lib/midi/types";
import { ColorPalette, MidiFileEntry, MultiMidiState } from "./types";
import { DEFAULT_PALETTES } from "./palette";
import { createMidiFileEntry, reassignEntryColors } from "./file-entry";
@@ -119,6 +119,27 @@ export class MultiMidiManager {
const shouldBeVisible = this.state.files.length < 2;
entry.isPianoRollVisible = shouldBeVisible;
entry.isVisible = shouldBeVisible;
+
+ // Initialize trackVisibility: all tracks visible by default
+ // Initialize trackMuted: all tracks unmuted by default
+ // Initialize trackVolume: all tracks at full volume by default
+ // Initialize trackLastNonZeroVolume: all tracks at full volume by default
+ // Initialize trackSustainVisibility: all tracks show sustain by default
+ if (parsedData.tracks && parsedData.tracks.length > 0) {
+ entry.trackVisibility = {};
+ entry.trackMuted = {};
+ entry.trackVolume = {};
+ entry.trackLastNonZeroVolume = {};
+ entry.trackSustainVisibility = {};
+ for (const track of parsedData.tracks) {
+ entry.trackVisibility[track.id] = true;
+ entry.trackMuted[track.id] = false;
+ entry.trackVolume[track.id] = 1.0;
+ entry.trackLastNonZeroVolume[track.id] = 1.0;
+ entry.trackSustainVisibility[track.id] = true;
+ }
+ }
+
this.state.files.push(entry);
try {
// Extract BPM from MIDI header's first/initial tempo
@@ -175,7 +196,8 @@ export class MultiMidiManager {
* Remove a MIDI file
*/
public removeMidiFile(id: string): void {
- const wasFirstFile = this.state.files.length > 0 && this.state.files[0].id === id;
+ const wasFirstFile =
+ this.state.files.length > 0 && this.state.files[0].id === id;
this.state.files = this.state.files.filter((f) => f.id !== id);
this.resetColorIndex();
@@ -223,6 +245,258 @@ export class MultiMidiManager {
}
}
+ /**
+ * Set visibility of a specific track within a MIDI file.
+ * @param fileId - The ID of the MIDI file
+ * @param trackId - The track ID (0-based index)
+ * @param visible - Whether the track should be visible
+ */
+ public setTrackVisibility(
+ fileId: string,
+ trackId: number,
+ visible: boolean
+ ): void {
+ const file = this.state.files.find((f) => f.id === fileId);
+ if (!file) return;
+
+ // Initialize trackVisibility if not present
+ if (!file.trackVisibility) {
+ file.trackVisibility = {};
+ }
+
+ file.trackVisibility[trackId] = visible;
+ this.notifyStateChange();
+ }
+
+ /**
+ * Toggle visibility of a specific track within a MIDI file.
+ * @param fileId - The ID of the MIDI file
+ * @param trackId - The track ID (0-based index)
+ */
+ public toggleTrackVisibility(fileId: string, trackId: number): void {
+ const file = this.state.files.find((f) => f.id === fileId);
+ if (!file) return;
+
+ // Initialize trackVisibility if not present
+ if (!file.trackVisibility) {
+ file.trackVisibility = {};
+ }
+
+ // Default to true (visible) if not set, then toggle
+ const currentVisibility = file.trackVisibility[trackId] ?? true;
+ file.trackVisibility[trackId] = !currentVisibility;
+ this.notifyStateChange();
+ }
+
+ /**
+ * Check if a specific track is visible.
+ * Returns true if trackVisibility is not set (default visible).
+ * @param fileId - The ID of the MIDI file
+ * @param trackId - The track ID (0-based index)
+ */
+ public isTrackVisible(fileId: string, trackId: number): boolean {
+ const file = this.state.files.find((f) => f.id === fileId);
+ if (!file) return false;
+ return file.trackVisibility?.[trackId] ?? true;
+ }
+
+ /**
+ * Toggle sustain pedal visibility for a specific track within a MIDI file.
+ * @param fileId - The ID of the MIDI file
+ * @param trackId - The track ID (0-based index)
+ */
+ public toggleTrackSustainVisibility(fileId: string, trackId: number): void {
+ const file = this.state.files.find((f) => f.id === fileId);
+ if (!file) return;
+
+ // Initialize trackSustainVisibility if not present
+ if (!file.trackSustainVisibility) {
+ file.trackSustainVisibility = {};
+ }
+
+ // Default to true (visible) if not set, then toggle
+ const currentVisibility = file.trackSustainVisibility[trackId] ?? true;
+ file.trackSustainVisibility[trackId] = !currentVisibility;
+ this.notifyStateChange();
+ }
+
+ /**
+ * Check if sustain pedal is visible for a specific track.
+ * Returns true if trackSustainVisibility is not set (default visible).
+ * @param fileId - The ID of the MIDI file
+ * @param trackId - The track ID (0-based index)
+ */
+ public isTrackSustainVisible(fileId: string, trackId: number): boolean {
+ const file = this.state.files.find((f) => f.id === fileId);
+ if (!file) return false;
+ return file.trackSustainVisibility?.[trackId] ?? true;
+ }
+
+ /**
+ * Toggle mute state of a specific track within a MIDI file.
+ * @param fileId - The ID of the MIDI file
+ * @param trackId - The track ID (0-based index)
+ */
+ public toggleTrackMute(fileId: string, trackId: number): void {
+ const file = this.state.files.find((f) => f.id === fileId);
+ if (!file) return;
+
+ // Initialize trackMuted if not present
+ if (!file.trackMuted) {
+ file.trackMuted = {};
+ }
+
+ // Default to false (unmuted) if not set, then toggle
+ const currentMuted = file.trackMuted[trackId] ?? false;
+ file.trackMuted[trackId] = !currentMuted;
+ this.notifyStateChange();
+ }
+
+ /**
+ * Check if a specific track is muted.
+ * Returns false if trackMuted is not set (default unmuted).
+ * @param fileId - The ID of the MIDI file
+ * @param trackId - The track ID (0-based index)
+ */
+ public isTrackMuted(fileId: string, trackId: number): boolean {
+ const file = this.state.files.find((f) => f.id === fileId);
+ if (!file) return false;
+ return file.trackMuted?.[trackId] ?? false;
+ }
+
+ /**
+ * Set volume for a specific track within a MIDI file.
+ * @param fileId - The ID of the MIDI file
+ * @param trackId - The track ID (0-based index)
+ * @param volume - Volume level (0-1)
+ */
+ public setTrackVolume(fileId: string, trackId: number, volume: number): void {
+ const file = this.state.files.find((f) => f.id === fileId);
+ if (!file) return;
+
+ // Initialize trackVolume if not present
+ if (!file.trackVolume) {
+ file.trackVolume = {};
+ }
+
+ // Initialize trackLastNonZeroVolume if not present
+ if (!file.trackLastNonZeroVolume) {
+ file.trackLastNonZeroVolume = {};
+ }
+
+ // Clamp volume to 0-1 range
+ const clampedVolume = Math.max(0, Math.min(1, volume));
+ file.trackVolume[trackId] = clampedVolume;
+
+ // Store last non-zero volume for unmute restore
+ if (clampedVolume > 0) {
+ file.trackLastNonZeroVolume[trackId] = clampedVolume;
+ }
+
+ this.notifyStateChange();
+ }
+
+ /**
+ * Get volume for a specific track.
+ * Returns 1.0 if trackVolume is not set (default full volume).
+ * @param fileId - The ID of the MIDI file
+ * @param trackId - The track ID (0-based index)
+ */
+ public getTrackVolume(fileId: string, trackId: number): number {
+ const file = this.state.files.find((f) => f.id === fileId);
+ if (!file) return 1.0;
+ return file.trackVolume?.[trackId] ?? 1.0;
+ }
+
+ /**
+ * Get last non-zero volume for a specific track.
+ * Used to restore volume when unmuting a track.
+ * Returns 1.0 if trackLastNonZeroVolume is not set (default full volume).
+ * @param fileId - The ID of the MIDI file
+ * @param trackId - The track ID (0-based index)
+ */
+ public getTrackLastNonZeroVolume(fileId: string, trackId: number): number {
+ const file = this.state.files.find((f) => f.id === fileId);
+ if (!file) return 1.0;
+ return file.trackLastNonZeroVolume?.[trackId] ?? 1.0;
+ }
+
+ /**
+ * Set auto instrument state for a specific track within a MIDI file.
+ * When enabled, the track will use its instrumentFamily soundfont instead of default piano.
+ * @param fileId - The ID of the MIDI file
+ * @param trackId - The track ID (0-based index)
+ * @param useAuto - Whether to use auto instrument matching
+ */
+ public setTrackAutoInstrument(
+ fileId: string,
+ trackId: number,
+ useAuto: boolean
+ ): void {
+ const file = this.state.files.find((f) => f.id === fileId);
+ if (!file) return;
+
+ // Initialize trackUseAutoInstrument if not present
+ if (!file.trackUseAutoInstrument) {
+ file.trackUseAutoInstrument = {};
+ }
+
+ file.trackUseAutoInstrument[trackId] = useAuto;
+ this.notifyStateChange();
+ }
+
+ /**
+ * Check if a specific track uses auto instrument matching.
+ * Returns true if trackUseAutoInstrument is not set (default auto-instrument).
+ * @param fileId - The ID of the MIDI file
+ * @param trackId - The track ID (0-based index)
+ */
+ public isTrackAutoInstrument(fileId: string, trackId: number): boolean {
+ const file = this.state.files.find((f) => f.id === fileId);
+ if (!file) return true;
+ return file.trackUseAutoInstrument?.[trackId] ?? true;
+ }
+
+ /**
+ * Get the instrument family for a specific track.
+ * Looks up the instrumentFamily from the parsed MIDI track data.
+ * Returns 'piano' as fallback if track info is not available.
+ * @param fileId - The ID of the MIDI file
+ * @param trackId - The track ID (0-based index)
+ */
+ public getTrackInstrumentFamily(
+ fileId: string,
+ trackId: number
+ ): InstrumentFamily {
+ const file = this.state.files.find((f) => f.id === fileId);
+ if (!file?.parsedData?.tracks) return "piano";
+
+ const track = file.parsedData.tracks.find((t) => t.id === trackId);
+ return track?.instrumentFamily ?? "piano";
+ }
+
+ /**
+ * Get the MIDI Program Number for a specific track.
+ * Returns 118 (synth_drum) for drum tracks (channel 9/10).
+ * Returns 0 (acoustic_grand_piano) as fallback if track info is not available.
+ * @param fileId - The ID of the MIDI file
+ * @param trackId - The track ID (0-based index)
+ */
+ public getTrackProgram(fileId: string, trackId: number): number {
+ const file = this.state.files.find((f) => f.id === fileId);
+ if (!file?.parsedData?.tracks) return 0;
+
+ const track = file.parsedData.tracks.find((t) => t.id === trackId);
+ if (!track) return 0;
+
+ // Use synth_drum (program 118) for drum tracks
+ if (track.isDrum) {
+ return 118;
+ }
+
+ return track.program ?? 0;
+ }
+
/**
* Update name
*/
@@ -351,7 +625,8 @@ export class MultiMidiManager {
}
/**
- * Get combined notes from visible files
+ * Get combined notes from visible files, respecting track visibility.
+ * Notes from hidden tracks are excluded.
*/
public getVisibleNotes(): Array<{
note: NoteData;
@@ -364,11 +639,19 @@ export class MultiMidiManager {
this.state.files.forEach((file) => {
if (file.isPianoRollVisible && file.parsedData) {
file.parsedData.notes.forEach((note) => {
- allNotes.push({
- note,
- color: file.color,
- fileId: file.id,
- });
+ // Check track visibility: if trackId is set, respect trackVisibility
+ // Default to visible if trackVisibility is not defined for this track
+ const trackId = note.trackId;
+ const isTrackVisible =
+ trackId === undefined || file.trackVisibility?.[trackId] !== false;
+
+ if (isTrackVisible) {
+ allNotes.push({
+ note,
+ color: file.color,
+ fileId: file.id,
+ });
+ }
});
}
});
diff --git a/src/lib/core/midi/types.ts b/src/lib/core/midi/types.ts
index fe627f9..73f07bc 100644
--- a/src/lib/core/midi/types.ts
+++ b/src/lib/core/midi/types.ts
@@ -30,6 +30,37 @@ export interface MidiFileEntry {
error?: string;
/** Volume level (0-1) */
volume?: number;
+ /**
+ * Per-track visibility state. Key is trackId, value is visibility.
+ * All tracks are visible by default when not specified.
+ */
+ trackVisibility?: Record;
+ /**
+ * Per-track mute state. Key is trackId, value is muted.
+ * All tracks are unmuted by default when not specified.
+ */
+ trackMuted?: Record;
+ /**
+ * Per-track volume level. Key is trackId, value is volume (0-1).
+ * All tracks have full volume (1.0) by default when not specified.
+ */
+ trackVolume?: Record;
+ /**
+ * Per-track last non-zero volume level. Key is trackId, value is volume (0-1).
+ * Used to restore volume when unmuting a track.
+ */
+ trackLastNonZeroVolume?: Record;
+ /**
+ * Per-track auto instrument state. Key is trackId, value is whether to use
+ * the track's instrumentFamily soundfont (true) or default piano (false).
+ * Default is false (piano) when not specified.
+ */
+ trackUseAutoInstrument?: Record;
+ /**
+ * Per-track sustain pedal visibility. Key is trackId, value is visibility.
+ * All tracks show sustain by default (true) when not specified.
+ */
+ trackSustainVisibility?: Record;
}
/**
diff --git a/src/lib/core/parsers/midi-parser.ts b/src/lib/core/parsers/midi-parser.ts
index d31cb02..05a26b2 100644
--- a/src/lib/core/parsers/midi-parser.ts
+++ b/src/lib/core/parsers/midi-parser.ts
@@ -4,20 +4,81 @@ import {
MidiInput,
NoteData,
TrackData,
+ TrackInfo,
MidiHeader,
TempoEvent,
TimeSignatureEvent,
ControlChangeEvent,
+ InstrumentFamily,
} from "@/lib/midi/types";
// Sustain-pedal elongation utilities (ported from onsets-and-frames)
// Local analysis types
-export interface SustainRegion { start: number; end: number; duration: number }
+export interface SustainRegion {
+ start: number;
+ end: number;
+ duration: number;
+}
import {
midiToNoteName,
midiToPitchClass,
midiToOctave,
} from "@/lib/core/utils/midi";
+import { getGMInstrumentDisplayName } from "@/lib/core/audio/gm-instruments";
+
+/**
+ * GM Program Number to Instrument Family mapping.
+ * Based on General MIDI Level 1 specification.
+ * Reference: https://gleitz.github.io/midi-js-soundfonts/FluidR3_GM/names.json
+ */
+export function getInstrumentFamily(
+ program: number,
+ channel: number
+): InstrumentFamily {
+ // Channel 9 (0-indexed) or Channel 10 (1-indexed) is always drums in GM
+ if (channel === 9 || channel === 10) {
+ return "drums";
+ }
+
+ // GM Program Number ranges (0-127)
+ if (program >= 0 && program <= 7) return "piano"; // 0-7: Piano
+ if (program >= 8 && program <= 15) return "mallet"; // 8-15: Chromatic Percussion (mallet instruments)
+ if (program >= 16 && program <= 23) return "organ"; // 16-23: Organ, Accordion, Harmonica
+ if (program >= 24 && program <= 31) return "guitar"; // 24-31: Guitar
+ if (program >= 32 && program <= 39) return "bass"; // 32-39: Bass
+ if (program >= 40 && program <= 51) return "strings"; // 40-51: Strings (Violin, Viola, Cello, etc.)
+ if (program >= 52 && program <= 54) return "vocal"; // 52-54: Choir/Voice (choir_aahs, voice_oohs, synth_choir)
+ if (program === 55) return "strings"; // 55: Orchestra Hit (keep with strings/ensemble)
+ if (program >= 56 && program <= 63) return "brass"; // 56-63: Brass
+ if (program >= 64 && program <= 79) return "winds"; // 64-79: Reed & Pipe
+ if (program >= 80 && program <= 103) return "synth"; // 80-103: Synth Lead, Pad, FX
+ if (program >= 104 && program <= 111) return "others"; // 104-111: Ethnic
+ if (program >= 112 && program <= 119) return "drums"; // 112-119: Percussive
+ if (program >= 120 && program <= 127) return "others"; // 120-127: Sound Effects
+
+ return "others";
+}
+
+/**
+ * Get a human-readable default name for an instrument family.
+ */
+export function getInstrumentFamilyName(family: InstrumentFamily): string {
+ const names: Record = {
+ piano: "Piano",
+ strings: "Strings",
+ drums: "Drums",
+ guitar: "Guitar",
+ bass: "Bass",
+ synth: "Synth",
+ winds: "Winds",
+ brass: "Brass",
+ vocal: "Vocal",
+ organ: "Organ",
+ mallet: "Mallet",
+ others: "Other",
+ };
+ return names[family] ?? "Other";
+}
/**
* Loads MIDI data from a URL by fetching the file
@@ -112,8 +173,10 @@ function extractMidiHeader(midi: any): MidiHeader {
/**
* Extracts control change events (e.g., sustain-pedal CC 64) from a Tone.js
* MIDI track.
+ * @param track - The Tone.js MIDI track object
+ * @param trackId - Optional track ID to associate with these control changes
*/
-function extractControlChanges(track: any): ControlChangeEvent[] {
+function extractControlChanges(track: any, trackId?: number): ControlChangeEvent[] {
// Tone.js represents controlChanges as Record
const result: ControlChangeEvent[] = [];
@@ -131,6 +194,7 @@ function extractControlChanges(track: any): ControlChangeEvent[] {
ticks: evt.ticks,
name: evt.name,
fileId: track.name,
+ trackId,
});
}
}
@@ -140,9 +204,10 @@ function extractControlChanges(track: any): ControlChangeEvent[] {
/**
* Converts Tone.js note data to our NoteData format
* @param note - The Tone.js note object
+ * @param trackId - Optional track ID to associate with this note
* @returns NoteData object in the specified format
*/
-function convertNote(note: any): NoteData {
+function convertNote(note: any, trackId?: number): NoteData {
return {
midi: note.midi,
time: note.time,
@@ -152,6 +217,7 @@ function convertNote(note: any): NoteData {
octave: midiToOctave(note.midi),
velocity: note.velocity,
duration: note.duration,
+ trackId,
};
}
@@ -176,7 +242,7 @@ export function applySustainPedalElongation(
type Event = {
time: number;
- type: 'note_on' | 'note_off' | 'sustain_on' | 'sustain_off';
+ type: "note_on" | "note_off" | "sustain_on" | "sustain_off";
index: number; // original note index for note events
midi?: number;
velocity?: number;
@@ -186,8 +252,20 @@ export function applySustainPedalElongation(
// Note on/off events
notes.forEach((note, index) => {
- events.push({ time: note.time, type: 'note_on', index, midi: note.midi, velocity: note.velocity });
- events.push({ time: note.time + note.duration, type: 'note_off', index, midi: note.midi, velocity: note.velocity });
+ events.push({
+ time: note.time,
+ type: "note_on",
+ index,
+ midi: note.midi,
+ velocity: note.velocity,
+ });
+ events.push({
+ time: note.time + note.duration,
+ type: "note_off",
+ index,
+ midi: note.midi,
+ velocity: note.velocity,
+ });
});
// Sustain events (CC64), only add on state changes
@@ -197,7 +275,11 @@ export function applySustainPedalElongation(
.forEach((cc) => {
const isOn = (cc.value ?? 0) >= normalizedThreshold;
if (isOn !== prevSustain) {
- events.push({ time: cc.time, type: isOn ? 'sustain_on' : 'sustain_off', index: -1 });
+ events.push({
+ time: cc.time,
+ type: isOn ? "sustain_on" : "sustain_off",
+ index: -1,
+ });
prevSustain = isOn;
}
});
@@ -205,7 +287,7 @@ export function applySustainPedalElongation(
// Sort events. Priority for ties: sustain_off > note_off > sustain_on > note_on
events.sort((a, b) => {
if (Math.abs(a.time - b.time) > EPS) return a.time - b.time;
- const prio: Record = {
+ const prio: Record = {
sustain_off: 0,
note_off: 1,
sustain_on: 2,
@@ -216,8 +298,15 @@ export function applySustainPedalElongation(
// State
let sustainOn = false;
- const activeNotes = new Map();
- interface SustainedNoteEntry { index: number; startTime: number; velocity: number }
+ const activeNotes = new Map<
+ number,
+ { startTime: number; midi: number; velocity: number }
+ >();
+ interface SustainedNoteEntry {
+ index: number;
+ startTime: number;
+ velocity: number;
+ }
const sustainedNotes = new Map();
const finalNotes = new Map();
@@ -245,30 +334,39 @@ export function applySustainPedalElongation(
for (const ev of events) {
switch (ev.type) {
- case 'sustain_on':
+ case "sustain_on":
sustainOn = true;
break;
- case 'sustain_off':
+ case "sustain_off":
sustainOn = false;
releaseAllSustained(ev.time);
break;
- case 'note_on':
+ case "note_on":
if (ev.midi !== undefined) {
// Re-striking same pitch cuts previous sustained instance
releasePitchFIFO(ev.midi, ev.time);
- activeNotes.set(ev.index, { startTime: ev.time, midi: ev.midi, velocity: ev.velocity || 0 });
+ activeNotes.set(ev.index, {
+ startTime: ev.time,
+ midi: ev.midi,
+ velocity: ev.velocity || 0,
+ });
}
break;
- case 'note_off':
+ case "note_off":
if (!activeNotes.has(ev.index)) break; // safety
const info = activeNotes.get(ev.index)!;
activeNotes.delete(ev.index);
if (sustainOn && ev.midi !== undefined) {
const stack = sustainedNotes.get(ev.midi) ?? [];
- stack.push({ index: ev.index, startTime: info.startTime, velocity: info.velocity });
+ stack.push({
+ index: ev.index,
+ startTime: info.startTime,
+ velocity: info.velocity,
+ });
sustainedNotes.set(ev.midi, stack);
} else {
- if (!finalNotes.has(ev.index)) finalNotes.set(ev.index, notes[ev.index]);
+ if (!finalNotes.has(ev.index))
+ finalNotes.set(ev.index, notes[ev.index]);
}
break;
}
@@ -285,8 +383,11 @@ export function applySustainPedalElongation(
// Build final array preserving index order, then sort by time, pitch
const out: NoteData[] = [];
- for (let i = 0; i < notes.length; i++) out.push(finalNotes.get(i) ?? notes[i]);
- out.sort((a, b) => (Math.abs(a.time - b.time) > EPS ? a.time - b.time : a.midi - b.midi));
+ for (let i = 0; i < notes.length; i++)
+ out.push(finalNotes.get(i) ?? notes[i]);
+ out.sort((a, b) =>
+ Math.abs(a.time - b.time) > EPS ? a.time - b.time : a.midi - b.midi
+ );
return out;
}
@@ -296,26 +397,50 @@ export function applySustainPedalElongation(
export function applySustainPedalElongationSafe(
notes: NoteData[],
controlChanges: ControlChangeEvent[],
- options: { threshold?: number; channel?: number; maxElongation?: number; verbose?: boolean } = {}
+ options: {
+ threshold?: number;
+ channel?: number;
+ maxElongation?: number;
+ verbose?: boolean;
+ } = {}
): NoteData[] {
- const { threshold = 64, channel = 0, maxElongation = 30, verbose = false } = options;
+ const {
+ threshold = 64,
+ channel = 0,
+ maxElongation = 30,
+ verbose = false,
+ } = options;
try {
if (!Array.isArray(notes) || !Array.isArray(controlChanges)) {
- if (verbose) console.warn('Invalid input to sustain pedal elongation');
+ if (verbose) console.warn("Invalid input to sustain pedal elongation");
return notes;
}
- let res = applySustainPedalElongation(notes, controlChanges, threshold, channel);
+ let res = applySustainPedalElongation(
+ notes,
+ controlChanges,
+ threshold,
+ channel
+ );
if (maxElongation > 0) {
- res = res.map((n) => (n.duration > maxElongation ? { ...n, duration: maxElongation } : n));
+ res = res.map((n) =>
+ n.duration > maxElongation ? { ...n, duration: maxElongation } : n
+ );
}
- const invalid = res.filter((n) => n.duration <= 0 || !isFinite(n.duration) || !isFinite(n.time));
+ const invalid = res.filter(
+ (n) => n.duration <= 0 || !isFinite(n.duration) || !isFinite(n.time)
+ );
if (invalid.length > 0) {
- if (verbose) console.warn(`Found ${invalid.length} invalid notes after sustain processing`);
- res = res.filter((n) => n.duration > 0 && isFinite(n.duration) && isFinite(n.time));
+ if (verbose)
+ console.warn(
+ `Found ${invalid.length} invalid notes after sustain processing`
+ );
+ res = res.filter(
+ (n) => n.duration > 0 && isFinite(n.duration) && isFinite(n.time)
+ );
}
return res;
} catch (err) {
- if (verbose) console.error('Error applying sustain pedal elongation:', err);
+ if (verbose) console.error("Error applying sustain pedal elongation:", err);
return notes;
}
}
@@ -333,9 +458,17 @@ export function analyzeSustainPedalUsage(
totalSustainTime: number;
sustainRegions: SustainRegion[];
} {
- const events = controlChanges.filter((cc) => cc.controller === 64).sort((a, b) => a.time - b.time);
+ const events = controlChanges
+ .filter((cc) => cc.controller === 64)
+ .sort((a, b) => a.time - b.time);
if (events.length === 0) {
- return { hasSustain: false, sustainCount: 0, averageSustainDuration: 0, totalSustainTime: 0, sustainRegions: [] };
+ return {
+ hasSustain: false,
+ sustainCount: 0,
+ averageSustainDuration: 0,
+ totalSustainTime: 0,
+ sustainRegions: [],
+ };
}
const thr = threshold / 127;
const regions: SustainRegion[] = [];
@@ -359,10 +492,15 @@ export function analyzeSustainPedalUsage(
}
const total = regions.reduce((s, r) => s + r.duration, 0);
const avg = regions.length > 0 ? total / regions.length : 0;
- return { hasSustain: true, sustainCount: regions.length, averageSustainDuration: avg, totalSustainTime: total, sustainRegions: regions };
+ return {
+ hasSustain: true,
+ sustainCount: regions.length,
+ averageSustainDuration: avg,
+ totalSustainTime: total,
+ sustainRegions: regions,
+ };
}
-
/**
* Parses a MIDI file and extracts musical data in the Tone.js format
*
@@ -408,47 +546,95 @@ export async function parseMidi(
// Step 3: Extract header information
const header = extractMidiHeader(midi);
- // Step 4: Collect ALL tracks that contain notes and merge them for evaluation.
- const noteTracks = midi.tracks.filter((t: any) => t.notes && t.notes.length > 0);
- if (noteTracks.length === 0) {
- throw new Error("No tracks with notes found in MIDI file");
- }
+ // Step 4: Collect ALL tracks that contain notes and merge them for evaluation.
+ const noteTracks = midi.tracks.filter(
+ (t: any) => t.notes && t.notes.length > 0
+ );
+ if (noteTracks.length === 0) {
+ throw new Error("No tracks with notes found in MIDI file");
+ }
- // Step 5: Extract track metadata (use the first track name/channel for compatibility)
- const primaryTrack = noteTracks[0];
- const track = extractTrackMetadata(primaryTrack, 0);
-
- // Step 6: Convert notes to our format (per-track), applying sustain per channel when enabled
- const applyPedal = options.applyPedalElongate !== false; // default ON
- const threshold = options.pedalThreshold ?? 64;
- const mergedNotes: NoteData[] = [];
- for (const t of noteTracks) {
- const channel = t.channel ?? 0;
- let trackNotes: NoteData[] = t.notes.map(convertNote);
- if (applyPedal) {
- // Extract CC exclusively from this track (channel)
- const cc = extractControlChanges(t);
- if (cc.some((e) => e.controller === 64)) {
- // Use the enhanced sustain-pedal elongation which follows
- // onsets-and-frames ordering (sustain_off > note_off > sustain_on > note_on)
- trackNotes = applySustainPedalElongation(trackNotes, cc, threshold, channel);
+ // Step 5: Extract track metadata (use the first track name/channel for compatibility)
+ const primaryTrack = noteTracks[0];
+ const track = extractTrackMetadata(primaryTrack, 0);
+
+ // Step 6: Build TrackInfo array and convert notes with trackId
+ const applyPedal = options.applyPedalElongate !== false; // default ON
+ const threshold = options.pedalThreshold ?? 64;
+ const mergedNotes: NoteData[] = [];
+ const tracks: TrackInfo[] = [];
+
+ for (let trackIndex = 0; trackIndex < noteTracks.length; trackIndex++) {
+ const t = noteTracks[trackIndex];
+ const channel = t.channel ?? 0;
+ const program = t.instrument?.number ?? 0;
+ const isDrum = channel === 9 || channel === 10;
+ const instrumentFamily = getInstrumentFamily(program, channel);
+
+ // Create TrackInfo for this track
+ // Always show program number for clarity
+ let trackName: string;
+ if (isDrum) {
+ // Drum tracks: "Drums (ch.9)" or "Drums (ch.10)"
+ trackName = t.name
+ ? `${t.name} (ch.${channel})`
+ : `Drums (ch.${channel})`;
+ } else if (t.name) {
+ // Named tracks: "Track Name (program)"
+ trackName = `${t.name} (${program})`;
+ } else {
+ // Unnamed tracks: "Acoustic Grand Piano (0)"
+ trackName = `${getGMInstrumentDisplayName(program)} (${program})`;
}
+
+ const trackInfo: TrackInfo = {
+ id: trackIndex,
+ name: trackName,
+ channel,
+ program,
+ isDrum,
+ instrumentFamily,
+ noteCount: t.notes.length,
+ };
+ tracks.push(trackInfo);
+
+ // Convert notes with trackId
+ let trackNotes: NoteData[] = t.notes.map((n: any) =>
+ convertNote(n, trackIndex)
+ );
+
+ if (applyPedal) {
+ // Extract CC exclusively from this track (channel) with trackId
+ const cc = extractControlChanges(t, trackIndex);
+ if (cc.some((e) => e.controller === 64)) {
+ // Use the enhanced sustain-pedal elongation which follows
+ // onsets-and-frames ordering (sustain_off > note_off > sustain_on > note_on)
+ trackNotes = applySustainPedalElongation(
+ trackNotes,
+ cc,
+ threshold,
+ channel
+ );
+ }
+ }
+ mergedNotes.push(...trackNotes);
}
- mergedNotes.push(...trackNotes);
- }
- // Merge and sort
- let notes: NoteData[] = mergedNotes.sort((a, b) =>
- a.time !== b.time ? a.time - b.time : a.midi - b.midi
- );
+ // Merge and sort
+ let notes: NoteData[] = mergedNotes.sort((a, b) =>
+ a.time !== b.time ? a.time - b.time : a.midi - b.midi
+ );
- // Collect merged control changes across all note tracks for UI/diagnostics
- const ccMerged: ControlChangeEvent[] = [];
- for (const t of noteTracks) {
- const channel = t.channel ?? 0;
- const cc = extractControlChanges(t).map((evt) => ({ ...evt, fileId: primaryTrack.name }));
- ccMerged.push(...cc);
- }
+ // Collect merged control changes across all note tracks for UI/diagnostics
+ const ccMerged: ControlChangeEvent[] = [];
+ for (let i = 0; i < noteTracks.length; i++) {
+ const t = noteTracks[i];
+ const cc = extractControlChanges(t, i).map((evt) => ({
+ ...evt,
+ fileId: primaryTrack.name,
+ }));
+ ccMerged.push(...cc);
+ }
// Step 9: Calculate total duration
const duration = midi.duration;
@@ -459,6 +645,7 @@ export async function parseMidi(
track,
notes,
controlChanges: ccMerged, // merged CC events from all note tracks
+ tracks, // detailed track info for multi-instrument support
};
} catch (error) {
if (error instanceof Error) {
diff --git a/src/lib/core/playback/core-playback-engine.ts b/src/lib/core/playback/core-playback-engine.ts
index 50c41ae..a71272d 100644
--- a/src/lib/core/playback/core-playback-engine.ts
+++ b/src/lib/core/playback/core-playback-engine.ts
@@ -206,8 +206,13 @@ export class CorePlaybackEngine implements AudioPlayerContainer {
);
// Restore originalTempo from pendingOriginalTempo or prevState
- const originalTempoToRestore = this.pendingOriginalTempo ?? prevState?.originalTempo;
- if (originalTempoToRestore && Number.isFinite(originalTempoToRestore) && originalTempoToRestore > 0) {
+ const originalTempoToRestore =
+ this.pendingOriginalTempo ?? prevState?.originalTempo;
+ if (
+ originalTempoToRestore &&
+ Number.isFinite(originalTempoToRestore) &&
+ originalTempoToRestore > 0
+ ) {
(this.audioPlayer as any)?.setOriginalTempo?.(originalTempoToRestore);
// Also set current tempo to match originalTempo if not explicitly different
if (!prevState || prevState.tempo === prevState.originalTempo) {
diff --git a/src/lib/core/state/default.ts b/src/lib/core/state/default.ts
index a279a03..60ebfb2 100644
--- a/src/lib/core/state/default.ts
+++ b/src/lib/core/state/default.ts
@@ -63,6 +63,7 @@ export const DEFAULT_VISUAL_STATE: VisualState = {
pedalThreshold: 64,
showOnsetMarkers: true,
fileOnsetMarkers: {},
+ uniformTrackColor: false,
};
export const DEFAULT_EVALUATION_STATE: EvaluationState = {
diff --git a/src/lib/core/state/types.ts b/src/lib/core/state/types.ts
index 782d256..f66c18b 100644
--- a/src/lib/core/state/types.ts
+++ b/src/lib/core/state/types.ts
@@ -107,6 +107,8 @@ export interface VisualState {
showOnsetMarkers: boolean;
/** Per-file onset marker style mapping (fileId -> style) */
fileOnsetMarkers: Record;
+ /** Whether to use uniform color for all tracks (no lightness variation) */
+ uniformTrackColor: boolean;
}
/**
diff --git a/src/lib/core/utils/midi/types.ts b/src/lib/core/utils/midi/types.ts
index 7801226..679bdf0 100644
--- a/src/lib/core/utils/midi/types.ts
+++ b/src/lib/core/utils/midi/types.ts
@@ -24,6 +24,24 @@ export interface TimeSignatureEvent {
denominator: number;
}
+/**
+ * Instrument family categories based on GM Program Number groupings.
+ * Used for UI icons and audio sampler routing.
+ */
+export type InstrumentFamily =
+ | "piano"
+ | "strings"
+ | "drums"
+ | "guitar"
+ | "bass"
+ | "synth"
+ | "winds"
+ | "brass"
+ | "vocal"
+ | "organ"
+ | "mallet"
+ | "others";
+
/**
* Represents a musical note in the Tone.js format
*/
@@ -46,6 +64,11 @@ export interface NoteData {
duration: number;
/** The ID of the source MIDI file this note belongs to */
fileId?: string;
+ /**
+ * Track ID within the MIDI file (0-based index).
+ * Combined with fileId to uniquely identify the source track.
+ */
+ trackId?: number;
/** Optional source index for note matching/mapping */
sourceIndex?: number;
/** Mark this rendered fragment as an evaluation highlight segment */
@@ -66,6 +89,27 @@ export interface TrackData {
channel: number;
}
+/**
+ * Extended track metadata with instrument information.
+ * Used for multi-instrument MIDI files.
+ */
+export interface TrackInfo {
+ /** Unique track ID within the MIDI file (0-based index) */
+ id: number;
+ /** The name of the track */
+ name: string;
+ /** The MIDI channel (0-15, channel 9 is typically drums) */
+ channel: number;
+ /** MIDI Program Number (0-127) for instrument selection */
+ program?: number;
+ /** Whether this track is a drum/percussion track (channel 9 or 10) */
+ isDrum: boolean;
+ /** Instrument family for UI icons and audio routing */
+ instrumentFamily: InstrumentFamily;
+ /** Number of notes in this track */
+ noteCount: number;
+}
+
/**
* Represents the header information of a MIDI file
*/
@@ -88,11 +132,17 @@ export interface ParsedMidi {
header: MidiHeader;
/** Total duration of the piece in seconds */
duration: number;
- /** Information about the piano track */
+ /** Information about the primary track (legacy, for backward compatibility) */
track: TrackData;
/** Array of all notes in the piece */
notes: NoteData[];
+ /** Control change events (e.g., sustain pedal) */
controlChanges: ControlChangeEvent[];
+ /**
+ * Detailed track information for multi-instrument support.
+ * Each track has its own ID, instrument family, and note count.
+ */
+ tracks: TrackInfo[];
}
/**
@@ -119,4 +169,9 @@ export interface ControlChangeEvent {
name?: string;
/** The ID of the source MIDI file this control change belongs to */
fileId?: string;
+ /**
+ * Track ID within the MIDI file (0-based index).
+ * Used to filter sustain pedal visibility per track.
+ */
+ trackId?: number;
}
diff --git a/src/lib/core/visualization/piano-roll/piano-roll.ts b/src/lib/core/visualization/piano-roll/piano-roll.ts
index 6492e64..5725af2 100644
--- a/src/lib/core/visualization/piano-roll/piano-roll.ts
+++ b/src/lib/core/visualization/piano-roll/piano-roll.ts
@@ -327,11 +327,11 @@ export class PianoRoll {
const notesAtPosition = this.findNotesAtPosition(time, note.midi);
const fileInfoMap = this.fileInfoMap as
- | Record
+ | Record }>
| undefined;
// Group notes by unique file IDs
- const fileInfos: Map = new Map();
+ const fileInfos: Map }; notes: NoteData[] }> = new Map();
for (const n of notesAtPosition) {
if (n.fileId && fileInfoMap) {
@@ -370,7 +370,26 @@ export class PianoRoll {
6,
"0"
)};border-radius:2px;margin-right:8px;vertical-align:middle;border:1px solid rgba(255,255,255,0.3);">`;
- return `${swatch}${info.kind}: ${info.name}
`;
+
+ // Collect unique track IDs from notes at this position
+ const uniqueTrackIds = [
+ ...new Set(
+ notes
+ .map((n) => n.trackId)
+ .filter((id): id is number => id !== undefined)
+ ),
+ ];
+
+ // Get track names from file info
+ const trackNames = uniqueTrackIds
+ .map((tid) => info.tracks?.find((t) => t.id === tid)?.name)
+ .filter((name): name is string => name !== undefined);
+
+ // Build track suffix with Β· separator
+ const trackSuffix =
+ trackNames.length > 0 ? ` Β· ${trackNames.join(", ")}` : "";
+
+ return `${swatch}${info.kind}: ${info.name}${trackSuffix}
`;
})
.join("");
}
@@ -908,6 +927,12 @@ export class PianoRoll {
// Resize Pixi renderer
this.app.renderer.resize(newWidth, newHeight);
+ // Ensure canvas CSS follows container dimensions
+ // (PixiJS autoDensity sets fixed pixel values which prevents responsive layout)
+ const canvas = this.app.canvas;
+ canvas.style.width = "100%";
+ canvas.style.height = "100%";
+
// Recalculate scales based on new size and re-render
// IMPORTANT: Drop cached pxPerSecond so createScales picks a new value
// that matches the new width. Otherwise scrolling speed stays stuck at
diff --git a/src/lib/core/visualization/piano-roll/types-internal.ts b/src/lib/core/visualization/piano-roll/types-internal.ts
index 2463fb8..e7965e4 100644
--- a/src/lib/core/visualization/piano-roll/types-internal.ts
+++ b/src/lib/core/visualization/piano-roll/types-internal.ts
@@ -8,6 +8,8 @@ export interface FileInfo {
fileName: string;
kind: string; // e.g., "Reference" | "Comparison" | "MIDI"
color: number; // hex number compatible with PixiJS tint
+ /** Track info for tooltip display (id -> name mapping) */
+ tracks?: Array<{ id: number; name: string }>;
}
export type FileInfoMap = Record;
diff --git a/src/lib/core/visualization/piano-roll/utils/color-calculator.ts b/src/lib/core/visualization/piano-roll/utils/color-calculator.ts
index dd85938..fdf0ccd 100644
--- a/src/lib/core/visualization/piano-roll/utils/color-calculator.ts
+++ b/src/lib/core/visualization/piano-roll/utils/color-calculator.ts
@@ -112,4 +112,109 @@ export class ColorCalculator {
return (r << 16) | (g << 8) | b;
}
+
+ /**
+ * Convert RGB color (hex number) to HSL
+ * @returns Object with h (0-360), s (0-1), l (0-1)
+ */
+ static rgbToHsl(color: number): { h: number; s: number; l: number } {
+ const r = ((color >> 16) & 0xff) / 255;
+ const g = ((color >> 8) & 0xff) / 255;
+ const b = (color & 0xff) / 255;
+
+ const max = Math.max(r, g, b);
+ const min = Math.min(r, g, b);
+ const l = (max + min) / 2;
+
+ if (max === min) {
+ // Achromatic (gray)
+ return { h: 0, s: 0, l };
+ }
+
+ const d = max - min;
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
+
+ let h = 0;
+ switch (max) {
+ case r:
+ h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
+ break;
+ case g:
+ h = ((b - r) / d + 2) / 6;
+ break;
+ case b:
+ h = ((r - g) / d + 4) / 6;
+ break;
+ }
+
+ return { h: h * 360, s, l };
+ }
+
+ /**
+ * Convert HSL to RGB color (hex number)
+ * @param h Hue (0-360)
+ * @param s Saturation (0-1)
+ * @param l Lightness (0-1)
+ */
+ static hslToRgb(h: number, s: number, l: number): number {
+ const hNorm = h / 360;
+
+ if (s === 0) {
+ // Achromatic (gray)
+ const gray = Math.round(l * 255);
+ return (gray << 16) | (gray << 8) | gray;
+ }
+
+ const hue2rgb = (p: number, q: number, t: number): number => {
+ let tNorm = t;
+ if (tNorm < 0) tNorm += 1;
+ if (tNorm > 1) tNorm -= 1;
+ if (tNorm < 1 / 6) return p + (q - p) * 6 * tNorm;
+ if (tNorm < 1 / 2) return q;
+ if (tNorm < 2 / 3) return p + (q - p) * (2 / 3 - tNorm) * 6;
+ return p;
+ };
+
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
+ const p = 2 * l - q;
+
+ const r = Math.round(hue2rgb(p, q, hNorm + 1 / 3) * 255);
+ const g = Math.round(hue2rgb(p, q, hNorm) * 255);
+ const b = Math.round(hue2rgb(p, q, hNorm - 1 / 3) * 255);
+
+ return (r << 16) | (g << 8) | b;
+ }
+
+ /**
+ * Get a lightness-variant color for a track within a file.
+ * Uses HSL color space to adjust lightness while preserving hue,
+ * ensuring file-level color identity is maintained.
+ *
+ * @param baseColor - The file's base color (hex number)
+ * @param trackIndex - Index of the track within the file (0-based)
+ * @param totalTracks - Total number of tracks in the file
+ * @returns Adjusted color with modified lightness
+ */
+ static getTrackVariantColor(
+ baseColor: number,
+ trackIndex: number,
+ totalTracks: number
+ ): number {
+ // No variation needed for single track or first track
+ if (totalTracks <= 1 || trackIndex === 0) return baseColor;
+
+ const { h, s, l } = ColorCalculator.rgbToHsl(baseColor);
+
+ // Alternating pattern: track 1 β lighter, track 2 β darker, track 3 β lighter+, ...
+ const step = Math.floor((trackIndex + 1) / 2);
+ const shift = step * 0.20; // 20% lightness shift per step
+ const isLighten = trackIndex % 2 === 1;
+
+ // Clamp lightness to avoid too dark (< 0.20) or too bright (> 0.90)
+ const newL = isLighten
+ ? Math.min(0.90, l + shift)
+ : Math.max(0.20, l - shift);
+
+ return ColorCalculator.hslToRgb(h, s, newL);
+ }
}
\ No newline at end of file
diff --git a/test/instrument-icons.test.ts b/test/instrument-icons.test.ts
new file mode 100644
index 0000000..408b98b
--- /dev/null
+++ b/test/instrument-icons.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from "vitest";
+
+import { INSTRUMENT_ICONS, getInstrumentIcon } from "@/assets/instrument-icons";
+import { InstrumentFamily } from "@/lib/midi/types";
+
+const svgMarkupPattern = /^$/;
+
+describe("instrument icon registry", () => {
+ it("exposes SVG markup for every instrument family", () => {
+ const families = Object.keys(INSTRUMENT_ICONS) as InstrumentFamily[];
+ expect(families.length).toBeGreaterThan(0);
+
+ families.forEach((family) => {
+ const svg = getInstrumentIcon(family);
+ expect(svgMarkupPattern.test(svg)).toBe(true);
+ expect(svg.includes('width="24"')).toBe(true);
+ expect(svg.includes('height="24"')).toBe(true);
+ });
+ });
+
+ it("falls back to the 'others' icon for unknown families", () => {
+ const fallback = INSTRUMENT_ICONS.others;
+ expect(getInstrumentIcon("others")).toBe(fallback);
+ expect(getInstrumentIcon("unknown" as InstrumentFamily)).toBe(fallback);
+ });
+});
+
diff --git a/test/multi-midi-manager.test.ts b/test/multi-midi-manager.test.ts
index 126f91d..bb89263 100644
--- a/test/multi-midi-manager.test.ts
+++ b/test/multi-midi-manager.test.ts
@@ -4,55 +4,85 @@
* Contracts:
* - toggleVisibility keeps `isVisible` and `isPianoRollVisible` in sync.
* - getVisibleNotes returns only notes from visible files, sorted by time.
+ * - trackVisibility controls per-track note filtering.
*/
-import { describe, it, expect } from 'vitest';
-import { MultiMidiManager } from '@/lib/core/midi/multi-midi-manager';
-import type { ParsedMidi, NoteData } from '@/lib/midi/types';
-
-function midi(name: string, notes: Partial[]): ParsedMidi {
- const full: NoteData[] = notes.map((n, i) => ({
- midi: n.midi ?? (60 + i),
- time: n.time ?? i * 0.25,
- ticks: 0,
- name: n.name ?? 'N',
- pitch: n.pitch ?? 'C',
- octave: n.octave ?? 4,
- velocity: n.velocity ?? 0.7,
- duration: n.duration ?? 0.5,
- } as any));
+import { describe, it, expect } from "vitest";
+import { MultiMidiManager } from "@/lib/core/midi/multi-midi-manager";
+import { getInstrumentFamily } from "@/lib/core/parsers/midi-parser";
+import type { ParsedMidi, NoteData, TrackInfo } from "@/lib/midi/types";
+
+/**
+ * Create a mock ParsedMidi object for testing.
+ * Optionally supports multi-track with trackId on notes.
+ */
+function midi(
+ name: string,
+ notes: Partial[],
+ tracks?: TrackInfo[]
+): ParsedMidi {
+ const full: NoteData[] = notes.map(
+ (n, i) =>
+ ({
+ midi: n.midi ?? 60 + i,
+ time: n.time ?? i * 0.25,
+ ticks: 0,
+ name: n.name ?? "N",
+ pitch: n.pitch ?? "C",
+ octave: n.octave ?? 4,
+ velocity: n.velocity ?? 0.7,
+ duration: n.duration ?? 0.5,
+ trackId: n.trackId,
+ }) as any
+ );
return {
header: { name, tempos: [], timeSignatures: [], PPQ: 480 },
- duration: Math.max(0, ...full.map(n => n.time + n.duration)),
+ duration: Math.max(0, ...full.map((n) => n.time + n.duration)),
track: { name, channel: 0 },
notes: full,
controlChanges: [],
+ tracks: tracks ?? [],
};
}
-describe('MultiMidiManager', () => {
- it('syncs isVisible and isPianoRollVisible on toggle', () => {
+describe("MultiMidiManager", () => {
+ it("syncs isVisible and isPianoRollVisible on toggle", () => {
const mm = new MultiMidiManager();
- const id = mm.addMidiFile('a.mid', midi('A', [{ time: 0, duration: 1 }]), 'A');
+ const id = mm.addMidiFile(
+ "a.mid",
+ midi("A", [{ time: 0, duration: 1 }]),
+ "A"
+ );
const state1 = mm.getState();
- const f1 = state1.files.find(f => f.id === id)!;
+ const f1 = state1.files.find((f) => f.id === id)!;
expect(f1.isVisible).toBe(true);
expect(f1.isPianoRollVisible).toBe(true);
mm.toggleVisibility(id);
- const f2 = mm.getState().files.find(f => f.id === id)!;
+ const f2 = mm.getState().files.find((f) => f.id === id)!;
expect(f2.isVisible).toBe(false);
expect(f2.isPianoRollVisible).toBe(false);
mm.toggleVisibility(id);
- const f3 = mm.getState().files.find(f => f.id === id)!;
+ const f3 = mm.getState().files.find((f) => f.id === id)!;
expect(f3.isVisible).toBe(true);
expect(f3.isPianoRollVisible).toBe(true);
});
- it('getVisibleNotes returns only visible files, sorted by time', () => {
+ it("getVisibleNotes returns only visible files, sorted by time", () => {
const mm = new MultiMidiManager();
- const idA = mm.addMidiFile('a.mid', midi('A', [ { time: 0.5, duration: 0.2 }, { time: 0.1, duration: 0.2 } ]), 'A');
- const idB = mm.addMidiFile('b.mid', midi('B', [ { time: 0.3, duration: 0.2 } ]), 'B');
+ const idA = mm.addMidiFile(
+ "a.mid",
+ midi("A", [
+ { time: 0.5, duration: 0.2 },
+ { time: 0.1, duration: 0.2 },
+ ]),
+ "A"
+ );
+ const idB = mm.addMidiFile(
+ "b.mid",
+ midi("B", [{ time: 0.3, duration: 0.2 }]),
+ "B"
+ );
// Hide B
mm.toggleVisibility(idB);
@@ -61,7 +91,141 @@ describe('MultiMidiManager', () => {
// Sorted by time ascending
expect(notes[0].note.time).toBeLessThanOrEqual(notes[1].note.time);
// All from file A
- expect(new Set(notes.map(n => n.fileId))).toEqual(new Set([idA]));
+ expect(new Set(notes.map((n) => n.fileId))).toEqual(new Set([idA]));
+ });
+
+ it("trackVisibility filters notes by track", () => {
+ const mm = new MultiMidiManager();
+
+ // Create multi-track MIDI with notes from different tracks
+ const tracks: TrackInfo[] = [
+ {
+ id: 0,
+ name: "Piano",
+ channel: 0,
+ isDrum: false,
+ instrumentFamily: "piano",
+ noteCount: 2,
+ },
+ {
+ id: 1,
+ name: "Drums",
+ channel: 9,
+ isDrum: true,
+ instrumentFamily: "drums",
+ noteCount: 1,
+ },
+ ];
+ const notes: Partial[] = [
+ { time: 0.0, duration: 0.5, trackId: 0 },
+ { time: 0.5, duration: 0.5, trackId: 0 },
+ { time: 0.25, duration: 0.25, trackId: 1 },
+ ];
+ const fileId = mm.addMidiFile(
+ "multi.mid",
+ midi("Multi", notes, tracks),
+ "Multi"
+ );
+
+ // All notes visible by default
+ expect(mm.getVisibleNotes().length).toBe(3);
+
+ // Hide track 1 (drums)
+ mm.setTrackVisibility(fileId, 1, false);
+ const afterHide = mm.getVisibleNotes();
+ expect(afterHide.length).toBe(2);
+ expect(afterHide.every((n) => n.note.trackId === 0)).toBe(true);
+
+ // Show track 1 again
+ mm.toggleTrackVisibility(fileId, 1);
+ expect(mm.getVisibleNotes().length).toBe(3);
+ });
+
+ it("isTrackVisible returns correct visibility state", () => {
+ const mm = new MultiMidiManager();
+ const tracks: TrackInfo[] = [
+ {
+ id: 0,
+ name: "Piano",
+ channel: 0,
+ isDrum: false,
+ instrumentFamily: "piano",
+ noteCount: 1,
+ },
+ ];
+ const fileId = mm.addMidiFile(
+ "test.mid",
+ midi("Test", [{ trackId: 0 }], tracks),
+ "Test"
+ );
+
+ // Default visible
+ expect(mm.isTrackVisible(fileId, 0)).toBe(true);
+
+ // Toggle off
+ mm.setTrackVisibility(fileId, 0, false);
+ expect(mm.isTrackVisible(fileId, 0)).toBe(false);
+
+ // Toggle on
+ mm.setTrackVisibility(fileId, 0, true);
+ expect(mm.isTrackVisible(fileId, 0)).toBe(true);
});
});
+describe("getInstrumentFamily GM Mapping", () => {
+ it("maps piano programs (0-7) to piano", () => {
+ expect(getInstrumentFamily(0, 0)).toBe("piano");
+ expect(getInstrumentFamily(7, 0)).toBe("piano");
+ });
+
+ it("maps guitar programs (24-31) to guitar", () => {
+ expect(getInstrumentFamily(24, 0)).toBe("guitar");
+ expect(getInstrumentFamily(31, 0)).toBe("guitar");
+ });
+
+ it("maps bass programs (32-39) to bass", () => {
+ expect(getInstrumentFamily(32, 0)).toBe("bass");
+ expect(getInstrumentFamily(39, 0)).toBe("bass");
+ });
+
+ it("maps strings programs (40-55) to strings", () => {
+ expect(getInstrumentFamily(40, 0)).toBe("strings");
+ expect(getInstrumentFamily(55, 0)).toBe("strings");
+ });
+
+ it("maps brass programs (56-63) to brass", () => {
+ expect(getInstrumentFamily(56, 0)).toBe("brass");
+ expect(getInstrumentFamily(63, 0)).toBe("brass");
+ });
+
+ it("maps winds programs (64-79) to winds", () => {
+ expect(getInstrumentFamily(64, 0)).toBe("winds");
+ expect(getInstrumentFamily(79, 0)).toBe("winds");
+ });
+
+ it("maps synth programs (80-103) to synth", () => {
+ expect(getInstrumentFamily(80, 0)).toBe("synth");
+ expect(getInstrumentFamily(103, 0)).toBe("synth");
+ });
+
+ it("maps percussion programs (112-119) to drums", () => {
+ expect(getInstrumentFamily(112, 0)).toBe("drums");
+ expect(getInstrumentFamily(119, 0)).toBe("drums");
+ });
+
+ it("maps channel 9 (GM drum channel) to drums regardless of program", () => {
+ expect(getInstrumentFamily(0, 9)).toBe("drums");
+ expect(getInstrumentFamily(50, 9)).toBe("drums");
+ expect(getInstrumentFamily(127, 9)).toBe("drums");
+ });
+
+ it("maps channel 10 (1-indexed drum channel) to drums", () => {
+ expect(getInstrumentFamily(0, 10)).toBe("drums");
+ });
+
+ it("maps chromatic percussion and sound effects correctly", () => {
+ expect(getInstrumentFamily(8, 0)).toBe("mallet"); // Chromatic percussion
+ expect(getInstrumentFamily(120, 0)).toBe("others"); // Sound effects
+ expect(getInstrumentFamily(127, 0)).toBe("others");
+ });
+});
diff --git a/wave-roll-logo.png b/wave-roll-logo.png
index d7a24ef..69175c6 100644
Binary files a/wave-roll-logo.png and b/wave-roll-logo.png differ