diff --git a/.gitignore b/.gitignore index e4f068b..07954e4 100644 --- a/.gitignore +++ b/.gitignore @@ -51,4 +51,6 @@ tmp/ temp/ # Sample MIDI files -src/sample_midi/ \ No newline at end of file +src/sample_midi/ + +.pnpm-store/ \ No newline at end of file diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..14b6831 --- /dev/null +++ b/.npmignore @@ -0,0 +1,34 @@ +# Exclude development and test sources +src/ +test/ +docs/ + +# Build and tooling configs +.vscode/ +.github/ +pnpm-lock.yaml +pnpm-workspace.yaml +vite.config.ts +tsconfig.json +tsconfig.*.json + +# Demo and example assets not needed in the package +index.html +multi_midi.html +test-umd.html +test-esm-cdn.html +wave-roll*.png + +# Temporary and cache files +*.log +*.tsbuildinfo +coverage/ +tmp/ +temp/ +.pnpm-store/ + +# Ensure published artifacts and docs stay included +!dist/ +!README.md +!CHANGELOG.md +!LICENSE \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fbe1d8..729883f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 > **Note**: This library is published at [ISMIR 2025 LBD](https://ismir2025program.ismir.net/lbd_459.html). See [Citation](#citation) for reference. +## [0.4.0] - 2025-12-06 + +### Added + +- Multi-instrument MIDI support with GM program detection and auto soundfont mapping +- Per-track controls for visibility, mute, volume, sustain, and waveform/piano-roll toggles +- Track-aware instrument icons, palettes, and color handling for multi-track MIDI files + +### Changed + +- Updated player, visualization, and loop controls to handle multi-track state across audio and UI +- Refreshed file list and wave list UI to expose per-track toggles and multi-file controls +- Improved MIDI parser and sampler manager to keep track metadata consistent across playback and rendering + +### Tests + +- Added coverage for multi-MIDI manager behaviors and instrument family mapping + ## [0.3.0] - 2025-12-02 @@ -16,7 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Tempo control with popover input for precise BPM adjustment - Flexible MIDI export options - Pitch hover indicator to piano roll -- VS Code extension support (`wave-roll-solo`) for viewing MIDI files directly in the editor +- VS Code extension support (`wave-roll-studio`, formerly `wave-roll-solo`) for viewing MIDI files directly in the editor - GitHub Actions workflow for automated release creation from tags ### Changed @@ -123,7 +141,8 @@ If you use WaveRoll in your research, please cite: --- -[Unreleased]: https://github.com/crescent-stdio/wave-roll/compare/v0.3.0...HEAD +[Unreleased]: https://github.com/crescent-stdio/wave-roll/compare/v0.4.0...HEAD +[0.4.0]: https://github.com/crescent-stdio/wave-roll/compare/v0.3.0...v0.4.0 [0.3.0]: https://github.com/crescent-stdio/wave-roll/compare/v0.2.5...v0.3.0 [0.2.5]: https://github.com/crescent-stdio/wave-roll/compare/v0.2.4...v0.2.5 [0.2.4]: https://github.com/crescent-stdio/wave-roll/compare/v0.2.3...v0.2.4 diff --git a/LICENSE b/LICENSE index 0653cd2..777af2b 100644 --- a/LICENSE +++ b/LICENSE @@ -19,31 +19,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - ---- - -ACKNOWLEDGMENTS: - -This software includes code ported from mir_eval (https://github.com/mir-evaluation/mir_eval), -which is licensed under the MIT License: - -Copyright (c) 2014 Colin Raffel, Brian McFee, Eric J. Humphrey, Justin Salamon, -Rachel Bittner, Oriol Nieto, Dan Ellis, and contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..a5535bf --- /dev/null +++ b/NOTICE @@ -0,0 +1,24 @@ +ACKNOWLEDGMENTS + +This software includes code ported from mir_eval (https://github.com/mir-evaluation/mir_eval), which is licensed under the MIT License: + +Copyright (c) 2014 Colin Raffel, Brian McFee, Eric J. Humphrey, Justin Salamon, +Rachel Bittner, Oriol Nieto, Dan Ellis, and contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md index cc5cdaf..12594e2 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ - You can try the web demo at [https://crescent-stdio.github.io/wave-roll/](https://crescent-stdio.github.io/wave-roll/). - NPM package: [https://www.npmjs.com/package/wave-roll](https://www.npmjs.com/package/wave-roll) +- VS Code Extension: [WaveRoll Studio](https://github.com/crescent-stdio/wave-roll-studio) (also available on Open VSX) +- Multi-instrument MIDI files are supported with automatic GM instrument mapping and per-track mute/volume/visibility controls. ## Installation @@ -166,18 +168,6 @@ IFrame(src='https://crescent-stdio.github.io/wave-roll/standalone.html', width=' This is particularly useful for music information retrieval (MIR) research, allowing you to visualize and compare transcription results directly in your analysis notebooks. -### VS Code Extension - -Use WaveRoll directly in VS Code with the **Wave Roll Solo** extension: - -- Open any `.mid` or `.midi` file to view it as an interactive piano roll -- Play MIDI files with built-in Tone.js synthesis -- Adjust tempo and export MIDI with modified tempo - -**Installation**: Search "WaveRoll Solo" in VS Code Extensions marketplace - -**GitHub**: [crescent-stdio/wave-roll-solo](https://github.com/crescent-stdio/wave-roll-solo) - ## API ### Attributes diff --git a/docs/examples/instrument-icons-preview.html b/docs/examples/instrument-icons-preview.html new file mode 100644 index 0000000..0f56f5e --- /dev/null +++ b/docs/examples/instrument-icons-preview.html @@ -0,0 +1,361 @@ + + + + + + Wave Roll Icon System Preview + + + +
+
+

🎨 Wave Roll Icon System

+

24x24 Grid β€’ Lucide Style β€’ Consistent Geometry

+
+ + +
+
+

Instrument Icons

+ src/assets/instrument-icons.ts +
+

+ Every tile renders the redesigned 16px vs 24px silhouettes so you can + sanity check readability before shipping. +

+
+
+ + +
+
+

Player Icons

+ src/assets/player-icons.ts +
+
+
+ + +
+
+

Onset Markers

+ src/assets/onset-icons.ts +
+
+
+ + +
+ + + + diff --git a/multi_midi.html b/multi_midi.html new file mode 100644 index 0000000..b2624c3 --- /dev/null +++ b/multi_midi.html @@ -0,0 +1,72 @@ + + + + + + + WaveRoll - MIDI Visualizer + + + +
+
+

+ WaveRoll - MIDI Visualizer +

