diff --git a/public/chat/help.md b/public/chat/help.md index 28a69a7..28943e9 100644 --- a/public/chat/help.md +++ b/public/chat/help.md @@ -2,11 +2,26 @@ Welcome to **K.G.Studio Musician Assistant**—your AI-powered agent for music composition and arrangement. Harnessing advanced language models, I am here to help you create, arrange, and refine your musical ideas with ease and intelligence. -To get started, you will need to configure an LLM (Large Language Model) Provider in the application settings. Please follow the instructions below to ensure proper setup: +To get started, you can either use the **built-in browser LLM** (no API key required) or configure an external LLM provider. Follow the instructions below for your preferred option. --- -### Configuring Your LLM Provider +### Using Local LLM (Browser) — No API Key Required + +K.G.Studio can run **Gemma 4 E4B** entirely inside your browser using WebGPU acceleration. No API calls are made, no cost is incurred, and your data never leaves your machine. + +1. In **Settings ⚙️ → General → LLM Provider**, select **Local LLM (Browser)** (this is the default). +2. The model (~2.8 GB) downloads automatically the first time you open the chat and is cached locally for instant subsequent launches. +3. Optionally configure the **Context Length** (32k / 64k / 128k tokens) — larger values require more VRAM. +4. Start chatting! No key, no account, no network traffic after the initial model download. + +**Requirements:** Chrome 113+ or Edge 113+, a secure context (HTTPS or localhost), and a GPU with at least 8 GB VRAM or a system with at least 16 GB unified RAM. + +> Note: The local model's quality cannot match commercial models like GPT or Claude. For complex tasks, an external provider will produce better results. + +--- + +### Configuring an External LLM Provider Navigate to **Settings ⚙️ → General → LLM Provider**. Depending on your chosen provider, you will need to supply the appropriate API Key and, if applicable, a custom base URL (for non-official OpenAI-compatible services such as Ollama, OpenRouter, etc.). @@ -34,35 +49,61 @@ OpenRouter is a platform that provides unified access to a wide range of languag 5. Enter your chosen model name in **OpenAI Compatible Server → Model**. Recommended model series include: - `Anthropic: Claude Sonnet 4.6` (`anthropic/claude-sonnet-4.6`: [Link](https://openrouter.ai/anthropic/claude-sonnet-4.6)) — best balance of quality and cost for the Claude series - `Qwen: Qwen3.5-35B-A3B` (`qwen/qwen3.5-35b-a3b`: [Link](https://openrouter.ai/qwen/qwen3.5-35b-a3b)) — recommended open source model - - `Qwen: Qwen3.6 Plus` (FREE MODEL: `qwen/qwen3.6-plus:free`: [Link](https://openrouter.ai/qwen/qwen3.6-plus:free)) — recommended free model; note that free model providers may collect your data, check the model page for details -6. Input the base URL `https://openrouter.ai/api/v1` **OpenAI Compatible Server → Base URL**. + - `Qwen: Qwen3-Next-80B-A3B` (FREE MODEL: `qwen/qwen3-next-80b-a3b-instruct:free`: [Link](https://openrouter.ai/qwen/qwen3-next-80b-a3b-instruct:free)) — recommended free model + - `OpenAI: GPT-OSS 120B` (FREE MODEL: `openai/gpt-oss-120b:free`: [Link](https://openrouter.ai/openai/gpt-oss-120b:free)) — recommended free model + - Note: free model availability changes frequently — for the latest free options, visit the [OpenRouter Models Page](https://openrouter.ai/models) and use the **Prompt Pricing** filter + - Note: free model providers may collect your data; check the model page for details before use +6. Input the base URL `https://openrouter.ai/api/v1` in **OpenAI Compatible Server → Base URL**. ### Basic DAW Operations +Chat commands: `/clear`, `/welcome`, `/help`, `/hotkeys` + - Tracks - Add, rename, and reorder tracks from the track info panel. - Change instrument using the instrument button (piano icon); adjust Solo (S), Mute (M), and Volume. - - Delete a track from the track’s settings menu (button to the right of the instrument). + - Delete a track from the track's settings menu (button to the right of the instrument). + - **Audio recording**: click the Rec button in the toolbar to record directly from your microphone into an audio track. - Regions - Create region: with the Pointer tool, double‑click; or hold Ctrl/Cmd and click. With the Pencil tool, single‑click. - Move/resize: drag the body to move; drag edges to resize. - - Open Piano Roll via the small pencil at a region’s top‑left. + - Open Piano Roll via the small pencil at a region's top‑left. - Piano Roll (MIDI notes) - Tools: Select vs Pencil. - Create notes: double‑click or Ctrl/Cmd+click (Select); single‑click (Pencil). - Select notes: click; Shift+click for multi‑select; drag to box‑select. - Move/resize: drag note body to move selected notes; drag edges to resize. + - **Sheet music view**: toggle between Piano Roll and Staff Notation views from the piano roll toolbar. Supports automatic clef selection, key signature rendering, beam grouping, and ties. Enable **Track Scope** to render all regions on the track as a continuous score. + - **Automation lanes**: draw and edit pitch bend and MIDI CC curves (Modulation, Breath, Volume, Expression, Sustain) in the editable lane below the piano grid. + - **Spectrogram mode**: view an audio region's spectrogram inside the piano roll as a reference layer while editing MIDI notes. + - **Event List Panel**: tabbed panel (Notes / Pitch Bend / Controller) for inspecting and inline-editing all events in the active MIDI region. - Close the piano roll with X or ESC. - Intelligent Chord Assistant - - Enable chord guide from the piano roll toolbar: select T (Tonic), S (Subdominant), or D (Dominant) function. - - Hover over any key to see context-aware chord suggestions highlighted in red, matching your selected key signature and mode. - - Press Tab to cycle through different chord voicings for the same harmonic function. + - Enable chord guide from the piano roll toolbar with `⊘`, `T`, `S`, or `D`. + - Chord-guide candidates follow the effective key signature at the playhead: major uses Ionian, minor uses Aeolian. + - Hover over any key to see context-aware chord suggestions highlighted in red. + - Press `g` to cycle chord-guide state, `Tab` to move to the next candidate chord, and `Shift+Tab` to move to the previous one. - Double-click (or Ctrl/Cmd+click) on a highlighted chord to create all notes at once. - Chord length automatically matches your last edited note for consistent rhythm. +- Global Track System + - Four persistent tracks at the top of the timeline: **Marker** (label spans), **Tempo** (BPM regions), **Key Signature**, and **Chord** (chord-symbol spans). + - Create a global region: double‑click or Ctrl/Cmd+click in a global track row; drag to move, drag edges to resize. + - These tracks drive playback timing, chord guide suggestions, sheet music key rendering, and chord detection results. + +- Audio Analysis Features + - **Detect Chords**: open the piano roll on an audio region and click **...** → **Detect Chords** to automatically analyse the recording and populate the global Chord Track. Configurable sensitivity, stability, and seventh-chord detection. + - **Detect Tempo**: open the piano roll on an audio region and click **...** → **Detect Tempo** to analyse the audio for BPM; optionally auto-aligns the project's Tempo Track regions to match detected beats. + +- K.G.One Music Generator + - Click the **✦** (magic wand) button in the toolbar to open the Music Generator panel. + - **Full Song Generation**: generate a complete song from a text caption and optional lyrics (requires K.G.One server). + - **Clip Generation**: generate short instrument clips and MIDI loops from text prompts (requires K.G.One server). + - **Stem Separation (browser)**: split any audio region into stems entirely in-browser — no server needed. Two models available: **UVR-MDX-NET-Inst_HQ_3** (2-stem: Vocals / Instrumental, ~64 MB) and **Demucs htdemucs_4s** (4-stem: Vocals / Drums / Bass / Others, ~172 MB). Download the model once from the Generator panel, then click **Separate Stems**. Requires WebGPU (Chrome 113+ / Edge 113+). + - Snapping and Quantize - Set snapping from the NO SNAP menu (top‑right). - Quantize timing with Qua. Pos. (start) and Qua. Len. (length). @@ -82,4 +123,4 @@ For security, when running from a non-local host we do not persist your API key K.G.Studio does not provide or host any of the models listed above, nor is it affiliated with any model provider. All data is stored locally on your device; K.G.Studio does not collect or transmit your data. You are solely responsible for any data you provide to third-party model providers. -We hope you enjoy using K.G.Studio Musician Assistant! \ No newline at end of file +We hope you enjoy using K.G.Studio Musician Assistant! diff --git a/public/chat/hotkeys.md b/public/chat/hotkeys.md new file mode 100644 index 0000000..e4ad003 --- /dev/null +++ b/public/chat/hotkeys.md @@ -0,0 +1,59 @@ +## Hotkeys + +This guide lists the keyboard shortcuts and click-modifier actions that are currently implemented in K.G.Studio. + +### Platform-specific create actions + +- On **macOS**, use `Cmd+click` to create a region, note, or automation point while using the pointer tool. +- On **Windows/Linux**, use `Ctrl+click` to create a region, note, or automation point while using the pointer tool. +- In the main timeline, double-click empty space to create a region when the pointer tool is active. +- In the piano roll and automation lanes, the pencil tool also creates items with a single click. + +### Main shortcuts + +- `Space` — Play / Stop +- `C` — Toggle loop +- `R` — Start / Stop recording +- `Cmd/Ctrl+Z` — Undo +- `Cmd/Ctrl+Shift+Z` — Redo +- `Cmd/Ctrl+C` — Copy selected items +- `Cmd/Ctrl+V` — Paste +- `Cmd/Ctrl+S` — Save project +- `Cmd/Ctrl+A` — Select all notes in the active MIDI region +- `Cmd/Ctrl+T` — Split the selected region at the playhead +- `Cmd/Ctrl+J` — Merge selected MIDI regions + +### Region and Piano Roll Window shortcuts + +- `E` — Open the piano roll window for a selected MIDI region, or the waveform editor for a selected audio region. If the editor is already open, `E` closes it. +- `N` — Open the piano roll window in sheet music view for a selected MIDI region. +- `Delete` / `Backspace` — Delete selected regions, notes, or automation points. +- `Esc` — Close the piano roll window. + +### Selection actions + +- `Shift+click` — Add to selection. +- Lasso in empty space — Box-select regions or automation points. +- `Shift+lasso` in Piano Roll Window — Toggle items in the lassoed area. + +### Piano roll shortcuts + +- `G` — Cycle chord guide mode +- `Tab` — Move to the next chord-guide candidate +- `Shift+Tab` — Move to the previous chord-guide candidate / voicing + +### Piano roll snap shortcuts + +- `1` — No snap +- `2` — Snap to `1/4` +- `3` — Snap to `1/8` +- `4` — Snap to `1/16` + +### Piano roll quantize shortcuts + +- `5` — Quantize position to `1/4` +- `6` — Quantize position to `1/8` +- `7` — Quantize position to `1/16` +- `8` — Quantize length to `1/4` +- `9` — Quantize length to `1/8` +- `0` — Quantize length to `1/16` diff --git a/public/chat/welcome_again.md b/public/chat/welcome_again.md index f533d0f..052c45b 100644 --- a/public/chat/welcome_again.md +++ b/public/chat/welcome_again.md @@ -6,5 +6,6 @@ Tips: - Use `/clear` anytime to reset the chat. - Type `/welcome` to view this message again. - Type `/help` to view the help message. +- Type `/hotkeys` to view the hotkeys guide. -Have fun composing with K.G.Studio Musician Assistant. \ No newline at end of file +Have fun composing with K.G.Studio Musician Assistant. diff --git a/public/config.json b/public/config.json index 5dd4168..17e41cd 100644 --- a/public/config.json +++ b/public/config.json @@ -59,7 +59,8 @@ "merge_regions": "ctrl+j" }, "piano_roll": { - "switch": "tab", + "switch": "g", + "switch_voicing": "shift+tab", "select": "q", "pencil": "w", "hold_to_create_note": "ctrl", diff --git a/src/components/piano-roll/PianoGrid.tsx b/src/components/piano-roll/PianoGrid.tsx index 492b60e..eb2bdb8 100644 --- a/src/components/piano-roll/PianoGrid.tsx +++ b/src/components/piano-roll/PianoGrid.tsx @@ -10,6 +10,7 @@ import SpectrogramCanvas from './SpectrogramCanvas'; import AudioWaveformCanvas from './AudioWaveformCanvas'; import type { KGAudioRegion } from '../../core/region/KGAudioRegion'; import type { SpectrogramHeightResolution } from '../../util/spectrogramUtil'; +import { getNextChordCandidateIndex } from './chordGuideUtil'; interface PianoGridProps { gridRef: MutableRefObject; @@ -27,7 +28,9 @@ interface PianoGridProps { regionStartBeat?: number; selectedMode: string; keySignature: KeySignature; - chordGuide: string; + chordGuide: 'N' | 'T' | 'S' | 'D'; + chordGuideKeySignature: KeySignature; + chordGuideMode: 'ionian' | 'aeolian'; audioRegion?: KGAudioRegion; trackId?: string; projectName?: string; @@ -59,6 +62,8 @@ const PianoGrid: React.FC = ({ selectedMode, keySignature, chordGuide, + chordGuideKeySignature, + chordGuideMode, audioRegion, trackId, projectName, @@ -162,8 +167,8 @@ const PianoGrid: React.FC = ({ // Use the utility function to get matching chords const functionType = chordGuide as 'T' | 'S' | 'D'; - return getMatchingChordsForPitch(cursorPosition.pitch, keySignature, selectedMode, functionType); - }, [cursorPosition, chordGuide, keySignature, selectedMode]); + return getMatchingChordsForPitch(cursorPosition.pitch, chordGuideKeySignature, chordGuideMode, functionType); + }, [cursorPosition, chordGuide, chordGuideKeySignature, chordGuideMode]); // Calculate chord highlights based on selected chord index const chordHighlights = useMemo(() => { @@ -212,9 +217,9 @@ const PianoGrid: React.FC = ({ // Expose switchChord function via window for hotkey handler useEffect(() => { - const switchChord = () => { + const switchChord = (direction: 1 | -1 = 1) => { if (matchingChords.length > 1) { - setSelectedChordIndex(prev => (prev + 1) % matchingChords.length); + setSelectedChordIndex(prev => getNextChordCandidateIndex(prev, matchingChords.length, direction)); } }; diff --git a/src/components/piano-roll/PianoRoll.css b/src/components/piano-roll/PianoRoll.css index 44cd24c..2e7c538 100644 --- a/src/components/piano-roll/PianoRoll.css +++ b/src/components/piano-roll/PianoRoll.css @@ -253,6 +253,12 @@ margin-right: 5px; } +.piano-roll-chord-guide-toolbar-group { + display: flex; + align-items: center; + margin-right: 5px; +} + .piano-roll-toolbar .automation-toggle-button { width: 20px; min-width: 20px; @@ -290,6 +296,37 @@ font-size: 10px; } +.piano-roll-toolbar .chord-guide-toggle-button { + margin: 0; + border-radius: 0; + border: 1px solid #444; + border-right-width: 0; +} + +.piano-roll-toolbar .chord-guide-toggle-button-0 { + margin-left: 3px; + border-top-left-radius: 3px; + border-bottom-left-radius: 3px; +} + +.piano-roll-toolbar .chord-guide-toggle-button-3 { + border-right-width: 1px; + border-top-right-radius: 3px; + border-bottom-right-radius: 3px; +} + +.piano-roll-toolbar .chord-guide-toggle-button.active { + border-color: #e0e0e0; +} + +.piano-roll-toolbar .chord-guide-toggle-button.active:hover { + border-color: #f0f0f0; +} + +.piano-roll-toolbar .chord-guide-toggle-button.active:active { + border-color: #d6d6d6; +} + /* Spectrogram toolbar controls */ .spectrogram-toolbar-controls { display: flex; diff --git a/src/components/piano-roll/PianoRoll.tsx b/src/components/piano-roll/PianoRoll.tsx index a1d51f4..848d949 100644 --- a/src/components/piano-roll/PianoRoll.tsx +++ b/src/components/piano-roll/PianoRoll.tsx @@ -21,6 +21,7 @@ import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface'; import { KGAudioFileStorage } from '../../core/io/KGAudioFileStorage'; import { getSuitableChords, noteNameToPitchClass } from '../../util/scaleUtil'; import { showAlert, showChordDetectionOptions, showMidiChordDetectionOptions, showTempoApply, showTempoDetectionOptions } from '../../util/dialogUtil'; +import { matchesKeyboardShortcut } from '../../util/osUtil'; import { normalizeSpectrogramHeightResolution, type SpectrogramHeightResolution, @@ -61,6 +62,7 @@ import { getScrollLeftForViewportRequest, type PendingModeSwitchRequest, } from './pianoRollViewport'; +import { getNextChordGuideSelection, resolveChordGuideContext, type ChordGuideFunction } from './chordGuideUtil'; interface PianoRollProps { onClose: () => void; @@ -126,7 +128,7 @@ const PianoRoll: React.FC = ({ const [snapping, setSnapping] = useState('NO SNAP'); // Chord guide state - const [chordGuide, setChordGuide] = useState('N'); + const [chordGuide, setChordGuide] = useState('N'); // Piano roll state with temporary initial values const [position, setPosition] = useState(initialPosition || { x: 0, y: 0 }); @@ -161,6 +163,12 @@ const PianoRoll: React.FC = ({ () => parseSheetQuantization(sheetQuantization), [sheetQuantization] ); + const chordGuideContext = useMemo(() => { + const project = KGCore.instance().getCurrentProject(); + return resolveChordGuideContext(project, playheadPosition); + }, [playheadPosition]); + const effectiveChordGuideKeySignature = chordGuideContext.keySignature; + const chordGuideMode = chordGuideContext.mode; const pianoRollRef = useRef(null); const pianoRollContentRef = useRef(null); @@ -721,11 +729,11 @@ const PianoRoll: React.FC = ({ }, [setSelectedMode]); // Handle chord guide selection - const handleChordGuideSelect = useCallback((value: string) => { + const handleChordGuideSelect = useCallback((value: ChordGuideFunction) => { setChordGuide(value); }, []); - // Update suitable chords whenever chord guide, key signature, or mode changes + // Update suitable chords whenever chord guide selection or effective key signature changes useEffect(() => { const pianoRollState = KGPianoRollState.instance(); @@ -740,7 +748,7 @@ const PianoRoll: React.FC = ({ } else { // Get suitable chords for the selected function (T/S/D) const functionType = chordGuide as 'T' | 'S' | 'D'; - const suitableChords = getSuitableChords(keySignature, selectedMode, functionType); + const suitableChords = getSuitableChords(effectiveChordGuideKeySignature, chordGuideMode, functionType); // Convert note names to pitch classes (ensuring ascending order) const chordsPitchClasses: Record = {}; @@ -769,11 +777,11 @@ const PianoRoll: React.FC = ({ if (DEBUG_MODE.PIANO_ROLL) { console.log(`Chord guide updated: ${chordGuide} (${functionType})`); - console.log(`Suitable chords for ${keySignature} in ${selectedMode} mode:`, suitableChords); + console.log(`Suitable chords for ${effectiveChordGuideKeySignature} in ${chordGuideMode} mode:`, suitableChords); console.log(`Pitch classes:`, chordsPitchClasses); } } - }, [chordGuide, keySignature, selectedMode]); + }, [chordGuide, chordGuideMode, effectiveChordGuideKeySignature]); // Handler for receiving the setNoteUpdateCounter function from PianoRollContent const handleSetNoteUpdateTrigger = (setNoteFn: React.Dispatch>) => { @@ -1363,14 +1371,30 @@ const PianoRoll: React.FC = ({ // Handle piano roll hotkeys const configManager = ConfigManager.instance(); if (configManager.getIsInitialized()) { - // Chord guide switch hotkey - const switch_key = configManager.get('hotkeys.piano_roll.switch') as string; - if (event.key && event.key.toLowerCase() === switch_key.toLowerCase()) { - // Call the switchChord function exposed by PianoGrid + const chordGuideSwitchShortcut = configManager.get('hotkeys.piano_roll.switch') as string; + const chordGuideSwitchVoicingShortcut = configManager.get('hotkeys.piano_roll.switch_voicing') as string; + + if (chordGuideSwitchShortcut && matchesKeyboardShortcut(event, chordGuideSwitchShortcut)) { + event.preventDefault(); + setChordGuide((current) => getNextChordGuideSelection(current)); + return; + } + + if (chordGuide !== 'N' && matchesKeyboardShortcut(event, 'tab')) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const switchChord = (window as any).__pianoGridSwitchChord; if (typeof switchChord === 'function') { - switchChord(); + switchChord(1); + event.preventDefault(); + } + return; + } + + if (chordGuide !== 'N' && chordGuideSwitchVoicingShortcut && matchesKeyboardShortcut(event, chordGuideSwitchVoicingShortcut)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const switchChord = (window as any).__pianoGridSwitchChord; + if (typeof switchChord === 'function') { + switchChord(-1); event.preventDefault(); } return; @@ -1486,7 +1510,7 @@ const PianoRoll: React.FC = ({ return () => { window.removeEventListener('keydown', handlePianoRollKeyDown); }; - }, [handleQuantSelect, handleSnappingSelect]); + }, [chordGuide, handleQuantSelect, handleSnappingSelect]); // Get the title for the piano roll based on the active region const getPianoRollTitle = () => { @@ -1594,6 +1618,8 @@ const PianoRoll: React.FC = ({ selectedMode={selectedMode} keySignature={keySignature} chordGuide={chordGuide} + chordGuideKeySignature={effectiveChordGuideKeySignature} + chordGuideMode={chordGuideMode} mode={currentMode} audioRegion={audioRegion} trackId={trackId} diff --git a/src/components/piano-roll/PianoRollContent.test.tsx b/src/components/piano-roll/PianoRollContent.test.tsx index c079365..b2fb5bd 100644 --- a/src/components/piano-roll/PianoRollContent.test.tsx +++ b/src/components/piano-roll/PianoRollContent.test.tsx @@ -76,7 +76,7 @@ describe('PianoRollContent', () => { tracks: [], selectedMode: 'ionian', keySignature: 'C major' as KeySignature, - chordGuide: 'N', + chordGuide: 'N' as const, bpm: 120, }; diff --git a/src/components/piano-roll/PianoRollContent.tsx b/src/components/piano-roll/PianoRollContent.tsx index 9b64448..5d47fd0 100644 --- a/src/components/piano-roll/PianoRollContent.tsx +++ b/src/components/piano-roll/PianoRollContent.tsx @@ -36,7 +36,9 @@ interface PianoRollContentProps { onSetDeleteNotesTrigger?: (deleteFn: () => boolean) => void; selectedMode: string; keySignature: KeySignature; - chordGuide: string; + chordGuide: 'N' | 'T' | 'S' | 'D'; + chordGuideKeySignature?: KeySignature; + chordGuideMode?: 'ionian' | 'aeolian'; mode?: 'midi-edit' | 'audio-waveform' | 'spectrogram' | 'hybrid'; audioRegion?: KGAudioRegion; trackId?: string; @@ -73,6 +75,8 @@ const PianoRollContent: React.FC = ({ selectedMode, keySignature, chordGuide, + chordGuideKeySignature = keySignature, + chordGuideMode = 'ionian', mode = 'midi-edit', audioRegion, trackId, @@ -376,6 +380,8 @@ const PianoRollContent: React.FC = ({ selectedMode={selectedMode} keySignature={keySignature} chordGuide={chordGuide} + chordGuideKeySignature={chordGuideKeySignature} + chordGuideMode={chordGuideMode} audioRegion={audioRegion} trackId={trackId} projectName={projectName} diff --git a/src/components/piano-roll/PianoRollToolbar.test.tsx b/src/components/piano-roll/PianoRollToolbar.test.tsx index acd552c..456d01b 100644 --- a/src/components/piano-roll/PianoRollToolbar.test.tsx +++ b/src/components/piano-roll/PianoRollToolbar.test.tsx @@ -56,7 +56,7 @@ describe('PianoRollToolbar', () => { onSnappingSelect: vi.fn(), selectedMode: 'ionian', onModeChange: vi.fn(), - chordGuide: 'N', + chordGuide: 'N' as const, onChordGuideChange: vi.fn(), zoom: 1, onZoomChange: vi.fn(), @@ -106,6 +106,46 @@ describe('PianoRollToolbar', () => { expect(onAutomationTypeChange).toHaveBeenCalledWith('cc-11'); }); + it('renders chord guide toggle buttons and marks off as active by default', () => { + render( + + ); + + expect(screen.getByRole('button', { name: 'Chord guide off' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Chord guide tonic' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Chord guide subdominant' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Chord guide dominant' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Chord guide off' }).className).toContain('active'); + expect(screen.queryByRole('button', { name: 'Chord' })).not.toBeInTheDocument(); + }); + + it('emits the selected chord guide value when toggle buttons are clicked', () => { + const onChordGuideChange = vi.fn(); + + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Chord guide off' })); + fireEvent.click(screen.getByRole('button', { name: 'Chord guide tonic' })); + fireEvent.click(screen.getByRole('button', { name: 'Chord guide subdominant' })); + fireEvent.click(screen.getByRole('button', { name: 'Chord guide dominant' })); + + expect(onChordGuideChange).toHaveBeenNthCalledWith(1, 'N'); + expect(onChordGuideChange).toHaveBeenNthCalledWith(2, 'T'); + expect(onChordGuideChange).toHaveBeenNthCalledWith(3, 'S'); + expect(onChordGuideChange).toHaveBeenNthCalledWith(4, 'D'); + }); + it('hides automation controls in spectrogram mode', () => { render( = [ + { label: '⊘', value: 'N', ariaLabel: 'Chord guide off' }, + { label: 'T', value: 'T', ariaLabel: 'Chord guide tonic' }, + { label: 'S', value: 'S', ariaLabel: 'Chord guide subdominant' }, + { label: 'D', value: 'D', ariaLabel: 'Chord guide dominant' }, +]; + interface PianoRollToolbarProps { showAudioSpectrogramToggle?: boolean; audioSpectrogramEnabled?: boolean; @@ -37,8 +44,8 @@ interface PianoRollToolbarProps { onSnappingSelect: (value: string) => void; selectedMode: string; onModeChange: (value: string) => void; - chordGuide: string; - onChordGuideChange: (value: string) => void; + chordGuide: 'N' | 'T' | 'S' | 'D'; + onChordGuideChange: (value: 'N' | 'T' | 'S' | 'D') => void; blinkButton?: string | null; mode?: 'midi-edit' | 'audio-waveform' | 'spectrogram' | 'hybrid'; thresholdDb?: number; @@ -208,19 +215,21 @@ const PianoRollToolbar: React.FC = ({ buttonClassName="mode-dropdown" showValueAsLabel={true} /> - onChordGuideChange(value)} - label="Chord" - buttonClassName="chord-guide-dropdown" - showValueAsLabel={true} - /> +
+ {CHORD_GUIDE_BUTTONS.map((button, index) => ( + + ))} +
)} diff --git a/src/components/piano-roll/chordGuideUtil.test.ts b/src/components/piano-roll/chordGuideUtil.test.ts new file mode 100644 index 0000000..e8a89e7 --- /dev/null +++ b/src/components/piano-roll/chordGuideUtil.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { KGProject } from '../../core/KGProject'; +import { GlobalTrackType } from '../../core/global-track'; +import { KGKeySignatureRegion } from '../../core/region/KGKeySignatureRegion'; +import { getNextChordCandidateIndex, getNextChordGuideSelection, resolveChordGuideContext } from './chordGuideUtil'; + +describe('chordGuideUtil', () => { + it('cycles chord guide selection in the expected order', () => { + expect(getNextChordGuideSelection('N')).toBe('T'); + expect(getNextChordGuideSelection('T')).toBe('S'); + expect(getNextChordGuideSelection('S')).toBe('D'); + expect(getNextChordGuideSelection('D')).toBe('N'); + }); + + it('cycles candidate indices forward and backward with wraparound', () => { + expect(getNextChordCandidateIndex(0, 4, 1)).toBe(1); + expect(getNextChordCandidateIndex(3, 4, 1)).toBe(0); + expect(getNextChordCandidateIndex(0, 4, -1)).toBe(3); + expect(getNextChordCandidateIndex(2, 4, -1)).toBe(1); + }); + + it('leaves candidate index unchanged for empty or single-candidate lists', () => { + expect(getNextChordCandidateIndex(0, 0, 1)).toBe(0); + expect(getNextChordCandidateIndex(0, 1, 1)).toBe(0); + expect(getNextChordCandidateIndex(0, 1, -1)).toBe(0); + }); + + it('resolves ionian from the effective major key signature at the playhead beat', () => { + const project = new KGProject('test', 16, 0, 120, { numerator: 4, denominator: 4 }, 'C major', 'dorian'); + const signatureTrack = project.getGlobalTracks().find((track) => track.getType() === GlobalTrackType.Signature); + signatureTrack?.setRegions([ + new KGKeySignatureRegion('sig-0', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'C major', 0, 2, 4), + new KGKeySignatureRegion('sig-1', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'G major', 2, 2, 4), + ]); + + expect(resolveChordGuideContext(project, 8)).toEqual({ + keySignature: 'G major', + mode: 'ionian', + }); + }); + + it('resolves aeolian from the effective minor key signature at the playhead beat', () => { + const project = new KGProject('test', 16, 0, 120, { numerator: 4, denominator: 4 }, 'C major', 'ionian'); + const signatureTrack = project.getGlobalTracks().find((track) => track.getType() === GlobalTrackType.Signature); + signatureTrack?.setRegions([ + new KGKeySignatureRegion('sig-0', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'C major', 0, 1, 4), + new KGKeySignatureRegion('sig-1', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'A minor', 1, 2, 4), + ]); + + expect(resolveChordGuideContext(project, 4)).toEqual({ + keySignature: 'A minor', + mode: 'aeolian', + }); + }); +}); diff --git a/src/components/piano-roll/chordGuideUtil.ts b/src/components/piano-roll/chordGuideUtil.ts new file mode 100644 index 0000000..012aeca --- /dev/null +++ b/src/components/piano-roll/chordGuideUtil.ts @@ -0,0 +1,42 @@ +import type { KeySignature, KGProject } from '../../core/KGProject'; +import { getEffectiveKeySignatureAtBeat } from '../../util/globalTrackUtil'; +import { getChordGuideModeFromKeySignature } from '../../util/scaleUtil'; + +export type ChordGuideFunction = 'N' | 'T' | 'S' | 'D'; +export type ChordGuideMode = 'ionian' | 'aeolian'; + +export function getNextChordGuideSelection(current: ChordGuideFunction): ChordGuideFunction { + switch (current) { + case 'N': + return 'T'; + case 'T': + return 'S'; + case 'S': + return 'D'; + default: + return 'N'; + } +} + +export function getNextChordCandidateIndex( + currentIndex: number, + candidateCount: number, + direction: 1 | -1 +): number { + if (candidateCount <= 1) { + return currentIndex; + } + + return (currentIndex + direction + candidateCount) % candidateCount; +} + +export function resolveChordGuideContext(project: KGProject, beat: number): { + keySignature: KeySignature; + mode: ChordGuideMode; +} { + const keySignature = getEffectiveKeySignatureAtBeat(project, beat); + return { + keySignature, + mode: getChordGuideModeFromKeySignature(keySignature), + }; +} diff --git a/src/core/config/ConfigManager.test.ts b/src/core/config/ConfigManager.test.ts new file mode 100644 index 0000000..92a4729 --- /dev/null +++ b/src/core/config/ConfigManager.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { enforceDefaultHotkeysForAppConfig } from './ConfigManager'; + +const baseConfig = { + general: {} as never, + hotkeys: { + main: {} as never, + piano_roll: { + switch: 'g', + switch_voicing: 'shift+tab', + select: 'q', + pencil: 'w', + hold_to_create_note: 'ctrl', + snap_none: '1', + snap_1_4: '2', + snap_1_8: '3', + snap_1_16: '4', + qua_pos_1_4: '5', + qua_pos_1_8: '6', + qua_pos_1_16: '7', + qua_len_1_4: '8', + qua_len_1_8: '9', + qua_len_1_16: '0', + }, + }, + editor: {} as never, + chatbox: {} as never, + audio: {} as never, + templates: {} as never, + chord_guide: {} as never, +}; + +describe('enforceDefaultHotkeysForAppConfig', () => { + it('always replaces saved hotkeys with config defaults', () => { + const result = enforceDefaultHotkeysForAppConfig( + { + ...baseConfig, + hotkeys: { + ...baseConfig.hotkeys, + piano_roll: { + ...baseConfig.hotkeys.piano_roll, + switch: 'tab', + switch_voicing: 'f', + }, + }, + } as never, + baseConfig as never + ); + + expect(result.hotkeys.main).toBe(baseConfig.hotkeys.main); + expect(result.hotkeys.piano_roll.switch).toBe('g'); + expect(result.hotkeys.piano_roll.switch_voicing).toBe('shift+tab'); + }); +}); diff --git a/src/core/config/ConfigManager.ts b/src/core/config/ConfigManager.ts index 10dd957..b7c3702 100644 --- a/src/core/config/ConfigManager.ts +++ b/src/core/config/ConfigManager.ts @@ -65,6 +65,7 @@ interface AppConfig { }; piano_roll: { switch: string; + switch_voicing: string; select: string; pencil: string; hold_to_create_note: string; @@ -106,6 +107,13 @@ interface AppConfig { [key: string]: unknown; } +export function enforceDefaultHotkeysForAppConfig(config: AppConfig, defaultConfig: AppConfig): AppConfig { + return { + ...config, + hotkeys: defaultConfig.hotkeys, + }; +} + /** * ConfigManager - Manages application configuration with IndexedDB persistence * Implements the singleton pattern for global access @@ -162,6 +170,7 @@ export class ConfigManager { // Merge with default config (saved config overrides defaults) this.config = this.mergeConfigs(this.defaultConfig!, savedConfig); + this.config = enforceDefaultHotkeysForAppConfig(this.config, this.defaultConfig!); this.isInitialized = true; console.log('ConfigManager initialized successfully with config:', this.config); @@ -253,7 +262,8 @@ export class ConfigManager { merge_regions: 'ctrl+j' }, piano_roll: { - switch: 'tab', + switch: 'g', + switch_voicing: 'shift+tab', select: 'q', pencil: 'w', hold_to_create_note: 'ctrl', @@ -329,9 +339,10 @@ export class ConfigManager { try { const shouldSanitize = !this.isRunningOnLocalhost() && !this.config.general.persist_api_keys_non_localhost; - const configToPersist = shouldSanitize + const baseConfigToPersist = shouldSanitize ? this.getSanitizedConfigForStorage() : this.config; + const configToPersist = this.removeHotkeysFromConfigForStorage(baseConfigToPersist); await this.storage.save( ConfigManager.CONFIG_KEY, @@ -475,6 +486,11 @@ export class ConfigManager { return { ...this.config }; } + private removeHotkeysFromConfigForStorage(config: AppConfig): AppConfig { + const { hotkeys: _hotkeys, ...configWithoutHotkeys } = config; + return configWithoutHotkeys as AppConfig; + } + /** * Get a value from an object using dot notation */ diff --git a/src/util/messageFilter/UserMessageFilter.test.ts b/src/util/messageFilter/UserMessageFilter.test.ts index 1eb3fe7..09e193a 100644 --- a/src/util/messageFilter/UserMessageFilter.test.ts +++ b/src/util/messageFilter/UserMessageFilter.test.ts @@ -49,7 +49,7 @@ vi.mock('../localLLMConfig', async () => { }; }); -describe('processUserMessage /welcome', () => { +describe('processUserMessage slash commands', () => { beforeEach(() => { configState.clear(); configState.set('general.llm_provider', 'local_browser'); @@ -132,6 +132,49 @@ describe('processUserMessage /welcome', () => { expect(message?.content).toContain('welcome_local_llm.md'); }); + it('fetches the hotkeys guide for /hotkeys', async () => { + const result = await processUserMessage('/hotkeys'); + + expect(fetch).toHaveBeenCalledWith(expect.stringContaining('chat/hotkeys.md')); + expect(result).toMatchObject({ + displayUserMessage: false, + sendToLLM: false, + finalMessageForLLM: null, + metadata: { command: 'hotkeys' }, + }); + expect(result.pseudoAssistantResponse).toContain('chat/hotkeys.md'); + }); + + it('supports /hotkey as an alias of /hotkeys', async () => { + const result = await processUserMessage('/hotkey'); + + expect(fetch).toHaveBeenCalledWith(expect.stringContaining('chat/hotkeys.md')); + expect(result).toMatchObject({ + displayUserMessage: false, + sendToLLM: false, + finalMessageForLLM: null, + metadata: { command: 'hotkeys' }, + }); + expect(result.pseudoAssistantResponse).toContain('chat/hotkeys.md'); + }); + + it('lists the new hotkeys commands for unknown slash commands', async () => { + const result = await processUserMessage('/unknown foo'); + + expect(storeState.setStatus).toHaveBeenCalledWith( + 'Unknown command: /unknown. Available commands: /clear, /welcome, /help, /hotkeys, /hotkey' + ); + expect(result.pseudoAssistantResponse).toBe( + 'Unknown command: /unknown foo.\nAvailable commands: /clear, /welcome, /help, /hotkeys, /hotkey' + ); + expect(result).toMatchObject({ + displayUserMessage: false, + sendToLLM: false, + finalMessageForLLM: null, + metadata: { command: 'unknown' }, + }); + }); + it('blocks local-browser messages when the runtime is hard unsupported', async () => { detectLocalLLMRuntimeSupportMock.mockReturnValue({ supported: false, diff --git a/src/util/messageFilter/UserMessageFilter.ts b/src/util/messageFilter/UserMessageFilter.ts index b168f85..9e9f149 100644 --- a/src/util/messageFilter/UserMessageFilter.ts +++ b/src/util/messageFilter/UserMessageFilter.ts @@ -148,9 +148,37 @@ export async function processUserMessage(originalMessage: string): Promise { + return keySignature.endsWith(' minor') ? 'aeolian' : 'ionian'; +}; + /** * Converts a note name (without octave) to pitch class (0-11) * @param noteName - Note name like "C", "C#", "Db", "F#"