feat: simplify chord guide feature; updated help doc; added hotkeys doc
This commit is contained in:
+51
-10
@@ -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.
|
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.).
|
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:
|
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
|
- `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.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
|
- `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
|
||||||
6. Input the base URL `https://openrouter.ai/api/v1` **OpenAI Compatible Server → Base URL**.
|
- `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
|
### Basic DAW Operations
|
||||||
|
|
||||||
|
Chat commands: `/clear`, `/welcome`, `/help`, `/hotkeys`
|
||||||
|
|
||||||
- Tracks
|
- Tracks
|
||||||
- Add, rename, and reorder tracks from the track info panel.
|
- 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.
|
- 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
|
- Regions
|
||||||
- Create region: with the Pointer tool, double‑click; or hold Ctrl/Cmd and click. With the Pencil tool, single‑click.
|
- 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.
|
- 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)
|
- Piano Roll (MIDI notes)
|
||||||
- Tools: Select vs Pencil.
|
- Tools: Select vs Pencil.
|
||||||
- Create notes: double‑click or Ctrl/Cmd+click (Select); single‑click (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.
|
- 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.
|
- 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.
|
- Close the piano roll with X or ESC.
|
||||||
|
|
||||||
- Intelligent Chord Assistant
|
- Intelligent Chord Assistant
|
||||||
- Enable chord guide from the piano roll toolbar: select T (Tonic), S (Subdominant), or D (Dominant) function.
|
- Enable chord guide from the piano roll toolbar with `⊘`, `T`, `S`, or `D`.
|
||||||
- Hover over any key to see context-aware chord suggestions highlighted in red, matching your selected key signature and mode.
|
- Chord-guide candidates follow the effective key signature at the playhead: major uses Ionian, minor uses Aeolian.
|
||||||
- Press Tab to cycle through different chord voicings for the same harmonic function.
|
- 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.
|
- 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.
|
- 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
|
- Snapping and Quantize
|
||||||
- Set snapping from the NO SNAP menu (top‑right).
|
- Set snapping from the NO SNAP menu (top‑right).
|
||||||
- Quantize timing with Qua. Pos. (start) and Qua. Len. (length).
|
- 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.
|
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!
|
We hope you enjoy using K.G.Studio Musician Assistant!
|
||||||
|
|||||||
@@ -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`
|
||||||
@@ -6,5 +6,6 @@ Tips:
|
|||||||
- Use `/clear` anytime to reset the chat.
|
- Use `/clear` anytime to reset the chat.
|
||||||
- Type `/welcome` to view this message again.
|
- Type `/welcome` to view this message again.
|
||||||
- Type `/help` to view the help message.
|
- Type `/help` to view the help message.
|
||||||
|
- Type `/hotkeys` to view the hotkeys guide.
|
||||||
|
|
||||||
Have fun composing with K.G.Studio Musician Assistant.
|
Have fun composing with K.G.Studio Musician Assistant.
|
||||||
|
|||||||
+2
-1
@@ -59,7 +59,8 @@
|
|||||||
"merge_regions": "ctrl+j"
|
"merge_regions": "ctrl+j"
|
||||||
},
|
},
|
||||||
"piano_roll": {
|
"piano_roll": {
|
||||||
"switch": "tab",
|
"switch": "g",
|
||||||
|
"switch_voicing": "shift+tab",
|
||||||
"select": "q",
|
"select": "q",
|
||||||
"pencil": "w",
|
"pencil": "w",
|
||||||
"hold_to_create_note": "ctrl",
|
"hold_to_create_note": "ctrl",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import SpectrogramCanvas from './SpectrogramCanvas';
|
|||||||
import AudioWaveformCanvas from './AudioWaveformCanvas';
|
import AudioWaveformCanvas from './AudioWaveformCanvas';
|
||||||
import type { KGAudioRegion } from '../../core/region/KGAudioRegion';
|
import type { KGAudioRegion } from '../../core/region/KGAudioRegion';
|
||||||
import type { SpectrogramHeightResolution } from '../../util/spectrogramUtil';
|
import type { SpectrogramHeightResolution } from '../../util/spectrogramUtil';
|
||||||
|
import { getNextChordCandidateIndex } from './chordGuideUtil';
|
||||||
|
|
||||||
interface PianoGridProps {
|
interface PianoGridProps {
|
||||||
gridRef: MutableRefObject<HTMLDivElement | null>;
|
gridRef: MutableRefObject<HTMLDivElement | null>;
|
||||||
@@ -27,7 +28,9 @@ interface PianoGridProps {
|
|||||||
regionStartBeat?: number;
|
regionStartBeat?: number;
|
||||||
selectedMode: string;
|
selectedMode: string;
|
||||||
keySignature: KeySignature;
|
keySignature: KeySignature;
|
||||||
chordGuide: string;
|
chordGuide: 'N' | 'T' | 'S' | 'D';
|
||||||
|
chordGuideKeySignature: KeySignature;
|
||||||
|
chordGuideMode: 'ionian' | 'aeolian';
|
||||||
audioRegion?: KGAudioRegion;
|
audioRegion?: KGAudioRegion;
|
||||||
trackId?: string;
|
trackId?: string;
|
||||||
projectName?: string;
|
projectName?: string;
|
||||||
@@ -59,6 +62,8 @@ const PianoGrid: React.FC<PianoGridProps> = ({
|
|||||||
selectedMode,
|
selectedMode,
|
||||||
keySignature,
|
keySignature,
|
||||||
chordGuide,
|
chordGuide,
|
||||||
|
chordGuideKeySignature,
|
||||||
|
chordGuideMode,
|
||||||
audioRegion,
|
audioRegion,
|
||||||
trackId,
|
trackId,
|
||||||
projectName,
|
projectName,
|
||||||
@@ -162,8 +167,8 @@ const PianoGrid: React.FC<PianoGridProps> = ({
|
|||||||
|
|
||||||
// Use the utility function to get matching chords
|
// Use the utility function to get matching chords
|
||||||
const functionType = chordGuide as 'T' | 'S' | 'D';
|
const functionType = chordGuide as 'T' | 'S' | 'D';
|
||||||
return getMatchingChordsForPitch(cursorPosition.pitch, keySignature, selectedMode, functionType);
|
return getMatchingChordsForPitch(cursorPosition.pitch, chordGuideKeySignature, chordGuideMode, functionType);
|
||||||
}, [cursorPosition, chordGuide, keySignature, selectedMode]);
|
}, [cursorPosition, chordGuide, chordGuideKeySignature, chordGuideMode]);
|
||||||
|
|
||||||
// Calculate chord highlights based on selected chord index
|
// Calculate chord highlights based on selected chord index
|
||||||
const chordHighlights = useMemo(() => {
|
const chordHighlights = useMemo(() => {
|
||||||
@@ -212,9 +217,9 @@ const PianoGrid: React.FC<PianoGridProps> = ({
|
|||||||
|
|
||||||
// Expose switchChord function via window for hotkey handler
|
// Expose switchChord function via window for hotkey handler
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const switchChord = () => {
|
const switchChord = (direction: 1 | -1 = 1) => {
|
||||||
if (matchingChords.length > 1) {
|
if (matchingChords.length > 1) {
|
||||||
setSelectedChordIndex(prev => (prev + 1) % matchingChords.length);
|
setSelectedChordIndex(prev => getNextChordCandidateIndex(prev, matchingChords.length, direction));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -253,6 +253,12 @@
|
|||||||
margin-right: 5px;
|
margin-right: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.piano-roll-chord-guide-toolbar-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
.piano-roll-toolbar .automation-toggle-button {
|
.piano-roll-toolbar .automation-toggle-button {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
min-width: 20px;
|
min-width: 20px;
|
||||||
@@ -290,6 +296,37 @@
|
|||||||
font-size: 10px;
|
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 */
|
||||||
.spectrogram-toolbar-controls {
|
.spectrogram-toolbar-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
|
|||||||
import { KGAudioFileStorage } from '../../core/io/KGAudioFileStorage';
|
import { KGAudioFileStorage } from '../../core/io/KGAudioFileStorage';
|
||||||
import { getSuitableChords, noteNameToPitchClass } from '../../util/scaleUtil';
|
import { getSuitableChords, noteNameToPitchClass } from '../../util/scaleUtil';
|
||||||
import { showAlert, showChordDetectionOptions, showMidiChordDetectionOptions, showTempoApply, showTempoDetectionOptions } from '../../util/dialogUtil';
|
import { showAlert, showChordDetectionOptions, showMidiChordDetectionOptions, showTempoApply, showTempoDetectionOptions } from '../../util/dialogUtil';
|
||||||
|
import { matchesKeyboardShortcut } from '../../util/osUtil';
|
||||||
import {
|
import {
|
||||||
normalizeSpectrogramHeightResolution,
|
normalizeSpectrogramHeightResolution,
|
||||||
type SpectrogramHeightResolution,
|
type SpectrogramHeightResolution,
|
||||||
@@ -61,6 +62,7 @@ import {
|
|||||||
getScrollLeftForViewportRequest,
|
getScrollLeftForViewportRequest,
|
||||||
type PendingModeSwitchRequest,
|
type PendingModeSwitchRequest,
|
||||||
} from './pianoRollViewport';
|
} from './pianoRollViewport';
|
||||||
|
import { getNextChordGuideSelection, resolveChordGuideContext, type ChordGuideFunction } from './chordGuideUtil';
|
||||||
|
|
||||||
interface PianoRollProps {
|
interface PianoRollProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -126,7 +128,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
const [snapping, setSnapping] = useState<string>('NO SNAP');
|
const [snapping, setSnapping] = useState<string>('NO SNAP');
|
||||||
|
|
||||||
// Chord guide state
|
// Chord guide state
|
||||||
const [chordGuide, setChordGuide] = useState<string>('N');
|
const [chordGuide, setChordGuide] = useState<ChordGuideFunction>('N');
|
||||||
|
|
||||||
// Piano roll state with temporary initial values
|
// Piano roll state with temporary initial values
|
||||||
const [position, setPosition] = useState(initialPosition || { x: 0, y: 0 });
|
const [position, setPosition] = useState(initialPosition || { x: 0, y: 0 });
|
||||||
@@ -161,6 +163,12 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
() => parseSheetQuantization(sheetQuantization),
|
() => parseSheetQuantization(sheetQuantization),
|
||||||
[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 pianoRollRef = useRef<HTMLDivElement>(null);
|
||||||
const pianoRollContentRef = useRef<HTMLDivElement>(null);
|
const pianoRollContentRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -721,11 +729,11 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
}, [setSelectedMode]);
|
}, [setSelectedMode]);
|
||||||
|
|
||||||
// Handle chord guide selection
|
// Handle chord guide selection
|
||||||
const handleChordGuideSelect = useCallback((value: string) => {
|
const handleChordGuideSelect = useCallback((value: ChordGuideFunction) => {
|
||||||
setChordGuide(value);
|
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(() => {
|
useEffect(() => {
|
||||||
const pianoRollState = KGPianoRollState.instance();
|
const pianoRollState = KGPianoRollState.instance();
|
||||||
|
|
||||||
@@ -740,7 +748,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
} else {
|
} else {
|
||||||
// Get suitable chords for the selected function (T/S/D)
|
// Get suitable chords for the selected function (T/S/D)
|
||||||
const functionType = chordGuide as '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)
|
// Convert note names to pitch classes (ensuring ascending order)
|
||||||
const chordsPitchClasses: Record<string, number[]> = {};
|
const chordsPitchClasses: Record<string, number[]> = {};
|
||||||
@@ -769,11 +777,11 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
|
|
||||||
if (DEBUG_MODE.PIANO_ROLL) {
|
if (DEBUG_MODE.PIANO_ROLL) {
|
||||||
console.log(`Chord guide updated: ${chordGuide} (${functionType})`);
|
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);
|
console.log(`Pitch classes:`, chordsPitchClasses);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [chordGuide, keySignature, selectedMode]);
|
}, [chordGuide, chordGuideMode, effectiveChordGuideKeySignature]);
|
||||||
|
|
||||||
// Handler for receiving the setNoteUpdateCounter function from PianoRollContent
|
// Handler for receiving the setNoteUpdateCounter function from PianoRollContent
|
||||||
const handleSetNoteUpdateTrigger = (setNoteFn: React.Dispatch<React.SetStateAction<number>>) => {
|
const handleSetNoteUpdateTrigger = (setNoteFn: React.Dispatch<React.SetStateAction<number>>) => {
|
||||||
@@ -1363,14 +1371,30 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
// Handle piano roll hotkeys
|
// Handle piano roll hotkeys
|
||||||
const configManager = ConfigManager.instance();
|
const configManager = ConfigManager.instance();
|
||||||
if (configManager.getIsInitialized()) {
|
if (configManager.getIsInitialized()) {
|
||||||
// Chord guide switch hotkey
|
const chordGuideSwitchShortcut = configManager.get('hotkeys.piano_roll.switch') as string;
|
||||||
const switch_key = configManager.get('hotkeys.piano_roll.switch') as string;
|
const chordGuideSwitchVoicingShortcut = configManager.get('hotkeys.piano_roll.switch_voicing') as string;
|
||||||
if (event.key && event.key.toLowerCase() === switch_key.toLowerCase()) {
|
|
||||||
// Call the switchChord function exposed by PianoGrid
|
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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const switchChord = (window as any).__pianoGridSwitchChord;
|
const switchChord = (window as any).__pianoGridSwitchChord;
|
||||||
if (typeof switchChord === 'function') {
|
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();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -1486,7 +1510,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('keydown', handlePianoRollKeyDown);
|
window.removeEventListener('keydown', handlePianoRollKeyDown);
|
||||||
};
|
};
|
||||||
}, [handleQuantSelect, handleSnappingSelect]);
|
}, [chordGuide, handleQuantSelect, handleSnappingSelect]);
|
||||||
|
|
||||||
// Get the title for the piano roll based on the active region
|
// Get the title for the piano roll based on the active region
|
||||||
const getPianoRollTitle = () => {
|
const getPianoRollTitle = () => {
|
||||||
@@ -1594,6 +1618,8 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
selectedMode={selectedMode}
|
selectedMode={selectedMode}
|
||||||
keySignature={keySignature}
|
keySignature={keySignature}
|
||||||
chordGuide={chordGuide}
|
chordGuide={chordGuide}
|
||||||
|
chordGuideKeySignature={effectiveChordGuideKeySignature}
|
||||||
|
chordGuideMode={chordGuideMode}
|
||||||
mode={currentMode}
|
mode={currentMode}
|
||||||
audioRegion={audioRegion}
|
audioRegion={audioRegion}
|
||||||
trackId={trackId}
|
trackId={trackId}
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ describe('PianoRollContent', () => {
|
|||||||
tracks: [],
|
tracks: [],
|
||||||
selectedMode: 'ionian',
|
selectedMode: 'ionian',
|
||||||
keySignature: 'C major' as KeySignature,
|
keySignature: 'C major' as KeySignature,
|
||||||
chordGuide: 'N',
|
chordGuide: 'N' as const,
|
||||||
bpm: 120,
|
bpm: 120,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ interface PianoRollContentProps {
|
|||||||
onSetDeleteNotesTrigger?: (deleteFn: () => boolean) => void;
|
onSetDeleteNotesTrigger?: (deleteFn: () => boolean) => void;
|
||||||
selectedMode: string;
|
selectedMode: string;
|
||||||
keySignature: KeySignature;
|
keySignature: KeySignature;
|
||||||
chordGuide: string;
|
chordGuide: 'N' | 'T' | 'S' | 'D';
|
||||||
|
chordGuideKeySignature?: KeySignature;
|
||||||
|
chordGuideMode?: 'ionian' | 'aeolian';
|
||||||
mode?: 'midi-edit' | 'audio-waveform' | 'spectrogram' | 'hybrid';
|
mode?: 'midi-edit' | 'audio-waveform' | 'spectrogram' | 'hybrid';
|
||||||
audioRegion?: KGAudioRegion;
|
audioRegion?: KGAudioRegion;
|
||||||
trackId?: string;
|
trackId?: string;
|
||||||
@@ -73,6 +75,8 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
|
|||||||
selectedMode,
|
selectedMode,
|
||||||
keySignature,
|
keySignature,
|
||||||
chordGuide,
|
chordGuide,
|
||||||
|
chordGuideKeySignature = keySignature,
|
||||||
|
chordGuideMode = 'ionian',
|
||||||
mode = 'midi-edit',
|
mode = 'midi-edit',
|
||||||
audioRegion,
|
audioRegion,
|
||||||
trackId,
|
trackId,
|
||||||
@@ -376,6 +380,8 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
|
|||||||
selectedMode={selectedMode}
|
selectedMode={selectedMode}
|
||||||
keySignature={keySignature}
|
keySignature={keySignature}
|
||||||
chordGuide={chordGuide}
|
chordGuide={chordGuide}
|
||||||
|
chordGuideKeySignature={chordGuideKeySignature}
|
||||||
|
chordGuideMode={chordGuideMode}
|
||||||
audioRegion={audioRegion}
|
audioRegion={audioRegion}
|
||||||
trackId={trackId}
|
trackId={trackId}
|
||||||
projectName={projectName}
|
projectName={projectName}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ describe('PianoRollToolbar', () => {
|
|||||||
onSnappingSelect: vi.fn(),
|
onSnappingSelect: vi.fn(),
|
||||||
selectedMode: 'ionian',
|
selectedMode: 'ionian',
|
||||||
onModeChange: vi.fn(),
|
onModeChange: vi.fn(),
|
||||||
chordGuide: 'N',
|
chordGuide: 'N' as const,
|
||||||
onChordGuideChange: vi.fn(),
|
onChordGuideChange: vi.fn(),
|
||||||
zoom: 1,
|
zoom: 1,
|
||||||
onZoomChange: vi.fn(),
|
onZoomChange: vi.fn(),
|
||||||
@@ -106,6 +106,46 @@ describe('PianoRollToolbar', () => {
|
|||||||
expect(onAutomationTypeChange).toHaveBeenCalledWith('cc-11');
|
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', () => {
|
it('hides automation controls in spectrogram mode', () => {
|
||||||
render(
|
render(
|
||||||
<PianoRollToolbar
|
<PianoRollToolbar
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ const POWER_OPTIONS = [
|
|||||||
{ label: 'Strong', value: '0.3' },
|
{ 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 {
|
interface PianoRollToolbarProps {
|
||||||
showAudioSpectrogramToggle?: boolean;
|
showAudioSpectrogramToggle?: boolean;
|
||||||
audioSpectrogramEnabled?: boolean;
|
audioSpectrogramEnabled?: boolean;
|
||||||
@@ -37,8 +44,8 @@ interface PianoRollToolbarProps {
|
|||||||
onSnappingSelect: (value: string) => void;
|
onSnappingSelect: (value: string) => void;
|
||||||
selectedMode: string;
|
selectedMode: string;
|
||||||
onModeChange: (value: string) => void;
|
onModeChange: (value: string) => void;
|
||||||
chordGuide: string;
|
chordGuide: 'N' | 'T' | 'S' | 'D';
|
||||||
onChordGuideChange: (value: string) => void;
|
onChordGuideChange: (value: 'N' | 'T' | 'S' | 'D') => void;
|
||||||
blinkButton?: string | null;
|
blinkButton?: string | null;
|
||||||
mode?: 'midi-edit' | 'audio-waveform' | 'spectrogram' | 'hybrid';
|
mode?: 'midi-edit' | 'audio-waveform' | 'spectrogram' | 'hybrid';
|
||||||
thresholdDb?: number;
|
thresholdDb?: number;
|
||||||
@@ -208,19 +215,21 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
|
|||||||
buttonClassName="mode-dropdown"
|
buttonClassName="mode-dropdown"
|
||||||
showValueAsLabel={true}
|
showValueAsLabel={true}
|
||||||
/>
|
/>
|
||||||
<KGDropdown
|
<div className="piano-roll-chord-guide-toolbar-group" role="group" aria-label="Chord guide">
|
||||||
options={[
|
{CHORD_GUIDE_BUTTONS.map((button, index) => (
|
||||||
{ label: 'Guide: Disabled', value: 'N' },
|
<button
|
||||||
{ label: 'Chord Guide: T', value: 'T' },
|
key={button.value}
|
||||||
{ label: 'Chord Guide: S', value: 'S' },
|
type="button"
|
||||||
{ label: 'Chord Guide: D', value: 'D' }
|
className={`tool-button automation-toggle-button chord-guide-toggle-button chord-guide-toggle-button-${index} ${chordGuide === button.value ? 'active' : ''}`}
|
||||||
]}
|
onClick={() => onChordGuideChange(button.value)}
|
||||||
value={chordGuide}
|
title={button.ariaLabel}
|
||||||
onChange={(value) => onChordGuideChange(value)}
|
aria-label={button.ariaLabel}
|
||||||
label="Chord"
|
aria-pressed={chordGuide === button.value}
|
||||||
buttonClassName="chord-guide-dropdown"
|
>
|
||||||
showValueAsLabel={true}
|
{button.label}
|
||||||
/>
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -65,6 +65,7 @@ interface AppConfig {
|
|||||||
};
|
};
|
||||||
piano_roll: {
|
piano_roll: {
|
||||||
switch: string;
|
switch: string;
|
||||||
|
switch_voicing: string;
|
||||||
select: string;
|
select: string;
|
||||||
pencil: string;
|
pencil: string;
|
||||||
hold_to_create_note: string;
|
hold_to_create_note: string;
|
||||||
@@ -106,6 +107,13 @@ interface AppConfig {
|
|||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function enforceDefaultHotkeysForAppConfig(config: AppConfig, defaultConfig: AppConfig): AppConfig {
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
hotkeys: defaultConfig.hotkeys,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ConfigManager - Manages application configuration with IndexedDB persistence
|
* ConfigManager - Manages application configuration with IndexedDB persistence
|
||||||
* Implements the singleton pattern for global access
|
* Implements the singleton pattern for global access
|
||||||
@@ -162,6 +170,7 @@ export class ConfigManager {
|
|||||||
|
|
||||||
// Merge with default config (saved config overrides defaults)
|
// Merge with default config (saved config overrides defaults)
|
||||||
this.config = this.mergeConfigs(this.defaultConfig!, savedConfig);
|
this.config = this.mergeConfigs(this.defaultConfig!, savedConfig);
|
||||||
|
this.config = enforceDefaultHotkeysForAppConfig(this.config, this.defaultConfig!);
|
||||||
|
|
||||||
this.isInitialized = true;
|
this.isInitialized = true;
|
||||||
console.log('ConfigManager initialized successfully with config:', this.config);
|
console.log('ConfigManager initialized successfully with config:', this.config);
|
||||||
@@ -253,7 +262,8 @@ export class ConfigManager {
|
|||||||
merge_regions: 'ctrl+j'
|
merge_regions: 'ctrl+j'
|
||||||
},
|
},
|
||||||
piano_roll: {
|
piano_roll: {
|
||||||
switch: 'tab',
|
switch: 'g',
|
||||||
|
switch_voicing: 'shift+tab',
|
||||||
select: 'q',
|
select: 'q',
|
||||||
pencil: 'w',
|
pencil: 'w',
|
||||||
hold_to_create_note: 'ctrl',
|
hold_to_create_note: 'ctrl',
|
||||||
@@ -329,9 +339,10 @@ export class ConfigManager {
|
|||||||
try {
|
try {
|
||||||
const shouldSanitize = !this.isRunningOnLocalhost() &&
|
const shouldSanitize = !this.isRunningOnLocalhost() &&
|
||||||
!this.config.general.persist_api_keys_non_localhost;
|
!this.config.general.persist_api_keys_non_localhost;
|
||||||
const configToPersist = shouldSanitize
|
const baseConfigToPersist = shouldSanitize
|
||||||
? this.getSanitizedConfigForStorage()
|
? this.getSanitizedConfigForStorage()
|
||||||
: this.config;
|
: this.config;
|
||||||
|
const configToPersist = this.removeHotkeysFromConfigForStorage(baseConfigToPersist);
|
||||||
|
|
||||||
await this.storage.save(
|
await this.storage.save(
|
||||||
ConfigManager.CONFIG_KEY,
|
ConfigManager.CONFIG_KEY,
|
||||||
@@ -475,6 +486,11 @@ export class ConfigManager {
|
|||||||
return { ...this.config };
|
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
|
* 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(() => {
|
beforeEach(() => {
|
||||||
configState.clear();
|
configState.clear();
|
||||||
configState.set('general.llm_provider', 'local_browser');
|
configState.set('general.llm_provider', 'local_browser');
|
||||||
@@ -132,6 +132,49 @@ describe('processUserMessage /welcome', () => {
|
|||||||
expect(message?.content).toContain('welcome_local_llm.md');
|
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 () => {
|
it('blocks local-browser messages when the runtime is hard unsupported', async () => {
|
||||||
detectLocalLLMRuntimeSupportMock.mockReturnValue({
|
detectLocalLLMRuntimeSupportMock.mockReturnValue({
|
||||||
supported: false,
|
supported: false,
|
||||||
|
|||||||
@@ -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: {
|
default: {
|
||||||
const { setStatus } = useProjectStore.getState();
|
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}`);
|
setStatus(`Unknown command: ${command}. ${help}`);
|
||||||
return {
|
return {
|
||||||
displayUserMessage: false,
|
displayUserMessage: false,
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ export const getRootNoteFromKeySignature = (keySignature: KeySignature): string
|
|||||||
return match[1];
|
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)
|
* Converts a note name (without octave) to pitch class (0-11)
|
||||||
* @param noteName - Note name like "C", "C#", "Db", "F#"
|
* @param noteName - Note name like "C", "C#", "Db", "F#"
|
||||||
|
|||||||
Reference in New Issue
Block a user