+
+ + + +
+ + diff --git a/package.json b/package.json index 236807e..e55942a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wave-roll", - "version": "0.3.0", + "version": "0.4.0", "description": "JavaScript Library for Comparative MIDI Piano-Roll Visualization", "main": "dist/wave-roll.umd.js", "module": "dist/wave-roll.es.js", diff --git a/src/assets/instrument-icons.ts b/src/assets/instrument-icons.ts new file mode 100644 index 0000000..6785607 --- /dev/null +++ b/src/assets/instrument-icons.ts @@ -0,0 +1,185 @@ +/** + * SVG Icons for instrument families + * Used in Settings UI to visually distinguish track types + * + * Design System: + * - Base Grid: 24x24 viewBox + * - Style: Lucide/Feather (clean lines, rounded corners) + * - Attributes: stroke="currentColor", stroke-width="2", fill="none" + * + * 16px Readability Heuristics: + * - Piano: compact keyboard with three clear black-key blocks + * - Strings: violin silhouette with paired f-holes and vertical strings + * - Drums: single shell + lid with one diagonal stick + * - Guitar: circular body plus angled neck with tuning nubs + * - Bass: offset body with long neck, bridge rails, and tuning dots + * - Synth: rack with two knobs plus three grouped keys + * - Winds: horizontal flute pill, mouthpiece, and three tone holes + * - Brass: flared trumpet bell, short lead pipe, and two valves + * - Vocal: microphone with singing figure silhouette + * - Organ: pipe organ with vertical pipes + * - Mallet: maracas percussion instrument + * - Others: generic musical note retained for catch-all usage + */ + +import { InstrumentFamily } from "@/lib/midi/types"; + +/** Common SVG attributes for consistent styling */ +const SVG_ATTRS = `width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="pointer-events: none;"`; + +/** + * SVG icons for each instrument family. + * All icons use 24x24 viewBox with Lucide/Feather styling. + */ +export const INSTRUMENT_ICONS: Record = { + // Piano: keyboard piano keys play (from SVG Repo) + piano: ` + + + + + + + + `, + + // Strings: violin/cello with bow (from SVG Repo) + strings: ` + + + + + + + + + `, + + // Drums: snare drum with stick (from SVG Repo) + drums: ` + + + + `, + + // Guitar: classic guitar (from SVG Repo) + guitar: ` + + + + + `, + + // Bass: electric bass guitar (from SVG Repo) + bass: ` + + + + + + + + `, + + // Synth: keyboard piano synth midi vst (from SVG Repo) + synth: ` + + + + + + + + + + + `, + + // Winds: flute instrument (from SVG Repo) + winds: ` + + + + + + + + + + + + `, + + // Brass: jazz trumpet band (from SVG Repo) + brass: ` + + + + + + + `, + + // Vocal: microphone with singing figure (from SVG Repo) + vocal: ` + + + + + + `, + + // Organ: pipe organ with vertical pipes (stroke style) + organ: ` + + + + + + + + + + + + `, + + // Mallet: maracas percussion (from SVG Repo) + mallet: ` + + + + + `, + + // Others: scores notes audio (from SVG Repo) + others: ` + + + + + + + + `, +}; + +/** + * Get the SVG icon for a given instrument family. + * @param family - The instrument family + * @returns SVG string for the icon + */ +export function getInstrumentIcon(family: InstrumentFamily): string { + return INSTRUMENT_ICONS[family] ?? INSTRUMENT_ICONS.others; +} + +/** + * Chevron icon for accordion expand/collapse + */ +export const CHEVRON_DOWN = ` + +`; + +export const CHEVRON_RIGHT = ` + +`; diff --git a/src/assets/onset-icons.ts b/src/assets/onset-icons.ts index e4c32a9..16c2026 100644 --- a/src/assets/onset-icons.ts +++ b/src/assets/onset-icons.ts @@ -3,6 +3,11 @@ import type { OnsetMarkerStyle, OnsetMarkerShape } from "@/types"; /** * Build an inline SVG string for an onset marker. * The shape geometry is consistent across UI call sites. + * + * Design System: + * - Base Grid: 24x24 viewBox + * - All coordinates are relative to this 24x24 grid + * - The 'size' parameter controls the rendered size (width/height attributes) */ export function renderOnsetSVG( style: OnsetMarkerStyle, @@ -11,12 +16,22 @@ export function renderOnsetSVG( ): string { const stroke = colorHex; const fill = style.variant === "filled" ? colorHex : "transparent"; - const sw = Math.max(1, style.strokeWidth || 2); - const w = size; - const h = size; - const cx = size / 2; - const cy = size / 2; - const r = size * 0.35; + + // Scale stroke width based on size relative to base grid (24) + // If size is 16, we want visually similar weight to 24px icons + // Standard stroke for 24px icons is 2. + const baseStroke = style.strokeWidth || 2; + // Normalize stroke width so it looks consistent regardless of render size + // If we render at 16px, a stroke of 2 in 24px coord system becomes 2 * (16/24) = 1.33px visual + // We want visual stroke of ~1.5-2px usually. + const sw = Math.max(1, baseStroke); + + // Base grid coordinates + const w = 24; + const h = 24; + const cx = 12; + const cy = 12; + const r = 9; // Radius for shapes within 24x24 box (allows padding for stroke) const poly = (pts: Array<[number, number]>) => `M ${pts.map(([x, y], i) => `${i === 0 ? "" : "L "}${x} ${y}`).join(" ")} Z`; @@ -26,42 +41,68 @@ export function renderOnsetSVG( case "circle": return ``; case "square": { - const d = r / Math.SQRT2; - return ``; + const d = r / Math.SQRT2; // Make square fit within the circle radius area approx + // Or just use full size? Let's use approx 16x16 square in 24x24 + const s = 16; + const o = (24 - s) / 2; + return ``; } case "diamond": - return ``; + return ``; case "triangle-up": - return ``; + return ``; case "triangle-down": - return ``; + return ``; case "triangle-left": - return ``; + return ``; case "triangle-right": - return ``; + return ``; case "star": { const pts: Array<[number, number]> = []; const spikes = 5; const outer = r; - const inner = r * 0.45; + const inner = r * 0.4; for (let i = 0; i < spikes * 2; i++) { const ang = (i * Math.PI) / spikes - Math.PI / 2; const rad = i % 2 === 0 ? outer : inner; pts.push([cx + Math.cos(ang) * rad, cy + Math.sin(ang) * rad]); } - return ``; + return ``; } - case "cross": - return ``; + case "cross": // X shape + return ``; case "plus": - return ``; + return ``; case "hexagon": { const pts: Array<[number, number]> = []; for (let i = 0; i < 6; i++) { - const a = (Math.PI / 3) * i + Math.PI / 6; - pts.push([cx + Math.cos(a) * r, cy + Math.sin(a) * r]); + const a = (Math.PI / 3) * i; // Flat top? or Pointy top? Lucide polygon usually pointy up? + // Let's do flat sides (pointy top) to match circle + const ang = a - Math.PI / 6; + pts.push([cx + Math.cos(ang) * r, cy + Math.sin(ang) * r]); } - return ``; + return ``; } case "pentagon": { const pts: Array<[number, number]> = []; @@ -69,20 +110,21 @@ export function renderOnsetSVG( const a = ((2 * Math.PI) / 5) * i - Math.PI / 2; pts.push([cx + Math.cos(a) * r, cy + Math.sin(a) * r]); } - return ``; + return ``; } case "chevron-up": - return ``; + return ``; case "chevron-down": - return ``; + return ``; default: { - const d = r / Math.SQRT2; - return ``; + const s = 16; + const o = (24 - s) / 2; + return ``; } } }; - return ``; + return ``; } - - diff --git a/src/assets/player-icons.ts b/src/assets/player-icons.ts index bfcb93a..9365727 100644 --- a/src/assets/player-icons.ts +++ b/src/assets/player-icons.ts @@ -1,151 +1,185 @@ /** * SVG Icons for player controls + * Used in Audio Player UI + * + * Design System: + * - Base Grid: 24x24 viewBox + * - Style: Lucide/Feather (clean lines, rounded corners) + * - Attributes: stroke="currentColor", stroke-width="2", fill="none" */ +/** Common SVG attributes for consistent styling */ +const SVG_ATTRS = `width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="pointer-events: none;"`; + export const PLAYER_ICONS = { - play: ` - + play: ` + `, - pause: ` - + pause: ` + + `, - restart: ` - - + // Restart: Skip Back / Rewind style + restart: ` + + `, - repeat: ` - + // Repeat: Cycle arrows + repeat: ` + + + + `, - volume: ` - - - + // Volume: Speaker with waves + volume: ` + + + `, - mute: ` - - - + // Mute: Speaker with X + mute: ` + + + `, - tempo: ` - - + // Tempo: Gauge / Speedometer style + tempo: ` + + + `, - skip_forward: ` - + skip_forward: ` + + `, - skip_backward: ` - + skip_backward: ` + + `, - shuffle: ` - - + // Shuffle: Crossed arrows + shuffle: ` + + + + + `, - list: ` - + // List: Menu list + list: ` + + + + + + `, - /** Hamburger menu icon used to toggle the sidebar */ - menu: ` - + // Menu: Hamburger + menu: ` + + + `, - midi: ` - + // Midi: DIN connector style + midi: ` + + + + + + `, - zoom_reset: ` - - - - + zoom_reset: ` + + + `, - settings: ` - + settings: ` + + `, - loop_restart: ` - - - - A - B + // AB Loop start: play-sized triangle with "AB" label + loop_start: ` + AB + `, - eye_open: ` - - + eye_open: ` + + `, - eye_closed: ` - - + eye_closed: ` + + `, - /** Push pin icon to mark reference file */ - pin: ` - - - `, + // Pin: Push pin + pin: ` + + + `, - /** Pencil icon for edit actions */ - edit: ` - + // Edit: Pencil + edit: ` + + `, - /** Overlapping squares icon for duplicate / clone actions */ - duplicate: ` - - + // Duplicate: Copy + duplicate: ` + + `, - /** Trash icon used for delete actions */ - trash: ` - - - - - + // Trash: Trash can + trash: ` + + `, - /** Palette icon for color picker */ - palette: ` - - - - - + // Palette: Color palette + palette: ` + + + + + `, - sustain: ` - S + sustain: ` + S `, - /** Estimation toggle icon (letter E) */ - est: ` - E + est: ` + E `, - /** File/folder icon for MIDI files */ - file: ` - + // File: Document / File + file: ` + + `, - /** Bar chart icon for evaluation results */ - results: ` - - - - + // Results: Bar Chart + results: ` + + + `, }; diff --git a/src/index.ts b/src/index.ts index dc56faf..86b3c14 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,10 @@ export { createPianoRoll } from "./lib/core/visualization/piano-roll"; // 2) Player demo helper used by the synchronized-player example -export { createWaveRollPlayer } from "./lib/components/player/wave-roll/player"; +export { + createWaveRollPlayer, + WaveRollPlayer, +} from "./lib/components/player/wave-roll/player"; // 3) Appearance settings types (for solo mode integration) export type { AppearanceSettings } from "./lib/components/player/wave-roll/player"; diff --git a/src/lib/components/player/wave-roll/player.ts b/src/lib/components/player/wave-roll/player.ts index 096e264..7316bcc 100644 --- a/src/lib/components/player/wave-roll/player.ts +++ b/src/lib/components/player/wave-roll/player.ts @@ -13,7 +13,11 @@ import { NoteData, ControlChangeEvent } from "@/lib/midi/types"; import { MultiMidiManager } from "@/lib/core/midi/multi-midi-manager"; import { MidiFileItemList } from "@/lib/core/file/types"; -import { WaveRollPlayerOptions, CreateWaveRollPlayerOptions, MidiExportOptions } from "./types"; +import { + WaveRollPlayerOptions, + CreateWaveRollPlayerOptions, + MidiExportOptions, +} from "./types"; import { createDefaultConfig, setupLayout, @@ -57,6 +61,7 @@ import type { OnsetMarkerStyle, OnsetMarkerShape } from "@/types"; import { ColorPalette } from "@/lib/core/midi/types"; import { DEFAULT_PALETTES } from "@/lib/core/midi/palette"; import { ONSET_MARKER_SHAPES } from "@/core/constants"; +import { HighlightMode } from "@/core/state/types"; /** * Appearance settings structure for solo mode integration @@ -144,10 +149,16 @@ export class WaveRollPlayer { // MIDI export options private midiExportOptions: MidiExportOptions | undefined; - + // Piano roll config overrides from options private pianoRollConfigOverrides: Partial = {}; + // File add request callbacks (for VS Code integration) + private fileAddRequestCallback: (() => void) | null = null; + private audioFileAddRequestCallback: (() => void) | null = null; + private defaultHighlightMode: HighlightMode | undefined; + private allowFileDrop: boolean = true; + // Compute effective UI duration considering tempo and WAV length private getEffectiveDuration(): number { try { @@ -157,9 +168,17 @@ export class WaveRollPlayer { const midiDur = st.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); @@ -181,7 +200,12 @@ export class WaveRollPlayer { this.container = container; this.midiManager = new MultiMidiManager(); this.initialFileItemList = initialFileItemList; - + + // Expose midiManager globally for audio player track mute/volume checks + ( + globalThis as unknown as { _waveRollMidiManager?: MultiMidiManager } + )._waveRollMidiManager = this.midiManager; + // Apply options if (options?.soloMode) { this.soloMode = true; @@ -199,6 +223,10 @@ export class WaveRollPlayer { ...options.pianoRoll, }; } + if (options?.allowFileDrop !== undefined) { + this.allowFileDrop = options.allowFileDrop; + } + this.defaultHighlightMode = options?.defaultHighlightMode; // Initialize configuration this.config = createDefaultConfig(); @@ -237,6 +265,11 @@ export class WaveRollPlayer { private initializeModules(): void { // Initialize state manager this.stateManager = new StateManager(); + if (this.defaultHighlightMode) { + this.stateManager.updateVisualState({ + highlightMode: this.defaultHighlightMode, + }); + } // Initialize file manager this.fileManager = new FileManager(this.midiManager, this.stateManager); @@ -256,30 +289,46 @@ export class WaveRollPlayer { }); // Keep seek bar and time display tightly synced with the engine's visual updates. - this.visualizationEngine.onVisualUpdate(({ currentTime, duration, isPlaying }) => { - // Always get fresh dependencies to ensure updateSeekBar is available - const deps = this.getUIDependencies(); - // Use max(MIDI, WAV) with tempo/playback-rate awareness - const effectiveDuration = this.getEffectiveDuration(); - deps.updateSeekBar?.({ currentTime, duration: effectiveDuration }); - this.updateTimeDisplay(currentTime); - // Avoid duplicate setTime calls here; CorePlaybackEngine already syncs - // the piano-roll playhead on its own update loop. - // Hook audio player's visual update callback only once per new instance - try { - const anyEngine = this.visualizationEngine as unknown as { coreEngine?: any }; - const ap = anyEngine.coreEngine?.audioPlayer; - if (ap && ap !== this.lastHookedAudioPlayer && typeof ap.setOnVisualUpdate === 'function') { - ap.setOnVisualUpdate(({ currentTime }: { currentTime: number; duration: number; isPlaying: boolean }) => { - const deps2 = this.getUIDependencies(); - const effDur2 = this.getEffectiveDuration(); - deps2.updateSeekBar?.({ currentTime, duration: effDur2 }); - this.updateTimeDisplay(currentTime); - }); - this.lastHookedAudioPlayer = ap; - } - } catch {} - }); + this.visualizationEngine.onVisualUpdate( + ({ currentTime, duration, isPlaying }) => { + // Always get fresh dependencies to ensure updateSeekBar is available + const deps = this.getUIDependencies(); + // Use max(MIDI, WAV) with tempo/playback-rate awareness + const effectiveDuration = this.getEffectiveDuration(); + deps.updateSeekBar?.({ currentTime, duration: effectiveDuration }); + this.updateTimeDisplay(currentTime); + // Avoid duplicate setTime calls here; CorePlaybackEngine already syncs + // the piano-roll playhead on its own update loop. + // Hook audio player's visual update callback only once per new instance + try { + const anyEngine = this.visualizationEngine as unknown as { + coreEngine?: any; + }; + const ap = anyEngine.coreEngine?.audioPlayer; + if ( + ap && + ap !== this.lastHookedAudioPlayer && + typeof ap.setOnVisualUpdate === "function" + ) { + ap.setOnVisualUpdate( + ({ + currentTime, + }: { + currentTime: number; + duration: number; + isPlaying: boolean; + }) => { + const deps2 = this.getUIDependencies(); + const effDur2 = this.getEffectiveDuration(); + deps2.updateSeekBar?.({ currentTime, duration: effDur2 }); + this.updateTimeDisplay(currentTime); + } + ); + this.lastHookedAudioPlayer = ap; + } + } catch {} + } + ); this.pianoRollManager = createPianoRollManager(); this.pianoRollManager.initialize(this.pianoRollContainer, []); @@ -306,7 +355,7 @@ export class WaveRollPlayer { // Requirement: user must press the play button to resume (no auto-resume) this.pausedBySilence = false; // console.log("Sound detected"); - } + }, }); // Ensure silence detector is aware of current MIDI state so that @@ -319,7 +368,7 @@ export class WaveRollPlayer { this.stateManager, this.visualizationEngine ); - + this.uiUpdater = new UIUpdater( this.stateManager, this.visualizationEngine, @@ -337,29 +386,29 @@ export class WaveRollPlayer { // Track previous mute states to detect changes const previousMuteStates = new Map(); - + // Now that handlers are ready, set up state change listeners this.midiManager.setOnStateChange(() => { if (this.stateManager.getUIState().isBatchLoading) return; - + // Check for mute state changes and apply them via setFileMute const state = this.midiManager.getState(); state.files.forEach((file: any) => { const prevMute = previousMuteStates.get(file.id) || false; const currMute = file.isMuted || false; - + if (prevMute !== currMute) { // Apply mute change without recreating the audio player this.visualizationEngine.setFileMute(file.id, currMute); previousMuteStates.set(file.id, currMute); } }); - + // Check if all sources are silent and auto-pause if needed if (this.silenceDetector) { this.silenceDetector.checkSilence(this.midiManager); } - + this.updateVisualization(); this.updateSidebar(); this.updateFileToggleSection(); @@ -419,14 +468,32 @@ export class WaveRollPlayer { permissions: { ...this.permissions }, soloMode: this.soloMode, midiExport: this.midiExportOptions, + allowFileDrop: this.allowFileDrop, + onFileAddRequest: this.fileAddRequestCallback + ? () => this.triggerFileAddRequest() + : undefined, + onAudioFileAddRequest: this.audioFileAddRequestCallback + ? () => this.triggerAudioFileAddRequest() + : undefined, + addFileFromData: (data: ArrayBuffer | string, filename: string) => + this.addFileFromData(data, filename), }; // After creation, convert seconds -> % once we know (tempo/WAV-aware) duration. const effectiveDuration = this.getEffectiveDuration(); - if (effectiveDuration > 0 && (loopPoints.a !== null || loopPoints.b !== null)) { + if ( + effectiveDuration > 0 && + (loopPoints.a !== null || loopPoints.b !== null) + ) { this.uiDeps!.loopPoints = { - a: loopPoints.a !== null ? (loopPoints.a / effectiveDuration) * 100 : null, - b: loopPoints.b !== null ? (loopPoints.b / effectiveDuration) * 100 : null, + a: + loopPoints.a !== null + ? (loopPoints.a / effectiveDuration) * 100 + : null, + b: + loopPoints.b !== null + ? (loopPoints.b / effectiveDuration) * 100 + : null, }; } } else { @@ -443,10 +510,19 @@ export class WaveRollPlayer { // Convert loopPoints (seconds) -> % for seek-bar visualisation (tempo/WAV-aware) const effectiveDuration2 = this.getEffectiveDuration(); - if (effectiveDuration2 > 0 && (loopPoints.a !== null || loopPoints.b !== null)) { + if ( + effectiveDuration2 > 0 && + (loopPoints.a !== null || loopPoints.b !== null) + ) { this.uiDeps.loopPoints = { - a: loopPoints.a !== null ? (loopPoints.a / effectiveDuration2) * 100 : null, - b: loopPoints.b !== null ? (loopPoints.b / effectiveDuration2) * 100 : null, + a: + loopPoints.a !== null + ? (loopPoints.a / effectiveDuration2) * 100 + : null, + b: + loopPoints.b !== null + ? (loopPoints.b / effectiveDuration2) * 100 + : null, }; } this.uiDeps.seeking = uiState.seeking; @@ -534,7 +610,11 @@ export class WaveRollPlayer { // Persist on the shared dependencies object so the seek-bar update // function can access the latest values. const deps = this.getUIDependencies(); - (deps as UIComponentDependencies & { loopPoints?: { a: number | null; b: number | null } | null }).loopPoints = lp; + ( + deps as UIComponentDependencies & { + loopPoints?: { a: number | null; b: number | null } | null; + } + ).loopPoints = lp; // Also persist A/B markers into the StateManager (in seconds) so that // markers survive UI refreshes and re-renders even before enabling loop. @@ -543,8 +623,10 @@ export class WaveRollPlayer { if (!lp) { this.stateManager.setLoopPoints(null, null); } else { - const aSec = lp.a !== null && effDur > 0 ? (lp.a / 100) * effDur : null; - const bSec = lp.b !== null && effDur > 0 ? (lp.b / 100) * effDur : null; + const aSec = + lp.a !== null && effDur > 0 ? (lp.a / 100) * effDur : null; + const bSec = + lp.b !== null && effDur > 0 ? (lp.b / 100) * effDur : null; this.stateManager.setLoopPoints(aSec, bSec); // console.log("setLoopPoints", aSec, bSec); } @@ -557,9 +639,10 @@ export class WaveRollPlayer { // If loop A exists, snap seekbar preview to A for an instant visual anchor. const startPct = lp?.a; - const startSec = startPct !== null && startPct !== undefined && effectiveDuration > 0 - ? (startPct / 100) * effectiveDuration - : this.visualizationEngine.getState().currentTime; + const startSec = + startPct !== null && startPct !== undefined && effectiveDuration > 0 + ? (startPct / 100) * effectiveDuration + : this.visualizationEngine.getState().currentTime; deps.updateSeekBar?.({ currentTime: startSec, @@ -645,7 +728,7 @@ export class WaveRollPlayer { this.updateVisualization(); this.updateSidebar(); this.updateFileToggleSection(); - + // Initialize silence detector with all loaded files if (this.silenceDetector) { this.silenceDetector.checkSilence(this.midiManager); @@ -655,15 +738,31 @@ export class WaveRollPlayer { // Ensure AudioPlayer visual update callback is attached once audio exists try { - const anyEngine = this.visualizationEngine as unknown as { coreEngine?: any }; + const anyEngine = this.visualizationEngine as unknown as { + coreEngine?: any; + }; const audioPlayer = anyEngine.coreEngine?.audioPlayer; - if (audioPlayer && typeof audioPlayer.setOnVisualUpdate === 'function') { - audioPlayer.setOnVisualUpdate(({ currentTime }: { currentTime: number; duration: number; isPlaying: boolean }) => { - const effectiveDuration = this.getEffectiveDuration(); - const deps = this.getUIDependencies(); - deps.updateSeekBar?.({ currentTime, duration: effectiveDuration }); - this.updateTimeDisplay(currentTime); - }); + if ( + audioPlayer && + typeof audioPlayer.setOnVisualUpdate === "function" + ) { + audioPlayer.setOnVisualUpdate( + ({ + currentTime, + }: { + currentTime: number; + duration: number; + isPlaying: boolean; + }) => { + const effectiveDuration = this.getEffectiveDuration(); + const deps = this.getUIDependencies(); + deps.updateSeekBar?.({ + currentTime, + duration: effectiveDuration, + }); + this.updateTimeDisplay(currentTime); + } + ); } } catch {} @@ -675,6 +774,8 @@ export class WaveRollPlayer { const deps = this.getUIDependencies(); deps.updateSeekBar?.({ currentTime, duration: effectiveDuration }); }, 100); + + // Note: Soundfont preload is done on first play() to ensure AudioContext is active }, }); } @@ -742,7 +843,7 @@ export class WaveRollPlayer { * Update mute state */ private updateMuteState(shouldMute: boolean): void { - // Note: VisualizationEngine doesn't have handleChannelMute, + // Note: VisualizationEngine doesn't have handleChannelMute, // but this functionality might not be needed with the new architecture // this.visualizationEngine.handleChannelMute?.(shouldMute); } @@ -820,7 +921,9 @@ export class WaveRollPlayer { /** * Update UI permissions at runtime (e.g., readonly mode) */ - public setPermissions(permissions: Partial<{ canAddFiles: boolean; canRemoveFiles: boolean }>): void { + public setPermissions( + permissions: Partial<{ canAddFiles: boolean; canRemoveFiles: boolean }> + ): void { this.permissions = { ...this.permissions, ...permissions }; // Ensure UI deps reflect latest permissions if (this.uiDeps) { @@ -917,10 +1020,12 @@ export class WaveRollPlayer { return { paletteId, noteColor, - onsetMarker: style ? { - shape: style.shape, - variant: style.variant, - } : undefined, + onsetMarker: style + ? { + shape: style.shape, + variant: style.variant, + } + : undefined, }; } @@ -954,7 +1059,9 @@ export class WaveRollPlayer { * Subscribe to appearance changes. * Returns an unsubscribe function. */ - public onAppearanceChange(callback: (settings: AppearanceSettings) => void): () => void { + public onAppearanceChange( + callback: (settings: AppearanceSettings) => void + ): () => void { // Subscribe to midiManager state changes for palette/color changes const unsubMidi = this.midiManager.subscribe(() => { callback(this.getAppearanceSettings()); @@ -971,6 +1078,148 @@ export class WaveRollPlayer { this.stateManager.offStateChange(stateCallback); }; } + + // --- File Add API (for VS Code integration) --- + + /** + * Register a callback to be invoked when the user clicks "Add Files" button. + * This allows VS Code extension to intercept the file add request and show + * a native file dialog instead of relying on HTML5 file input. + * Returns an unsubscribe function. + */ + public onFileAddRequest(callback: () => void): () => void { + this.fileAddRequestCallback = callback; + return () => { + this.fileAddRequestCallback = null; + }; + } + + /** + * Check if there's a file add request callback registered. + * Used by UI components to decide whether to trigger callback or use default behavior. + */ + public hasFileAddRequestCallback(): boolean { + return this.fileAddRequestCallback !== null; + } + + /** + * Trigger the file add request callback (called by UI components). + */ + public triggerFileAddRequest(): void { + if (this.fileAddRequestCallback) { + this.fileAddRequestCallback(); + } + } + + /** + * Register a callback to be invoked when the user clicks "Add Audio File" button. + * This allows VS Code extension to intercept the audio file add request and show + * a native file dialog with audio file filters. + * Returns an unsubscribe function. + */ + public onAudioFileAddRequest(callback: () => void): () => void { + this.audioFileAddRequestCallback = callback; + return () => { + this.audioFileAddRequestCallback = null; + }; + } + + /** + * Trigger the audio file add request callback (called by UI components). + */ + public triggerAudioFileAddRequest(): void { + if (this.audioFileAddRequestCallback) { + this.audioFileAddRequestCallback(); + } + } + + /** + * Add a file from raw data (ArrayBuffer or Base64 string). + * This is useful for VS Code integration where files are passed via postMessage. + * @param data - File data as ArrayBuffer or Base64 string + * @param filename - Original filename (used to determine file type) + */ + public async addFileFromData( + data: ArrayBuffer | string, + filename: string + ): Promise { + const MIDI_EXTENSIONS = [".mid", ".midi"]; + const AUDIO_EXTENSIONS = [".wav", ".mp3", ".m4a", ".ogg"]; + + const ext = filename.toLowerCase().match(/\.[^.]+$/)?.[0] || ""; + + // Convert Base64 to ArrayBuffer if needed + let arrayBuffer: ArrayBuffer; + if (typeof data === "string") { + // Decode Base64 to ArrayBuffer + const binaryString = atob(data); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + arrayBuffer = bytes.buffer; + } else { + arrayBuffer = data; + } + + if (MIDI_EXTENSIONS.includes(ext)) { + // Process MIDI file + try { + // Create a File object from ArrayBuffer for parseMidi + const blob = new Blob([arrayBuffer], { type: "audio/midi" }); + const file = new File([blob], filename, { type: "audio/midi" }); + + const { parseMidi } = await import("@/lib/core/parsers/midi-parser"); + const state = this.stateManager?.getState(); + const pedalElongate = state?.visual.pedalElongate ?? true; + const pedalThreshold = state?.visual.pedalThreshold ?? 64; + const parsed = await parseMidi(file, { + applyPedalElongate: pedalElongate, + pedalThreshold: pedalThreshold, + }); + + const isVsCodeWebview = + typeof (globalThis as unknown as { acquireVsCodeApi?: unknown }) + .acquireVsCodeApi === "function"; + + const entryId = this.midiManager.addMidiFile( + filename, + parsed, + // Keep file extension visible for VS Code integration + filename, + file + ); + + // Ensure VS Code shows the full filename (with extension) + if (isVsCodeWebview) { + this.midiManager.updateName(entryId, filename); + } + } catch (err) { + console.error("Failed to parse MIDI:", err); + throw err; + } + } else if (AUDIO_EXTENSIONS.includes(ext)) { + // Process audio file + try { + const blob = new Blob([arrayBuffer], { type: `audio/${ext.slice(1)}` }); + const url = URL.createObjectURL(blob); + const { addAudioFileFromUrl } = await import( + "@/lib/core/waveform/register" + ); + await addAudioFileFromUrl(null, url, filename); + } catch (err) { + console.error("Failed to load audio file:", err); + throw err; + } + } else { + throw new Error(`Unsupported file type: ${ext}`); + } + + // Trigger UI update + this.updateVisualization(); + this.updateSidebar(); + this.updateFileToggleSection(); + } } /** diff --git a/src/lib/components/player/wave-roll/types.ts b/src/lib/components/player/wave-roll/types.ts index 77525fd..50fc858 100644 --- a/src/lib/components/player/wave-roll/types.ts +++ b/src/lib/components/player/wave-roll/types.ts @@ -1,4 +1,5 @@ import { AudioControllerConfig, PianoRollConfig } from "@/core/playback"; +import { HighlightMode } from "@/core/state/types"; export interface WaveRollPlayerOptions { /** Configuration for the underlying AudioController */ @@ -41,4 +42,11 @@ export interface CreateWaveRollPlayerOptions { pianoRoll?: Partial; /** MIDI export options */ midiExport?: MidiExportOptions; + /** Initial highlight mode for note rendering */ + defaultHighlightMode?: HighlightMode; + /** + * When false, disables external drag & drop upload surfaces. + * Click-to-open remains available. + */ + allowFileDrop?: boolean; } diff --git a/src/lib/components/player/wave-roll/ui/ab-loop-controls.ts b/src/lib/components/player/wave-roll/ui/ab-loop-controls.ts index 8172918..7b13d59 100644 --- a/src/lib/components/player/wave-roll/ui/ab-loop-controls.ts +++ b/src/lib/components/player/wave-roll/ui/ab-loop-controls.ts @@ -79,13 +79,15 @@ export function createABLoopControls(deps: ABLoopDeps): ABLoopAPI { } // Reflect UI state restartBtn.dataset.active = loopRestart ? "true" : ""; - restartBtn.style.background = loopRestart ? "rgba(0,123,255,.1)" : "transparent"; + restartBtn.style.background = loopRestart + ? "rgba(0,123,255,.1)" + : "transparent"; restartBtn.style.color = loopRestart ? "#007bff" : "#495057"; // Always refresh overlay updateSeekBar(); }, undefined, - PLAYER_ICONS.loop_restart + PLAYER_ICONS.loop_start ); /* A & B buttons */ @@ -110,9 +112,17 @@ export function createABLoopControls(deps: ABLoopDeps): ABLoopAPI { const midiDur = st.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); @@ -221,7 +231,10 @@ export function createABLoopControls(deps: ABLoopDeps): ABLoopAPI { // Call update helper after initial render so existing loop points // (if any) appear on seek-bar immediately. // -------------------------------------------------------------- - setTimeout(() => { updateSeekBar(); syncRestartEnabled(); }, 0); + setTimeout(() => { + updateSeekBar(); + syncRestartEnabled(); + }, 0); function clear() { pointAPct = pointBPct = null; diff --git a/src/lib/components/player/wave-roll/visualization-handler.ts b/src/lib/components/player/wave-roll/visualization-handler.ts index 921b872..da919e4 100644 --- a/src/lib/components/player/wave-roll/visualization-handler.ts +++ b/src/lib/components/player/wave-roll/visualization-handler.ts @@ -14,7 +14,8 @@ import { mixColorsOklch } from "@/core/utils/color"; import { EvaluationHandler } from "./evaluation-handler"; import type { PianoRoll } from "@/core/visualization/piano-roll"; import type { PianoRollAugments } from "@/core/visualization/piano-roll/types-internal"; - type AugPR = PianoRoll & PianoRollAugments; +import { ColorCalculator } from "@/core/visualization/piano-roll/utils/color-calculator"; +type AugPR = PianoRoll & PianoRollAugments; export class VisualizationHandler { private evaluationHandler: EvaluationHandler; @@ -46,7 +47,8 @@ export class VisualizationHandler { // --- Audio mixing -------------------------------------------------- // IMPORTANT: Include ALL files in audioNotes regardless of mute state // This prevents audio player recreation when all MIDI tracks are muted - // The actual muting is handled at the sampler level via setFileMute + // FILE muting is handled at the sampler level via setFileMute + // TRACK muting/volume is handled at runtime in the audio player callbacks const totalFileCount = state.files.filter( (file: any) => file.parsedData ).length; @@ -55,15 +57,15 @@ export class VisualizationHandler { const audioNotes: NoteData[] = []; state.files.forEach((file: MidiFileEntry) => { if (file.parsedData) { - // Include ALL notes, even from muted files - // Muting is handled by the audio player, not by excluding notes + // Include ALL notes, even from muted files/tracks + // Muting is handled by the audio player at runtime, not by excluding notes file.parsedData.notes.forEach((note: NoteData) => { // Keep velocity within valid [0,1] range. const scaledVel = Math.min(1, note.velocity * velocityScale); - audioNotes.push({ - ...note, - velocity: scaledVel, - fileId: file.id + audioNotes.push({ + ...note, + velocity: scaledVel, + fileId: file.id, }); }); } @@ -74,16 +76,23 @@ export class VisualizationHandler { // -------------------------------------------------------------- const controlChanges: ControlChangeEvent[] = []; state.files.forEach((file: MidiFileEntry) => { - const sustainVisible = file.isSustainVisible ?? true; + const fileSustainVisible = file.isSustainVisible ?? true; if ( !file.isPianoRollVisible || - !sustainVisible || + !fileSustainVisible || !file.parsedData?.controlChanges ) return; // Stamp each CC event with the originating fileId so the renderer can // apply consistent per-track colouring. + // Also filter by per-track sustain visibility if trackId is present. file.parsedData.controlChanges.forEach((cc: ControlChangeEvent) => { + // Check track-level sustain visibility (defaults to true if not set) + if (cc.trackId !== undefined) { + const trackSustainVisible = + file.trackSustainVisibility?.[cc.trackId] ?? true; + if (!trackSustainVisible) return; + } controlChanges.push({ ...cc, fileId: file.id }); }); }); @@ -102,7 +111,8 @@ export class VisualizationHandler { const piano = this.visualizationEngine.getPianoRollInstance(); if (piano) { // Get the actual PianoRoll instance for internal property access - const pianoInstance = (piano as unknown as { _instance?: PianoRoll })._instance; + const pianoInstance = (piano as unknown as { _instance?: PianoRoll }) + ._instance; // ------------------------------------------------------------ // Provide original per-file colours (sidebar swatch) so that @@ -132,7 +142,7 @@ export class VisualizationHandler { const evalState = this.stateManager.getState().evaluation; const fileInfoMap: Record< string, - { name: string; fileName: string; kind: string; color: number } + { name: string; fileName: string; kind: string; color: number; tracks?: Array<{ id: number; name: string }> } > = {}; state.files.forEach((f: MidiFileEntry) => { const name = f.name || f.fileName || f.id; @@ -146,11 +156,17 @@ export class VisualizationHandler { (typeof f.color === "number" ? f.color : parseInt(String(f.color ?? 0).replace("#", ""), 16)); + // Extract track info for tooltip display (id -> name mapping) + const tracks = f.parsedData?.tracks?.map((t) => ({ + id: t.id, + name: t.name, + })); fileInfoMap[f.id] = { name, fileName: f.fileName ?? "", kind, color, + tracks, }; }); if (pianoInstance) { @@ -165,7 +181,8 @@ export class VisualizationHandler { (pianoInstance as AugPR).highlightMode = visual.highlightMode; (pianoInstance as AugPR).showOnsetMarkers = visual.showOnsetMarkers; // Ensure each file has a unique onset marker style assigned and pass mapping - const onsetStyles: Record = {}; + const onsetStyles: Record = + {}; state.files.forEach((f: MidiFileEntry) => { const style = this.stateManager.ensureOnsetMarkerForFile(f.id); onsetStyles[f.id] = style; @@ -200,6 +217,12 @@ export class VisualizationHandler { const toNumberColor = (c: string | number): number => typeof c === "number" ? c : parseInt(c.replace("#", ""), 16); + const visualState = this.stateManager.getState().visual; + const highlightMode = visualState.highlightMode ?? "file"; + const uniformTrackColor = visualState.uniformTrackColor ?? false; + // Apply track-based lightness variation only in "file" mode and when uniformTrackColor is false + const applyTrackColors = highlightMode === "file" && !uniformTrackColor; + // 1) Base notes ------------------------------------------------------- const baseNotes: ColoredNote[] = []; state.files.forEach((file, idx: number) => { @@ -208,21 +231,60 @@ export class VisualizationHandler { const raw = file.color ?? fallbackColors[idx % fallbackColors.length]; const baseColor = toNumberColor(raw); + // Pre-compute totalTracks once per file for performance + const totalTracks = file.parsedData.tracks?.length ?? 1; + + // Cache track variant colors to avoid repeated HSL conversion + const trackColorCache: Record = {}; + file.parsedData.notes.forEach((n, noteIdx: number) => { + // Check track visibility: if trackId is set, respect trackVisibility + // Default to visible if trackVisibility is not defined for this track + const trackId = n.trackId; + const isTrackVisible = + trackId === undefined || file.trackVisibility?.[trackId] !== false; + if (!isTrackVisible) return; + + // Check track mute: if trackId is set, respect trackMuted + // Default to unmuted if trackMuted is not defined for this track + const isTrackMuted = + trackId !== undefined && file.trackMuted?.[trackId] === true; + + // Apply track volume to note velocity + // Default to full volume (1.0) if trackVolume is not defined for this track + const trackVolume = + trackId !== undefined ? (file.trackVolume?.[trackId] ?? 1.0) : 1.0; + const scaledVelocity = n.velocity * trackVolume; + + // Determine note color: apply track-based lightness variation in "file" mode + let noteColor = baseColor; + if (applyTrackColors && trackId !== undefined && totalTracks > 1) { + if (trackColorCache[trackId] === undefined) { + trackColorCache[trackId] = ColorCalculator.getTrackVariantColor( + baseColor, + trackId, + totalTracks + ); + } + noteColor = trackColorCache[trackId]; + } + baseNotes.push({ - note: { ...n, fileId: file.id, sourceIndex: noteIdx } as NoteData, - color: baseColor, + note: { + ...n, + velocity: scaledVelocity, + fileId: file.id, + sourceIndex: noteIdx, + } as NoteData, + color: noteColor, fileId: file.id, - isMuted: file.isMuted ?? false, + isMuted: (file.isMuted ?? false) || isTrackMuted, }); }); }); if (baseNotes.length === 0) return []; - const highlightMode = - this.stateManager.getState().visual.highlightMode ?? "file"; - // Plain per-file colouring -> return early --------------------------- if (highlightMode === "file") { return baseNotes; diff --git a/src/lib/components/ui/controls/settings.ts b/src/lib/components/ui/controls/settings.ts index 72afdf1..6ed4375 100644 --- a/src/lib/components/ui/controls/settings.ts +++ b/src/lib/components/ui/controls/settings.ts @@ -27,7 +27,7 @@ export function createSettingsControlUI( container.appendChild(settingsBtn); // Appearance button (Palette, Color, Onset Marker) - only in solo mode - // In non-solo mode, use "Tracks & Appearance" button in Files section + // In non-solo mode, use "Files & Appearance" button in Files section if (dependencies.soloMode) { const appearanceBtn = createIconButton(PLAYER_ICONS.palette, () => { openSettingsModal(dependencies); diff --git a/src/lib/components/ui/controls/tempo.ts b/src/lib/components/ui/controls/tempo.ts index 4a62683..802d5c0 100644 --- a/src/lib/components/ui/controls/tempo.ts +++ b/src/lib/components/ui/controls/tempo.ts @@ -60,7 +60,10 @@ export function createTempoControlUI( min-width: 70px; `; badge.classList.add("wr-focusable"); - badge.setAttribute("aria-label", `Playback tempo: ${Math.round(initialTempo)} BPM`); + badge.setAttribute( + "aria-label", + `Playback tempo: ${Math.round(initialTempo)} BPM` + ); // Hover effect for badge (consistent with volume button) badge.addEventListener("mouseenter", () => { @@ -221,7 +224,10 @@ export function createTempoControlUI( // Helper to apply tempo safely const applyTempo = (bpm: number) => { - const clampedBpm = Math.max(MIN_TEMPO, Math.min(MAX_TEMPO, Math.round(bpm))); + const clampedBpm = Math.max( + MIN_TEMPO, + Math.min(MAX_TEMPO, Math.round(bpm)) + ); currentTempo = clampedBpm; input.value = String(clampedBpm); badge.textContent = `${clampedBpm} BPM`; @@ -230,7 +236,10 @@ export function createTempoControlUI( const state = dependencies.audioPlayer?.getState(); if (state && dependencies.updateSeekBar) { - dependencies.updateSeekBar({ currentTime: state.currentTime, duration: state.duration }); + dependencies.updateSeekBar({ + currentTime: state.currentTime, + duration: state.duration, + }); } }; @@ -324,7 +333,10 @@ export function createTempoControlUI( const { currentTempo: newTempo } = getTempoState(); currentTempo = newTempo; badge.textContent = `${Math.round(newTempo)} BPM`; - badge.setAttribute("aria-label", `Playback tempo: ${Math.round(newTempo)} BPM`); + badge.setAttribute( + "aria-label", + `Playback tempo: ${Math.round(newTempo)} BPM` + ); // Only update input if it's not focused if (document.activeElement !== input) { input.value = String(Math.round(newTempo)); @@ -339,7 +351,10 @@ export function createTempoControlUI( const observer = new MutationObserver((mutations) => { for (const mutation of mutations) { for (const node of mutation.removedNodes) { - if (node === container || (node instanceof Element && node.contains(container))) { + if ( + node === container || + (node instanceof Element && node.contains(container)) + ) { document.removeEventListener("wr-force-ui-refresh", handleRefresh); observer.disconnect(); return; @@ -349,7 +364,10 @@ export function createTempoControlUI( }); requestAnimationFrame(() => { if (container.parentElement) { - observer.observe(container.parentElement, { childList: true, subtree: true }); + observer.observe(container.parentElement, { + childList: true, + subtree: true, + }); } }); diff --git a/src/lib/components/ui/file/components/file-toggle-item.ts b/src/lib/components/ui/file/components/file-toggle-item.ts index a92b7a4..e8c6d74 100644 --- a/src/lib/components/ui/file/components/file-toggle-item.ts +++ b/src/lib/components/ui/file/components/file-toggle-item.ts @@ -3,21 +3,38 @@ */ import { PLAYER_ICONS } from "@/assets/player-icons"; +import { + getInstrumentIcon, + CHEVRON_DOWN, + CHEVRON_RIGHT, +} from "@/assets/instrument-icons"; import { MidiFileEntry } from "@/lib/core/midi"; +import { TrackInfo } from "@/lib/midi/types"; import { UIComponentDependencies } from "@/lib/components/ui"; import { createIconButton } from "../../utils/icon-button"; import { FileVolumeControl } from "../../controls/file-volume"; import { ShapeRenderer } from "../utils/shape-renderer"; import { EvaluationControls } from "./evaluation-controls"; +import { ColorCalculator } from "@/core/visualization/piano-roll/utils/color-calculator"; + +/** + * Stores accordion expanded state per fileId. + * Persists across re-renders so accordion doesn't collapse when track visibility changes. + */ +const accordionExpandedState = new Map(); export class FileToggleItem { /** - * Create a MIDI file toggle item + * Create a MIDI file toggle item with optional track accordion */ static create( file: MidiFileEntry, dependencies: UIComponentDependencies ): HTMLElement { + // Container for file row + track accordion + const container = document.createElement("div"); + container.style.cssText = `display:flex;flex-direction:column;gap:0;`; + const item = document.createElement("div"); item.style.cssText = ` display: flex; @@ -30,6 +47,30 @@ export class FileToggleItem { border: 1px solid var(--ui-border); `; + // Check if this file has multiple tracks for accordion toggle + const tracks = file.parsedData?.tracks; + const hasMultipleTracks = tracks && tracks.length > 1; + + // Add accordion toggle chevron at the start if multi-track + let chevronSpan: HTMLElement | null = null; + let trackList: HTMLElement | null = null; + let isExpanded = accordionExpandedState.get(file.id) ?? false; + + // Always create chevron space for alignment + 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`; + } + + item.appendChild(chevronSpan); + // Add all components item.appendChild(this.createColorIndicator(file, dependencies)); item.appendChild(this.createFileName(file)); @@ -38,38 +79,295 @@ export class FileToggleItem { const evalGroup = document.createElement("div"); evalGroup.style.cssText = `display:flex;align-items:center;gap:2px;`; evalGroup.appendChild(this.createReferenceButton(file, dependencies, item)); - evalGroup.appendChild(this.createEstimationButton(file, dependencies, item)); + evalGroup.appendChild( + this.createEstimationButton(file, dependencies, item) + ); item.appendChild(evalGroup); item.appendChild(this.createVisibilityButton(file, dependencies)); item.appendChild(this.createSustainButton(file, dependencies)); item.appendChild(this.createVolumeControl(file, dependencies)); - - const { labelL, slider, labelR } = this.createPanControls(file, dependencies); + + const { labelL, slider, labelR } = this.createPanControls( + file, + dependencies + ); item.appendChild(labelL); item.appendChild(slider); item.appendChild(labelR); // Dim/tooltip when master muted const handleMasterMirror = (e: Event) => { - const detail = (e as CustomEvent<{ mode: 'mirror-mute' | 'mirror-restore' | 'mirror-set'; volume?: number }>).detail; + const detail = ( + e as CustomEvent<{ + mode: "mirror-mute" | "mirror-restore" | "mirror-set"; + volume?: number; + }> + ).detail; if (!detail || !detail.mode) return; - if (detail.mode === 'mirror-mute') { - item.style.opacity = '0.6'; - item.title = 'Master muted β€” changes apply after unmute'; - } else if (detail.mode === 'mirror-restore') { - item.style.opacity = ''; - item.removeAttribute('title'); + if (detail.mode === "mirror-mute") { + item.style.opacity = "0.6"; + item.title = "Master muted β€” changes apply after unmute"; + } else if (detail.mode === "mirror-restore") { + item.style.opacity = ""; + item.removeAttribute("title"); } }; - window.addEventListener('wr-master-mirror', handleMasterMirror); - (item as any).__cleanupMasterMirror = () => window.removeEventListener('wr-master-mirror', handleMasterMirror); + window.addEventListener("wr-master-mirror", handleMasterMirror); + (item as any).__cleanupMasterMirror = () => + window.removeEventListener("wr-master-mirror", handleMasterMirror); + + container.appendChild(item); + + // Add track accordion for multi-track MIDI files + if (hasMultipleTracks && chevronSpan) { + const accordionResult = this.createTrackAccordion( + file, + tracks, + dependencies, + isExpanded + ); + trackList = accordionResult.trackList; + container.appendChild(trackList); + + // Toggle accordion on chevron click + chevronSpan.onclick = (e: MouseEvent) => { + e.stopPropagation(); + isExpanded = !isExpanded; + accordionExpandedState.set(file.id, isExpanded); + if (trackList) { + trackList.style.display = isExpanded ? "flex" : "none"; + } + if (chevronSpan) { + chevronSpan.innerHTML = isExpanded ? CHEVRON_DOWN : CHEVRON_RIGHT; + } + }; + } + + return container; + } + + /** + * Create track accordion for multi-track MIDI files + * Returns the track list element for external toggle control + */ + private static createTrackAccordion( + file: MidiFileEntry, + tracks: TrackInfo[], + dependencies: UIComponentDependencies, + isExpanded: boolean + ): { trackList: HTMLElement } { + // Track list container - aligned with file row (chevron + colorIndicator width) + const trackList = document.createElement("div"); + trackList.style.cssText = `display:${isExpanded ? "flex" : "none"};flex-direction:column;gap:1px;padding:4px 8px;background:var(--surface);border-radius:4px;margin-left:27px;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); + }); + + // Pre-compute file base color and total tracks for track color indicators + const fileBaseColor = file.color; + const totalTracks = tracks.length; + const uniformTrackColor = + dependencies.stateManager.getState().visual.uniformTrackColor ?? false; + + // Populate track items + sortedTracks.forEach((track: TrackInfo) => { + const trackRow = document.createElement("div"); + trackRow.style.cssText = + "display:flex;align-items:center;gap:8px;padding:2px 0;"; + + // Track color dot (first) - shows lightness-variant color for this track (unless uniformTrackColor is enabled) + const trackColor = uniformTrackColor + ? fileBaseColor + : ColorCalculator.getTrackVariantColor( + fileBaseColor, + track.id, + totalTracks + ); + const colorDot = document.createElement("span"); + colorDot.style.cssText = ` + width: 8px; + height: 8px; + border-radius: 50%; + background-color: #${trackColor.toString(16).padStart(6, "0")}; + flex-shrink: 0; + `; + colorDot.title = `Track ${track.id + 1} color`; + + // Instrument icon (second) + 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;"; + + // Eye icon button for track visibility (third) + const isTrackVisible = dependencies.midiManager.isTrackVisible( + file.id, + track.id + ); + const visBtn = createIconButton( + isTrackVisible ? PLAYER_ICONS.eye_open : PLAYER_ICONS.eye_closed, + () => { + dependencies.midiManager.toggleTrackVisibility(file.id, track.id); + }, + "Toggle track visibility", + { size: 20 } + ); + visBtn.onclick = (e: MouseEvent) => { + e.stopPropagation(); + dependencies.midiManager.toggleTrackVisibility(file.id, track.id); + }; + visBtn.style.color = isTrackVisible + ? "var(--text-muted)" + : "rgba(71,85,105,0.4)"; + visBtn.style.border = "none"; + visBtn.style.boxShadow = "none"; + visBtn.style.padding = "0"; + visBtn.style.minWidth = "20px"; + visBtn.style.marginRight = "2px"; + + // Sustain pedal visibility button - after Eye (visibility) + const isTrackSustainVisible = + dependencies.midiManager.isTrackSustainVisible?.(file.id, track.id) ?? + true; + const sustainBtn = document.createElement("button"); + sustainBtn.innerHTML = PLAYER_ICONS.sustain; + sustainBtn.style.cssText = ` + width: 20px; + height: 20px; + padding: 0; + border: none; + background: transparent; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: ${isTrackSustainVisible ? "var(--text-muted)" : "rgba(71,85,105,0.4)"}; + transition: color 0.15s ease; + `; + sustainBtn.title = isTrackSustainVisible + ? "Hide sustain pedal regions" + : "Show sustain pedal regions"; + sustainBtn.onclick = (e: MouseEvent) => { + e.stopPropagation(); + dependencies.midiManager.toggleTrackSustainVisibility?.( + file.id, + track.id + ); + }; - return item; + // Auto-instrument toggle button - after TrackName, before Eye + const isAutoInstrument = + dependencies.midiManager.isTrackAutoInstrument?.(file.id, track.id) ?? + true; + const autoInstrumentBtn = document.createElement("button"); + // Show current playing instrument icon: auto-instrument ON β†’ track instrument, OFF β†’ piano + autoInstrumentBtn.innerHTML = isAutoInstrument + ? getInstrumentIcon(track.instrumentFamily) + : getInstrumentIcon("piano"); + autoInstrumentBtn.style.cssText = ` + width: 20px; + height: 20px; + padding: 0; + border: none; + background: transparent; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: ${isAutoInstrument ? "var(--accent-primary, #3b82f6)" : "var(--text-muted)"}; + transition: color 0.15s ease; + margin-right: 20px; + `; + autoInstrumentBtn.title = isAutoInstrument + ? `Using ${track.instrumentFamily} sound (click for piano)` + : "Using piano sound (click for auto instrument)"; + autoInstrumentBtn.onclick = (e: MouseEvent) => { + e.stopPropagation(); + const newState = !dependencies.midiManager.isTrackAutoInstrument?.( + file.id, + track.id + ); + dependencies.midiManager.setTrackAutoInstrument?.( + file.id, + track.id, + newState + ); + }; + + // Volume slider for track audio (fifth) - increased size + const isTrackMuted = dependencies.midiManager.isTrackMuted( + file.id, + track.id + ); + const trackVolume = dependencies.midiManager.getTrackVolume( + file.id, + track.id + ); + const trackLastNonZeroVolume = + dependencies.midiManager.getTrackLastNonZeroVolume(file.id, track.id); + const volumeControl = new FileVolumeControl({ + initialVolume: isTrackMuted ? 0 : trackVolume, + lastNonZeroVolume: trackLastNonZeroVolume, + size: 22, + onVolumeChange: (volume) => { + dependencies.midiManager.setTrackVolume(file.id, track.id, volume); + // Also toggle mute state based on volume + const shouldMute = volume === 0; + const currentlyMuted = dependencies.midiManager.isTrackMuted( + file.id, + track.id + ); + if (shouldMute !== currentlyMuted) { + dependencies.midiManager.toggleTrackMute(file.id, track.id); + } + }, + }); + const volumeEl = volumeControl.getElement(); + + // Note count badge (fifth/last) - aligns with file row's L-slider-R (Pan control) ~106px + 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;min-width:95px;text-align:right;margin-right:10px;"; + + // Append in order: ColorDot | InstrumentIcon | TrackName | AutoInstrument | Eye | Sustain | Volume | NoteCount + trackRow.appendChild(colorDot); + trackRow.appendChild(iconSpan); + trackRow.appendChild(trackName); + trackRow.appendChild(autoInstrumentBtn); + trackRow.appendChild(visBtn); + trackRow.appendChild(sustainBtn); + trackRow.appendChild(volumeEl); + trackRow.appendChild(noteCount); + trackList.appendChild(trackRow); + }); + + return { trackList }; } - private static createColorIndicator(file: MidiFileEntry, dependencies: UIComponentDependencies): HTMLElement { + private static createColorIndicator( + file: MidiFileEntry, + dependencies: UIComponentDependencies + ): HTMLElement { const fileColor = `#${file.color.toString(16).padStart(6, "0")}`; - return ShapeRenderer.createColorIndicator(file.id, fileColor, dependencies.stateManager as any); + return ShapeRenderer.createColorIndicator( + file.id, + fileColor, + dependencies.stateManager as any + ); } private static createFileName(file: MidiFileEntry): HTMLElement { @@ -123,13 +421,13 @@ export class FileToggleItem { "Toggle visibility", { size: 24 } ); - + visBtn.style.color = file.isPianoRollVisible ? "var(--text-muted)" : "rgba(71,85,105,0.5)"; visBtn.style.border = "none"; visBtn.style.boxShadow = "none"; - + return visBtn; } @@ -193,8 +491,8 @@ export class FileToggleItem { }); const el = volumeControl.getElement(); - el.setAttribute('data-role', 'file-volume'); - el.setAttribute('data-file-id', file.id); + el.setAttribute("data-role", "file-volume"); + el.setAttribute("data-file-id", file.id); return el; } @@ -255,4 +553,4 @@ export class FileToggleItem { return { labelL, slider: panSlider, labelR }; } -} \ No newline at end of file +} diff --git a/src/lib/components/ui/file/toggle-manager.ts b/src/lib/components/ui/file/toggle-manager.ts index 19efa10..e1d27ea 100644 --- a/src/lib/components/ui/file/toggle-manager.ts +++ b/src/lib/components/ui/file/toggle-manager.ts @@ -96,7 +96,7 @@ export class FileToggleManager { */ private static createSettingsButton(dependencies: UIComponentDependencies): HTMLButtonElement { const btn = document.createElement("button"); - btn.innerHTML = `${PLAYER_ICONS.file} Tracks & Appearance`; + btn.innerHTML = `${PLAYER_ICONS.file} Files & Appearance`; btn.style.cssText = ` padding: 4px 8px; border: none; diff --git a/src/lib/components/ui/settings/modal/index.ts b/src/lib/components/ui/settings/modal/index.ts index 3c9912b..26a006b 100644 --- a/src/lib/components/ui/settings/modal/index.ts +++ b/src/lib/components/ui/settings/modal/index.ts @@ -23,7 +23,7 @@ export function openSettingsModal(deps: UIComponentDependencies): void { // ---- Build modal content ---- const isSoloMode = deps.soloMode === true; - const headerTitle = isSoloMode ? "Appearance" : "Tracks & Appearance"; + const headerTitle = isSoloMode ? "Appearance" : "Files & Appearance"; const header = createModalHeader(headerTitle, () => overlay.remove()); modal.appendChild(header); diff --git a/src/lib/components/ui/settings/sections/file-list.ts b/src/lib/components/ui/settings/sections/file-list.ts index 43c91b3..e65ef06 100644 --- a/src/lib/components/ui/settings/sections/file-list.ts +++ b/src/lib/components/ui/settings/sections/file-list.ts @@ -2,15 +2,30 @@ import { UIComponentDependencies } from "../../types"; import { renderOnsetSVG } from "@/assets/onset-icons"; import { openOnsetPicker } from "../components/onset-picker"; import { PLAYER_ICONS } from "@/assets/player-icons"; +import { + getInstrumentIcon, + CHEVRON_DOWN, + CHEVRON_RIGHT, +} from "@/assets/instrument-icons"; import { toHexColor } from "@/lib/core/utils/color"; import { parseMidi } from "@/lib/core/parsers/midi-parser"; import { MidiFileEntry } from "@/core/midi"; +import { TrackInfo } from "@/lib/midi/types"; import { DEFAULT_PALETTES } from "@/lib/core/midi/palette"; import { computeNoteMetrics, DEFAULT_TOLERANCES, } from "@/lib/evaluation/transcription"; +/** Supported MIDI file extensions */ +const MIDI_EXTENSIONS = [".mid", ".midi"]; + +/** + * Stores accordion expanded state per fileId. + * Persists across re-renders so accordion doesn't collapse when track visibility changes. + */ +const accordionExpandedState = new Map(); + /** * Build the "MIDI Files" list section of the settings modal. * Returns the root
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