From ccc87c47cc2cefafd92ac35174610ae817b7ad09 Mon Sep 17 00:00:00 2001 From: Hannah Park Date: Mon, 1 Dec 2025 20:07:20 +0900 Subject: [PATCH 01/16] Adds appearance API and solo mode --- package.json | 4 +- pnpm-lock.yaml | 29 +- src/assets/player-icons.ts | 4 +- src/index.ts | 11 +- src/lib/components/player/wave-roll/layout.ts | 3 +- src/lib/components/player/wave-roll/player.ts | 212 ++++++++- src/lib/components/player/wave-roll/types.ts | 8 + .../components/player/wave-roll/ui/marker.ts | 13 - src/lib/components/ui/controls/index.ts | 13 +- src/lib/components/ui/controls/loop.ts | 21 +- src/lib/components/ui/controls/settings.ts | 13 +- src/lib/components/ui/controls/tempo.ts | 2 +- .../components/ui/controls/time-display.ts | 21 +- src/lib/components/ui/controls/volume.ts | 433 ++++++++++++------ .../ui/settings/components/onset-picker.ts | 70 ++- src/lib/components/ui/settings/modal/index.ts | 25 +- .../components/ui/settings/modal/zoom-grid.ts | 36 +- .../ui/settings/sections/solo-appearance.ts | 238 ++++++++++ src/lib/components/ui/types.ts | 3 + src/lib/core/audio/audio-player.ts | 9 + .../controllers/file-audio-controller.ts | 2 +- src/lib/core/audio/player-types.ts | 2 +- src/lib/core/playback/audio-controller.ts | 2 +- src/lib/core/playback/core-playback-engine.ts | 2 +- src/lib/core/playback/piano-roll-manager.ts | 2 + src/lib/core/state/default.ts | 2 +- .../visualization/piano-roll/piano-roll.ts | 50 +- .../piano-roll/renderers/grid.ts | 7 +- .../core/visualization/piano-roll/types.ts | 2 + 29 files changed, 973 insertions(+), 266 deletions(-) create mode 100644 src/lib/components/ui/settings/sections/solo-appearance.ts diff --git a/package.json b/package.json index e325bdf..d473138 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "author": "Hannah Park", "license": "MIT", "type": "module", - "sideEffects": false, + "sideEffects": true, "bugs": { "url": "https://github.com/crescent-stdio/wave-roll/issues" }, @@ -48,7 +48,7 @@ "@types/node": "^24.0.3", "jsdom": "^27.0.0", "typescript": "^5.8.3", - "vite": "^7.0.0", + "vite": "^7.2.6", "vitest": "^2.1.9" }, "dependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa8c812..c1253ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,8 +31,8 @@ importers: specifier: ^5.8.3 version: 5.9.2 vite: - specifier: ^7.0.0 - version: 7.0.6(@types/node@24.2.0) + specifier: ^7.2.6 + version: 7.2.6(@types/node@24.2.0) vitest: specifier: ^2.1.9 version: 2.1.9(@types/node@24.2.0)(jsdom@27.0.0(postcss@8.5.6)) @@ -653,8 +653,9 @@ packages: resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} engines: {node: '>=12.0.0'} - fdir@6.4.6: - resolution: {integrity: sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} peerDependencies: picomatch: ^3 || ^4 peerDependenciesMeta: @@ -806,8 +807,8 @@ packages: tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} - tinyglobby@0.2.14: - resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} tinypool@1.1.1: @@ -887,8 +888,8 @@ packages: terser: optional: true - vite@7.0.6: - resolution: {integrity: sha512-MHFiOENNBd+Bd9uvc8GEsIzdkn1JxMmEeYX35tI3fv0sJBUTfW5tQsoaOwuY4KhBI09A3dUJ/DXf2yxPVPUceg==} + vite@7.2.6: + resolution: {integrity: sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -1461,7 +1462,7 @@ snapshots: expect-type@1.2.2: {} - fdir@6.4.6(picomatch@4.0.3): + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -1637,9 +1638,9 @@ snapshots: tinyexec@0.3.2: {} - tinyglobby@0.2.14: + tinyglobby@0.2.15: dependencies: - fdir: 6.4.6(picomatch@4.0.3) + fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 tinypool@1.1.1: {} @@ -1700,14 +1701,14 @@ snapshots: '@types/node': 24.2.0 fsevents: 2.3.3 - vite@7.0.6(@types/node@24.2.0): + vite@7.2.6(@types/node@24.2.0): dependencies: esbuild: 0.25.8 - fdir: 6.4.6(picomatch@4.0.3) + fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 postcss: 8.5.6 rollup: 4.46.2 - tinyglobby: 0.2.14 + tinyglobby: 0.2.15 optionalDependencies: '@types/node': 24.2.0 fsevents: 2.3.3 diff --git a/src/assets/player-icons.ts b/src/assets/player-icons.ts index 01c5fdc..bfcb93a 100644 --- a/src/assets/player-icons.ts +++ b/src/assets/player-icons.ts @@ -20,13 +20,13 @@ export const PLAYER_ICONS = { `, - volume: ` + volume: ` `, - mute: ` + mute: ` diff --git a/src/index.ts b/src/index.ts index e2a0f34..aa17ecf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,12 +6,19 @@ 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"; -// 3) Evaluation helpers +// 3) Appearance settings types (for solo mode integration) +export type { AppearanceSettings } from "./lib/components/player/wave-roll/player"; +export type { ColorPalette } from "./lib/core/midi/types"; +export type { OnsetMarkerStyle, OnsetMarkerShape } from "./lib/types"; +export { DEFAULT_PALETTES } from "./lib/core/midi/palette"; +export { ONSET_MARKER_SHAPES } from "./lib/core/constants"; + +// 4) Evaluation helpers export { computeNoteMetrics, DEFAULT_TOLERANCES, } from "./lib/evaluation/transcription"; -// 4) Register Web Component +// 5) Register Web Component import "./web-component"; export { WaveRollElement } from "./web-component"; diff --git a/src/lib/components/player/wave-roll/layout.ts b/src/lib/components/player/wave-roll/layout.ts index 1790108..3ac5287 100644 --- a/src/lib/components/player/wave-roll/layout.ts +++ b/src/lib/components/player/wave-roll/layout.ts @@ -29,6 +29,7 @@ export function setupLayout( background: var(--surface-alt); border-radius: 8px; position: relative; + z-index: 1; `; uiElements.playerContainer.appendChild(pianoRollContainer); @@ -41,7 +42,7 @@ export function setupLayout( export function createDefaultConfig(): WaveRollPlayerOptions { return { audioController: { - defaultVolume: 0.7, + defaultVolume: 1.0, defaultTempo: 120, minTempo: 50, maxTempo: 200, diff --git a/src/lib/components/player/wave-roll/player.ts b/src/lib/components/player/wave-roll/player.ts index 96789f4..2845141 100644 --- a/src/lib/components/player/wave-roll/player.ts +++ b/src/lib/components/player/wave-roll/player.ts @@ -13,7 +13,7 @@ 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 } from "./types"; +import { WaveRollPlayerOptions, CreateWaveRollPlayerOptions } from "./types"; import { createDefaultConfig, setupLayout, @@ -52,6 +52,24 @@ import { KeyboardHandler } from "./keyboard-handler"; import { FileLoader } from "./file-loader"; import { SilenceDetector } from "@/core/playback"; +// Import types and constants for appearance API +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"; + +/** + * Appearance settings structure for solo mode integration + */ +export interface AppearanceSettings { + paletteId: string; + noteColor?: number; + onsetMarker?: { + shape: OnsetMarkerShape; + variant: "filled" | "outlined"; + }; +} + /** * Demo for multiple MIDI files - Acts as orchestrator for extracted modules */ @@ -121,6 +139,12 @@ export class WaveRollPlayer { canRemoveFiles: true, }; + // Solo mode: hides evaluation UI, file sections, and waveform band + private soloMode: boolean = false; + + // Piano roll config overrides from options + private pianoRollConfigOverrides: Partial = {}; + // Compute effective UI duration considering tempo and WAV length private getEffectiveDuration(): number { try { @@ -148,11 +172,27 @@ export class WaveRollPlayer { path: string; displayName?: string; type?: "midi" | "audio"; - }> = [] + }> = [], + options?: CreateWaveRollPlayerOptions ) { this.container = container; this.midiManager = new MultiMidiManager(); this.initialFileItemList = initialFileItemList; + + // Apply options + if (options?.soloMode) { + this.soloMode = true; + // In solo mode, hide waveform band by default + this.pianoRollConfigOverrides.showWaveformBand = false; + // Force light background color for solo mode + this.pianoRollConfigOverrides.backgroundColor = 0xffffff; + } + if (options?.pianoRoll) { + this.pianoRollConfigOverrides = { + ...this.pianoRollConfigOverrides, + ...options.pianoRoll, + }; + } // Initialize configuration this.config = createDefaultConfig(); @@ -199,6 +239,7 @@ export class WaveRollPlayer { const pianoRollConfig: PianoRollConfig = { ...DEFAULT_PIANO_ROLL_CONFIG, ...this.config.pianoRoll, + ...this.pianoRollConfigOverrides, } as PianoRollConfig; // Initialize visualization engine with resolved piano-roll configuration @@ -370,6 +411,7 @@ export class WaveRollPlayer { formatTime: (seconds: number) => formatTime(seconds), silenceDetector: this.silenceDetector, permissions: { ...this.permissions }, + soloMode: this.soloMode, }; // After creation, convert seconds -> % once we know (tempo/WAV-aware) duration. @@ -520,11 +562,14 @@ export class WaveRollPlayer { ); // 4) File-visibility toggle section (below controls) - this.fileToggleContainer = setupFileToggleSection( - uiElements.playerContainer, - depsReady - ); - uiElements.fileToggleContainer = this.fileToggleContainer; + // Skip in solo mode + if (!this.soloMode) { + this.fileToggleContainer = setupFileToggleSection( + uiElements.playerContainer, + depsReady + ); + uiElements.fileToggleContainer = this.fileToggleContainer; + } // Load initial files if provided if (this.initialFileItemList.length > 0) { @@ -614,6 +659,15 @@ export class WaveRollPlayer { }); } } catch {} + + // Force seekbar update after audio player initialization + // Use setTimeout to ensure audio player is fully initialized + setTimeout(() => { + const effectiveDuration = this.getEffectiveDuration(); + const currentTime = this.visualizationEngine.getState().currentTime; + const deps = this.getUIDependencies(); + deps.updateSeekBar?.({ currentTime, duration: effectiveDuration }); + }, 100); }, }); } @@ -771,6 +825,145 @@ export class WaveRollPlayer { this.updateFileToggleSection(); } catch {} } + + // --- Appearance API (for solo mode / external integrations) --- + + /** + * Set the active color palette by ID. + * This will reassign colors to all loaded files. + */ + public setActivePalette(paletteId: string): void { + this.midiManager.setActivePalette(paletteId); + } + + /** + * Get the list of available color palettes (built-in + custom). + */ + public getAvailablePalettes(): ColorPalette[] { + const state = this.midiManager.getState(); + return [...DEFAULT_PALETTES, ...state.customPalettes]; + } + + /** + * Get the currently active palette ID. + */ + public getActivePaletteId(): string { + return this.midiManager.getState().activePaletteId; + } + + /** + * Set the note color for the first loaded file (useful in solo mode). + * @param color - Hex color as number (e.g., 0x4e79a7) + */ + public setNoteColor(color: number): void { + const files = this.midiManager.getState().files; + if (files.length > 0) { + this.midiManager.updateColor(files[0].id, color); + } + } + + /** + * Get the note color of the first loaded file. + * @returns Color as hex number, or 0x666666 if no file is loaded. + */ + public getNoteColor(): number { + const files = this.midiManager.getState().files; + return files.length > 0 ? files[0].color : 0x666666; + } + + /** + * Set the onset marker style for the first loaded file. + */ + public setOnsetMarkerStyle(style: OnsetMarkerStyle): void { + const files = this.midiManager.getState().files; + if (files.length > 0) { + this.stateManager.setOnsetMarkerForFile(files[0].id, style); + } + } + + /** + * Get the onset marker style for the first loaded file. + */ + public getOnsetMarkerStyle(): OnsetMarkerStyle | null { + const files = this.midiManager.getState().files; + if (files.length > 0) { + return this.stateManager.getOnsetMarkerForFile(files[0].id) || null; + } + return null; + } + + /** + * Get the list of available onset marker shapes. + */ + public getAvailableOnsetMarkerShapes(): OnsetMarkerShape[] { + return [...ONSET_MARKER_SHAPES]; + } + + /** + * Get current appearance settings (for persistence). + */ + public getAppearanceSettings(): AppearanceSettings { + const paletteId = this.getActivePaletteId(); + const noteColor = this.getNoteColor(); + const style = this.getOnsetMarkerStyle(); + + return { + paletteId, + noteColor, + onsetMarker: style ? { + shape: style.shape, + variant: style.variant, + } : undefined, + }; + } + + /** + * Apply appearance settings (for restoration from persistence). + */ + public applyAppearanceSettings(settings: AppearanceSettings): void { + // Apply palette first (this may reassign colors) + if (settings.paletteId) { + this.setActivePalette(settings.paletteId); + } + + // Then apply specific note color if provided + if (settings.noteColor !== undefined) { + this.setNoteColor(settings.noteColor); + } + + // Apply onset marker style if provided + if (settings.onsetMarker) { + const style: OnsetMarkerStyle = { + shape: settings.onsetMarker.shape, + variant: settings.onsetMarker.variant, + size: 12, + strokeWidth: 2, + }; + this.setOnsetMarkerStyle(style); + } + } + + /** + * Subscribe to appearance changes. + * Returns an unsubscribe function. + */ + public onAppearanceChange(callback: (settings: AppearanceSettings) => void): () => void { + // Subscribe to midiManager state changes for palette/color changes + const unsubMidi = this.midiManager.subscribe(() => { + callback(this.getAppearanceSettings()); + }); + + // Subscribe to stateManager for onset marker changes + const stateCallback = () => { + callback(this.getAppearanceSettings()); + }; + this.stateManager.onStateChange(stateCallback); + + return () => { + unsubMidi(); + this.stateManager.offStateChange(stateCallback); + }; + } } /** @@ -778,9 +971,10 @@ export class WaveRollPlayer { */ export async function createWaveRollPlayer( container: HTMLElement, - files: Array<{ path: string; name?: string }> = [] + files: Array<{ path: string; name?: string }> = [], + options?: CreateWaveRollPlayerOptions ): Promise { - const demo = new WaveRollPlayer(container, files); + const demo = new WaveRollPlayer(container, files, options); await demo.initialize(); return demo; } diff --git a/src/lib/components/player/wave-roll/types.ts b/src/lib/components/player/wave-roll/types.ts index e2d4d1b..3d28b41 100644 --- a/src/lib/components/player/wave-roll/types.ts +++ b/src/lib/components/player/wave-roll/types.ts @@ -12,3 +12,11 @@ export interface WaveRollPlayerOptions { updateInterval: number; }; } + +/** Options for createWaveRollPlayer factory function */ +export interface CreateWaveRollPlayerOptions { + /** Solo mode: hides evaluation UI, file sections, and waveform band */ + soloMode?: boolean; + /** Override piano roll config */ + pianoRoll?: Partial; +} diff --git a/src/lib/components/player/wave-roll/ui/marker.ts b/src/lib/components/player/wave-roll/ui/marker.ts index bc49f5b..604e128 100644 --- a/src/lib/components/player/wave-roll/ui/marker.ts +++ b/src/lib/components/player/wave-roll/ui/marker.ts @@ -45,17 +45,6 @@ function ensureGlobalMarkerCss(): void { width: auto; min-width: 16px; } - - .wr-marker::after { - content: ""; - position: absolute; - top: 100%; - left: 50%; - transform: translateX(-50%); - width: 2px; - height: var(--stem-height, 30px); - background: var(--stem-color); - } `; document.head.appendChild(style); } @@ -100,8 +89,6 @@ export function createMarker( /* ----- Dynamic styling --------------------------------------------- */ el.style.background = color; // Label background - el.style.setProperty("--stem-color", color); // Set stem color via CSS variable - el.style.setProperty("--stem-height", `${stemHeight}px`); // Choose text color based on background luminance for AA contrast const textColor = (() => { diff --git a/src/lib/components/ui/controls/index.ts b/src/lib/components/ui/controls/index.ts index b732edc..7d400b4 100644 --- a/src/lib/components/ui/controls/index.ts +++ b/src/lib/components/ui/controls/index.ts @@ -27,11 +27,15 @@ export function setupUI( display: flex; flex-direction: column; gap: 8px; + width: 100%; + box-sizing: border-box; background: var(--surface-alt); color: var(--text-primary); padding: 12px; border-radius: 8px; box-shadow: var(--shadow-sm); + position: relative; + z-index: 10; `; // First row: playback / loop / misc controls @@ -42,15 +46,18 @@ export function setupUI( gap: 12px; justify-content: flex-start; flex-wrap: wrap; - overflow-x: auto; + overflow: visible; `; row.appendChild(createPlaybackControlsUI(deps)); - row.appendChild(createLoopControlsUI(deps)); row.appendChild(createVolumeControlUI(deps)); + row.appendChild(createLoopControlsUI(deps)); row.appendChild(createTempoControlUI(deps)); row.appendChild(createZoomControlsUI(deps)); - row.appendChild(createHighlightModeGroup(deps)); + // Hide highlight mode (Show notes) dropdown in solo mode + if (!deps.soloMode) { + row.appendChild(createHighlightModeGroup(deps)); + } row.appendChild(createSettingsControlUI(deps)); controlsContainer.appendChild(row); diff --git a/src/lib/components/ui/controls/loop.ts b/src/lib/components/ui/controls/loop.ts index 7e417a3..3c179d5 100644 --- a/src/lib/components/ui/controls/loop.ts +++ b/src/lib/components/ui/controls/loop.ts @@ -12,26 +12,15 @@ export function createLoopControlsUI( if (!deps.audioPlayer || !deps.pianoRoll) { throw new Error("Audio player and piano roll are required"); } - const { element, updateSeekBar } = createCoreLoopControls({ + const { element } = createCoreLoopControls({ audioPlayer: deps.audioPlayer, pianoRoll: deps.pianoRoll, }); - // Preserve any existing seek-bar updater so we can chain both updates. - const originalUpdateSeekBar = deps.updateSeekBar; - - // Expose a wrapper that forwards to the previous handler (progress / time - // labels) *and* triggers the loop-overlay refresh coming from the core A-B - // controls. This avoids accidentally overriding the seek-bar sync logic, - // which previously prevented the loop markers from showing up. - deps.updateSeekBar = (state?: { currentTime: number; duration: number }) => { - // 1) Trigger core loop-controls refresh & event dispatch. - updateSeekBar(); - - // 2) Now update the main seek-bar/time display with the latest loopWindow - // already stored by the playerʼs «wr-loop-update» handler. - originalUpdateSeekBar?.(state); - }; + // The core loop controls' updateSeekBar() dispatches 'wr-loop-update' event + // which is handled by player.ts to update the seekbar overlay. + // We don't chain deps.updateSeekBar here to avoid infinite recursion, + // as the update loop already calls deps.updateSeekBar directly. return element; } diff --git a/src/lib/components/ui/controls/settings.ts b/src/lib/components/ui/controls/settings.ts index 4a85cb2..1d2c8da 100644 --- a/src/lib/components/ui/controls/settings.ts +++ b/src/lib/components/ui/controls/settings.ts @@ -2,6 +2,7 @@ import { PLAYER_ICONS } from "@/assets/player-icons"; import { UIComponentDependencies } from "../types"; import { createIconButton } from "../utils/icon-button"; import { openZoomGridSettingsModal } from "../settings/modal/zoom-grid"; +import { openSettingsModal } from "../settings/modal"; export function createSettingsControlUI( dependencies: UIComponentDependencies @@ -10,14 +11,22 @@ export function createSettingsControlUI( container.style.cssText = ` display: flex; align-items: center; + gap: 4px; `; - // Settings button + // Settings button (View & Grid) const settingsBtn = createIconButton(PLAYER_ICONS.settings, () => { openZoomGridSettingsModal(dependencies); }); - settingsBtn.title = "Settings"; + settingsBtn.title = "View & Grid"; container.appendChild(settingsBtn); + // Appearance button (Palette, Color, Onset Marker) + const appearanceBtn = createIconButton(PLAYER_ICONS.palette, () => { + openSettingsModal(dependencies); + }); + appearanceBtn.title = "Appearance"; + container.appendChild(appearanceBtn); + return container; } diff --git a/src/lib/components/ui/controls/tempo.ts b/src/lib/components/ui/controls/tempo.ts index 703f1df..267643a 100644 --- a/src/lib/components/ui/controls/tempo.ts +++ b/src/lib/components/ui/controls/tempo.ts @@ -86,7 +86,7 @@ export function createTempoControlUI( // Adjust MIDI tempo button: prompt to enter percentage (10-200) const adjustBtn = document.createElement("button"); - adjustBtn.textContent = "Adjust MIDI tempo"; + adjustBtn.textContent = "Speed"; adjustBtn.title = "Set playback speed (10-200%)"; adjustBtn.style.cssText = ` height: 28px; padding: 0 8px; border: none; border-radius: 6px; diff --git a/src/lib/components/ui/controls/time-display.ts b/src/lib/components/ui/controls/time-display.ts index 56a5647..cb1f1d5 100644 --- a/src/lib/components/ui/controls/time-display.ts +++ b/src/lib/components/ui/controls/time-display.ts @@ -100,16 +100,7 @@ export function createTimeDisplayUI( pointer-events: none; z-index: 3; } - .wr-marker::after { - content: ""; - position: absolute; - top: 100%; - left: 50%; - transform: translateX(-50%); - width: 2px; - height: 14px; - background: currentColor; - } + /* stem is now created as a real DOM element (.wr-marker-stem) in marker.ts */ `; document.head.appendChild(style); } @@ -222,7 +213,7 @@ export function createTimeDisplayUI( // Cache for last known effective duration (tempo/WAV-aware) let lastEffectiveDuration = 0; - const updateSeekBar = (override?: { + const localUpdateSeekBar = (override?: { currentTime: number; duration: number; }): void => { @@ -365,10 +356,10 @@ export function createTimeDisplayUI( }; // Expose to external update loop - dependencies.updateSeekBar = updateSeekBar; + dependencies.updateSeekBar = localUpdateSeekBar; // Initial draw - updateSeekBar(); + localUpdateSeekBar(); /** Click / seek interaction */ // Flag to distinguish a simple click from a drag (pointermove). @@ -391,7 +382,7 @@ export function createTimeDisplayUI( const newTime = clamp(duration * percent, 0, duration); dependencies.audioPlayer?.seek(newTime, true); - updateSeekBar(); + localUpdateSeekBar(); }; seekBarContainer.addEventListener("click", handleSeek); @@ -422,7 +413,7 @@ export function createTimeDisplayUI( pendingSeekTime = newTime; // Immediate visual feedback while dragging - no engine seek yet. - updateSeekBar({ currentTime: newTime, duration }); + localUpdateSeekBar({ currentTime: newTime, duration }); }; const endDrag = (): void => { diff --git a/src/lib/components/ui/controls/volume.ts b/src/lib/components/ui/controls/volume.ts index 40fdb2d..624eb23 100644 --- a/src/lib/components/ui/controls/volume.ts +++ b/src/lib/components/ui/controls/volume.ts @@ -2,7 +2,7 @@ import { PLAYER_ICONS } from "@/assets/player-icons"; import { UIComponentDependencies } from "../types"; /** - * Create a volume control element. + * Create a volume control element with vertical popup slider. * * @param dependencies - The UI component dependencies. * @returns The volume control element. @@ -10,259 +10,424 @@ import { UIComponentDependencies } from "../types"; export function createVolumeControlUI( dependencies: UIComponentDependencies ): HTMLElement { + // Main container const container = document.createElement("div"); container.style.cssText = ` - display: flex; - align-items: center; - gap: 10px; - height: 48px; - background: var(--panel-bg); - padding: 4px 12px; - border-radius: 8px; - box-shadow: var(--shadow-sm); - `; + position: relative; + display: inline-flex; + align-items: center; + `; // Volume icon button const iconBtn = document.createElement("button"); iconBtn.innerHTML = PLAYER_ICONS.volume; iconBtn.style.cssText = ` - width: 20px; - height: 20px; - padding: 0; - border: none; - background: none; - color: var(--text-muted); - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: color 0.2s ease; - `; + background: transparent; + border: 1px solid var(--ui-border); + padding: 0; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 8px; + transition: transform 0.15s ease, box-shadow 0.15s ease; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); + color: var(--text-muted); + `; iconBtn.classList.add("wr-focusable"); + iconBtn.setAttribute("aria-label", "Master volume: 100%"); + iconBtn.title = "Master Volume"; + + // Hover effect for button (consistent with other icon buttons) + iconBtn.addEventListener("mouseenter", () => { + iconBtn.style.transform = "translateY(-1px)"; + iconBtn.style.boxShadow = "0 2px 4px rgba(0, 0, 0, 0.1)"; + }); + iconBtn.addEventListener("mouseleave", () => { + iconBtn.style.transform = "translateY(0)"; + iconBtn.style.boxShadow = "0 1px 2px rgba(0, 0, 0, 0.05)"; + }); + iconBtn.addEventListener("mousedown", () => { + iconBtn.style.transform = "translateY(0) scale(0.96)"; + }); + iconBtn.addEventListener("mouseup", () => { + iconBtn.style.transform = "translateY(-1px) scale(1)"; + }); - // Note: silence sync is registered after slider/updateVolume are defined below + // Create slider container (popup) + const sliderContainer = document.createElement("div"); + sliderContainer.style.cssText = ` + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + margin-bottom: 4px; + background: var(--surface); + border: 1px solid var(--ui-border); + border-radius: 8px; + padding: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + display: none; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + z-index: 9999; + width: 50px; + height: 160px; + `; - // Volume slider + // Master label + const masterLabel = document.createElement("div"); + masterLabel.textContent = "Master"; + masterLabel.style.cssText = ` + font-size: 11px; + font-weight: 600; + color: var(--text-muted); + margin-bottom: 4px; + user-select: none; + `; + + // Volume display + const volumeDisplay = document.createElement("span"); + volumeDisplay.textContent = "100%"; + volumeDisplay.style.cssText = ` + font-size: 10px; + color: var(--text-muted); + font-weight: 600; + user-select: none; + margin-bottom: 4px; + `; + + // Slider wrapper + const sliderWrapper = document.createElement("div"); + sliderWrapper.style.cssText = ` + width: 24px; + height: 80px; + position: relative; + display: flex; + align-items: center; + justify-content: center; + `; + + // Create vertical slider const slider = document.createElement("input"); slider.type = "range"; slider.min = "0"; slider.max = "100"; slider.value = "100"; + slider.setAttribute("aria-label", "Master volume slider"); + slider.setAttribute("aria-orientation", "vertical"); slider.style.cssText = ` - width: 70px; + width: 80px; + height: 4px; + transform: rotate(-90deg); + transform-origin: center; + position: absolute; + cursor: pointer; + -webkit-appearance: none; + appearance: none; + background: var(--ui-border); + outline: none; + border-radius: 2px; + `; + + // Style the slider thumb + const sliderId = `master-volume-slider-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + slider.className = sliderId; + const style = document.createElement("style"); + style.textContent = ` + .${sliderId}::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; - height: 4px; - background: var(--track-bg); - border-radius: 8px; - outline: none; + width: 12px; + height: 12px; + background: #0d6efd; + cursor: pointer; + border-radius: 50%; + border: 2px solid white; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + } + .${sliderId}::-moz-range-thumb { + width: 12px; + height: 12px; + background: #0d6efd; cursor: pointer; - `; - slider.classList.add("wr-slider", "wr-focusable"); - - // Volume input - const input = document.createElement("input"); - input.type = "number"; - input.min = "0"; - input.max = "100"; - input.value = "100"; - input.style.cssText = ` - width: 52px; - padding: 4px 6px; - border: none; - border-radius: 4px; - font-size: 12px; - font-weight: 600; - color: var(--accent); - background: rgba(37, 99, 235, 0.10); - text-align: center; - `; - input.classList.add("wr-focusable"); - - // Volume control logic - // Keep track of the last non-zero master volume so master unmute can restore it + border-radius: 50%; + border: 2px solid white; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + } + `; + document.head.appendChild(style); + + // Assemble slider container + sliderWrapper.appendChild(slider); + sliderContainer.appendChild(masterLabel); + sliderContainer.appendChild(volumeDisplay); + sliderContainer.appendChild(sliderWrapper); + + // Assemble main container + container.appendChild(iconBtn); + container.appendChild(sliderContainer); + + // State + let currentVolume = 1.0; let lastNonZeroVolume = 1.0; + let isSliderVisible = false; + let hideTimeout: number | null = null; // Emit a single event for all per-file controls to mirror UI without engine writes - function emitMasterMirror(mode: 'mirror-mute' | 'mirror-restore' | 'mirror-set', volume?: number): void { + function emitMasterMirror( + mode: "mirror-mute" | "mirror-restore" | "mirror-set", + volume?: number + ): void { try { - window.dispatchEvent(new CustomEvent('wr-master-mirror', { detail: { mode, volume } })); + window.dispatchEvent( + new CustomEvent("wr-master-mirror", { detail: { mode, volume } }) + ); } catch {} } - const updateVolume = (percent: number) => { - const vol = Math.max(0, Math.min(100, percent)) / 100; - // Apply to audio engine (prefer v2 masterVolume property) + const updateSliderTrack = () => { + const pct = currentVolume * 100; + slider.style.background = `linear-gradient(to right, var(--accent) 0%, var(--accent) ${pct}%, var(--ui-border) ${pct}%, var(--ui-border) 100%)`; + }; + + const updateVolume = (vol: number) => { + currentVolume = Math.max(0, Math.min(1, vol)); + + // Apply to audio engine try { const anyPlayer = dependencies.audioPlayer as any; - if (anyPlayer && typeof anyPlayer.masterVolume === 'number') { - anyPlayer.masterVolume = vol; + if (anyPlayer && typeof anyPlayer.masterVolume === "number") { + anyPlayer.masterVolume = currentVolume; } else { - dependencies.audioPlayer?.setVolume(vol); + dependencies.audioPlayer?.setVolume(currentVolume); } } catch { - dependencies.audioPlayer?.setVolume(vol); + dependencies.audioPlayer?.setVolume(currentVolume); } - // Reflect in UI controls - const percentStr = (vol * 100).toString(); - slider.value = percentStr; - input.value = percentStr; + // Update UI + slider.value = String(currentVolume * 100); + volumeDisplay.textContent = `${Math.round(currentVolume * 100)}%`; + iconBtn.innerHTML = + currentVolume === 0 ? PLAYER_ICONS.mute : PLAYER_ICONS.volume; + iconBtn.style.color = + currentVolume > 0 ? "var(--text-muted)" : "rgba(71,85,105,0.5)"; + iconBtn.setAttribute( + "aria-label", + `Master volume: ${Math.round(currentVolume * 100)}%` + ); + updateSliderTrack(); - // Update icon visual state - iconBtn.innerHTML = vol === 0 ? PLAYER_ICONS.mute : PLAYER_ICONS.volume; - - // Remember last audible volume for unmute restoration - if (vol > 0) { - lastNonZeroVolume = vol; + // Remember last audible volume + if (currentVolume > 0) { + lastNonZeroVolume = currentVolume; } // Sync master volume to SilenceDetector for auto-pause - dependencies.silenceDetector?.setMasterVolume?.(vol); + dependencies.silenceDetector?.setMasterVolume?.(currentVolume); + + // Mirror policy: when master becomes 0, emit event + if (currentVolume === 0) { + emitMasterMirror("mirror-mute"); + } + }; - // Mirror policy: when master becomes 0, drop all per-file UI to 0 via event (no engine/file calls) - if (vol === 0) { - emitMasterMirror('mirror-mute'); + // Show/hide slider popup + const showSlider = () => { + if (hideTimeout !== null) { + clearTimeout(hideTimeout); + hideTimeout = null; } + sliderContainer.style.display = "flex"; + isSliderVisible = true; + }; + + const hideSlider = () => { + sliderContainer.style.display = "none"; + isSliderVisible = false; }; + const hideSliderDelayed = () => { + if (hideTimeout !== null) { + clearTimeout(hideTimeout); + } + hideTimeout = window.setTimeout(() => { + hideSlider(); + }, 300); + }; + + // Event handlers for showing/hiding slider + iconBtn.addEventListener("mouseenter", showSlider); + iconBtn.addEventListener("focus", showSlider); + sliderContainer.addEventListener("mouseenter", () => { + if (hideTimeout !== null) { + clearTimeout(hideTimeout); + hideTimeout = null; + } + }); + sliderContainer.addEventListener("mouseleave", hideSliderDelayed); + container.addEventListener("mouseleave", hideSliderDelayed); + + // Slider input + slider.addEventListener("input", () => { + updateVolume(parseFloat(slider.value) / 100); + }); + // Initialize UI from engine masterVolume if available try { const anyPlayer = dependencies.audioPlayer as any; - if (anyPlayer && typeof anyPlayer.masterVolume === 'number') { + if (anyPlayer && typeof anyPlayer.masterVolume === "number") { const mv = anyPlayer.masterVolume as number; if (mv > 0) { lastNonZeroVolume = mv; } - updateVolume(mv * 100); + currentVolume = mv; + updateVolume(mv); } } catch {} - slider.addEventListener("input", () => { - updateVolume(parseFloat(slider.value)); - }); - - input.addEventListener("input", () => { - updateVolume(parseFloat(input.value)); - }); + // Update initial track + updateSliderTrack(); - // Reflect global all-silent state in master icon, and if both WAV+MIDI are muted, set master to 0 (no auto-restore) + // Reflect global all-silent state in master icon try { const updateIconVisual = (muted: boolean) => { iconBtn.innerHTML = muted ? PLAYER_ICONS.mute : PLAYER_ICONS.volume; iconBtn.style.color = muted ? "rgba(71,85,105,0.5)" : "var(--text-muted)"; }; const isMasterZero = (): boolean => { - try { - const anyPlayer = dependencies.audioPlayer as any; - if (anyPlayer && typeof anyPlayer.masterVolume === 'number') { - return anyPlayer.masterVolume === 0; - } - } catch {} - const current = Math.max(0, Math.min(100, parseFloat(slider.value))) / 100; - return current === 0; + return currentVolume === 0; }; const computeBothAllMuted = (): boolean => { try { const midiFiles = dependencies.midiManager?.getState?.()?.files || []; - const api = (globalThis as unknown as { _waveRollAudio?: { getFiles?: () => Array<{ id: string; isMuted?: boolean }> } })._waveRollAudio; + const api = ( + globalThis as unknown as { + _waveRollAudio?: { + getFiles?: () => Array<{ id: string; isMuted?: boolean }>; + }; + } + )._waveRollAudio; const wavs = api?.getFiles?.() || []; - const midiAllMuted = midiFiles.length > 0 && midiFiles.every((f: any) => f?.isMuted === true); - const wavAllMuted = wavs.length > 0 && wavs.every((w: any) => w?.isMuted === true); + const midiAllMuted = + midiFiles.length > 0 && + midiFiles.every((f: any) => f?.isMuted === true); + const wavAllMuted = + wavs.length > 0 && wavs.every((w: any) => w?.isMuted === true); return midiAllMuted && wavAllMuted; } catch { return false; } }; - // Initial icon state considers master 0 OR both muted + // Initial icon state updateIconVisual(isMasterZero() || computeBothAllMuted()); // Listen to silence changes - window.addEventListener('wr-silence-changed', () => { + window.addEventListener("wr-silence-changed", () => { const bothMuted = computeBothAllMuted(); - // Icon is muted when master is zero OR both muted updateIconVisual(isMasterZero() || bothMuted); if (bothMuted) { - const current = Math.max(0, Math.min(100, parseFloat(slider.value))) / 100; - if (current > 0) { - lastNonZeroVolume = current; + if (currentVolume > 0) { + lastNonZeroVolume = currentVolume; updateVolume(0); } } }); } catch {} - // Single-click on the master volume icon → toggle master mute visually and mirror per-file UI only + // Click on icon → toggle mute iconBtn.addEventListener("click", () => { - const current = Math.max(0, Math.min(100, parseFloat(slider.value))) / 100; - if (current > 0) { - // Mute master and mirror all file controls to 0 - lastNonZeroVolume = current; + if (currentVolume > 0) { + lastNonZeroVolume = currentVolume; updateVolume(0); - emitMasterMirror('mirror-mute'); + emitMasterMirror("mirror-mute"); } else { - // Unmute master and restore all file controls from their last values const restore = lastNonZeroVolume > 0 ? lastNonZeroVolume : 1; - updateVolume(restore * 100); - emitMasterMirror('mirror-restore'); + updateVolume(restore); + emitMasterMirror("mirror-restore"); } }); // Snapshot/restore of per-file states across master mute cycle - // Snapshot is logical: real engine/file states are left intact; we remember desired UI states let masterSnapshot: { midi: Record; wav: Record; } | null = null; - window.addEventListener('wr-master-mirror', (e: Event) => { - const detail = (e as CustomEvent<{ mode: 'mirror-mute' | 'mirror-restore' | 'mirror-set'; volume?: number }>).detail; + window.addEventListener("wr-master-mirror", (e: Event) => { + 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') { - // Take snapshot from UI controls; do not change per-file UI + if (detail.mode === "mirror-mute") { const snapMidi: Record = {}; - const midiNodes = Array.from(document.querySelectorAll('[data-role="file-volume"][data-file-id]')) as any[]; + const midiNodes = Array.from( + document.querySelectorAll('[data-role="file-volume"][data-file-id]') + ) as any[]; for (const node of midiNodes) { - const id = node?.getAttribute?.('data-file-id'); + const id = node?.getAttribute?.("data-file-id"); const inst = node?.__controlInstance; if (!id || !inst?.getLastNonZeroVolume) continue; const v = inst.getLastNonZeroVolume(); - const vol = typeof v === 'number' ? Math.max(0, Math.min(1, v)) : 1; + const vol = typeof v === "number" ? Math.max(0, Math.min(1, v)) : 1; snapMidi[id] = { volume: vol }; } const snapWav: Record = {}; - const wavNodes = Array.from(document.querySelectorAll('[data-role="wav-volume"][data-file-id]')) as any[]; + const wavNodes = Array.from( + document.querySelectorAll('[data-role="wav-volume"][data-file-id]') + ) as any[]; for (const node of wavNodes) { - const id = node?.getAttribute?.('data-file-id'); + const id = node?.getAttribute?.("data-file-id"); const inst = node?.__controlInstance; if (!id || !inst?.getLastNonZeroVolume) continue; const v = inst.getLastNonZeroVolume(); - const vol = typeof v === 'number' ? Math.max(0, Math.min(1, v)) : 1; + const vol = typeof v === "number" ? Math.max(0, Math.min(1, v)) : 1; snapWav[id] = { volume: vol }; } masterSnapshot = { midi: snapMidi, wav: snapWav }; - } else if (detail.mode === 'mirror-restore') { - // Restore snapshot to UI controls with previous volume values + } else if (detail.mode === "mirror-restore") { if (!masterSnapshot) return; - const midiNodes = Array.from(document.querySelectorAll('[data-role="file-volume"][data-file-id]')) as any[]; + const midiNodes = Array.from( + document.querySelectorAll('[data-role="file-volume"][data-file-id]') + ) as any[]; for (const node of midiNodes) { - const id = node?.getAttribute?.('data-file-id'); + const id = node?.getAttribute?.("data-file-id"); const inst = node?.__controlInstance; const v = id && masterSnapshot.midi[id]?.volume; - if (inst?.setVolume && typeof v === 'number') inst.setVolume(v); + if (inst?.setVolume && typeof v === "number") inst.setVolume(v); } - const wavNodes = Array.from(document.querySelectorAll('[data-role="wav-volume"][data-file-id]')) as any[]; + const wavNodes = Array.from( + document.querySelectorAll('[data-role="wav-volume"][data-file-id]') + ) as any[]; for (const node of wavNodes) { - const id = node?.getAttribute?.('data-file-id'); + const id = node?.getAttribute?.("data-file-id"); const inst = node?.__controlInstance; const v = id && masterSnapshot.wav[id]?.volume; - if (inst?.setVolume && typeof v === 'number') inst.setVolume(v); + if (inst?.setVolume && typeof v === "number") inst.setVolume(v); } } }); - container.appendChild(iconBtn); - container.appendChild(slider); - container.appendChild(input); + // Keyboard navigation + container.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + hideSlider(); + iconBtn.focus(); + } else if (e.key === "ArrowUp" || e.key === "ArrowRight") { + e.preventDefault(); + updateVolume(Math.min(1, currentVolume + 0.05)); + } else if (e.key === "ArrowDown" || e.key === "ArrowLeft") { + e.preventDefault(); + updateVolume(Math.max(0, currentVolume - 0.05)); + } + }); return container; } diff --git a/src/lib/components/ui/settings/components/onset-picker.ts b/src/lib/components/ui/settings/components/onset-picker.ts index 83fa7a2..fe14134 100644 --- a/src/lib/components/ui/settings/components/onset-picker.ts +++ b/src/lib/components/ui/settings/components/onset-picker.ts @@ -112,14 +112,53 @@ export function openOnsetPicker( const hr = document.createElement("div"); hr.style.cssText = "height:1px;background:var(--ui-border);margin:2px 0;"; - // Marker grid + // Marker grid with Filled/Outlined toggle const gridWrapper = document.createElement("div"); gridWrapper.style.cssText = "display:flex;flex-direction:column;gap:6px;"; const variants: Array = ["filled", "outlined"]; const shapeButtons: HTMLButtonElement[] = []; let selectedStyle: OnsetMarkerStyle = { ...currentStyle }; + let activeVariant: OnsetMarkerStyle["variant"] = currentStyle.variant; const COLUMNS = 7; + + // Toggle button container + const toggleContainer = document.createElement("div"); + toggleContainer.style.cssText = "display:flex;gap:2px;margin-bottom:6px;background:var(--ui-border);border-radius:6px;padding:2px;"; + + const toggleButtons: HTMLButtonElement[] = []; + const updateToggleStyles = () => { + toggleButtons.forEach((btn) => { + const isActive = btn.dataset.variant === activeVariant; + btn.style.background = isActive ? "var(--surface)" : "transparent"; + btn.style.color = isActive ? "var(--text-primary)" : "var(--text-muted)"; + btn.style.fontWeight = isActive ? "600" : "400"; + btn.style.boxShadow = isActive ? "0 1px 2px rgba(0,0,0,0.1)" : "none"; + }); + }; + + variants.forEach((variant) => { + const toggleBtn = document.createElement("button"); + toggleBtn.type = "button"; + toggleBtn.textContent = variant === "filled" ? "Filled" : "Outlined"; + toggleBtn.dataset.variant = variant; + toggleBtn.style.cssText = ` + flex:1;padding:4px 8px;border:none;border-radius:4px; + font-size:11px;cursor:pointer;transition:all 0.15s ease; + `; + toggleBtn.onclick = () => { + activeVariant = variant; + updateToggleStyles(); + updateGrid(); + }; + toggleButtons.push(toggleBtn); + toggleContainer.appendChild(toggleBtn); + }); + + // Single grid for active variant + const grid = document.createElement("div"); + grid.style.cssText = "display:grid;grid-template-columns:repeat(7,28px);gap:6px;"; + const highlightSelectedStyle = () => { shapeButtons.forEach((b) => { const isSel = b.dataset.shape === selectedStyle.shape && b.dataset.variant === selectedStyle.variant; @@ -128,27 +167,23 @@ export function openOnsetPicker( if (isSel) b.tabIndex = 0; }); }; - variants.forEach((variant) => { - const rowLabel = document.createElement("div"); - rowLabel.textContent = variant === "filled" ? "Filled" : "Outlined"; - rowLabel.style.cssText = "font-size:11px;color:var(--text-muted);"; - const grid = document.createElement("div"); - grid.style.cssText = "display:grid;grid-template-columns:repeat(7,28px);gap:6px;"; + const updateGrid = () => { + grid.innerHTML = ""; + shapeButtons.length = 0; ONSET_MARKER_SHAPES.forEach((shape) => { - const style: OnsetMarkerStyle = { shape: shape as OnsetMarkerShape, variant, 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"; - btn.setAttribute("aria-label", `${shape} ${variant}`); + btn.setAttribute("aria-label", `${shape} ${activeVariant}`); btn.dataset.shape = String(shape); - btn.dataset.variant = String(variant); + btn.dataset.variant = String(activeVariant); btn.dataset.index = String(shapeButtons.length); btn.style.cssText = "width:28px;height:28px;border:1px solid var(--ui-border);border-radius:6px;background:var(--surface);display:flex;align-items:center;justify-content:center;cursor:pointer;"; btn.innerHTML = renderOnsetSVG(style, currentColorHex, 16); btn.onclick = () => { deps.stateManager.setOnsetMarkerForFile(fileId, style); - // Keep picker open; let caller update preview const f = deps.midiManager.getState().files.find((x) => x.id === fileId); const hex = toHexColor(f?.color ?? 0x000000); selectedStyle = style; @@ -159,10 +194,15 @@ export function openOnsetPicker( shapeButtons.push(btn); }); - gridWrapper.appendChild(rowLabel); - gridWrapper.appendChild(grid); - }); - highlightSelectedStyle(); + highlightSelectedStyle(); + }; + + // Initialize + updateToggleStyles(); + updateGrid(); + + gridWrapper.appendChild(toggleContainer); + gridWrapper.appendChild(grid); // Footer actions const footer = document.createElement("div"); diff --git a/src/lib/components/ui/settings/modal/index.ts b/src/lib/components/ui/settings/modal/index.ts index 0ec4c29..3c9912b 100644 --- a/src/lib/components/ui/settings/modal/index.ts +++ b/src/lib/components/ui/settings/modal/index.ts @@ -3,6 +3,7 @@ import { createModalHeader } from "./header"; import { createFileList } from "../sections/file-list"; import { createPaletteSelectorSection } from "../sections/palette-selector"; import { createWaveListSection } from "../sections/wave-list"; +import { createSoloAppearanceSection } from "../sections/solo-appearance"; import { UIComponentDependencies } from "@/lib/components/ui"; /** @@ -21,16 +22,26 @@ export function openSettingsModal(deps: UIComponentDependencies): void { } // ---- Build modal content ---- - const header = createModalHeader("Tracks & Appearance", () => overlay.remove()); + const isSoloMode = deps.soloMode === true; + const headerTitle = isSoloMode ? "Appearance" : "Tracks & Appearance"; + const header = createModalHeader(headerTitle, () => overlay.remove()); + modal.appendChild(header); - // Append sections + // Append sections based on mode const paletteSection = createPaletteSelectorSection(deps); - const waveListSection = createWaveListSection(deps); - const fileListSection = createFileList(deps); - modal.appendChild(header); modal.appendChild(paletteSection); - modal.appendChild(waveListSection); - modal.appendChild(fileListSection); + + if (isSoloMode) { + // Solo mode: show simplified single-file appearance section + const soloAppearanceSection = createSoloAppearanceSection(deps); + modal.appendChild(soloAppearanceSection); + } else { + // Normal mode: show wave list and file list + const waveListSection = createWaveListSection(deps); + const fileListSection = createFileList(deps); + modal.appendChild(waveListSection); + modal.appendChild(fileListSection); + } // Close when clicking outside the modal panel. overlay.addEventListener("click", (e) => { diff --git a/src/lib/components/ui/settings/modal/zoom-grid.ts b/src/lib/components/ui/settings/modal/zoom-grid.ts index a32db23..131b899 100644 --- a/src/lib/components/ui/settings/modal/zoom-grid.ts +++ b/src/lib/components/ui/settings/modal/zoom-grid.ts @@ -66,12 +66,30 @@ export function openZoomGridSettingsModal(deps: UIComponentDependencies): void { // Controls const tsGroup = createTimeStepGroup(deps); const mnGroup = createMinorStepGroup(deps); - const offsetTolGroup = createOffsetMinToleranceGroup(deps); - const hlGroup = createHighlightModeGroup(deps); const pedalGroup = createPedalElongateGroup(deps); const pedalThresholdGroup = createPedalThresholdGroup(deps); - // Onset markers toggle (default false) + // Sustain visibility toggle + const sustainVisRow = document.createElement("div"); + sustainVisRow.style.cssText = "display:flex;align-items:center;gap:8px;"; + const sustainVisLabel = document.createElement("label"); + sustainVisLabel.textContent = "Show Sustain Pedal Regions"; + sustainVisLabel.style.cssText = "font-size:12px;font-weight:600;color:var(--text-primary);"; + const sustainVisCheckbox = document.createElement("input"); + sustainVisCheckbox.type = "checkbox"; + // Get initial sustain visibility state from first file or default to true + const files = deps.midiManager.getState().files; + const initialSustainVisible = files.length > 0 ? (files[0].isSustainVisible ?? true) : true; + sustainVisCheckbox.checked = initialSustainVisible; + sustainVisCheckbox.addEventListener("change", () => { + // Toggle sustain visibility for all files + files.forEach((file: { id: string }) => { + deps.midiManager.toggleSustainVisibility(file.id); + }); + }); + sustainVisRow.append(sustainVisCheckbox, sustainVisLabel); + + // Onset markers toggle const onsetRow = document.createElement("div"); onsetRow.style.cssText = "display:flex;align-items:center;gap:8px;"; const onsetLabel = document.createElement("label"); @@ -90,9 +108,17 @@ export function openZoomGridSettingsModal(deps: UIComponentDependencies): void { modal.appendChild(onsetRow); modal.appendChild(tsGroup); modal.appendChild(mnGroup); - modal.appendChild(offsetTolGroup); - modal.appendChild(hlGroup); + + // Only show evaluation-related controls in non-solo mode + if (!deps.soloMode) { + const offsetTolGroup = createOffsetMinToleranceGroup(deps); + const hlGroup = createHighlightModeGroup(deps); + modal.appendChild(offsetTolGroup); + modal.appendChild(hlGroup); + } + modal.appendChild(pedalGroup); + modal.appendChild(sustainVisRow); modal.appendChild(pedalThresholdGroup); overlay.appendChild(modal); diff --git a/src/lib/components/ui/settings/sections/solo-appearance.ts b/src/lib/components/ui/settings/sections/solo-appearance.ts new file mode 100644 index 0000000..7a4fb1b --- /dev/null +++ b/src/lib/components/ui/settings/sections/solo-appearance.ts @@ -0,0 +1,238 @@ +import { UIComponentDependencies } from "../../types"; +import { renderOnsetSVG } from "@/assets/onset-icons"; +import { toHexColor } from "@/lib/core/utils/color"; +import { ONSET_MARKER_SHAPES } from "@/core/constants"; +import { DEFAULT_PALETTES } from "@/lib/core/midi/palette"; +import type { OnsetMarkerStyle, OnsetMarkerShape } from "@/types"; + +/** + * Build a simplified appearance section for solo mode. + * Shows color and onset marker selection for the single MIDI file. + * + * @param deps - The UI component dependencies. + * @returns The root
for the solo appearance section. + */ +export function createSoloAppearanceSection( + deps: UIComponentDependencies +): HTMLElement { + const wrapper = document.createElement("div"); + wrapper.style.cssText = "margin-top:16px;"; + + // Get the first (and only) file in solo mode + const files = deps.midiManager.getState().files; + if (files.length === 0) { + const emptyMsg = document.createElement("p"); + emptyMsg.textContent = "No MIDI file loaded."; + emptyMsg.style.cssText = "color:var(--text-muted);font-size:14px;"; + wrapper.appendChild(emptyMsg); + return wrapper; + } + + const file = files[0]; + const fileId = file.id; + + // 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);"; + wrapper.appendChild(title); + + // Get current palette colors + const { activePaletteId, customPalettes } = deps.midiManager.getState(); + const allPalettes = [...DEFAULT_PALETTES, ...customPalettes]; + const palette = allPalettes.find((p) => p.id === activePaletteId) || DEFAULT_PALETTES[0]; + + // Current color and style + let currentColorHex = toHexColor(file.color); + let currentStyle = deps.stateManager.getOnsetMarkerForFile(fileId) + || deps.stateManager.ensureOnsetMarkerForFile(fileId); + + // ---- Color Selection ---- + const colorSection = document.createElement("div"); + colorSection.style.cssText = "margin-bottom:16px;"; + + const colorLabel = document.createElement("div"); + colorLabel.textContent = "Note Color"; + colorLabel.style.cssText = "font-size:13px;color:var(--text-muted);margin-bottom:8px;"; + colorSection.appendChild(colorLabel); + + const colorsRow = document.createElement("div"); + colorsRow.style.cssText = "display:flex;gap:8px;flex-wrap:wrap;"; + + const colorButtons: HTMLButtonElement[] = []; + + const updateColorSelection = () => { + colorButtons.forEach((btn) => { + const isSelected = (btn.dataset.hex || "").toLowerCase() === currentColorHex.toLowerCase(); + btn.style.outline = isSelected ? "2px solid var(--focus-ring)" : "none"; + btn.style.outlineOffset = isSelected ? "2px" : "0"; + }); + }; + + palette.colors.forEach((color) => { + const hex = toHexColor(color); + const btn = document.createElement("button"); + btn.type = "button"; + btn.dataset.hex = hex; + btn.setAttribute("aria-label", `Select color ${hex}`); + btn.style.cssText = ` + width:28px;height:28px;border-radius:6px; + 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.onclick = () => { + deps.midiManager.updateColor(fileId, color); + currentColorHex = hex; + updateColorSelection(); + updateMarkerPreviews(); + dispatchSettingsChange(); + }; + colorButtons.push(btn); + colorsRow.appendChild(btn); + }); + + updateColorSelection(); + colorSection.appendChild(colorsRow); + wrapper.appendChild(colorSection); + + // ---- Onset Marker Style Selection with Toggle ---- + const markerSection = document.createElement("div"); + + const markerLabel = document.createElement("div"); + markerLabel.textContent = "Onset Marker"; + markerLabel.style.cssText = "font-size:13px;color:var(--text-muted);margin-bottom:8px;"; + markerSection.appendChild(markerLabel); + + const markerWrapper = document.createElement("div"); + markerWrapper.style.cssText = "display:flex;flex-direction:column;gap:8px;"; + + const variants: Array = ["filled", "outlined"]; + const markerButtons: HTMLButtonElement[] = []; + let activeVariant: OnsetMarkerStyle["variant"] = currentStyle.variant; + + // Toggle button container + const toggleContainer = document.createElement("div"); + toggleContainer.style.cssText = "display:flex;gap:2px;background:var(--ui-border);border-radius:6px;padding:2px;"; + + const toggleButtons: HTMLButtonElement[] = []; + const updateToggleStyles = () => { + toggleButtons.forEach((btn) => { + const isActive = btn.dataset.variant === activeVariant; + btn.style.background = isActive ? "var(--surface)" : "transparent"; + btn.style.color = isActive ? "var(--text-primary)" : "var(--text-muted)"; + btn.style.fontWeight = isActive ? "600" : "400"; + btn.style.boxShadow = isActive ? "0 1px 2px rgba(0,0,0,0.1)" : "none"; + }); + }; + + variants.forEach((variant) => { + const toggleBtn = document.createElement("button"); + toggleBtn.type = "button"; + toggleBtn.textContent = variant === "filled" ? "Filled" : "Outlined"; + toggleBtn.dataset.variant = variant; + toggleBtn.style.cssText = ` + flex:1;padding:6px 12px;border:none;border-radius:4px; + font-size:12px;cursor:pointer;transition:all 0.15s ease; + `; + toggleBtn.onclick = () => { + activeVariant = variant; + updateToggleStyles(); + updateMarkerGrid(); + }; + toggleButtons.push(toggleBtn); + toggleContainer.appendChild(toggleBtn); + }); + + markerWrapper.appendChild(toggleContainer); + + // Single grid for active variant + const grid = document.createElement("div"); + 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 && + btn.dataset.variant === currentStyle.variant; + btn.style.outline = isSelected ? "2px solid var(--focus-ring)" : "none"; + btn.style.outlineOffset = isSelected ? "1px" : "0"; + }); + }; + + const updateMarkerPreviews = () => { + 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 }; + btn.innerHTML = renderOnsetSVG(style, currentColorHex, 18); + }); + }; + + const updateMarkerGrid = () => { + grid.innerHTML = ""; + markerButtons.length = 0; + + ONSET_MARKER_SHAPES.forEach((shape) => { + const style: OnsetMarkerStyle = { + shape: shape as OnsetMarkerShape, + variant: activeVariant, + size: 12, + strokeWidth: 2 + }; + const btn = document.createElement("button"); + btn.type = "button"; + btn.setAttribute("aria-label", `${shape} ${activeVariant}`); + btn.dataset.shape = shape; + btn.dataset.variant = activeVariant; + btn.style.cssText = ` + width:32px;height:32px; + border:1px solid var(--ui-border);border-radius:6px; + background:var(--surface); + display:flex;align-items:center;justify-content:center; + 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.onclick = () => { + deps.stateManager.setOnsetMarkerForFile(fileId, style); + currentStyle = style; + updateMarkerSelection(); + dispatchSettingsChange(); + }; + markerButtons.push(btn); + grid.appendChild(btn); + }); + + updateMarkerSelection(); + }; + + // Initialize + updateToggleStyles(); + updateMarkerGrid(); + + markerWrapper.appendChild(grid); + markerSection.appendChild(markerWrapper); + wrapper.appendChild(markerSection); + + // Dispatch custom event when settings change (for wave-roll-solo integration) + const dispatchSettingsChange = () => { + const event = new CustomEvent("wr-appearance-change", { + bubbles: true, + detail: { + paletteId: activePaletteId, + noteColor: parseInt(currentColorHex.replace("#", ""), 16), + onsetMarker: { + shape: currentStyle.shape, + variant: currentStyle.variant, + }, + }, + }); + wrapper.dispatchEvent(event); + }; + + return wrapper; +} + diff --git a/src/lib/components/ui/types.ts b/src/lib/components/ui/types.ts index 5e88458..8a38459 100644 --- a/src/lib/components/ui/types.ts +++ b/src/lib/components/ui/types.ts @@ -76,6 +76,9 @@ export interface UIComponentDependencies { style?: Partial; }; }; + + /** Solo mode: hides evaluation UI, file sections, and waveform band */ + soloMode?: boolean; } export interface UIElements { diff --git a/src/lib/core/audio/audio-player.ts b/src/lib/core/audio/audio-player.ts index 33dfb82..22b8085 100644 --- a/src/lib/core/audio/audio-player.ts +++ b/src/lib/core/audio/audio-player.ts @@ -61,6 +61,15 @@ export class AudioPlayer { if (this.notes && this.notes.length > 0) { // console.log('[AudioPlayer] Setting MIDI manager with', this.notes.length, 'notes'); this.unifiedController.setMidiManager({ notes: this.notes }); + + // Calculate and set duration from notes + const duration = this.notes.reduce((max: number, note: any) => { + const endTime = (note.time || 0) + (note.duration || 0); + return Math.max(max, endTime); + }, 0); + if (duration > 0) { + this.unifiedController.totalTime = duration; + } // console.log('[AudioPlayer] MIDI manager set successfully'); } else { // console.log('[AudioPlayer] No notes provided - MIDI manager not initialized'); diff --git a/src/lib/core/audio/controllers/file-audio-controller.ts b/src/lib/core/audio/controllers/file-audio-controller.ts index 1ac6fa2..7de2114 100644 --- a/src/lib/core/audio/controllers/file-audio-controller.ts +++ b/src/lib/core/audio/controllers/file-audio-controller.ts @@ -68,7 +68,7 @@ export class FileAudioController { // console.log("[FileAudioController.setFileVolume]", { fileId, volume: clamped }); // setFileVolume requires masterVolume parameter - const masterVolume = 0.7; // Default master volume + const masterVolume = 1.0; // Default master volume this.deps.samplerManager.setFileVolume(fileId, clamped, masterVolume); } diff --git a/src/lib/core/audio/player-types.ts b/src/lib/core/audio/player-types.ts index 1a31f0a..8c3331c 100644 --- a/src/lib/core/audio/player-types.ts +++ b/src/lib/core/audio/player-types.ts @@ -188,7 +188,7 @@ export const AUDIO_CONSTANTS = { /** Schedule ahead time for notes in seconds */ SCHEDULE_AHEAD_TIME: 0.25, /** Default volume level */ - DEFAULT_VOLUME: 0.7, + DEFAULT_VOLUME: 1.0, /** Default tempo in BPM */ DEFAULT_TEMPO: 120, /** Default playback rate percentage */ diff --git a/src/lib/core/playback/audio-controller.ts b/src/lib/core/playback/audio-controller.ts index 62346da..baf2450 100644 --- a/src/lib/core/playback/audio-controller.ts +++ b/src/lib/core/playback/audio-controller.ts @@ -66,7 +66,7 @@ export class AudioController { ) { this.stateManager = stateManager; this.config = { - defaultVolume: 0.7, + defaultVolume: 1.0, defaultTempo: 120, minTempo: 30, maxTempo: 300, diff --git a/src/lib/core/playback/core-playback-engine.ts b/src/lib/core/playback/core-playback-engine.ts index f2239b4..8e9b8c4 100644 --- a/src/lib/core/playback/core-playback-engine.ts +++ b/src/lib/core/playback/core-playback-engine.ts @@ -50,7 +50,7 @@ export interface VisualUpdateParams { * Default configuration values */ const DEFAULT_CONFIG: Required = { - defaultVolume: 0.7, + defaultVolume: 1.0, defaultTempo: 120, minTempo: 30, maxTempo: 300, diff --git a/src/lib/core/playback/piano-roll-manager.ts b/src/lib/core/playback/piano-roll-manager.ts index db956d6..58e0c60 100644 --- a/src/lib/core/playback/piano-roll-manager.ts +++ b/src/lib/core/playback/piano-roll-manager.ts @@ -23,6 +23,8 @@ export interface PianoRollConfig { showPianoKeys: boolean; noteRange: { min: number; max: number }; minorTimeStep: number; + /** Whether to show waveform band at the bottom (default: true) */ + showWaveformBand?: boolean; } /** diff --git a/src/lib/core/state/default.ts b/src/lib/core/state/default.ts index 110be24..861900e 100644 --- a/src/lib/core/state/default.ts +++ b/src/lib/core/state/default.ts @@ -9,7 +9,7 @@ import type { OnsetMarkerStyle } from "@/types"; import { EvaluationState } from "./types"; export const DEFAULT_STATE_CONFIG: StateManagerConfig = { - defaultVolume: 0.7, + defaultVolume: 1.0, defaultMinorTimeStep: 0.1, defaultZoomLevel: 1.0, updateInterval: 50, // 50ms update interval diff --git a/src/lib/core/visualization/piano-roll/piano-roll.ts b/src/lib/core/visualization/piano-roll/piano-roll.ts index a5af23d..7f970ec 100644 --- a/src/lib/core/visualization/piano-roll/piano-roll.ts +++ b/src/lib/core/visualization/piano-roll/piano-roll.ts @@ -138,6 +138,7 @@ export class PianoRoll { timeStep: 1, minorTimeStep: 0.1, noteRenderer: undefined, + showWaveformBand: true, ...options, } as Required; @@ -160,12 +161,16 @@ export class PianoRoll { private initializeScales(): void { // Reserve bottom pixels for waveform band so pitch scale never maps into it - const bandPadding = 6; - const bandHeight = Math.max( - 24, - Math.min(96, Math.floor(this.options.height * 0.22)) - ); - const reservedBottomPx = bandPadding + bandHeight; + // Skip reservation if waveform band is disabled + let reservedBottomPx = 0; + if (this.options.showWaveformBand !== false) { + const bandPadding = 6; + const bandHeight = Math.max( + 24, + Math.min(96, Math.floor(this.options.height * 0.22)) + ); + reservedBottomPx = bandPadding + bandHeight; + } const { timeScale, pitchScale, pxPerSecond } = createScales( this.notes, @@ -196,12 +201,18 @@ export class PianoRoll { const instance = new PianoRoll(canvas, domContainer, options); await instance.initializeApp(canvas); instance.initializeContainers(); + instance.initializeScales(); // Add tooltip overlay after containers are ready instance.initializeTooltip(canvas); instance.initializeHelpButton(canvas); instance.setupInteraction(); + instance.render(); // Full render including playhead + + // Force a manual render to ensure content is displayed + instance.app.renderer.render(instance.app.stage); + return instance; } @@ -217,13 +228,8 @@ export class PianoRoll { antialias: true, resolution: window.devicePixelRatio || 1, autoDensity: true, + preference: 'webgl', // Prefer WebGL over WebGPU for better compatibility }); - // console.log( - // "[initializeApp] renderer", - // this.app.renderer.resolution, - // this.app.renderer.width, - // this.app.renderer.height - // ); } /** @@ -516,13 +522,17 @@ export class PianoRoll { // to background and note layers (e.g., pianoKeys shading uses playheadX). // Update mask for notes/sustains prior to drawing + // Skip waveform band reservation if showWaveformBand is disabled { - const bandPadding = 6; - const bandHeight = Math.max( - 24, - Math.min(96, Math.floor(this.options.height * 0.22)) - ); - const reservedBottomPx = bandPadding + bandHeight; + let reservedBottomPx = 0; + if (this.options.showWaveformBand !== false) { + const bandPadding = 6; + const bandHeight = Math.max( + 24, + Math.min(96, Math.floor(this.options.height * 0.22)) + ); + reservedBottomPx = bandPadding + bandHeight; + } const usableHeight = Math.max(0, this.options.height - reservedBottomPx); this.notesMask.clear(); this.notesMask.rect(0, 0, this.options.width, usableHeight); @@ -581,11 +591,13 @@ export class PianoRoll { * Set note data and trigger re-render */ public setNotes(notes: NoteData[]): void { - // console.log("[setNotes] incoming notes", notes.length); this.notes = notes; this.initializeScales(); // Recalculate scales based on new data this.needsNotesRedraw = true; // geometry must be rebuilt this.render(); + + // Force manual render + this.app.renderer.render(this.app.stage); } /** diff --git a/src/lib/core/visualization/piano-roll/renderers/grid.ts b/src/lib/core/visualization/piano-roll/renderers/grid.ts index 981e2c3..a387d63 100644 --- a/src/lib/core/visualization/piano-roll/renderers/grid.ts +++ b/src/lib/core/visualization/piano-roll/renderers/grid.ts @@ -264,7 +264,12 @@ export function renderGrid(pianoRoll: PianoRoll): void { // A lightweight visualization: vertical min/max bars per peak column mapped to time. // Render as a bottom band so it appears "below" the piano-roll grid content while // remaining synchronized with pan/zoom. - try { + // Skip waveform rendering if showWaveformBand is disabled + if (pianoRoll.options.showWaveformBand === false) { + // Clear any existing waveform graphics + pianoRoll.waveformLayer?.clear(); + pr.waveformKeysLayer?.clear(); + } else try { const api = (globalThis as unknown as { _waveRollAudio?: WaveRollAudioAPI })._waveRollAudio; if (api?.getVisiblePeaks) { const peaksPayload = api.getVisiblePeaks(); diff --git a/src/lib/core/visualization/piano-roll/types.ts b/src/lib/core/visualization/piano-roll/types.ts index 92a93d8..6e62856 100644 --- a/src/lib/core/visualization/piano-roll/types.ts +++ b/src/lib/core/visualization/piano-roll/types.ts @@ -29,6 +29,8 @@ export interface PianoRollConfig { minorTimeStep?: number; /** Custom note renderer function to determine color per note */ noteRenderer?: (note: NoteData, index: number) => number; + /** Whether to show waveform band at the bottom (default: true) */ + showWaveformBand?: boolean; } /** From 8bf3837a01ff1bf34c9967cfec537da215912b6c Mon Sep 17 00:00:00 2001 From: Hannah Park Date: Mon, 1 Dec 2025 20:33:31 +0900 Subject: [PATCH 02/16] Improves UI controls and audio handling. --- src/lib/components/ui/controls/index.ts | 2 +- src/lib/components/ui/controls/playback.ts | 2 +- src/lib/components/ui/controls/settings.ts | 20 +++++--- src/lib/components/ui/controls/tempo.ts | 2 +- src/lib/components/ui/controls/volume.ts | 5 ++ .../settings/controls/highlight-mode-group.ts | 48 ++++++++++++------ .../controllers/file-audio-controller.ts | 50 +++++++++++-------- src/lib/core/audio/player-types.ts | 4 +- src/lib/core/playback/audio-controller.ts | 14 ++++-- src/lib/core/playback/core-playback-engine.ts | 32 +++++++----- src/lib/core/playback/piano-roll-manager.ts | 2 + src/lib/core/state/default.ts | 4 +- .../visualization/piano-roll/piano-roll.ts | 3 +- .../core/visualization/piano-roll/types.ts | 2 + 14 files changed, 125 insertions(+), 65 deletions(-) diff --git a/src/lib/components/ui/controls/index.ts b/src/lib/components/ui/controls/index.ts index 7d400b4..640210f 100644 --- a/src/lib/components/ui/controls/index.ts +++ b/src/lib/components/ui/controls/index.ts @@ -56,7 +56,7 @@ export function setupUI( row.appendChild(createZoomControlsUI(deps)); // Hide highlight mode (Show notes) dropdown in solo mode if (!deps.soloMode) { - row.appendChild(createHighlightModeGroup(deps)); + row.appendChild(createHighlightModeGroup(deps, { withWrapper: true })); } row.appendChild(createSettingsControlUI(deps)); diff --git a/src/lib/components/ui/controls/playback.ts b/src/lib/components/ui/controls/playback.ts index 85fe63d..1b9ce3e 100644 --- a/src/lib/components/ui/controls/playback.ts +++ b/src/lib/components/ui/controls/playback.ts @@ -26,7 +26,7 @@ export function createPlaybackControlsUI( height: 48px; background: var(--panel-bg); color: var(--text-primary); - padding: 4px; + padding: 4px 8px; border-radius: 8px; position: relative; z-index: 10; diff --git a/src/lib/components/ui/controls/settings.ts b/src/lib/components/ui/controls/settings.ts index 1d2c8da..56c6a5d 100644 --- a/src/lib/components/ui/controls/settings.ts +++ b/src/lib/components/ui/controls/settings.ts @@ -12,6 +12,11 @@ export function createSettingsControlUI( display: flex; align-items: center; gap: 4px; + height: 48px; + background: var(--panel-bg); + padding: 4px 8px; + border-radius: 8px; + box-shadow: var(--shadow-sm); `; // Settings button (View & Grid) @@ -21,12 +26,15 @@ export function createSettingsControlUI( settingsBtn.title = "View & Grid"; container.appendChild(settingsBtn); - // Appearance button (Palette, Color, Onset Marker) - const appearanceBtn = createIconButton(PLAYER_ICONS.palette, () => { - openSettingsModal(dependencies); - }); - appearanceBtn.title = "Appearance"; - container.appendChild(appearanceBtn); + // Appearance button (Palette, Color, Onset Marker) - only in solo mode + // In non-solo mode, use "Tracks & Appearance" button in Files section + if (dependencies.soloMode) { + const appearanceBtn = createIconButton(PLAYER_ICONS.palette, () => { + openSettingsModal(dependencies); + }); + appearanceBtn.title = "Appearance"; + container.appendChild(appearanceBtn); + } return container; } diff --git a/src/lib/components/ui/controls/tempo.ts b/src/lib/components/ui/controls/tempo.ts index 267643a..2e5e122 100644 --- a/src/lib/components/ui/controls/tempo.ts +++ b/src/lib/components/ui/controls/tempo.ts @@ -17,7 +17,7 @@ export function createTempoControlUI( gap: 8px; height: 48px; background: var(--panel-bg); - padding: 4px 12px; + padding: 4px 8px; border-radius: 8px; box-shadow: var(--shadow-sm); `; diff --git a/src/lib/components/ui/controls/volume.ts b/src/lib/components/ui/controls/volume.ts index 624eb23..85c66f3 100644 --- a/src/lib/components/ui/controls/volume.ts +++ b/src/lib/components/ui/controls/volume.ts @@ -16,6 +16,11 @@ export function createVolumeControlUI( position: relative; display: inline-flex; align-items: center; + height: 48px; + background: var(--panel-bg); + padding: 4px 8px; + border-radius: 8px; + box-shadow: var(--shadow-sm); `; // Volume icon button diff --git a/src/lib/components/ui/settings/controls/highlight-mode-group.ts b/src/lib/components/ui/settings/controls/highlight-mode-group.ts index d6220e8..a926673 100644 --- a/src/lib/components/ui/settings/controls/highlight-mode-group.ts +++ b/src/lib/components/ui/settings/controls/highlight-mode-group.ts @@ -1,24 +1,40 @@ import { UIComponentDependencies } from "../../types"; import { HighlightMode } from "@/core/state/types"; +export interface HighlightModeGroupOptions { + /** Apply wrapper styles (background, padding, border-radius, box-shadow) for toolbar use */ + withWrapper?: boolean; +} + /** - * Build the “Highlight Mode” select menu. + * Build the "Highlight Mode" select menu. */ export function createHighlightModeGroup( - deps: UIComponentDependencies + deps: UIComponentDependencies, + options?: HighlightModeGroupOptions ): HTMLDivElement { + const withWrapper = options?.withWrapper ?? false; + const group = document.createElement("div"); - group.style.cssText = `display:flex; + + // Base styles + const baseStyles = `display:flex; align-items:center; gap:8px; font-size:12px; - height: 48px; + max-width: 100%; + overflow: hidden;`; + + // Wrapper styles for toolbar use + const wrapperStyles = withWrapper + ? `height: 48px; background: var(--panel-bg); - padding: 4px 12px; + padding: 4px 8px; border-radius: 8px; - max-width: 100%; - overflow: hidden; - `; + box-shadow: var(--shadow-sm);` + : ""; + + group.style.cssText = baseStyles + wrapperStyles; const label = document.createElement("span"); label.textContent = "Show notes:"; @@ -70,10 +86,12 @@ export function createHighlightModeGroup( "eval-tp-only-own": "Highlight True Positive (TP) segments, mute others", "eval-tp-only-gray": "Mute True Positive (TP) segments, keep others normal", "eval-fp-only-own": "Highlight False Positive (FP) segments, mute others", - "eval-fp-only-gray": "Mute False Positive (FP) segments, keep others normal", + "eval-fp-only-gray": + "Mute False Positive (FP) segments, keep others normal", "eval-fn-only-own": "Highlight False Negative (FN) segments, mute others", - "eval-fn-only-gray": "Mute False Negative (FN) segments, keep others normal", - } + "eval-fn-only-gray": + "Mute False Negative (FN) segments, keep others normal", + }; // Short labels for compact select text; hover/tap shows detailed descriptions above const labels: Record = { @@ -94,7 +112,7 @@ export function createHighlightModeGroup( "eval-fp-only-gray": "Mute False Positive (FP)", "eval-fn-only-own": "Highlight False Negative (FN)", "eval-fn-only-gray": "Mute False Negative (FN)", - };;; + }; // Hidden modes (kept for backward-compat in state, but not shown in UI) const hiddenModes: Set = new Set([ @@ -146,7 +164,7 @@ export function createHighlightModeGroup( label: "Reference missed only", items: ["eval-gt-missed-only-own", "eval-gt-missed-only-gray"], }, - ];; + ]; grouped.forEach((g) => { const og = document.createElement("optgroup"); @@ -184,8 +202,8 @@ export function createHighlightModeGroup( } const opts = deps.uiOptions?.highlightToast; // Position - const pos = opts?.position ?? 'bottom'; - if (pos === 'top') { + const pos = opts?.position ?? "bottom"; + if (pos === "top") { tip.style.bottom = ""; tip.style.top = "52px"; } else { diff --git a/src/lib/core/audio/controllers/file-audio-controller.ts b/src/lib/core/audio/controllers/file-audio-controller.ts index 7de2114..c2b8774 100644 --- a/src/lib/core/audio/controllers/file-audio-controller.ts +++ b/src/lib/core/audio/controllers/file-audio-controller.ts @@ -27,7 +27,7 @@ export class FileAudioController { setFilePan(fileId: string, pan: number): void { const clamped = clamp(pan, -1, 1); // console.log("[FileAudioController.setFilePan]", { fileId, pan: clamped }); - + this.deps.samplerManager.setFilePan(fileId, clamped); } @@ -35,16 +35,21 @@ export class FileAudioController { * Set per-file mute */ setFileMute(fileId: string, mute: boolean): void { - const { samplerManager, wavPlayerManager, midiManager, onFileSettingsChange } = this.deps; - + const { + samplerManager, + wavPlayerManager, + midiManager, + onFileSettingsChange, + } = this.deps; + // console.log("[FileAudioController.setFileMute]", { fileId, mute }); // Try sampler first samplerManager.setFileMute(fileId, mute); - + // Also try external WAV player const wavResult = wavPlayerManager.setFileMute(fileId, mute); - + if (!wavResult) { // WAV player might not have this file, which is okay } @@ -66,7 +71,7 @@ export class FileAudioController { setFileVolume(fileId: string, volume: number): void { const clamped = clamp(volume, 0, 1); // console.log("[FileAudioController.setFileVolume]", { fileId, volume: clamped }); - + // setFileVolume requires masterVolume parameter const masterVolume = 1.0; // Default master volume this.deps.samplerManager.setFileVolume(fileId, clamped, masterVolume); @@ -75,12 +80,17 @@ export class FileAudioController { /** * Set per-file WAV volume */ - setWavVolume(fileId: string, volume: number, masterVolume: number, state: { isPlaying: boolean; currentTime: number }): void { + setWavVolume( + fileId: string, + volume: number, + masterVolume: number, + state: { isPlaying: boolean; currentTime: number } + ): void { const { wavPlayerManager, midiManager } = this.deps; - + const clamped = clamp(volume, 0, 1); // console.log("[FileAudioController.setWavVolume]", { fileId, volume: clamped }); - + // Use the wav-specific method wavPlayerManager.setWavVolume(fileId, clamped, masterVolume, state); @@ -95,19 +105,19 @@ export class FileAudioController { */ getFileMuteStates(): Map { const states = new Map(); - + // Get from sampler const samplerStates = this.deps.samplerManager.getFileMuteStates(); for (const [fileId, muted] of samplerStates) { states.set(fileId, muted); } - + // Get from WAV player const wavStates = this.deps.wavPlayerManager.getFileMuteStates(); for (const [fileId, muted] of wavStates) { states.set(fileId, muted); } - + return states; } @@ -116,19 +126,19 @@ export class FileAudioController { */ getFileVolumeStates(): Map { const states = new Map(); - + // Get from sampler const samplerVolumes = this.deps.samplerManager.getFileVolumeStates(); for (const [fileId, volume] of samplerVolumes) { states.set(fileId, volume); } - + // Get from WAV player const wavVolumes = this.deps.wavPlayerManager.getFileVolumeStates(); for (const [fileId, volume] of wavVolumes) { states.set(fileId, volume); } - + return states; } @@ -138,7 +148,7 @@ export class FileAudioController { areAllFilesMuted(): boolean { const samplerMuted = this.deps.samplerManager.areAllTracksMuted(); const wavMuted = this.deps.wavPlayerManager.areAllPlayersMuted(); - + return samplerMuted && wavMuted; } @@ -148,7 +158,7 @@ export class FileAudioController { areAllFilesZeroVolume(): boolean { const samplerZero = this.deps.samplerManager.areAllTracksZeroVolume(); const wavZero = this.deps.wavPlayerManager.areAllPlayersZeroVolume(); - + return samplerZero && wavZero; } @@ -157,11 +167,11 @@ export class FileAudioController { */ refreshAudioPlayers(): void { const { wavPlayerManager, midiManager } = this.deps; - + // console.log("[FileAudioController.refreshAudioPlayers] Refreshing external audio players"); - + const refreshed = wavPlayerManager.refreshFromMidiManager(midiManager); - + if (refreshed) { // console.log("[FileAudioController.refreshAudioPlayers] Audio players refreshed"); } diff --git a/src/lib/core/audio/player-types.ts b/src/lib/core/audio/player-types.ts index 8c3331c..a555ec7 100644 --- a/src/lib/core/audio/player-types.ts +++ b/src/lib/core/audio/player-types.ts @@ -54,12 +54,12 @@ export interface AudioPlayerState { playbackRate?: number; /** Generation token to prevent ghost audio - increments on play/seek/tempo changes */ playbackGeneration?: number; - + // New unified state management fields /** Master volume for all audio sources */ masterVolume: number; /** Loop mode configuration */ - loopMode: 'off' | 'all' | 'ab'; + loopMode: "off" | "all" | "ab"; /** AB loop start marker in seconds */ markerA: number | null; /** AB loop end marker in seconds */ diff --git a/src/lib/core/playback/audio-controller.ts b/src/lib/core/playback/audio-controller.ts index baf2450..4932290 100644 --- a/src/lib/core/playback/audio-controller.ts +++ b/src/lib/core/playback/audio-controller.ts @@ -101,9 +101,11 @@ export class AudioController { // This is a bit of a hack to maintain backward compatibility if (pianoRollInstance) { // Store reference to external piano roll instance (compatibility path) - (this.pianoRollManager as unknown as { - pianoRollInstance: import("@/core/playback").PianoRollInstance | null; - }).pianoRollInstance = pianoRollInstance; + ( + this.pianoRollManager as unknown as { + pianoRollInstance: import("@/core/playback").PianoRollInstance | null; + } + ).pianoRollInstance = pianoRollInstance; } // Initialize core engine @@ -179,7 +181,11 @@ export class AudioController { /** * Set A-B loop points */ - public setLoopPoints(start: number | null, end: number | null, preservePosition: boolean = false): void { + public setLoopPoints( + start: number | null, + end: number | null, + preservePosition: boolean = false + ): void { this.loopPoints = { a: start, b: end }; this.coreEngine.setLoopPoints(start, end, preservePosition); } diff --git a/src/lib/core/playback/core-playback-engine.ts b/src/lib/core/playback/core-playback-engine.ts index 8e9b8c4..7f01da5 100644 --- a/src/lib/core/playback/core-playback-engine.ts +++ b/src/lib/core/playback/core-playback-engine.ts @@ -134,7 +134,7 @@ export class CorePlaybackEngine implements AudioPlayerContainer { */ public async updateAudio(notes: NoteData[]): Promise { // console.log('[CorePlaybackEngine] updateAudio called with', notes.length, 'notes'); - + // Calculate signature based on file IDs present in notes, not the actual notes // This prevents recreation when only mute states change const fileIds = new Set(); @@ -146,7 +146,7 @@ export class CorePlaybackEngine implements AudioPlayerContainer { // Signature based only on file IDs to avoid unnecessary audio player // recreation when note lists change due to UI transforms (tempo/loop/seek). const signature = Array.from(fileIds).sort().join(","); - + // console.log('[CorePlaybackEngine] File IDs found:', Array.from(fileIds)); // console.log('[CorePlaybackEngine] Current signature:', signature, 'Last signature:', this.lastAudioSignature); @@ -192,11 +192,15 @@ export class CorePlaybackEngine implements AudioPlayerContainer { } // Create new player with preserved settings - this.audioPlayer = await createAudioPlayer(notes, { - tempo: prevState?.tempo || this.config.defaultTempo, - volume: prevState?.volume || this.config.defaultVolume, - repeat: prevState?.isRepeating || false, - }, pianoRollInstance); + this.audioPlayer = await createAudioPlayer( + notes, + { + tempo: prevState?.tempo || this.config.defaultTempo, + volume: prevState?.volume || this.config.defaultVolume, + repeat: prevState?.isRepeating || false, + }, + pianoRollInstance + ); // Restore state if (prevState) { @@ -274,12 +278,12 @@ export class CorePlaybackEngine implements AudioPlayerContainer { */ public async play(): Promise { // console.log('[CorePlaybackEngine] Play called, audioPlayer exists:', !!this.audioPlayer); - + if (!this.audioPlayer) { - console.error('[CorePlaybackEngine] audioPlayer is null, cannot play'); + console.error("[CorePlaybackEngine] audioPlayer is null, cannot play"); return; } - + // Get state before playing to check if we're starting from the beginning const stateBefore = this.audioPlayer!.getState(); // Do not auto-rewind here; preserve last bookmark position exactly @@ -387,7 +391,11 @@ export class CorePlaybackEngine implements AudioPlayerContainer { (this.audioPlayer as any)?.setOriginalTempo?.(bpm); } - public setLoopPoints(start: number | null, end: number | null, preservePosition: boolean = false): void { + public setLoopPoints( + start: number | null, + end: number | null, + preservePosition: boolean = false + ): void { this.loopPoints = { a: start, b: end }; this.audioPlayer?.setLoopPoints(start, end, preservePosition); @@ -478,7 +486,7 @@ export class CorePlaybackEngine implements AudioPlayerContainer { isRepeating: false, // New unified state management fields masterVolume: this.config.defaultVolume, - loopMode: 'off' as const, + loopMode: "off" as const, markerA: null, markerB: null, nowTime: 0, diff --git a/src/lib/core/playback/piano-roll-manager.ts b/src/lib/core/playback/piano-roll-manager.ts index 58e0c60..8addf73 100644 --- a/src/lib/core/playback/piano-roll-manager.ts +++ b/src/lib/core/playback/piano-roll-manager.ts @@ -25,6 +25,8 @@ export interface PianoRollConfig { minorTimeStep: number; /** Whether to show waveform band at the bottom (default: true) */ showWaveformBand?: boolean; + /** Preferred renderer type for PixiJS (default: auto-select) */ + rendererPreference?: 'webgl' | 'webgpu'; } /** diff --git a/src/lib/core/state/default.ts b/src/lib/core/state/default.ts index 861900e..a279a03 100644 --- a/src/lib/core/state/default.ts +++ b/src/lib/core/state/default.ts @@ -33,10 +33,10 @@ export const DEFAULT_PLAYBACK_STATE: PlaybackState = { nowTime: 0, masterVolume: DEFAULT_STATE_CONFIG.defaultVolume, tempo: 120, - loopMode: 'off', + loopMode: "off", markerA: null, markerB: null, -};; +}; export const DEFAULT_FILE_VISIBILITY_STATE: FileVisibilityState = { visibleFileIds: new Set(), diff --git a/src/lib/core/visualization/piano-roll/piano-roll.ts b/src/lib/core/visualization/piano-roll/piano-roll.ts index 7f970ec..3a07557 100644 --- a/src/lib/core/visualization/piano-roll/piano-roll.ts +++ b/src/lib/core/visualization/piano-roll/piano-roll.ts @@ -228,7 +228,8 @@ export class PianoRoll { antialias: true, resolution: window.devicePixelRatio || 1, autoDensity: true, - preference: 'webgl', // Prefer WebGL over WebGPU for better compatibility + // Only set preference if explicitly configured; otherwise let PixiJS auto-select + ...(this.options.rendererPreference && { preference: this.options.rendererPreference }), }); } diff --git a/src/lib/core/visualization/piano-roll/types.ts b/src/lib/core/visualization/piano-roll/types.ts index 6e62856..c274b01 100644 --- a/src/lib/core/visualization/piano-roll/types.ts +++ b/src/lib/core/visualization/piano-roll/types.ts @@ -31,6 +31,8 @@ export interface PianoRollConfig { noteRenderer?: (note: NoteData, index: number) => number; /** Whether to show waveform band at the bottom (default: true) */ showWaveformBand?: boolean; + /** Preferred renderer type for PixiJS (default: auto-select) */ + rendererPreference?: 'webgl' | 'webgpu'; } /** From 74983fd53296334bd11851769e30b857e2de702e Mon Sep 17 00:00:00 2001 From: Hannah Park Date: Mon, 1 Dec 2025 20:48:12 +0900 Subject: [PATCH 03/16] Adds pitch hover indicator to piano roll --- .../visualization/piano-roll/piano-roll.ts | 151 +++++++++++++++++- .../piano-roll/renderers/grid.ts | 27 +++- 2 files changed, 175 insertions(+), 3 deletions(-) diff --git a/src/lib/core/visualization/piano-roll/piano-roll.ts b/src/lib/core/visualization/piano-roll/piano-roll.ts index 3a07557..5123ffb 100644 --- a/src/lib/core/visualization/piano-roll/piano-roll.ts +++ b/src/lib/core/visualization/piano-roll/piano-roll.ts @@ -24,6 +24,7 @@ import { drawOverlapRegions } from "@/core/visualization/piano-roll/renderers/ov import { NoteInterval } from "@/lib/core/controls/utils/overlap"; import type { FileInfoMap } from "./types-internal"; import { initializeContainers } from "@/lib/core/visualization/piano-roll/ui/containers"; +import { midiToNoteName } from "@/lib/core/utils/midi/pitch"; // (Note) Evaluation utilities removed - no longer required here export class PianoRoll { @@ -52,6 +53,10 @@ export class PianoRoll { // Help button and panel (overlay UI for interaction hints) private helpButtonEl: HTMLButtonElement | null = null; private helpPanelEl: HTMLDivElement | null = null; + // Pitch hover indicator element (shows current pitch row on hover) + private pitchHoverDiv: HTMLDivElement | null = null; + // PIXI Graphics for pitch row highlight + public pitchHoverHighlight!: PIXI.Graphics; public playheadX: number = 0; public notes: NoteData[] = []; @@ -206,6 +211,7 @@ export class PianoRoll { // Add tooltip overlay after containers are ready instance.initializeTooltip(canvas); instance.initializeHelpButton(canvas); + instance.initializePitchHover(); instance.setupInteraction(); instance.render(); // Full render including playhead @@ -251,6 +257,33 @@ export class PianoRoll { this.helpPanelEl = panel; } + /** Initialize pitch hover indicator for showing current pitch row */ + private initializePitchHover(): void { + // Create DOM element for pitch label display + this.pitchHoverDiv = document.createElement("div"); + this.pitchHoverDiv.style.cssText = ` + position: absolute; + left: 0; + padding: 2px 6px; + background: rgba(30, 64, 175, 0.9); + color: white; + font-size: 10px; + font-weight: 600; + border-radius: 0 4px 4px 0; + pointer-events: none; + z-index: 100; + display: none; + white-space: nowrap; + box-shadow: 0 1px 3px rgba(0,0,0,0.3); + `; + this.domContainer.appendChild(this.pitchHoverDiv); + + // Create PIXI Graphics for row highlight + this.pitchHoverHighlight = new PIXI.Graphics(); + this.pitchHoverHighlight.zIndex = 5; + this.container.addChild(this.pitchHoverHighlight); + } + /** * Find all notes at the given time and pitch position */ @@ -409,6 +442,104 @@ export class PianoRoll { } } + /** + * Update pitch hover indicator based on mouse Y position + * @param clientY - Mouse Y position relative to viewport + */ + public updatePitchHover(clientY: number): void { + if (!this.pitchHoverDiv || !this.options.showPianoKeys) return; + + const canvas = this.app.canvas; + const rect = canvas.getBoundingClientRect(); + const localY = clientY - rect.top; + + // Calculate the waveform band height to exclude from pitch area + let reservedBottomPx = 0; + if (this.options.showWaveformBand !== false) { + const bandPadding = 6; + const bandHeight = Math.max( + 24, + Math.min(96, Math.floor(this.options.height * 0.22)) + ); + reservedBottomPx = bandPadding + bandHeight; + } + + // Check if mouse is within the pitch area (not in waveform band) + const usableHeight = this.options.height - reservedBottomPx; + if (localY < 0 || localY > usableHeight) { + this.hidePitchHover(); + return; + } + + // Convert Y position to pitch using pitchScale + const canvasMid = this.options.height / 2; + // Reverse the zoom transformation: y = (yBase - canvasMid) * zoomY + canvasMid + // So: yBase = (y - canvasMid) / zoomY + canvasMid + const yBase = (localY - canvasMid) / this.state.zoomY + canvasMid; + const pitch = Math.round(this.pitchScale.invert(yBase)); + + // Clamp pitch to valid MIDI range + const clampedPitch = clamp( + pitch, + this.options.noteRange.min, + this.options.noteRange.max + ); + + // Get pitch name + let pitchName: string; + try { + pitchName = midiToNoteName(clampedPitch); + } catch { + pitchName = `MIDI ${clampedPitch}`; + } + + // Calculate row Y position and height for highlight + const yPitchBase = this.pitchScale(clampedPitch); + const yPitch = (yPitchBase - canvasMid) * this.state.zoomY + canvasMid; + const yNextBase = this.pitchScale(clampedPitch + 1); + const yNext = (yNextBase - canvasMid) * this.state.zoomY + canvasMid; + const rowHeight = Math.abs(yPitch - yNext); + const rowTop = Math.min(yPitch, yNext); + + // Update pitch hover label + this.pitchHoverDiv.textContent = `${pitchName} (${clampedPitch})`; + this.pitchHoverDiv.style.display = "block"; + this.pitchHoverDiv.style.top = `${rowTop + rowHeight / 2 - 10}px`; + + // Update PIXI highlight + const pianoKeysWidth = this.playheadX; + this.pitchHoverHighlight.clear(); + this.pitchHoverHighlight.rect(0, rowTop, pianoKeysWidth, rowHeight); + this.pitchHoverHighlight.fill({ color: 0x1e40af, alpha: 0.15 }); + + // Draw dashed horizontal line across the entire timeline + const rowMidY = rowTop + rowHeight / 2; + const dashLength = 4; + const gapLength = 4; + const lineColor = 0x1e40af; + const lineAlpha = 0.15; + + // Start from after piano keys area and draw to the end of canvas + let x = pianoKeysWidth; + while (x < this.options.width) { + const dashEnd = Math.min(x + dashLength, this.options.width); + this.pitchHoverHighlight.moveTo(x, rowMidY); + this.pitchHoverHighlight.lineTo(dashEnd, rowMidY); + this.pitchHoverHighlight.stroke({ width: 1, color: lineColor, alpha: lineAlpha }); + x += dashLength + gapLength; + } + } + + /** Hide pitch hover indicator */ + public hidePitchHover(): void { + if (this.pitchHoverDiv) { + this.pitchHoverDiv.style.display = "none"; + } + if (this.pitchHoverHighlight) { + this.pitchHoverHighlight.clear(); + } + } + /** * Set up mouse/touch interaction for panning and zooming */ @@ -432,11 +563,20 @@ export class PianoRoll { ); canvas.addEventListener( "mousemove", - (event) => onPointerMove(event, this), + (event) => { + onPointerMove(event, this); + // Update pitch hover indicator when not panning + if (!this.state.isPanning) { + this.updatePitchHover(event.clientY); + } + }, nonPassive ); canvas.addEventListener("mouseup", (event) => onPointerUp(event, this)); - canvas.addEventListener("mouseleave", (event) => onPointerUp(event, this)); + canvas.addEventListener("mouseleave", (event) => { + onPointerUp(event, this); + this.hidePitchHover(); + }); // Touch events - explicit non-passive options because we call preventDefault() in the handlers. canvas.addEventListener( @@ -787,6 +927,13 @@ export class PianoRoll { this.noteGraphics.forEach((graphic) => graphic.destroy()); // Clean up Sprite instances used by the default renderer this.noteSprites.forEach((sprite) => sprite.destroy()); + // Clean up pitch hover elements + if (this.pitchHoverDiv && this.pitchHoverDiv.parentElement) { + this.pitchHoverDiv.parentElement.removeChild(this.pitchHoverDiv); + } + if (this.pitchHoverHighlight) { + this.pitchHoverHighlight.destroy(); + } this.app.destroy(true); } diff --git a/src/lib/core/visualization/piano-roll/renderers/grid.ts b/src/lib/core/visualization/piano-roll/renderers/grid.ts index a387d63..2c38a06 100644 --- a/src/lib/core/visualization/piano-roll/renderers/grid.ts +++ b/src/lib/core/visualization/piano-roll/renderers/grid.ts @@ -8,6 +8,7 @@ import { DrawingPrimitives, ColorCalculator } from "../utils"; +import { midiToNoteName } from "@/lib/core/utils/midi/pitch"; // import { drawOverlapRegions } from "./overlaps"; // kept for future use export function renderGrid(pianoRoll: PianoRoll): void { @@ -66,7 +67,7 @@ export function renderGrid(pianoRoll: PianoRoll): void { ); pianoRoll.backgroundGrid.stroke({ width: 1, color: 0x999999, alpha: 0.6 }); - // Draw piano key lines + // Draw piano key lines and octave labels (C notes) for ( let midi = pianoRoll.options.noteRange.min; midi <= pianoRoll.options.noteRange.max; @@ -85,6 +86,30 @@ export function renderGrid(pianoRoll: PianoRoll): void { // color: isBlackKey ? 0x000000 : 0xcccccc, alpha: 0.3, }); + + // Add octave reference labels for C notes (C0, C1, C2, ... C8) + if (midi % 12 === 0) { + // Calculate next note's Y position to get row height + const yNextBase = pianoRoll.pitchScale(midi + 1); + const yNext = (yNextBase - canvasMid) * pianoRoll.state.zoomY + canvasMid; + const rowHeight = Math.abs(y - yNext); + + // Only show label if row height is sufficient for readability + if (rowHeight >= 8) { + const label = new PIXI.Text({ + text: midiToNoteName(midi), + style: { + fontSize: Math.min(9, Math.max(7, rowHeight * 0.7)), + fill: 0x666666, + fontWeight: "500", + }, + }); + label.x = 4; + // Center vertically within the row (y is the bottom edge of the row) + label.y = y - rowHeight / 2 - label.height / 2; + pianoRoll.backgroundLabelContainer.addChild(label); + } + } } } From 52321191958adbba4f21495d17aeb9f3d1bedb0d Mon Sep 17 00:00:00 2001 From: Hannah Park Date: Mon, 1 Dec 2025 23:39:39 +0900 Subject: [PATCH 04/16] Improves piano roll pitch hover and piano key visuals --- .../visualization/piano-roll/piano-roll.ts | 59 ++++++++++++++----- .../piano-roll/renderers/grid.ts | 42 +++++++------ .../visualization/piano-roll/ui/containers.ts | 12 +++- 3 files changed, 78 insertions(+), 35 deletions(-) diff --git a/src/lib/core/visualization/piano-roll/piano-roll.ts b/src/lib/core/visualization/piano-roll/piano-roll.ts index 5123ffb..6492e64 100644 --- a/src/lib/core/visualization/piano-roll/piano-roll.ts +++ b/src/lib/core/visualization/piano-roll/piano-roll.ts @@ -35,6 +35,10 @@ export class PianoRoll { public sustainContainer!: PIXI.Container; public playheadLine!: PIXI.Graphics; public backgroundGrid!: PIXI.Graphics; + /** Piano key horizontal lines (panY applied via container) */ + public pianoKeyLines!: PIXI.Graphics; + /** Container for octave labels on piano keys (panY applied via container) */ + public pianoKeyLabelContainer!: PIXI.Container; /** Waveform overlay layer (rendered below the grid) */ public waveformLayer!: PIXI.Graphics; /** Waveform overlay drawn above the piano-keys area so it shows left of playhead */ @@ -264,6 +268,7 @@ export class PianoRoll { this.pitchHoverDiv.style.cssText = ` position: absolute; left: 0; + width: 60px; padding: 2px 6px; background: rgba(30, 64, 175, 0.9); color: white; @@ -274,7 +279,7 @@ export class PianoRoll { z-index: 100; display: none; white-space: nowrap; - box-shadow: 0 1px 3px rgba(0,0,0,0.3); + box-sizing: border-box; `; this.domContainer.appendChild(this.pitchHoverDiv); @@ -473,9 +478,11 @@ export class PianoRoll { // Convert Y position to pitch using pitchScale const canvasMid = this.options.height / 2; + // Subtract panY to align with the note container's vertical offset + const adjustedY = localY - this.state.panY; // Reverse the zoom transformation: y = (yBase - canvasMid) * zoomY + canvasMid // So: yBase = (y - canvasMid) / zoomY + canvasMid - const yBase = (localY - canvasMid) / this.state.zoomY + canvasMid; + const yBase = (adjustedY - canvasMid) / this.state.zoomY + canvasMid; const pitch = Math.round(this.pitchScale.invert(yBase)); // Clamp pitch to valid MIDI range @@ -494,26 +501,35 @@ export class PianoRoll { } // Calculate row Y position and height for highlight + // NOTE: panY is NOT applied here - it will be applied via container.y in render() + // Use pitchScale(clampedPitch) as CENTER (same as notes), not edge const yPitchBase = this.pitchScale(clampedPitch); - const yPitch = (yPitchBase - canvasMid) * this.state.zoomY + canvasMid; const yNextBase = this.pitchScale(clampedPitch + 1); - const yNext = (yNextBase - canvasMid) * this.state.zoomY + canvasMid; - const rowHeight = Math.abs(yPitch - yNext); - const rowTop = Math.min(yPitch, yNext); + const rowHeight = Math.abs( + (yPitchBase - canvasMid) * this.state.zoomY - + (yNextBase - canvasMid) * this.state.zoomY + ); + // centerY is where the note is centered (pitchScale returns center, not edge) + const centerY = (yPitchBase - canvasMid) * this.state.zoomY + canvasMid; + const rowTop = centerY - rowHeight / 2; - // Update pitch hover label + // Update pitch hover label (DOM element - add panY to CSS top) this.pitchHoverDiv.textContent = `${pitchName} (${clampedPitch})`; this.pitchHoverDiv.style.display = "block"; - this.pitchHoverDiv.style.top = `${rowTop + rowHeight / 2 - 10}px`; + // Add panY for DOM element since it doesn't get container transform + this.pitchHoverDiv.style.top = `${centerY - 10 + this.state.panY}px`; - // Update PIXI highlight + // Update PIXI highlight (panY applied via container.y) const pianoKeysWidth = this.playheadX; this.pitchHoverHighlight.clear(); this.pitchHoverHighlight.rect(0, rowTop, pianoKeysWidth, rowHeight); this.pitchHoverHighlight.fill({ color: 0x1e40af, alpha: 0.15 }); // Draw dashed horizontal line across the entire timeline - const rowMidY = rowTop + rowHeight / 2; + // NOTE: panY is NOT applied here - it will be applied via container.y in render() + const pitchCenterBase = this.pitchScale(clampedPitch); + const rowMidY = (pitchCenterBase - canvasMid) * this.state.zoomY + canvasMid; + const dashLength = 4; const gapLength = 4; const lineColor = 0x1e40af; @@ -565,10 +581,8 @@ export class PianoRoll { "mousemove", (event) => { onPointerMove(event, this); - // Update pitch hover indicator when not panning - if (!this.state.isPanning) { - this.updatePitchHover(event.clientY); - } + // Update pitch hover indicator (also during panning for real-time feedback) + this.updatePitchHover(event.clientY); }, nonPassive ); @@ -615,7 +629,11 @@ export class PianoRoll { // Wheel event for zooming - preventDefault() is used, so keep it non-passive. canvas.addEventListener( "wheel", - (event) => onWheel(event, this), + (event) => { + onWheel(event, this); + // Update pitch hover indicator after zoom changes + this.updatePitchHover(event.clientY); + }, nonPassive ); @@ -706,6 +724,17 @@ export class PianoRoll { this.overlapOverlay.x = this.state.panX; this.overlapOverlay.y = this.state.panY; + // Apply panY to piano key lines/labels and hover highlight + // (These elements should move vertically with notes) + this.pianoKeyLines.y = this.state.panY; + this.pianoKeyLabelContainer.y = this.state.panY; + this.pitchHoverHighlight.y = this.state.panY; + + // Fixed elements (no panY applied) + // this.backgroundGrid.y = 0; // already 0 by default + // this.waveformLayer.y = 0; // already 0 by default + // this.loopOverlay.y = 0; // already 0 by default + // Ensure proper rendering order this.container.sortChildren(); diff --git a/src/lib/core/visualization/piano-roll/renderers/grid.ts b/src/lib/core/visualization/piano-roll/renderers/grid.ts index 2c38a06..f561670 100644 --- a/src/lib/core/visualization/piano-roll/renderers/grid.ts +++ b/src/lib/core/visualization/piano-roll/renderers/grid.ts @@ -19,10 +19,18 @@ export function renderGrid(pianoRoll: PianoRoll): void { if (pianoRoll.loopLabelContainer) { pianoRoll.loopLabelContainer.removeChildren(); } + // Clear piano key labels (panY applied via container) + if (pianoRoll.pianoKeyLabelContainer) { + pianoRoll.pianoKeyLabelContainer.removeChildren(); + } // Remove any previously added children (e.g., text labels) and clear drawings pianoRoll.backgroundGrid.removeChildren(); pianoRoll.backgroundGrid.clear(); + // Clear piano key lines (panY applied via container) + if (pianoRoll.pianoKeyLines) { + pianoRoll.pianoKeyLines.clear(); + } // Also clear waveform layer so it re-renders cleanly each frame if (pianoRoll.waveformLayer) { pianoRoll.waveformLayer.clear(); @@ -51,6 +59,8 @@ export function renderGrid(pianoRoll: PianoRoll): void { if (pianoRoll.options.showPianoKeys) { // const pianoKeysWidth = this.timeScale(1) * this.state.zoomX; const pianoKeysWidth = pianoRoll.playheadX; + + // Draw piano key background (fixed, no panY) pianoRoll.backgroundGrid.rect( 0, 0, @@ -67,30 +77,23 @@ export function renderGrid(pianoRoll: PianoRoll): void { ); pianoRoll.backgroundGrid.stroke({ width: 1, color: 0x999999, alpha: 0.6 }); - // Draw piano key lines and octave labels (C notes) + // Draw octave labels for C notes (no horizontal lines - cleaner design) + // NOTE: panY is NOT applied here - it will be applied via container.y in render() + const canvasMid = pianoRoll.options.height / 2; + for ( let midi = pianoRoll.options.noteRange.min; midi <= pianoRoll.options.noteRange.max; midi++ ) { - const yBase = pianoRoll.pitchScale(midi); - const canvasMid = pianoRoll.options.height / 2; - const y = (yBase - canvasMid) * pianoRoll.state.zoomY + canvasMid; - // const isBlackKey = [1, 3, 6, 8, 10].includes(midi % 12); - - pianoRoll.backgroundGrid.moveTo(0, y); - pianoRoll.backgroundGrid.lineTo(pianoKeysWidth, y); - pianoRoll.backgroundGrid.stroke({ - width: 1, - color: 0xcccccc, - // color: isBlackKey ? 0x000000 : 0xcccccc, - alpha: 0.3, - }); - - // Add octave reference labels for C notes (C0, C1, C2, ... C8) + // Only process C notes for labels if (midi % 12 === 0) { + const yBase = pianoRoll.pitchScale(midi); + // No panY here - container handles it + const y = (yBase - canvasMid) * pianoRoll.state.zoomY + canvasMid; // Calculate next note's Y position to get row height const yNextBase = pianoRoll.pitchScale(midi + 1); + // No panY here - container handles it const yNext = (yNextBase - canvasMid) * pianoRoll.state.zoomY + canvasMid; const rowHeight = Math.abs(y - yNext); @@ -105,9 +108,10 @@ export function renderGrid(pianoRoll: PianoRoll): void { }, }); label.x = 4; - // Center vertically within the row (y is the bottom edge of the row) - label.y = y - rowHeight / 2 - label.height / 2; - pianoRoll.backgroundLabelContainer.addChild(label); + // Center vertically within the row (y is the center of the row) + label.y = y - label.height / 2; + // Add to pianoKeyLabelContainer (panY applied via container) + pianoRoll.pianoKeyLabelContainer.addChild(label); } } } diff --git a/src/lib/core/visualization/piano-roll/ui/containers.ts b/src/lib/core/visualization/piano-roll/ui/containers.ts index 08c10f8..8d02e29 100644 --- a/src/lib/core/visualization/piano-roll/ui/containers.ts +++ b/src/lib/core/visualization/piano-roll/ui/containers.ts @@ -11,11 +11,21 @@ export function initializeContainers(pr: PianoRoll): void { pr.container.sortableChildren = true; // Enable z-index sorting pr.app.stage.addChild(pr.container); - // Background grid container + // Background grid container (fixed: piano key background, vertical grid lines, waveform) pr.backgroundGrid = new PIXI.Graphics(); pr.backgroundGrid.zIndex = 1; pr.container.addChild(pr.backgroundGrid); + // Piano key horizontal lines (panY applied - moves with notes) + pr.pianoKeyLines = new PIXI.Graphics(); + pr.pianoKeyLines.zIndex = 2; // above background, below notes + pr.container.addChild(pr.pianoKeyLines); + + // Container for octave labels on piano keys (panY applied - moves with notes) + pr.pianoKeyLabelContainer = new PIXI.Container(); + pr.pianoKeyLabelContainer.zIndex = 3; // above lines + pr.container.addChild(pr.pianoKeyLabelContainer); + // Waveform layer (below grid) pr.waveformLayer = new PIXI.Graphics(); pr.waveformLayer.zIndex = 0; From 3d61e0a1a6da08869ccfe426202b03f8e7e2eee2 Mon Sep 17 00:00:00 2001 From: Hannah Park Date: Tue, 2 Dec 2025 00:32:02 +0900 Subject: [PATCH 05/16] Adds tempo control and MIDI export --- README.md | 7 +- index.html | 19 +- src/lib/components/ui/controls/settings.ts | 4 +- src/lib/components/ui/controls/tempo.ts | 192 +++++++++----- .../ui/settings/controls/midi-export-group.ts | 241 ++++++++++++++++++ .../components/ui/settings/modal/zoom-grid.ts | 12 +- .../ui/settings/sections/solo-appearance.ts | 131 +++++++--- src/lib/core/file/index.ts | 1 + src/lib/core/file/midi-export.ts | 146 +++++++++++ src/lib/core/midi/multi-midi-manager.ts | 27 +- src/lib/core/midi/tempo-event-bus.ts | 95 +++++++ src/lib/core/playback/core-playback-engine.ts | 19 ++ .../visualization/visualization-engine.ts | 43 +++- test/tempo-event-bus.test.ts | 103 ++++++++ 14 files changed, 914 insertions(+), 126 deletions(-) create mode 100644 src/lib/components/ui/settings/controls/midi-export-group.ts create mode 100644 src/lib/core/file/midi-export.ts create mode 100644 src/lib/core/midi/tempo-event-bus.ts create mode 100644 test/tempo-event-bus.test.ts diff --git a/README.md b/README.md index af63a80..d9cf599 100644 --- a/README.md +++ b/README.md @@ -237,5 +237,10 @@ MIT License - see [LICENSE](LICENSE) file for details If you use WaveRoll in your research, please cite: ```bibtex - +@inproceedings{waveroll2025, + title={WaveRoll: JavaScript Library for Comparative MIDI Piano-Roll Visualization}, + author={Park, Hannah and Jeong, Dasaem}, + booktitle={Proceedings of 26th International Society for Music Information Retrieval Conference (ISMIR)}, + year={2025} +} ``` \ No newline at end of file diff --git a/index.html b/index.html index e418905..93f6055 100644 --- a/index.html +++ b/index.html @@ -48,17 +48,24 @@
-

WaveRoll - MIDI Visualizer

+

+ WaveRoll - MIDI Visualizer +

+ >