feat: added i18n support; added Simplified Chinese support

This commit is contained in:
Xiaohan-Tian
2026-05-30 20:02:55 -07:00
parent c17d169dc6
commit 957f6db950
58 changed files with 3354 additions and 520 deletions
+87 -5
View File
@@ -3,6 +3,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen } from '@testing-library/react';
import PianoKeys from './PianoKeys';
import { createMockMidiNote, createMockMidiRegion, createMockMidiTrack } from '../../test/utils/mock-data';
import { I18nContext } from '../../i18n/I18nProvider';
import { translate } from '../../i18n/translate';
type TestLiveNoteActivityListener = (...args: [{ pitch: number; isNoteOn: boolean }]) => void;
@@ -48,6 +50,24 @@ vi.mock('../../core/midi-input/KGMidiInput', () => ({
},
}));
function renderWithLocale(
ui: React.ReactElement,
resolvedLocale: 'en_us' | 'zh_cn' = 'en_us',
) {
return render(
<I18nContext.Provider
value={{
languageSetting: resolvedLocale,
resolvedLocale,
setLanguageSetting: async () => undefined,
t: (key, params) => translate(key, params, resolvedLocale),
}}
>
{ui}
</I18nContext.Provider>,
);
}
describe('PianoKeys', () => {
const activeRegion = createMockMidiRegion({
trackId: '1',
@@ -66,7 +86,7 @@ describe('PianoKeys', () => {
});
it('shows dot and background feedback for mouse preview while held', () => {
const { container } = render(<PianoKeys activeRegion={activeRegion} />);
const { container } = renderWithLocale(<PianoKeys activeRegion={activeRegion} />);
const key = container.querySelector('[data-note="C4"]') as HTMLElement;
fireEvent.mouseDown(key);
@@ -81,7 +101,7 @@ describe('PianoKeys', () => {
});
it('shows MIDI activity dot without background feedback', () => {
const { container } = render(<PianoKeys activeRegion={activeRegion} />);
const { container } = renderWithLocale(<PianoKeys activeRegion={activeRegion} />);
const key = container.querySelector('[data-note="C4"]') as HTMLElement;
expect(midiInputMock.addLiveNoteActivityListener).toHaveBeenCalledTimes(1);
@@ -103,7 +123,7 @@ describe('PianoKeys', () => {
storeState.isPlaying = true;
storeState.playheadPosition = 1;
const { container } = render(<PianoKeys activeRegion={activeRegion} />);
const { container } = renderWithLocale(<PianoKeys activeRegion={activeRegion} />);
const key = container.querySelector('[data-note="C4"]') as HTMLElement;
expect(key.className).toContain('playback-active');
@@ -115,7 +135,7 @@ describe('PianoKeys', () => {
storeState.isPlaying = true;
storeState.playheadPosition = 1;
const { container, rerender } = render(<PianoKeys activeRegion={activeRegion} />);
const { container, rerender } = renderWithLocale(<PianoKeys activeRegion={activeRegion} />);
const key = container.querySelector('[data-note="C4"]') as HTMLElement;
fireEvent.mouseDown(key);
@@ -127,7 +147,18 @@ describe('PianoKeys', () => {
expect(screen.getByTestId('piano-key-dot-C4')).toBeInTheDocument();
storeState.isPlaying = false;
rerender(<PianoKeys activeRegion={activeRegion} />);
rerender(
<I18nContext.Provider
value={{
languageSetting: 'en_us',
resolvedLocale: 'en_us',
setLanguageSetting: async () => undefined,
t: (key, params) => translate(key, params, 'en_us'),
}}
>
<PianoKeys activeRegion={activeRegion} />
</I18nContext.Provider>,
);
expect(key.className).toContain('visual-active');
expect(screen.getByTestId('piano-key-dot-C4')).toBeInTheDocument();
@@ -143,4 +174,55 @@ describe('PianoKeys', () => {
expect(screen.queryByTestId('piano-key-dot-C4')).not.toBeInTheDocument();
});
it('renders translated drum key labels under zh-CN for GM drum-kit tracks', () => {
storeState.tracks = [createMockMidiTrack({ id: 1, instrument: 'standard' })];
const drumRegion = createMockMidiRegion({
trackId: '1',
notes: [createMockMidiNote({ id: 'kick', pitch: 35, startBeat: 0, endBeat: 1 })],
});
const { container } = renderWithLocale(<PianoKeys activeRegion={drumRegion} />, 'zh_cn');
const key = container.querySelector('[data-note="B1"]');
expect(key?.querySelector('.key-label')?.textContent).toBe('原底鼓');
});
it('updates visible drum labels when locale changes', () => {
storeState.tracks = [createMockMidiTrack({ id: 1, instrument: 'standard' })];
const drumRegion = createMockMidiRegion({
trackId: '1',
notes: [createMockMidiNote({ id: 'hihat', pitch: 42, startBeat: 0, endBeat: 1 })],
});
const view = renderWithLocale(<PianoKeys activeRegion={drumRegion} />, 'en_us');
expect(screen.getByText('ClosedHH')).toBeInTheDocument();
view.rerender(
<I18nContext.Provider
value={{
languageSetting: 'zh_cn',
resolvedLocale: 'zh_cn',
setLanguageSetting: async () => undefined,
t: (key, params) => translate(key, params, 'zh_cn'),
}}
>
<PianoKeys activeRegion={drumRegion} />
</I18nContext.Provider>,
);
expect(screen.getByText('闭镲')).toBeInTheDocument();
});
it('keeps tuned percussion tracks on standard note labels', () => {
storeState.tracks = [createMockMidiTrack({ id: 1, instrument: 'taiko_drum' })];
const { container } = renderWithLocale(<PianoKeys activeRegion={activeRegion} />, 'zh_cn');
const key = container.querySelector('[data-note="C4"]');
expect(key?.querySelector('.key-label')?.textContent).toBe('C4');
expect(screen.queryByText('原底鼓')).not.toBeInTheDocument();
});
});
+8 -5
View File
@@ -1,10 +1,12 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
import { noteNameToPitch, midiPercussionKeyMap } from '../../util/midiUtil';
import { noteNameToPitch } from '../../util/midiUtil';
import { useProjectStore } from '../../stores/projectStore';
import { KGMidiTrack } from '../../core/track/KGMidiTrack';
import { KGMidiInput, type LiveMidiNoteActivityEvent } from '../../core/midi-input/KGMidiInput';
import { useI18n } from '../../i18n/useI18n';
import { getPercussionKeyShortLabel, isGmDrumKitInstrument } from '../../i18n/percussion';
interface PianoKeysProps {
activeRegion: KGMidiRegion | null;
@@ -30,6 +32,7 @@ function decrementPitchCount(source: Map<number, number>, pitch: number): Map<nu
}
const PianoKeys: React.FC<PianoKeysProps> = ({ activeRegion }) => {
const { t } = useI18n();
const [mouseActivePitches, setMouseActivePitches] = useState<Map<number, number>>(new Map());
const [midiActivePitches, setMidiActivePitches] = useState<Map<number, number>>(new Map());
const mouseActivePitchesRef = useRef<Map<number, number>>(new Map());
@@ -41,7 +44,7 @@ const PianoKeys: React.FC<PianoKeysProps> = ({ activeRegion }) => {
const isDrumTrack = useMemo(() => {
if (!activeRegion) return false;
const track = tracks.find(t => t.getId().toString() === activeRegion.getTrackId());
return track instanceof KGMidiTrack && track.getInstrument() === 'standard';
return track instanceof KGMidiTrack && isGmDrumKitInstrument(track.getInstrument());
}, [activeRegion, tracks]);
const playbackActivePitches = useMemo(() => {
@@ -198,9 +201,9 @@ const PianoKeys: React.FC<PianoKeysProps> = ({ activeRegion }) => {
// For drum tracks, show drum labels when available
let labelContent = null;
if (isDrumTrack) {
const drumInfo = midiPercussionKeyMap[pitch];
if (drumInfo) {
labelContent = <span className="key-label">{drumInfo.shortName}</span>;
const drumLabel = getPercussionKeyShortLabel(pitch, t);
if (drumLabel) {
labelContent = <span className="key-label">{drumLabel}</span>;
}
} else if (isC) {
labelContent = <span className="key-label">C{octave}</span>;
+17
View File
@@ -296,6 +296,16 @@
font-size: 10px;
}
.piano-roll-toolbar .mode-dropdown {
min-width: 112px;
white-space: nowrap;
}
.piano-roll-toolbar .toolbar-left .quant-dropdown {
white-space: nowrap;
min-width: max-content;
}
.piano-roll-toolbar .chord-guide-toggle-button {
margin: 0;
border-radius: 0;
@@ -715,10 +725,17 @@
}
.key-label {
box-sizing: border-box;
display: block;
flex: 1 1 auto;
font-size: 10px;
line-height: 1;
min-width: 0;
overflow: hidden;
padding-left: 5px;
padding-right: 18px;
position: relative;
white-space: nowrap;
z-index: 1;
}
+25 -14
View File
@@ -13,7 +13,13 @@ import PianoRollContent from './PianoRollContent';
import { KGCore } from '../../core/KGCore';
import { KGMidiNote } from '../../core/midi/KGMidiNote';
import { KGMidiTrack, type InstrumentType } from '../../core/track/KGMidiTrack';
import { KGPianoRollState } from '../../core/state/KGPianoRollState';
import {
KGPianoRollState,
PIANO_ROLL_NO_SNAP,
type PianoRollQuantizeLengthValue,
type PianoRollQuantizePositionValue,
type PianoRollSnapValue,
} from '../../core/state/KGPianoRollState';
import { ConfigManager } from '../../core/config/ConfigManager';
import { beatsToBar } from '../../util/midiUtil';
import { ReplaceChordRegionsInRangeCommand, UpdateRegionCommand } from '../../core/commands';
@@ -121,11 +127,11 @@ const PianoRoll: React.FC<PianoRollProps> = ({
const titleInputRef = useRef<HTMLInputElement>(null);
// Quantization state
const [quantPosition, setQuantPosition] = useState<string>('1/8');
const [quantLength, setQuantLength] = useState<string>('1/8');
const [quantPosition, setQuantPosition] = useState<PianoRollQuantizePositionValue>('1/8');
const [quantLength, setQuantLength] = useState<PianoRollQuantizeLengthValue>('1/8');
// Snapping state
const [snapping, setSnapping] = useState<string>('NO SNAP');
const [snapping, setSnapping] = useState<PianoRollSnapValue>(PIANO_ROLL_NO_SNAP);
// Chord guide state
const [chordGuide, setChordGuide] = useState<ChordGuideFunction>('N');
@@ -713,8 +719,9 @@ const PianoRoll: React.FC<PianoRollProps> = ({
// Handle snapping selection
const handleSnappingSelect = useCallback((value: string) => {
setSnapping(value);
KGPianoRollState.instance().setCurrentSnap(value);
const nextValue = value as PianoRollSnapValue;
setSnapping(nextValue);
KGPianoRollState.instance().setCurrentSnap(nextValue);
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Selected snapping: ${value}`);
}
@@ -974,19 +981,21 @@ const PianoRoll: React.FC<PianoRollProps> = ({
// Handle quantization selection
const handleQuantSelect = useCallback((type: 'position' | 'length', value: string) => {
if (type === 'position') {
setQuantPosition(value);
const nextValue = value as PianoRollQuantizePositionValue;
setQuantPosition(nextValue);
// Apply quantization immediately when position quantization is changed
quantizeSelectedNotes(value);
quantizeSelectedNotes(nextValue);
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`quant-position selected: ${value}`);
}
} else {
setQuantLength(value);
const nextValue = value as PianoRollQuantizeLengthValue;
setQuantLength(nextValue);
// Apply length quantization immediately when length quantization is changed
quantizeNoteLength(value);
quantizeNoteLength(nextValue);
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`quant-length selected: ${value}`);
@@ -1407,7 +1416,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
// Check snapping hotkeys
if (event.key === snap_none_key) {
actionType = 'snap';
actionValue = 'NO SNAP';
actionValue = PIANO_ROLL_NO_SNAP;
} else if (event.key === snap_1_4_key) {
actionType = 'snap';
actionValue = '1/4';
@@ -1453,7 +1462,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
if (actionType === 'snap') {
// Validate the snap value exists in snap options
if (KGPianoRollState.SNAP_OPTIONS.includes(actionValue)) {
if (KGPianoRollState.SNAP_OPTIONS.some(option => option.value === actionValue)) {
// Change snapping value
handleSnappingSelect(actionValue);
@@ -1467,9 +1476,11 @@ const PianoRoll: React.FC<PianoRollProps> = ({
}
} else if (actionType === 'quantize' && quantType) {
// Validate the quantValue exists in the appropriate options
const validOptions = quantType === 'length' ? KGPianoRollState.QUANT_LEN_OPTIONS : KGPianoRollState.QUANT_POS_OPTIONS;
const validOptions = quantType === 'length'
? KGPianoRollState.QUANT_LEN_OPTIONS
: KGPianoRollState.QUANT_POS_OPTIONS;
if (validOptions.includes(actionValue)) {
if (validOptions.some(option => option.value === actionValue)) {
// Apply quantization
handleQuantSelect(quantType, actionValue);
@@ -1,8 +1,10 @@
import React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { KGPianoRollState } from '../../core/state/KGPianoRollState';
import { KGPianoRollState, PIANO_ROLL_NO_SNAP } from '../../core/state/KGPianoRollState';
import { createMockMidiControllerEvent, createMockMidiPitchBend, createMockMidiRegion, createMockMidiTrack } from '../../test/utils/mock-data';
import { I18nContext } from '../../i18n/I18nProvider';
import { translate } from '../../i18n/translate';
const coreMock = {
selectedItems: [] as Array<{ getId(): string }>,
@@ -45,6 +47,19 @@ import PianoRollAutomationLane from './PianoRollAutomationLane';
import { getControllerNumberForAutomationType } from './pianoRollAutomation';
describe('PianoRollAutomationLane', () => {
const renderWithLocale = (ui: React.ReactElement, locale: 'en_us' | 'zh_cn' = 'en_us') => render(
<I18nContext.Provider
value={{
languageSetting: locale,
resolvedLocale: locale,
setLanguageSetting: async () => undefined,
t: (key, params) => translate(key, params, locale),
}}
>
{ui}
</I18nContext.Provider>
);
beforeEach(() => {
coreMock.selectedItems = [];
coreMock.currentProjectTracks = [];
@@ -76,7 +91,7 @@ describe('PianoRollAutomationLane', () => {
coreMock.currentProjectTracks = storeState.tracks;
storeState.selectedPitchBendIds = ['bend-1'];
const { container } = render(
const { container } = renderWithLocale(
<PianoRollAutomationLane
activeRegion={region}
automationType="pitch-bend"
@@ -106,7 +121,7 @@ describe('PianoRollAutomationLane', () => {
storeState.tracks = [createMockMidiTrack({ id: 1, regions: [region] })];
coreMock.currentProjectTracks = storeState.tracks;
const { container } = render(
const { container } = renderWithLocale(
<PianoRollAutomationLane
activeRegion={region}
automationType="pitch-bend"
@@ -144,9 +159,9 @@ describe('PianoRollAutomationLane', () => {
});
storeState.tracks = [createMockMidiTrack({ id: 1, regions: [region] })];
coreMock.currentProjectTracks = storeState.tracks;
KGPianoRollState.instance().setCurrentSnap('NO SNAP');
KGPianoRollState.instance().setCurrentSnap(PIANO_ROLL_NO_SNAP);
const { container } = render(
const { container } = renderWithLocale(
<PianoRollAutomationLane
activeRegion={region}
automationType="pitch-bend"
@@ -188,7 +203,7 @@ describe('PianoRollAutomationLane', () => {
coreMock.currentProjectTracks = storeState.tracks;
storeState.selectedControllerEventIds = ['cc7-1'];
const { container } = render(
const { container } = renderWithLocale(
<PianoRollAutomationLane
activeRegion={region}
automationType="cc-7"
@@ -224,7 +239,7 @@ describe('PianoRollAutomationLane', () => {
storeState.tracks = [createMockMidiTrack({ id: 1, regions: [region] })];
coreMock.currentProjectTracks = storeState.tracks;
const { container } = render(
const { container } = renderWithLocale(
<PianoRollAutomationLane
activeRegion={region}
automationType="cc-64"
@@ -253,7 +268,7 @@ describe('PianoRollAutomationLane', () => {
coreMock.currentProjectTracks = storeState.tracks;
storeState.selectedPitchBendIds = ['bend-2'];
const { container } = render(
const { container } = renderWithLocale(
<PianoRollAutomationLane
activeRegion={region}
automationType="pitch-bend"
@@ -286,7 +301,7 @@ describe('PianoRollAutomationLane', () => {
storeState.tracks = [createMockMidiTrack({ id: 1, regions: [region] })];
coreMock.currentProjectTracks = storeState.tracks;
const { container } = render(
const { container } = renderWithLocale(
<PianoRollAutomationLane
activeRegion={region}
automationType="pitch-bend"
@@ -308,4 +323,27 @@ describe('PianoRollAutomationLane', () => {
expect(getScrollLayer()?.classList.contains('pencil-cursor')).toBe(false);
});
});
it('renders translated automation lane labels in zh-CN', () => {
const region = createMockMidiRegion({
trackId: '1',
trackIndex: 0,
startFromBeat: 4,
pitchBends: [createMockMidiPitchBend({ id: 'bend-1', beat: 0.5, value: 8192 })],
});
storeState.tracks = [createMockMidiTrack({ id: 1, regions: [region] })];
coreMock.currentProjectTracks = storeState.tracks;
renderWithLocale(
<PianoRollAutomationLane
activeRegion={region}
automationType="pitch-bend"
maxBars={8}
timeSignature={{ numerator: 4, denominator: 4 }}
/>,
'zh_cn',
);
expect(screen.getByLabelText('弯音 自动化轨')).toBeInTheDocument();
});
});
@@ -5,7 +5,7 @@ import { UpdateControllerEventPropertiesCommand } from '../../core/commands/note
import { UpdatePitchBendPropertiesCommand } from '../../core/commands/note/UpdatePitchBendPropertiesCommand';
import { KGMidiControllerEvent } from '../../core/midi/KGMidiControllerEvent';
import { KGMidiPitchBend } from '../../core/midi/KGMidiPitchBend';
import { KGPianoRollState } from '../../core/state/KGPianoRollState';
import { KGPianoRollState, PIANO_ROLL_NO_SNAP } from '../../core/state/KGPianoRollState';
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
import {
MIDI_PITCH_BEND_MAX,
@@ -19,11 +19,12 @@ import { useProjectStore } from '../../stores/projectStore';
import { PIANO_ROLL_CONSTANTS } from '../../constants';
import { getSnappedBeatPosition } from './pianoRollSnap';
import {
getAutomationLabel,
getAutomationInterpolationMode,
getControllerNumberForAutomationType,
PIANO_ROLL_AUTOMATION_OPTIONS,
type PianoRollAutomationType,
} from './pianoRollAutomation';
import { useI18n } from '../../i18n/useI18n';
interface AutomationPoint {
id: string;
@@ -73,6 +74,7 @@ const PianoRollAutomationLane: React.FC<PianoRollAutomationLaneProps> = ({
horizontalScrollLeft = 0,
onHorizontalWheel,
}) => {
const { t } = useI18n();
const laneRef = useRef<HTMLDivElement | null>(null);
const isLassoSelectingRef = useRef(false);
const lassoShiftKeyRef = useRef(false);
@@ -237,8 +239,7 @@ const PianoRollAutomationLane: React.FC<PianoRollAutomationLaneProps> = ({
? tracks.find(track => track.getId().toString() === activeRegion.getTrackId()) ?? null
: null;
const selectedOption = PIANO_ROLL_AUTOMATION_OPTIONS.find(option => option.value === automationType);
const laneLabel = selectedOption?.label ?? automationType;
const laneLabel = getAutomationLabel(automationType, t);
const interpolationMode = getAutomationInterpolationMode(automationType);
const totalBeats = maxBars * timeSignature.numerator;
const totalWidth = 'calc(var(--max-number-of-bars) * var(--region-grid-bar-width) + var(--region-piano-key-width))';
@@ -412,7 +413,7 @@ const PianoRollAutomationLane: React.FC<PianoRollAutomationLaneProps> = ({
}
const rawAbsoluteBeat = (coordinates.x - keyWidth) / beatWidth;
const snappedAbsoluteBeat = KGPianoRollState.instance().getCurrentSnap() === 'NO SNAP'
const snappedAbsoluteBeat = KGPianoRollState.instance().getCurrentSnap() === PIANO_ROLL_NO_SNAP
? rawAbsoluteBeat
: getSnappedBeatPosition(rawAbsoluteBeat, KGPianoRollState.instance().getCurrentSnap());
const beatDelta = snappedAbsoluteBeat - dragState.originAbsoluteBeat;
@@ -673,7 +674,7 @@ const PianoRollAutomationLane: React.FC<PianoRollAutomationLaneProps> = ({
const rawAbsoluteBeat = (coordinates.x - keyWidth) / beatWidth;
const currentSnap = KGPianoRollState.instance().getCurrentSnap();
const absoluteBeat = currentSnap === 'NO SNAP'
const absoluteBeat = currentSnap === PIANO_ROLL_NO_SNAP
? rawAbsoluteBeat
: getSnappedBeatPosition(rawAbsoluteBeat, currentSnap);
const relativeBeat = absoluteBeat - activeRegion.getStartFromBeat();
@@ -770,7 +771,7 @@ const PianoRollAutomationLane: React.FC<PianoRollAutomationLaneProps> = ({
<div
className="piano-roll-automation-lane"
data-testid="piano-roll-automation-lane"
aria-label={`${laneLabel} automation lane`}
aria-label={t('pianoRoll.automationLane', { label: laneLabel })}
ref={laneRef}
>
<div className="piano-roll-automation-track" style={{ width: totalWidth }}>
@@ -2,6 +2,8 @@ import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import PianoRollToolbar from './PianoRollToolbar';
import { I18nContext } from '../../i18n/I18nProvider';
import { translate } from '../../i18n/translate';
vi.mock('../common', () => ({
KGDropdown: ({
@@ -34,11 +36,25 @@ vi.mock('../../core/KGCore', () => ({
FUNCTIONAL_CHORDS_DATA: {
ionian: { name: 'Ionian' },
dorian: { name: 'Dorian' },
harmonic_minor: { name: 'Harmonic Minor' },
},
},
}));
describe('PianoRollToolbar', () => {
const renderWithLocale = (ui: React.ReactElement, locale: 'en_us' | 'zh_cn' = 'en_us') => render(
<I18nContext.Provider
value={{
languageSetting: locale,
resolvedLocale: locale,
setLanguageSetting: async () => undefined,
t: (key, params) => translate(key, params, locale),
}}
>
{ui}
</I18nContext.Provider>
);
const baseProps = {
sheetMusicViewEnabled: false,
onSheetMusicViewToggle: vi.fn(),
@@ -52,7 +68,7 @@ describe('PianoRollToolbar', () => {
quantPosition: '1/8',
quantLength: '1/8',
onQuantSelect: vi.fn(),
snapping: 'NO SNAP',
snapping: 'none',
onSnappingSelect: vi.fn(),
selectedMode: 'ionian',
onModeChange: vi.fn(),
@@ -66,7 +82,7 @@ describe('PianoRollToolbar', () => {
it('shows automation controls in midi mode and toggles the lane', () => {
const onAutomationToggle = vi.fn();
render(
renderWithLocale(
<PianoRollToolbar
{...baseProps}
mode="midi-edit"
@@ -89,7 +105,7 @@ describe('PianoRollToolbar', () => {
it('changes the automation type from the dropdown', () => {
const onAutomationTypeChange = vi.fn();
render(
renderWithLocale(
<PianoRollToolbar
{...baseProps}
mode="hybrid"
@@ -107,7 +123,7 @@ describe('PianoRollToolbar', () => {
});
it('renders chord guide toggle buttons and marks off as active by default', () => {
render(
renderWithLocale(
<PianoRollToolbar
{...baseProps}
mode="midi-edit"
@@ -126,7 +142,7 @@ describe('PianoRollToolbar', () => {
it('emits the selected chord guide value when toggle buttons are clicked', () => {
const onChordGuideChange = vi.fn();
render(
renderWithLocale(
<PianoRollToolbar
{...baseProps}
mode="midi-edit"
@@ -147,7 +163,7 @@ describe('PianoRollToolbar', () => {
});
it('hides automation controls in spectrogram mode', () => {
render(
renderWithLocale(
<PianoRollToolbar
{...baseProps}
mode="spectrogram"
@@ -162,7 +178,7 @@ describe('PianoRollToolbar', () => {
it('shows the spectrogram toggle for pure audio waveform mode and toggles it', () => {
const onAudioSpectrogramToggle = vi.fn();
render(
renderWithLocale(
<PianoRollToolbar
{...baseProps}
mode="audio-waveform"
@@ -179,7 +195,7 @@ describe('PianoRollToolbar', () => {
});
it('hides the spectrogram toggle in hybrid mode', () => {
render(
renderWithLocale(
<PianoRollToolbar
{...baseProps}
mode="hybrid"
@@ -193,7 +209,7 @@ describe('PianoRollToolbar', () => {
it('shows the detect chords action in spectrogram mode and triggers it', () => {
const onDetectChords = vi.fn();
render(
renderWithLocale(
<PianoRollToolbar
{...baseProps}
mode="spectrogram"
@@ -211,7 +227,7 @@ describe('PianoRollToolbar', () => {
it('shows the detect tempo action when the audio callback is provided and triggers it', () => {
const onDetectTempo = vi.fn();
render(
renderWithLocale(
<PianoRollToolbar
{...baseProps}
mode="spectrogram"
@@ -229,7 +245,7 @@ describe('PianoRollToolbar', () => {
it('shows the detect chords action in midi mode and triggers it', () => {
const onDetectChords = vi.fn();
render(
renderWithLocale(
<PianoRollToolbar
{...baseProps}
mode="midi-edit"
@@ -247,7 +263,7 @@ describe('PianoRollToolbar', () => {
it('disables the detect chords action while detection is running', () => {
const onDetectChords = vi.fn();
render(
renderWithLocale(
<PianoRollToolbar
{...baseProps}
mode="spectrogram"
@@ -267,7 +283,7 @@ describe('PianoRollToolbar', () => {
it('disables the detect tempo action while detection is running', () => {
const onDetectTempo = vi.fn();
render(
renderWithLocale(
<PianoRollToolbar
{...baseProps}
mode="spectrogram"
@@ -286,7 +302,7 @@ describe('PianoRollToolbar', () => {
});
it('does not show the detect tempo action without the audio callback', () => {
render(
renderWithLocale(
<PianoRollToolbar
{...baseProps}
mode="midi-edit"
@@ -299,7 +315,7 @@ describe('PianoRollToolbar', () => {
});
it('shows only the sheet controls when sheet mode is enabled', () => {
render(
renderWithLocale(
<PianoRollToolbar
{...baseProps}
sheetMusicViewEnabled={true}
@@ -316,7 +332,7 @@ describe('PianoRollToolbar', () => {
});
it('shows the zoom button outside sheet mode', () => {
render(
renderWithLocale(
<PianoRollToolbar
{...baseProps}
sheetMusicViewEnabled={false}
@@ -324,13 +340,13 @@ describe('PianoRollToolbar', () => {
/>
);
expect(screen.getByRole('button', { name: '1x' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Zoom' })).toBeInTheDocument();
});
it('toggles the full-track sheet scope button', () => {
const onSheetMusicTrackScopeToggle = vi.fn();
render(
renderWithLocale(
<PianoRollToolbar
{...baseProps}
sheetMusicViewEnabled={true}
@@ -344,24 +360,74 @@ describe('PianoRollToolbar', () => {
});
it('hides the full-track sheet scope button outside sheet mode and spectrogram mode', () => {
const localeValue = {
languageSetting: 'en_us' as const,
resolvedLocale: 'en_us' as const,
setLanguageSetting: async () => undefined,
t: (key: string, params?: Record<string, string | number>) => translate(key, params, 'en_us'),
};
const { rerender } = render(
<PianoRollToolbar
{...baseProps}
sheetMusicViewEnabled={false}
mode="midi-edit"
/>
<I18nContext.Provider value={localeValue}>
<PianoRollToolbar
{...baseProps}
sheetMusicViewEnabled={false}
mode="midi-edit"
/>
</I18nContext.Provider>
);
expect(screen.queryByRole('button', { name: 'Show Entire Track' })).not.toBeInTheDocument();
rerender(
<PianoRollToolbar
{...baseProps}
sheetMusicViewEnabled={true}
mode="spectrogram"
/>
<I18nContext.Provider value={localeValue}>
<PianoRollToolbar
{...baseProps}
sheetMusicViewEnabled={true}
mode="spectrogram"
/>
</I18nContext.Provider>
);
expect(screen.queryByRole('button', { name: 'Show Entire Track' })).not.toBeInTheDocument();
});
it('renders translated compact/full labels in zh-CN while emitting stable values', () => {
const onAutomationTypeChange = vi.fn();
renderWithLocale(
<PianoRollToolbar
{...baseProps}
mode="midi-edit"
showAutomationControls={true}
automationEnabled={true}
automationType="pitch-bend"
onAutomationToggle={vi.fn()}
onAutomationTypeChange={onAutomationTypeChange}
/>,
'zh_cn',
);
expect(screen.getByRole('button', { name: '弯音' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '伊奥尼亚' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '无吸附' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '位置量化' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '长度量化' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '弯音' }));
expect(onAutomationTypeChange).toHaveBeenCalledWith('cc-11');
});
it('translates extended mode labels like harmonic minor', () => {
renderWithLocale(
<PianoRollToolbar
{...baseProps}
mode="midi-edit"
selectedMode="harmonic_minor"
/>,
'zh_cn',
);
expect(screen.getByRole('button', { name: '和声小调' })).toBeInTheDocument();
});
});
+69 -43
View File
@@ -5,23 +5,10 @@ import { KGDropdown } from '../common';
import { KGPianoRollState } from '../../core/state/KGPianoRollState';
import { KGCore } from '../../core/KGCore';
import {
PIANO_ROLL_AUTOMATION_OPTIONS,
getTranslatedAutomationOptions,
type PianoRollAutomationType,
} from './pianoRollAutomation';
const POWER_OPTIONS = [
{ label: 'Linear', value: '1.0' },
{ label: '√ (default)', value: '0.5' },
{ label: 'Mild', value: '0.4' },
{ 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' },
];
import { useI18n } from '../../i18n/useI18n';
interface PianoRollToolbarProps {
showAudioSpectrogramToggle?: boolean;
@@ -106,11 +93,48 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
onDetectTempo,
detectingTempo = false,
}) => {
const { t } = useI18n();
const showMidiControls = mode !== 'spectrogram' && mode !== 'audio-waveform' && !sheetMusicViewEnabled;
const showAudioOnlyControls = mode === 'audio-waveform' && !sheetMusicViewEnabled;
const showSpectrogramOnlyControls = mode === 'spectrogram' && !sheetMusicViewEnabled;
const showSpecControls = !sheetMusicViewEnabled && (mode === 'spectrogram' || mode === 'hybrid');
const showSpecMenu = !sheetMusicViewEnabled && (!!onDetectChords || !!onDetectTempo);
const automationOptions = React.useMemo(() => getTranslatedAutomationOptions(t), [t]);
const snapOptions = React.useMemo(
() => KGPianoRollState.SNAP_OPTIONS.map(option => ({ label: t(option.labelKey), value: option.value })),
[t],
);
const quantPositionOptions = React.useMemo(
() => KGPianoRollState.QUANT_POS_OPTIONS.map(option => ({ label: t(option.labelKey), value: option.value })),
[t],
);
const quantLengthOptions = React.useMemo(
() => KGPianoRollState.QUANT_LEN_OPTIONS.map(option => ({ label: t(option.labelKey), value: option.value })),
[t],
);
const POWER_OPTIONS = [
{ label: t('pianoRoll.power.linear'), value: '1.0' },
{ label: t('pianoRoll.power.sqrtDefault'), value: '0.5' },
{ label: t('pianoRoll.power.mild'), value: '0.4' },
{ label: t('pianoRoll.power.strong'), value: '0.3' },
];
const modeOptions = React.useMemo(
() => Object.entries(KGCore.FUNCTIONAL_CHORDS_DATA).map(([id, data]) => {
const translationKey = `pianoRoll.modeOption.${id}`;
const translatedLabel = t(translationKey);
return {
label: translatedLabel === translationKey ? data.name : translatedLabel,
value: id,
};
}),
[t],
);
const CHORD_GUIDE_BUTTONS: Array<{ label: string; value: 'N' | 'T' | 'S' | 'D'; ariaLabel: string }> = [
{ label: '⊘', value: 'N', ariaLabel: t('pianoRoll.chordGuide.off') },
{ label: 'T', value: 'T', ariaLabel: t('pianoRoll.chordGuide.tonic') },
{ label: 'S', value: 'S', ariaLabel: t('pianoRoll.chordGuide.subdominant') },
{ label: 'D', value: 'D', ariaLabel: t('pianoRoll.chordGuide.dominant') },
];
const [showZoomSlider, setShowZoomSlider] = React.useState(false);
const zoomSliderRef = React.useRef<HTMLDivElement>(null);
@@ -144,8 +168,8 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
<button
className={`tool-button icon-only sheet-mode-toggle ${audioSpectrogramEnabled ? 'active' : ''}`}
onClick={() => onAudioSpectrogramToggle?.()}
title="Spectrogram View"
aria-label="Spectrogram View"
title={t('pianoRoll.spectrogramView')}
aria-label={t('pianoRoll.spectrogramView')}
>
<svg className="spectrogram-view-icon" width="12" height="12" viewBox="0 0 10 10" fill="currentColor">
<rect x="3" y="0.5" width="6.5" height="2.5" rx="0.4" />
@@ -159,8 +183,8 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
<button
className={`tool-button sheet-mode-toggle ${sheetMusicViewEnabled ? 'active' : ''}`}
onClick={() => onSheetMusicViewToggle?.()}
title="Sheet Music View"
aria-label="Sheet Music View"
title={t('pianoRoll.sheetMusicView')}
aria-label={t('pianoRoll.sheetMusicView')}
disabled={sheetMusicToggleDisabled}
>
@@ -176,14 +200,14 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
<button
className={`tool-button ${activeTool === 'pointer' ? 'active' : ''}`}
onClick={() => onToolSelect('pointer')}
title="Pointer Tool"
title={t('pianoRoll.pointerTool')}
>
<FaMousePointer className="piano-roll-tool-icon" />
</button>
<button
className={`tool-button ${activeTool === 'pencil' ? 'active' : ''}`}
onClick={() => onToolSelect('pencil')}
title="Pencil Tool"
title={t('pianoRoll.pencilTool')}
>
<FaPencilAlt className="piano-roll-tool-icon" />
</button>
@@ -192,30 +216,30 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
<button
className={`tool-button automation-toggle-button ${automationEnabled ? 'active' : ''}`}
onClick={() => onAutomationToggle?.()}
title="Toggle automation lane"
aria-label="Toggle automation lane"
title={t('pianoRoll.toggleAutomationLane')}
aria-label={t('pianoRoll.toggleAutomationLane')}
>
A
</button>
<KGDropdown
options={PIANO_ROLL_AUTOMATION_OPTIONS}
options={automationOptions}
value={automationType}
onChange={(value) => onAutomationTypeChange?.(value as PianoRollAutomationType)}
label="Automation"
label={t('pianoRoll.automation')}
buttonClassName="automation-type-dropdown"
showValueAsLabel={true}
/>
</div>
)}
<KGDropdown
options={Object.entries(KGCore.FUNCTIONAL_CHORDS_DATA).map(([id, data]) => ({ label: data.name, value: id }))}
options={modeOptions}
value={selectedMode}
onChange={(value) => onModeChange(value)}
label="Mode"
label={t('pianoRoll.mode')}
buttonClassName="mode-dropdown"
showValueAsLabel={true}
/>
<div className="piano-roll-chord-guide-toolbar-group" role="group" aria-label="Chord guide">
<div className="piano-roll-chord-guide-toolbar-group" role="group" aria-label={t('pianoRoll.chordGuide')}>
{CHORD_GUIDE_BUTTONS.map((button, index) => (
<button
key={button.value}
@@ -253,8 +277,8 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
<button
className={`tool-button icon-only sheet-track-scope-toggle ${sheetMusicTrackScopeEnabled ? 'active' : ''}`}
onClick={() => onSheetMusicTrackScopeToggle?.()}
title={sheetMusicTrackScopeEnabled ? 'Show Active Region Only' : 'Show Entire Track'}
aria-label={sheetMusicTrackScopeEnabled ? 'Show Active Region Only' : 'Show Entire Track'}
title={sheetMusicTrackScopeEnabled ? t('pianoRoll.showActiveRegionOnly') : t('pianoRoll.showEntireTrack')}
aria-label={sheetMusicTrackScopeEnabled ? t('pianoRoll.showActiveRegionOnly') : t('pianoRoll.showEntireTrack')}
>
<TbArrowBarToUp className="sheet-track-scope-icon" strokeWidth={2.5} />
</button>
@@ -268,7 +292,7 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
options={sheetQuantizationOptions}
value={sheetQuantization}
onChange={(value) => onSheetQuantizationChange?.(value)}
label="Sheet Quant."
label={t('pianoRoll.sheetQuantization')}
buttonClassName="sheet-quantization"
showValueAsLabel={true}
/>
@@ -276,25 +300,25 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
{showMidiControls && (
<>
<KGDropdown
options={KGPianoRollState.SNAP_OPTIONS}
options={snapOptions}
value={snapping}
onChange={(value) => onSnappingSelect(value)}
label="Snap"
label={t('pianoRoll.snap')}
buttonClassName="snapping"
showValueAsLabel={true}
/>
<KGDropdown
options={KGPianoRollState.QUANT_POS_OPTIONS}
options={quantPositionOptions}
value={quantPosition}
onChange={(value) => onQuantSelect('position', value)}
label="Qua. Pos."
label={t('pianoRoll.quantizePositionCompact')}
buttonClassName={`quant-position ${blinkButton === 'quant-position' ? 'button-blink' : ''}`}
/>
<KGDropdown
options={KGPianoRollState.QUANT_LEN_OPTIONS}
options={quantLengthOptions}
value={quantLength}
onChange={(value) => onQuantSelect('length', value)}
label="Qua. Len."
label={t('pianoRoll.quantizeLengthCompact')}
buttonClassName={`quant-length ${blinkButton === 'quant-length' ? 'button-blink' : ''}`}
/>
</>
@@ -302,7 +326,7 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
{showSpecControls && (
<div className="spectrogram-toolbar-controls">
<span className="spectrogram-control-label">Floor</span>
<span className="spectrogram-control-label">{t('pianoRoll.floor')}</span>
<input
type="range"
className="spectrogram-threshold-slider"
@@ -318,7 +342,7 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
options={POWER_OPTIONS}
value={power.toString()}
onChange={v => onPowerChange?.(parseFloat(v))}
label="Curve"
label={t('pianoRoll.curve')}
buttonClassName="curve-dropdown"
showValueAsLabel={true}
/>
@@ -330,7 +354,8 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
<button
className="quant-button"
onClick={() => setShowZoomSlider(!showZoomSlider)}
title="Zoom"
title={t('pianoRoll.zoom')}
aria-label={t('pianoRoll.zoom')}
>
{zoom}x
</button>
@@ -355,7 +380,8 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
<button
className="quant-button"
onClick={() => setShowMoreMenu(!showMoreMenu)}
title="More options"
title={t('pianoRoll.moreOptions')}
aria-label={t('pianoRoll.moreOptions')}
>
...
</button>
@@ -373,7 +399,7 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
}}
aria-disabled={detectingChords}
>
{detectingChords ? 'Detecting chords...' : 'Detect chords...'}
{detectingChords ? t('pianoRoll.detectingChords') : t('pianoRoll.detectChords')}
</div>
)}
{onDetectTempo && (
@@ -388,7 +414,7 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
}}
aria-disabled={detectingTempo}
>
{detectingTempo ? 'Detecting tempo...' : 'Detect tempo...'}
{detectingTempo ? t('pianoRoll.detectingTempo') : t('pianoRoll.detectTempo')}
</div>
)}
</div>
@@ -6,7 +6,7 @@ import { createMockMidiRegion, createMockMidiTrack } from '../../test/utils/mock
const pianoRollState = {
zoom: 1,
getCurrentSnap: vi.fn(() => 'NO SNAP'),
getCurrentSnap: vi.fn(() => 'none'),
getActiveTool: vi.fn(() => 'pointer'),
getAutomationViewEnabled: vi.fn(() => false),
getCurrentAutomationType: vi.fn(() => 'pitch-bend'),
@@ -26,6 +26,7 @@ const pianoRollState = {
setCurrentSnap: vi.fn(),
setCurrentSuitableChords: vi.fn(),
setCurrentSuitableChordsPitchClasses: vi.fn(),
setCurrentHoveredChordGuideCandidate: vi.fn(),
};
const mockProject = {
@@ -71,10 +72,11 @@ vi.mock('../../stores/projectStore', () => ({
vi.mock('../../core/state/KGPianoRollState', () => ({
KGPianoRollState: {
instance: () => pianoRollState,
SNAP_OPTIONS: ['NO SNAP'],
QUANT_POS_OPTIONS: ['1/8'],
QUANT_LEN_OPTIONS: ['1/8'],
SNAP_OPTIONS: [{ value: 'none', labelKey: 'pianoRoll.snap.none' }],
QUANT_POS_OPTIONS: [{ value: '1/8', labelKey: 'pianoRoll.quantize.1/8' }],
QUANT_LEN_OPTIONS: [{ value: '1/8', labelKey: 'pianoRoll.quantize.1/8' }],
},
PIANO_ROLL_NO_SNAP: 'none',
}));
vi.mock('../../core/KGCore', () => ({
@@ -1,3 +1,5 @@
import type { TranslationParams } from '../../i18n/types';
export type PianoRollAutomationType =
| 'pitch-bend'
| 'cc-1'
@@ -7,20 +9,37 @@ export type PianoRollAutomationType =
| 'cc-64';
export interface PianoRollAutomationOption {
label: string;
value: PianoRollAutomationType;
labelKey: string;
interpolationMode: 'linear' | 'step';
}
export const PIANO_ROLL_AUTOMATION_OPTIONS: PianoRollAutomationOption[] = [
{ label: 'Pitch Bend', value: 'pitch-bend', interpolationMode: 'linear' },
{ label: 'CC1', value: 'cc-1', interpolationMode: 'linear' },
{ label: 'CC2', value: 'cc-2', interpolationMode: 'linear' },
{ label: 'CC7', value: 'cc-7', interpolationMode: 'linear' },
{ label: 'CC11', value: 'cc-11', interpolationMode: 'linear' },
{ label: 'CC64', value: 'cc-64', interpolationMode: 'step' },
{ value: 'pitch-bend', labelKey: 'pianoRoll.automationType.pitchBend', interpolationMode: 'linear' },
{ value: 'cc-1', labelKey: 'pianoRoll.automationType.cc1', interpolationMode: 'linear' },
{ value: 'cc-2', labelKey: 'pianoRoll.automationType.cc2', interpolationMode: 'linear' },
{ value: 'cc-7', labelKey: 'pianoRoll.automationType.cc7', interpolationMode: 'linear' },
{ value: 'cc-11', labelKey: 'pianoRoll.automationType.cc11', interpolationMode: 'linear' },
{ value: 'cc-64', labelKey: 'pianoRoll.automationType.cc64', interpolationMode: 'step' },
];
export function getTranslatedAutomationOptions(
t: (key: string, params?: TranslationParams) => string,
): Array<{ label: string; value: PianoRollAutomationType }> {
return PIANO_ROLL_AUTOMATION_OPTIONS.map(option => ({
label: t(option.labelKey),
value: option.value,
}));
}
export function getAutomationLabel(
type: PianoRollAutomationType,
t: (key: string, params?: TranslationParams) => string,
): string {
const option = PIANO_ROLL_AUTOMATION_OPTIONS.find(candidate => candidate.value === type);
return option ? t(option.labelKey) : type;
}
export function getControllerNumberForAutomationType(type: PianoRollAutomationType): number | null {
switch (type) {
case 'cc-1':
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest';
import { PIANO_ROLL_NO_SNAP } from '../../core/state/KGPianoRollState';
import { getSnapStep } from './pianoRollSnap';
describe('pianoRollSnap', () => {
it('returns null for the no-snap sentinel', () => {
expect(getSnapStep(PIANO_ROLL_NO_SNAP)).toBeNull();
});
it('returns a beat step for fractional snap values', () => {
expect(getSnapStep('1/4')).toBe(1);
expect(getSnapStep('1/8')).toBe(0.5);
});
});
+5 -4
View File
@@ -1,7 +1,8 @@
import { PIANO_ROLL_NO_SNAP, type PianoRollSnapValue } from '../../core/state/KGPianoRollState';
import { DEBUG_MODE } from '../../constants';
export function getSnapStep(currentSnap: string): number | null {
if (currentSnap === 'NO SNAP') {
export function getSnapStep(currentSnap: PianoRollSnapValue): number | null {
if (currentSnap === PIANO_ROLL_NO_SNAP) {
return null;
}
@@ -15,7 +16,7 @@ export function getSnapStep(currentSnap: string): number | null {
export function getSnappedBeatPosition(
beatPosition: number,
currentSnap: string,
currentSnap: PianoRollSnapValue,
useFloorSnapping: boolean = false,
): number {
const snapStep = getSnapStep(currentSnap);
@@ -36,7 +37,7 @@ export function getSnappedBeatPosition(
return snappedPosition;
}
export function getSnappedLength(length: number, currentSnap: string, minimumLength: number): number {
export function getSnappedLength(length: number, currentSnap: PianoRollSnapValue, minimumLength: number): number {
const snapStep = getSnapStep(currentSnap);
if (snapStep === null) {
return length;