feat: simplify chord guide feature; updated help doc; added hotkeys doc

This commit is contained in:
Xiaohan-Tian
2026-05-28 21:03:02 -07:00
parent cd4d5847eb
commit 4d415a7f7d
18 changed files with 522 additions and 51 deletions
+50 -9
View File
@@ -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 tracks 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, doubleclick; or hold Ctrl/Cmd and click. With the Pencil tool, singleclick.
- Move/resize: drag the body to move; drag edges to resize.
- Open Piano Roll via the small pencil at a regions topleft.
- Open Piano Roll via the small pencil at a region's topleft.
- Piano Roll (MIDI notes)
- Tools: Select vs Pencil.
- Create notes: doubleclick or Ctrl/Cmd+click (Select); singleclick (Pencil).
- Select notes: click; Shift+click for multiselect; drag to boxselect.
- 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: doubleclick 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 (topright).
- Quantize timing with Qua. Pos. (start) and Qua. Len. (length).
+59
View File
@@ -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`
+1
View File
@@ -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.
+2 -1
View File
@@ -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",
+10 -5
View File
@@ -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<HTMLDivElement | null>;
@@ -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<PianoGridProps> = ({
selectedMode,
keySignature,
chordGuide,
chordGuideKeySignature,
chordGuideMode,
audioRegion,
trackId,
projectName,
@@ -162,8 +167,8 @@ const PianoGrid: React.FC<PianoGridProps> = ({
// 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<PianoGridProps> = ({
// 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));
}
};
+37
View File
@@ -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;
+38 -12
View File
@@ -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<PianoRollProps> = ({
const [snapping, setSnapping] = useState<string>('NO SNAP');
// Chord guide state
const [chordGuide, setChordGuide] = useState<string>('N');
const [chordGuide, setChordGuide] = useState<ChordGuideFunction>('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<PianoRollProps> = ({
() => 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<HTMLDivElement>(null);
const pianoRollContentRef = useRef<HTMLDivElement>(null);
@@ -721,11 +729,11 @@ const PianoRoll: React.FC<PianoRollProps> = ({
}, [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<PianoRollProps> = ({
} 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<string, number[]> = {};
@@ -769,11 +777,11 @@ const PianoRoll: React.FC<PianoRollProps> = ({
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<React.SetStateAction<number>>) => {
@@ -1363,14 +1371,30 @@ const PianoRoll: React.FC<PianoRollProps> = ({
// 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<PianoRollProps> = ({
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<PianoRollProps> = ({
selectedMode={selectedMode}
keySignature={keySignature}
chordGuide={chordGuide}
chordGuideKeySignature={effectiveChordGuideKeySignature}
chordGuideMode={chordGuideMode}
mode={currentMode}
audioRegion={audioRegion}
trackId={trackId}
@@ -76,7 +76,7 @@ describe('PianoRollContent', () => {
tracks: [],
selectedMode: 'ionian',
keySignature: 'C major' as KeySignature,
chordGuide: 'N',
chordGuide: 'N' as const,
bpm: 120,
};
@@ -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<PianoRollContentProps> = ({
selectedMode,
keySignature,
chordGuide,
chordGuideKeySignature = keySignature,
chordGuideMode = 'ionian',
mode = 'midi-edit',
audioRegion,
trackId,
@@ -376,6 +380,8 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
selectedMode={selectedMode}
keySignature={keySignature}
chordGuide={chordGuide}
chordGuideKeySignature={chordGuideKeySignature}
chordGuideMode={chordGuideMode}
audioRegion={audioRegion}
trackId={trackId}
projectName={projectName}
@@ -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(
<PianoRollToolbar
{...baseProps}
mode="midi-edit"
showAutomationControls={false}
/>
);
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(
<PianoRollToolbar
{...baseProps}
mode="midi-edit"
showAutomationControls={false}
onChordGuideChange={onChordGuideChange}
/>
);
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(
<PianoRollToolbar
+24 -15
View File
@@ -16,6 +16,13 @@ const POWER_OPTIONS = [
{ label: 'Strong', value: '0.3' },
];
const CHORD_GUIDE_BUTTONS: Array<{ label: string; value: 'N' | 'T' | 'S' | 'D'; ariaLabel: string }> = [
{ 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<PianoRollToolbarProps> = ({
buttonClassName="mode-dropdown"
showValueAsLabel={true}
/>
<KGDropdown
options={[
{ label: 'Guide: Disabled', value: 'N' },
{ label: 'Chord Guide: T', value: 'T' },
{ label: 'Chord Guide: S', value: 'S' },
{ label: 'Chord Guide: D', value: 'D' }
]}
value={chordGuide}
onChange={(value) => onChordGuideChange(value)}
label="Chord"
buttonClassName="chord-guide-dropdown"
showValueAsLabel={true}
/>
<div className="piano-roll-chord-guide-toolbar-group" role="group" aria-label="Chord guide">
{CHORD_GUIDE_BUTTONS.map((button, index) => (
<button
key={button.value}
type="button"
className={`tool-button automation-toggle-button chord-guide-toggle-button chord-guide-toggle-button-${index} ${chordGuide === button.value ? 'active' : ''}`}
onClick={() => onChordGuideChange(button.value)}
title={button.ariaLabel}
aria-label={button.ariaLabel}
aria-pressed={chordGuide === button.value}
>
{button.label}
</button>
))}
</div>
</div>
)}
@@ -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',
});
});
});
@@ -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),
};
}
+54
View File
@@ -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');
});
});
+18 -2
View File
@@ -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
*/
@@ -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,
+29 -1
View File
@@ -148,9 +148,37 @@ export async function processUserMessage(originalMessage: string): Promise<UserM
}
}
case '/hotkeys':
case '/hotkey': {
try {
const url = `${import.meta.env.BASE_URL}chat/hotkeys.md`;
const resp = await fetch(url);
if (!resp.ok) {
throw new Error(`Failed to fetch ${url}: ${resp.status}`);
}
const md = await resp.text();
return {
displayUserMessage: false,
sendToLLM: false,
finalMessageForLLM: null,
pseudoAssistantResponse: md,
metadata: { command: 'hotkeys' }
};
} catch (err) {
const fallback = 'Hotkeys guide is currently unavailable.';
return {
displayUserMessage: false,
sendToLLM: false,
finalMessageForLLM: null,
pseudoAssistantResponse: fallback,
metadata: { command: 'hotkeys', error: String(err) }
};
}
}
default: {
const { setStatus } = useProjectStore.getState();
const help = 'Available commands: /clear, /welcome, /help';
const help = 'Available commands: /clear, /welcome, /help, /hotkeys, /hotkey';
setStatus(`Unknown command: ${command}. ${help}`);
return {
displayUserMessage: false,
+8
View File
@@ -17,6 +17,14 @@ export const getRootNoteFromKeySignature = (keySignature: KeySignature): string
return match[1];
};
/**
* Resolves the chord-guide mode from a key signature.
* Chord guiding only supports major/minor quality and maps them to Ionian/Aeolian.
*/
export const getChordGuideModeFromKeySignature = (keySignature: KeySignature): 'ionian' | 'aeolian' => {
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#"