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
+39 -1
View File
@@ -26,6 +26,8 @@ import {
createMockMidiTrack,
} from '../test/utils/mock-data';
import { showAlert } from '../util/dialogUtil';
import { I18nContext } from '../i18n/I18nProvider';
import { translate } from '../i18n/translate';
const clickDropdownOption = (label: string) => {
const option = Array.from(document.querySelectorAll('.quant-option'))
@@ -185,6 +187,19 @@ vi.mock('../core/KGCore', () => ({
}));
describe('EventListPanel', () => {
const renderWithLocale = (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),
}}
>
<EventListPanel isVisible={true} />
</I18nContext.Provider>
);
beforeEach(() => {
project = new KGProject(
'Test Project',
@@ -460,7 +475,7 @@ describe('EventListPanel', () => {
expect(screen.getAllByRole('button', { name: 'Marker' })[0]).toBeInTheDocument();
expect(screen.getAllByRole('button', { name: 'Tempo' })[0]).toBeInTheDocument();
expect(screen.getAllByRole('button', { name: 'Key Signature' })[0]).toBeInTheDocument();
expect(screen.getAllByRole('button', { name: 'Key Sig.' })[0]).toBeInTheDocument();
expect(screen.getAllByRole('button', { name: 'Chord' })[0]).toBeInTheDocument();
expect(screen.getByText('Intro')).toBeInTheDocument();
expect(screen.getByText('120')).toBeInTheDocument();
@@ -554,4 +569,27 @@ describe('EventListPanel', () => {
expect((tempoTrack.getRegions()[0] as KGTempoRegion).getStartBar()).toBe(0);
});
});
it('renders translated event-list controls in zh-CN', () => {
renderWithLocale('zh_cn');
expect(screen.getAllByRole('button', { name: '音符' }).length).toBeGreaterThan(0);
expect(screen.getAllByRole('button', { name: '弯音' }).length).toBeGreaterThan(0);
expect(screen.getAllByRole('button', { name: '控制器' }).length).toBeGreaterThan(0);
fireEvent.click(screen.getByRole('button', { name: '音轨' }));
expect(screen.getAllByRole('button', { name: '区域' }).length).toBeGreaterThan(0);
expect(screen.getByRole('button', { name: '音量' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '声像' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '全局' }));
expect(screen.getAllByRole('button', { name: '标记' }).length).toBeGreaterThan(0);
expect(screen.getByRole('button', { name: '速度' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '调号' })).toBeInTheDocument();
expect(screen.getAllByRole('button', { name: '和弦' }).length).toBeGreaterThan(0);
expect(screen.getByRole('columnheader', { name: '位置' })).toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: '状态' })).toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: '数值' })).toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: '长度/信息' })).toBeInTheDocument();
});
});
+7 -5
View File
@@ -7,6 +7,7 @@ import { KGAudioTrack } from '../core/track/KGAudioTrack';
import RegionEventListTab from './event-list-panel/RegionEventListTab';
import TrackEventListTab from './event-list-panel/TrackEventListTab';
import GlobalEventListTab from './event-list-panel/GlobalEventListTab';
import { useI18n } from '../i18n/useI18n';
interface EventListPanelProps {
isVisible: boolean;
@@ -15,6 +16,7 @@ interface EventListPanelProps {
type ScopeTab = 'region' | 'track' | 'global';
const EventListPanel: React.FC<EventListPanelProps> = ({ isVisible }) => {
const { t } = useI18n();
const {
tracks,
globalTracks,
@@ -55,30 +57,30 @@ const EventListPanel: React.FC<EventListPanelProps> = ({ isVisible }) => {
return (
<div className={`event-list-panel${isVisible ? '' : ' is-hidden'}`}>
<div className="event-list-panel-header">
<h3>Event List</h3>
<h3>{t('eventList.title')}</h3>
</div>
<div className="event-list-scope-tabs" role="tablist" aria-label="Event list scopes">
<div className="event-list-scope-tabs" role="tablist" aria-label={t('eventList.scopeTabs')}>
<button
className={`event-list-scope-tab${scopeTab === 'region' ? ' active' : ''}`}
type="button"
onClick={() => setScopeTab('region')}
>
Region
{t('eventList.scope.region')}
</button>
<button
className={`event-list-scope-tab${scopeTab === 'track' ? ' active' : ''}`}
type="button"
onClick={() => setScopeTab('track')}
>
Track
{t('eventList.scope.track')}
</button>
<button
className={`event-list-scope-tab${scopeTab === 'global' ? ' active' : ''}`}
type="button"
onClick={() => setScopeTab('global')}
>
Global
{t('eventList.scope.global')}
</button>
</div>
@@ -0,0 +1,78 @@
import React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import InstrumentSelection from './InstrumentSelection';
import { KGMidiTrack } from '../core/track/KGMidiTrack';
import { I18nContext } from '../i18n/I18nProvider';
import { translate } from '../i18n/translate';
const midiTrack = new KGMidiTrack('Lead Track', 1, 'acoustic_grand_piano');
const storeState = {
tracks: [midiTrack],
selectedTrackId: '1',
closeInstrumentSelection: vi.fn(),
setTrackInstrument: vi.fn().mockResolvedValue(undefined),
};
vi.mock('../stores/projectStore', () => ({
useProjectStore: () => storeState,
}));
function renderWithLocale(resolvedLocale: 'en_us' | 'zh_cn') {
return render(
<I18nContext.Provider
value={{
languageSetting: resolvedLocale,
resolvedLocale,
setLanguageSetting: async () => undefined,
t: (key, params) => translate(key, params, resolvedLocale),
}}
>
<InstrumentSelection />
</I18nContext.Provider>,
);
}
describe('InstrumentSelection', () => {
beforeEach(() => {
storeState.setTrackInstrument.mockClear();
midiTrack.setInstrument('acoustic_grand_piano');
});
it('renders translated group and instrument names under zh-CN', () => {
renderWithLocale('zh_cn');
expect(screen.getByText('钢琴与键盘')).toBeTruthy();
expect(screen.getAllByText('原声大钢琴').length).toBeGreaterThan(0);
});
it('updates visible labels when locale changes', () => {
const view = renderWithLocale('en_us');
expect(screen.getByText('Piano and Keyboards')).toBeTruthy();
expect(screen.getAllByText('Acoustic Grand Piano').length).toBeGreaterThan(0);
view.rerender(
<I18nContext.Provider
value={{
languageSetting: 'zh_cn',
resolvedLocale: 'zh_cn',
setLanguageSetting: async () => undefined,
t: (key, params) => translate(key, params, 'zh_cn'),
}}
>
<InstrumentSelection />
</I18nContext.Provider>,
);
expect(screen.getByText('钢琴与键盘')).toBeTruthy();
expect(screen.getAllByText('原声大钢琴').length).toBeGreaterThan(0);
});
it('keeps instrument selection behavior on the same instrument key', async () => {
renderWithLocale('zh_cn');
fireEvent.click(screen.getByText('电钢琴 1'));
expect(storeState.setTrackInstrument).toHaveBeenCalledWith(1, 'electric_piano_1');
});
});
+12 -12
View File
@@ -4,8 +4,11 @@ import { useProjectStore } from '../stores/projectStore';
import { INSTRUMENT_GROUPS, FLUIDR3_INSTRUMENT_MAP } from '../constants/generalMidiConstants';
import { KGMidiTrack, type InstrumentType } from '../core/track/KGMidiTrack';
import { KGAudioTrack } from '../core/track/KGAudioTrack';
import { useI18n } from '../i18n/useI18n';
import { getInstrumentDisplayName, getInstrumentGroupLabel, type InstrumentGroupKey } from '../i18n/instruments';
const InstrumentSelection: React.FC = () => {
const { t } = useI18n();
const {
tracks,
selectedTrackId,
@@ -31,24 +34,23 @@ const InstrumentSelection: React.FC = () => {
setSelectedGroupKey(currentInstrumentDef?.group || 'PIANO_AND_KEYBOARDS');
}, [selectedTrackId, currentInstrumentKey, currentInstrumentDef]);
const groups = useMemo(() => Object.entries(INSTRUMENT_GROUPS) as Array<[string, string]>, []);
const groups = useMemo(() => Object.keys(INSTRUMENT_GROUPS) as InstrumentGroupKey[], []);
const instrumentsInGroup = useMemo(() => {
const instrumentsInGroup = useMemo<Array<{ key: InstrumentType; label: string }>>(() => {
return Object.entries(FLUIDR3_INSTRUMENT_MAP)
.filter((entry) => entry[1].group === selectedGroupKey)
.map((entry) => ({ key: entry[0], label: entry[1].displayName }));
}, [selectedGroupKey]);
.map((entry) => ({ key: entry[0] as InstrumentType, label: getInstrumentDisplayName(entry[0] as InstrumentType, t) }));
}, [selectedGroupKey, t]);
const handleSelectGroup = (groupKey: string) => {
setSelectedGroupKey(groupKey);
};
const handleSelectInstrument = async (instrumentKey: string) => {
const handleSelectInstrument = async (instrumentKey: InstrumentType) => {
// If no valid target track, ignore user interaction
if (!targetTrack || !(targetTrack instanceof KGMidiTrack)) return;
const instrument = instrumentKey as InstrumentType;
try {
await setTrackInstrument(targetTrack.getId(), instrument);
await setTrackInstrument(targetTrack.getId(), instrumentKey);
} catch (err) {
console.error('Failed to change instrument from panel:', err);
}
@@ -56,7 +58,7 @@ const InstrumentSelection: React.FC = () => {
const isAudioTrack = targetTrack instanceof KGAudioTrack;
const previewImage = isAudioTrack ? 'speaker.png' : (FLUIDR3_INSTRUMENT_MAP[currentInstrumentKey]?.image || 'piano.png');
const previewAlt = isAudioTrack ? 'Audio Track' : (FLUIDR3_INSTRUMENT_MAP[currentInstrumentKey]?.displayName || currentInstrumentKey);
const previewAlt = isAudioTrack ? 'Audio Track' : getInstrumentDisplayName(currentInstrumentKey, t);
const hasTargetTrack = !!targetTrack;
return (
@@ -82,13 +84,13 @@ const InstrumentSelection: React.FC = () => {
<div className="instrument-selection-bottom">
<div className="instrument-groups">
<div className="instrument-groups-list">
{groups.map(([key, label]) => (
{groups.map((key) => (
<div
key={key}
className={`instrument-group-item${selectedGroupKey === key ? ' active' : ''}`}
onClick={() => handleSelectGroup(key)}
>
{label}
{getInstrumentGroupLabel(key, t)}
</div>
))}
</div>
@@ -113,5 +115,3 @@ const InstrumentSelection: React.FC = () => {
};
export default InstrumentSelection;
+6 -1
View File
@@ -3,9 +3,14 @@ import './StatusBar.css';
import { useProjectStore } from '../stores/projectStore';
import { KGPianoRollState } from '../core/state/KGPianoRollState';
import type { ResolvedChordGuideItem } from '../core/ChordGuideTypes';
import { translate } from '../i18n/translate';
function formatChordGuideCandidateStatus(candidate: ResolvedChordGuideItem): string {
return `Chord Guide Candidate: ${candidate.name}${candidate.resolvedNotes.join(' ')}${candidate.note}`;
return translate('status.chordGuideCandidate', {
name: candidate.name,
notes: candidate.resolvedNotes.join(' '),
note: candidate.note,
});
}
const StatusBar: React.FC = () => {
+115 -108
View File
@@ -40,8 +40,10 @@ import MetronomeIcon from './common/icons/MetronomeIcon';
import { mergeSelectedMidiRegions, splitSelectedRegionAtPlayhead } from '../util/regionEditUtil';
import { showAlert, showChoice, showConfirm, showPrompt, showTimeSigPrompt } from '../util/dialogUtil';
import { UpdateKeySignatureRegionCommand, UpdateTempoRegionCommand } from '../core/commands';
import { useI18n } from '../i18n/useI18n';
const Toolbar: React.FC = () => {
const { t } = useI18n();
const {
projectName, setProjectName,
savedProjectName, setSavedProjectName,
@@ -116,18 +118,23 @@ const Toolbar: React.FC = () => {
}, [showZoomSlider]);
// Export options
const exportOptions = ["Export to KGStudio file", "Export to MIDI file", "Export to WAV", "Export to MP3"];
const exportOptions = [
{ label: t('toolbar.export.kgstudio'), value: 'kgstudio' },
{ label: t('toolbar.export.midi'), value: 'midi' },
{ label: t('toolbar.export.wav'), value: 'wav' },
{ label: t('toolbar.export.mp3'), value: 'mp3' },
];
const lastSelectedRegionId = selectedRegionIds[selectedRegionIds.length - 1] ?? null;
const handleProjectNameClick = async () => {
const newName = await showPrompt("Enter project name:", projectName);
const newName = await showPrompt(t('toolbar.projectName.prompt'), projectName);
if (!newName) return;
if (!isValidProjectName(newName)) {
await showAlert("Invalid project name. Only letters, numbers, spaces, hyphens, underscores, periods, and parentheses are allowed.");
await showAlert(t('toolbar.projectName.invalid'));
return;
}
if (isReservedProjectName(newName)) {
await showAlert(`"${RESERVED_PROJECT_NAME}" is a reserved project name. Please choose a different name.`);
await showAlert(t('toolbar.projectName.reserved', { name: RESERVED_PROJECT_NAME }));
return;
}
@@ -147,7 +154,7 @@ const Toolbar: React.FC = () => {
const exists = await storage.exists(newName);
if (exists) {
const confirmed = await showConfirm(
`Project "${newName}" already exists. Do you want to overwrite it?`
t('toolbar.projectName.overwrite', { name: newName })
);
if (!confirmed) return;
setProjectName(newName);
@@ -166,11 +173,11 @@ const Toolbar: React.FC = () => {
}
// Ask whether the user wants to rename or save as a copy
const choice = await showChoice(
"Would you like to rename this project, or save it as a new copy?",
const choice = await showChoice(
t('toolbar.projectName.renameOrCopy'),
[
{ label: 'Save as Copy', value: 'saveas' },
{ label: 'Rename', value: 'rename' },
{ label: t('toolbar.projectName.saveAsCopy'), value: 'saveas' },
{ label: t('toolbar.projectName.rename'), value: 'rename' },
]
);
if (!choice) return;
@@ -181,7 +188,7 @@ const Toolbar: React.FC = () => {
const exists = await storage.exists(newName);
if (exists) {
const confirmed = await showConfirm(
`Project "${newName}" already exists. Do you want to overwrite it?`
t('toolbar.projectName.overwrite', { name: newName })
);
if (!confirmed) return;
setProjectName(newName);
@@ -204,10 +211,10 @@ const Toolbar: React.FC = () => {
await storage.saveAs(savedProjectName, finalName, KGCore.instance().getCurrentProject());
setProjectName(finalName);
setSavedProjectName(finalName);
setStatus(`Saved as "${finalName}"`);
setStatus(t('toolbar.projectName.saveAsCopy') + ` "${finalName}"`);
} catch (error) {
console.error('Error saving project as copy:', error);
await showAlert(`An error occurred while saving: ${error}`);
await showAlert(t('toolbar.save.error', { error: String(error) }));
}
}
};
@@ -232,7 +239,7 @@ const Toolbar: React.FC = () => {
}
// Update status to indicate project loaded
setStatus(`${sourceDescription} loaded successfully`);
setStatus(t('toolbar.status.projectLoaded', { description: sourceDescription }));
if (DEBUG_MODE.TOOLBAR) {
console.log(`project loaded successfully from ${sourceDescription}`);
@@ -240,8 +247,8 @@ const Toolbar: React.FC = () => {
} catch (error) {
console.error(`Error loading project from ${sourceDescription}:`, error);
setStatus(`Failed to load project: ${error}`);
await showAlert(`An error occurred while loading the project: ${error}`);
setStatus(t('toolbar.status.loadFailed', { error: String(error) }));
await showAlert(t('toolbar.load.error', { error: String(error) }));
}
};
@@ -258,7 +265,7 @@ const Toolbar: React.FC = () => {
const { loadProject: storeLoadProject } = useProjectStore.getState();
storeLoadProject(newProject);
setStatus(`New project "${newProject.getName()}" created`);
setStatus(t('toolbar.status.newProjectCreated', { name: newProject.getName() }));
if (DEBUG_MODE.TOOLBAR) {
console.log("new project created successfully");
@@ -267,7 +274,7 @@ const Toolbar: React.FC = () => {
// Handler functions for file operations
const handleNewProject = async () => {
const confirmed = await showConfirm("Are you sure you want to create a new project? Any unsaved changes will be lost.");
const confirmed = await showConfirm(t('toolbar.newProject.confirm'));
if (confirmed) {
createNewProject();
}
@@ -288,14 +295,14 @@ const Toolbar: React.FC = () => {
const loadedProject = await storage.load(projectNameToLoad);
if (!loadedProject) {
await showAlert(`Project "${projectNameToLoad}" not found.`);
await showAlert(t('toolbar.project.notFound', { name: projectNameToLoad }));
return;
}
await loadProjectFromData(loadedProject, `Project "${projectNameToLoad}"`, projectNameToLoad);
} catch (error) {
console.error("Error loading project:", error);
await showAlert(`An error occurred while loading the project: ${error}`);
await showAlert(t('toolbar.load.error', { error: String(error) }));
} finally {
setIsOpeningProject(false);
}
@@ -303,7 +310,7 @@ const Toolbar: React.FC = () => {
const handleConfirmOpenProject = async () => {
const confirmed = await showConfirm(
'Open this project? Any unsaved changes in the current project will be lost.'
t('toolbar.openProject.confirm')
);
return confirmed;
};
@@ -326,13 +333,13 @@ const Toolbar: React.FC = () => {
console.log("user selected export option:", exportType);
}
if (exportType === "Export to KGStudio file") {
if (exportType === 'kgstudio') {
handleExportKGStudio();
} else if (exportType === "Export to MIDI file") {
} else if (exportType === 'midi') {
handleExportMIDI();
} else if (exportType === "Export to WAV") {
} else if (exportType === 'wav') {
handleBounceToWav();
} else if (exportType === "Export to MP3") {
} else if (exportType === 'mp3') {
handleBounceToMp3();
}
@@ -362,7 +369,7 @@ const Toolbar: React.FC = () => {
document.body.removeChild(link);
URL.revokeObjectURL(url);
setStatus(`Project "${projectName}" exported as KGStudio file`);
setStatus(t('toolbar.status.projectExportedKgstudio', { name: projectName }));
if (DEBUG_MODE.TOOLBAR) {
console.log("KGStudio export completed successfully");
@@ -370,8 +377,8 @@ const Toolbar: React.FC = () => {
} catch (error) {
console.error("Error exporting KGStudio file:", error);
setStatus(`Error exporting project: ${error}`);
await showAlert(`Failed to export project: ${error}`);
setStatus(t('toolbar.status.exportProjectError', { error: String(error) }));
await showAlert(t('toolbar.export.failedProject', { error: String(error) }));
}
};
@@ -404,7 +411,7 @@ const Toolbar: React.FC = () => {
document.body.removeChild(link);
URL.revokeObjectURL(url);
setStatus(`Project "${projectName}" exported as MIDI file`);
setStatus(t('toolbar.status.projectExportedMidi', { name: projectName }));
if (DEBUG_MODE.TOOLBAR) {
console.log("MIDI export completed successfully");
@@ -412,8 +419,8 @@ const Toolbar: React.FC = () => {
} catch (error) {
console.error("Error exporting MIDI:", error);
setStatus(`Error exporting MIDI: ${error}`);
await showAlert(`Failed to export project as MIDI: ${error}`);
setStatus(t('toolbar.status.exportMidiError', { error: String(error) }));
await showAlert(t('toolbar.export.failedMidi', { error: String(error) }));
}
};
@@ -425,11 +432,11 @@ const Toolbar: React.FC = () => {
try {
const currentProject = KGCore.instance().getCurrentProject();
await KGOfflineRenderer.instance().bounceToWav(currentProject, projectName);
setStatus(`Project "${projectName}" exported as WAV file`);
setStatus(t('toolbar.status.projectExportedWav', { name: projectName }));
} catch (error) {
console.error("Error bouncing to WAV:", error);
setStatus(`Error exporting WAV: ${error}`);
await showAlert(`Failed to export project as WAV: ${error}`);
setStatus(t('toolbar.status.exportWavError', { error: String(error) }));
await showAlert(t('toolbar.export.failedWav', { error: String(error) }));
}
};
@@ -441,11 +448,11 @@ const Toolbar: React.FC = () => {
try {
const currentProject = KGCore.instance().getCurrentProject();
await KGOfflineRenderer.instance().bounceToMp3(currentProject, projectName);
setStatus(`Project "${projectName}" exported as MP3 file`);
setStatus(t('toolbar.status.projectExportedMp3', { name: projectName }));
} catch (error) {
console.error("Error bouncing to MP3:", error);
setStatus(`Error exporting MP3: ${error}`);
await showAlert(`Failed to export project as MP3: ${error}`);
setStatus(t('toolbar.status.exportMp3Error', { error: String(error) }));
await showAlert(t('toolbar.export.failedMp3', { error: String(error) }));
}
};
@@ -480,8 +487,8 @@ const Toolbar: React.FC = () => {
} catch (error) {
console.error("Error importing file:", error);
setStatus(`Failed to import file: ${error}`);
await showAlert(`Failed to import project file: ${error}`);
setStatus(t('toolbar.status.importFailed', { error: String(error) }));
await showAlert(t('toolbar.import.failedProjectFile', { error: String(error) }));
}
};
@@ -503,7 +510,7 @@ const Toolbar: React.FC = () => {
console.log("KGStudio file imported successfully:", projectName);
}
} catch (error) {
await showAlert(`The .kgstudio file is corrupted or invalid: ${error}`);
await showAlert(t('toolbar.import.corruptedKgstudio', { error: String(error) }));
throw error;
}
};
@@ -555,7 +562,7 @@ const Toolbar: React.FC = () => {
}
// Show loading status
setStatus(`Importing MIDI file "${file.name}"...`);
setStatus(t('toolbar.status.importingMidi', { name: file.name }));
// Read the MIDI file as binary data
const arrayBuffer = await file.arrayBuffer();
@@ -585,7 +592,7 @@ const Toolbar: React.FC = () => {
} catch (error) {
console.error("Error importing MIDI file:", error);
const errorMessage = error instanceof Error ? error.message : String(error);
setStatus(`Failed to import MIDI file: ${errorMessage}`);
setStatus(t('toolbar.status.failedImportMidi', { error: errorMessage }));
throw new Error(`Invalid MIDI file: ${errorMessage}`);
}
};
@@ -599,7 +606,7 @@ const Toolbar: React.FC = () => {
await startPlaying();
} catch (error) {
console.error("Failed to start playback:", error);
setStatus("Playback failed to start");
setStatus(t('toolbar.status.playbackFailedStart'));
}
};
@@ -610,11 +617,11 @@ const Toolbar: React.FC = () => {
try {
await stopTransport();
if (isRecording) {
setStatus("Recording stopped — notes committed");
setStatus(t('toolbar.status.recordingStoppedCommitted'));
}
} catch (error) {
console.error("Failed to stop playback:", error);
setStatus("Failed to stop playback");
setStatus(t('toolbar.status.failedStopPlayback'));
}
};
@@ -637,21 +644,21 @@ const Toolbar: React.FC = () => {
// Prompt to change max bars when clicking on current-time display
const handleCurrentTimeClick = async () => {
const MIN_BARS = 16;
const newMaxBarsStr = await showPrompt(`Enter new max bars (>= ${MIN_BARS}):`, String(maxBars ?? 32));
const newMaxBarsStr = await showPrompt(t('toolbar.maxBars.prompt', { min: MIN_BARS }), String(maxBars ?? 32));
if (newMaxBarsStr === null) {
return; // cancelled
}
const parsed = parseInt(newMaxBarsStr.trim(), 10);
if (isNaN(parsed)) {
await showAlert('Invalid input. Please enter a valid number.');
await showAlert(t('toolbar.input.invalidNumber'));
return;
}
if (parsed < MIN_BARS) {
await showAlert(`Invalid value. Please enter a number >= ${MIN_BARS}.`);
await showAlert(t('toolbar.maxBars.invalid', { min: MIN_BARS }));
return;
}
setMaxBars(parsed);
setStatus(`Max bars changed to ${parsed}`);
setStatus(t('toolbar.status.maxBarsChanged', { value: parsed }));
};
const handleBpmClick = async () => {
@@ -659,7 +666,7 @@ const Toolbar: React.FC = () => {
console.log("BPM clicked, current BPM:", bpm);
}
const newBpmStr = await showPrompt(`Enter new BPM (${TIME_CONSTANTS.MIN_BPM}-${TIME_CONSTANTS.MAX_BPM}):`, displayedBpm.toString());
const newBpmStr = await showPrompt(t('toolbar.bpm.prompt', { min: TIME_CONSTANTS.MIN_BPM, max: TIME_CONSTANTS.MAX_BPM }), displayedBpm.toString());
// Check if user cancelled
if (newBpmStr === null) {
@@ -671,13 +678,13 @@ const Toolbar: React.FC = () => {
// Check if it's a valid number
if (isNaN(newBpm)) {
await showAlert("Invalid input. Please enter a valid number.");
await showAlert(t('toolbar.input.invalidNumber'));
return;
}
// Check if it's within valid range
if (newBpm <= TIME_CONSTANTS.MIN_BPM || newBpm >= TIME_CONSTANTS.MAX_BPM) {
await showAlert(`Invalid BPM. Please enter a value between ${TIME_CONSTANTS.MIN_BPM} and ${TIME_CONSTANTS.MAX_BPM}.`);
await showAlert(t('toolbar.bpm.invalid', { min: TIME_CONSTANTS.MIN_BPM, max: TIME_CONSTANTS.MAX_BPM }));
return;
}
@@ -689,7 +696,7 @@ const Toolbar: React.FC = () => {
} else {
setBpm(newBpm);
}
setStatus(`BPM changed to ${newBpm}`);
setStatus(t('toolbar.status.bpmChanged', { value: newBpm }));
if (DEBUG_MODE.TOOLBAR) {
console.log(`BPM updated from ${displayedBpm} to ${newBpm}`);
@@ -701,7 +708,7 @@ const Toolbar: React.FC = () => {
console.log("Time signature clicked, current:", `${timeSignature.numerator}/${timeSignature.denominator}`);
}
const result = await showTimeSigPrompt('Set the time signature:', timeSignature);
const result = await showTimeSigPrompt(t('toolbar.timeSignature.prompt'), timeSignature);
if (result === null) return;
const newTimeSignature = parseTimeSignature(`${result.numerator}/${result.denominator}`);
@@ -711,7 +718,7 @@ const Toolbar: React.FC = () => {
}
setTimeSignature(newTimeSignature);
setStatus(`Time signature changed to ${newTimeSignature.numerator}/${newTimeSignature.denominator}`);
setStatus(t('toolbar.status.timeSignatureChanged', { value: `${newTimeSignature.numerator}/${newTimeSignature.denominator}` }));
if (DEBUG_MODE.TOOLBAR) {
console.log(`Time signature updated to ${newTimeSignature.numerator}/${newTimeSignature.denominator}`);
@@ -732,7 +739,7 @@ const Toolbar: React.FC = () => {
} else {
setKeySignature(newKeySignature as KeySignature);
}
setStatus(`Key signature changed to ${newKeySignature}`);
setStatus(t('toolbar.status.keySignatureChanged', { value: newKeySignature }));
setShowKeySignatureDropdown(false);
};
@@ -764,12 +771,12 @@ const Toolbar: React.FC = () => {
const copied = handleCopyOperation();
if (copied) {
setStatus("Items copied to clipboard");
setStatus(t('toolbar.status.copied'));
if (DEBUG_MODE.TOOLBAR) {
console.log("Items copied successfully");
}
} else {
setStatus("No items selected to copy");
setStatus(t('toolbar.status.copyNone'));
if (DEBUG_MODE.TOOLBAR) {
console.log("No items were selected for copying");
}
@@ -785,12 +792,12 @@ const Toolbar: React.FC = () => {
const pasted = handlePasteOperation();
if (pasted) {
setStatus("Items pasted from clipboard");
setStatus(t('toolbar.status.pasted'));
if (DEBUG_MODE.TOOLBAR) {
console.log("Items pasted successfully");
}
} else {
setStatus("Cannot paste - no valid clipboard content or context");
setStatus(t('toolbar.status.pasteFailed'));
if (DEBUG_MODE.TOOLBAR) {
console.log("Paste operation failed or no valid context");
}
@@ -806,12 +813,12 @@ const Toolbar: React.FC = () => {
const deleted = regionDeleteManager.deleteSelectedRegions();
if (deleted) {
setStatus("Selected regions deleted");
setStatus(t('toolbar.status.deleted'));
if (DEBUG_MODE.TOOLBAR) {
console.log("Regions deleted successfully");
}
} else {
setStatus("No regions selected for deletion");
setStatus(t('toolbar.status.deleteNone'));
if (DEBUG_MODE.TOOLBAR) {
console.log("No regions were selected for deletion");
}
@@ -863,13 +870,13 @@ const Toolbar: React.FC = () => {
}
if (!canUndo) {
await showAlert("Nothing to undo");
await showAlert(t('toolbar.undo.none'));
return;
}
undo();
const description = undoDescription || "action";
setStatus(`Undid: ${description}`);
const description = undoDescription || t('toolbar.action');
setStatus(t('toolbar.status.undid', { description }));
if (DEBUG_MODE.TOOLBAR) {
console.log(`Undo successful: ${description}`);
@@ -883,13 +890,13 @@ const Toolbar: React.FC = () => {
}
if (!canRedo) {
await showAlert("Nothing to redo");
await showAlert(t('toolbar.redo.none'));
return;
}
redo();
const description = redoDescription || "action";
setStatus(`Redid: ${description}`);
const description = redoDescription || t('toolbar.action');
setStatus(t('toolbar.status.redid', { description }));
if (DEBUG_MODE.TOOLBAR) {
console.log(`Redo successful: ${description}`);
@@ -907,7 +914,7 @@ const Toolbar: React.FC = () => {
} else {
toggleChatBox();
}
setStatus("Chat toggled");
setStatus(t('toolbar.status.chatToggled'));
};
// Handle settings button click
@@ -917,7 +924,7 @@ const Toolbar: React.FC = () => {
}
toggleSettings();
setStatus("Settings toggled");
setStatus(t('toolbar.status.settingsToggled'));
};
// K.G.One panel toggle
@@ -967,7 +974,7 @@ const Toolbar: React.FC = () => {
if (DEBUG_MODE.TOOLBAR) {
console.log('No active or selected region; piano roll will not open');
}
await showAlert('Please select a MIDI region to open the Piano Roll.');
await showAlert(t('toolbar.pianoRoll.selectMidiRegion'));
return;
}
@@ -988,21 +995,21 @@ const Toolbar: React.FC = () => {
const handleRecordClick = async () => {
if (isRecording) {
await stopRecording();
setStatus("Recording stopped");
setStatus(t('toolbar.status.recordingStopped'));
return;
}
const selectedTrack = useProjectStore.getState().tracks.find(track => track.getId().toString() === selectedTrackId) ?? null;
if (selectedTrack instanceof KGAudioTrack) {
await startRecording();
setStatus("Audio recording started...");
setStatus(t('toolbar.status.audioRecordingStarted'));
return;
}
// Require an active or selected MIDI region
const candidateId = activeRegionId ?? lastSelectedRegionId;
if (!candidateId) {
await showAlert("Please open a MIDI region in the Piano Roll before starting recording.");
await showAlert(t('toolbar.recording.openMidiRegion'));
return;
}
const tracks = KGCore.instance().getCurrentProject().getTracks();
@@ -1012,13 +1019,13 @@ const Toolbar: React.FC = () => {
if (region) { isMidi = region instanceof KGMidiRegion; break; }
}
if (!isMidi) {
await showAlert("Please select a MIDI region before starting recording.");
await showAlert(t('toolbar.recording.selectMidiRegion'));
return;
}
// Require at least one connected MIDI device
if (KGMidiInput.instance().getConnectedInputCount() === 0) {
await showAlert("No MIDI device detected. Please connect a MIDI keyboard and try again.");
await showAlert(t('toolbar.recording.noMidiDevice'));
return;
}
@@ -1029,7 +1036,7 @@ const Toolbar: React.FC = () => {
}
await startRecording();
setStatus("Recording started...");
setStatus(t('toolbar.status.recordingStarted'));
};
return (
@@ -1049,12 +1056,12 @@ const Toolbar: React.FC = () => {
</div>
<div className="toolbar-center">
<button title="New" onClick={handleNewProject}><FaPlus /></button>
<button title="Load" onClick={handleLoadProject}><FaFolderOpen /></button>
<button title="Save" onClick={handleSaveProject}><FaSave /></button>
<button title={t('toolbar.button.new')} onClick={handleNewProject}><FaPlus /></button>
<button title={t('toolbar.button.load')} onClick={handleLoadProject}><FaFolderOpen /></button>
<button title={t('toolbar.button.save')} onClick={handleSaveProject}><FaSave /></button>
<div style={{ position: 'relative', display: 'inline-block' }}>
<button
title="Export"
title={t('toolbar.button.export')}
onClick={() => setShowExportDropdown(!showExportDropdown)}
style={{ display: 'flex', alignItems: 'center', gap: '4px' }}
>
@@ -1063,9 +1070,9 @@ const Toolbar: React.FC = () => {
<div style={{ position: 'absolute', top: '100%', left: 0, zIndex: 10000 }}>
<KGDropdown
options={exportOptions}
value={exportOptions[0]}
value={exportOptions[0].value}
onChange={handleExportProject}
label="Export"
label={t('toolbar.export.label')}
hideButton={true}
isOpen={showExportDropdown}
onToggle={setShowExportDropdown}
@@ -1073,71 +1080,71 @@ const Toolbar: React.FC = () => {
/>
</div>
</div>
<button title="Import" onClick={handleImportProject}><FaUpload /></button>
<button title={t('toolbar.button.import')} onClick={handleImportProject}><FaUpload /></button>
<button
title="Settings"
title={t('toolbar.button.settings')}
className={`tool-button ${showSettings ? 'active' : ''}`}
onClick={handleSettingsClick}
>
<FaCog />
</button>
<div className="toolbar-separator"></div>
<button title="Undo" onClick={handleUndoClick}><FaUndo /></button>
<button title="Redo" onClick={handleRedoClick}><FaRedo /></button>
<button title={t('toolbar.button.undo')} onClick={handleUndoClick}><FaUndo /></button>
<button title={t('toolbar.button.redo')} onClick={handleRedoClick}><FaRedo /></button>
<div className="toolbar-separator"></div>
<button
title="Select"
title={t('toolbar.button.select')}
className={`tool-button ${activeMainTool === 'pointer' ? 'active' : ''}`}
onClick={() => handleMainToolSelect('pointer')}
>
<FaMousePointer />
</button>
<button
title="Pencil"
title={t('toolbar.button.pencil')}
className={`tool-button ${activeMainTool === 'pencil' ? 'active' : ''}`}
onClick={() => handleMainToolSelect('pencil')}
>
<FaPencil />
</button>
<button
title="Split Region at Playhead"
title={t('toolbar.button.splitRegion')}
onClick={handleSplitClick}
>
<FaCut />
</button>
<button
title="Merge Selected MIDI Regions"
title={t('toolbar.button.mergeRegions')}
onClick={handleMergeClick}
>
<FaCompress />
</button>
<button
title="Snap to Grid"
title={t('toolbar.button.snapToGrid')}
className={`tool-button ${isSnapping ? 'active' : ''}`}
onClick={handleSnappingToggle}
>
<FaMagnet />
</button>
<div className="toolbar-separator"></div>
<button title="Copy" onClick={handleCopyClick}><FaCopy /></button>
<button title="Paste" onClick={handlePasteClick}><FaPaste /></button>
<button title="Delete" onClick={handleDeleteClick}><FaTrash /></button>
<button title={t('toolbar.button.copy')} onClick={handleCopyClick}><FaCopy /></button>
<button title={t('toolbar.button.paste')} onClick={handlePasteClick}><FaPaste /></button>
<button title={t('toolbar.button.delete')} onClick={handleDeleteClick}><FaTrash /></button>
<div className="toolbar-separator"></div>
<button title="Back to beginning" className="button-back-to-beginning" onClick={handleBackToBeginningClick}><FaStepBackward /></button>
<button title={t('toolbar.button.backToBeginning')} className="button-back-to-beginning" onClick={handleBackToBeginningClick}><FaStepBackward /></button>
{!isPlaying ? (
<button title="Play" className="button-play" onClick={handlePlayClick} disabled={isPreparingPlayback}><FaPlay /></button>
<button title={t('toolbar.button.play')} className="button-play" onClick={handlePlayClick} disabled={isPreparingPlayback}><FaPlay /></button>
) : (
<button title="Pause" className="tool-button button-pause active" onClick={handlePauseClick}><FaPause /></button>
<button title={t('toolbar.button.pause')} className="tool-button button-pause active" onClick={handlePauseClick}><FaPause /></button>
)}
<button
title={isRecording ? "Stop Recording" : "Record"}
title={isRecording ? t('toolbar.button.stopRecording') : t('toolbar.button.record')}
className={`tool-button record-button ${isRecording ? 'active' : ''}`}
onClick={handleRecordClick}
>
<FaCircle />
</button>
<button
title="Loop"
title={t('toolbar.button.loop')}
className={`tool-button ${isLooping ? 'active' : ''}`}
onClick={handleLoopToggle}
>
@@ -1145,13 +1152,13 @@ const Toolbar: React.FC = () => {
</button>
<div className="toolbar-separator"></div>
<button
title="Metronome"
title={t('toolbar.button.metronome')}
className={`tool-button ${isMetronomeEnabled ? 'active' : ''}`}
onClick={handleMetronomeToggle}
>
<MetronomeIcon />
</button>
<button title="Piano" onClick={handlePianoButtonClick}><PianoIcon /></button>
<button title={t('toolbar.button.piano')} onClick={handlePianoButtonClick}><PianoIcon /></button>
{/* <button title="Record"><FaCircle className="record-btn" /></button>
<button title="Metronome">🎵</button> */}
</div>
@@ -1205,7 +1212,7 @@ const Toolbar: React.FC = () => {
onClick={() => setShowKeySignatureDropdown((current) => !current)}
aria-haspopup="dialog"
aria-expanded={showKeySignatureDropdown}
aria-label={`Choose key signature, current ${displayedKeySignature}`}
aria-label={t('toolbar.keySignatureChooser', { value: displayedKeySignature })}
>
{displayedKeySignature}
</button>
@@ -1216,21 +1223,21 @@ const Toolbar: React.FC = () => {
</div>
</div>
<button
title="K.G.One Music Generator"
title={t('toolbar.button.kgone')}
onClick={handleKGOneClick}
className={!showSettings && showKGOnePanel ? 'active' : ''}
>
<FaWandMagicSparkles />
</button>
<button
title="Chat"
title={t('toolbar.button.chat')}
onClick={handleChatClick}
className={!showSettings && showChatBox ? 'active' : ''}
>
<FaComments />
</button>
<button
title="Event List Editor"
title={t('toolbar.button.eventList')}
onClick={handleEventListClick}
className={!showSettings && showEventListPanel ? 'active' : ''}
>
@@ -1244,13 +1251,13 @@ const Toolbar: React.FC = () => {
onClose={() => setShowImportModal(false)}
onFileImport={handleFileImport}
acceptedTypes={['.kgstudio', '.json', '.mid', '.midi']}
title="Import Project"
description="Drag and drop your project file here"
title={t('toolbar.importProject.title')}
description={t('toolbar.importProject.description')}
/>
<LoadingOverlay
visible={isOpeningProject}
message="Opening project..."
message={t('toolbar.openingProject')}
/>
{showOpenProject && (
+41 -40
View File
@@ -2,6 +2,7 @@ import React, { useState, useCallback, useRef } from 'react';
import './DialogProvider.css';
import { FaTimes } from 'react-icons/fa';
import { ConfigManager } from '../../core/config/ConfigManager';
import { useI18n } from '../../i18n/useI18n';
import { registerDialogFns } from '../../util/dialogUtil';
import type {
ChoiceOption,
@@ -44,15 +45,8 @@ const DEFAULT_TEMPO_DETECTION_OPTIONS: TempoDetectionOptionsResult = {
maxTempo: 180,
};
const DETECTION_HINT_TITLE = 'Experimental Feature';
const CHORD_DETECTION_HINT_TEXT = 'Chord analysis is still experimental. Harmonic content, arrangement density, and transient-heavy material can affect accuracy. For more reliable results, start with the default settings, then refine sensitivity and stability until the detected harmony best matches the musical phrasing.';
const MIDI_CHORD_DETECTION_HINT_TEXT = 'Chord analysis is still experimental. Voicing density, overlaps, and ornamental notes can influence the result. For more reliable chord labels, begin with the default settings, then adjust note suppression and harmonic focus to match the musical role of the passage.';
const TEMPO_DETECTION_HINT_TEXT = 'Tempo analysis is still experimental. Rubato phrasing, sparse transients, and layered percussion can reduce accuracy. Start with the default BPM range, then narrow the analysis window to the most plausible tempo span for the material if the first pass is not musically convincing.';
const AUDIO_CHORD_SOURCE_HINT_TITLE = 'Recommended Source Material';
const AUDIO_CHORD_SOURCE_HINT_KGONE_TEXT = 'For the most dependable chord labels, analyze a stem with vocals and percussion reduced or removed. If K.G.One Music Studio server integration is available, run Separator with the "Vocal, Drums, Bass, Guitar, Piano, and Others" model and use the Piano or Others stem for analysis.';
const AUDIO_CHORD_SOURCE_HINT_LOCAL_TEXT = 'For the most dependable chord labels, analyze a stem with vocals and percussion reduced or removed. If you are using the local separator, choose the "Vocal, Drums, Bass, and Others" model and use the Others stem for analysis.';
const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { t } = useI18n();
const [dialog, setDialog] = useState<DialogInfo | null>(null);
const [isClosing, setIsClosing] = useState(false);
const [inputValue, setInputValue] = useState('');
@@ -205,18 +199,18 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
const isKGOneEnabled = (ConfigManager.instance().get('general.kgone.enabled') as boolean | undefined) ?? false;
const title = isAlert
? 'Notice'
? t('dialog.title.notice')
: isTimeSig
? 'Time Signature'
? t('dialog.title.timeSignature')
: isTempoDetection
? 'Tempo Detection'
? t('dialog.title.tempoDetection')
: isTempoApply
? 'Apply Tempo'
: (isChordDetection || isMidiChordDetection)
? 'Chord Detection'
? t('dialog.title.applyTempo')
: (isChordDetection || isMidiChordDetection)
? t('dialog.title.chordDetection')
: isPrompt
? 'Input'
: 'Confirm';
? t('dialog.title.input')
: t('dialog.title.confirm');
const handleOverlayMouseDown = (e: React.MouseEvent) => {
mouseDownOnOverlay.current = e.target === e.currentTarget;
@@ -281,11 +275,11 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
};
const detectionHintText = isChordDetection
? CHORD_DETECTION_HINT_TEXT
? t('dialog.chordHint.audio')
: isMidiChordDetection
? MIDI_CHORD_DETECTION_HINT_TEXT
? t('dialog.chordHint.midi')
: isTempoDetection
? TEMPO_DETECTION_HINT_TEXT
? t('dialog.chordHint.tempo')
: null;
return (
@@ -298,7 +292,7 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
<button
className="dialog-close-btn"
onClick={handleCancel}
aria-label="Close dialog"
aria-label={t('dialog.close')}
>
<FaTimes />
</button>
@@ -307,7 +301,7 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
<p className="dialog-message">{dialog.message}</p>
{detectionHintText && (
<div className="dialog-hint-card">
<div className="dialog-hint-card-title">{DETECTION_HINT_TITLE}</div>
<div className="dialog-hint-card-title">{t('dialog.experimentalFeature')}</div>
<div className="dialog-hint-card-text">{detectionHintText}</div>
</div>
)}
@@ -356,14 +350,14 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
{isChordDetection && (
<div className="dialog-chord-detection-form">
<div className="dialog-hint-card">
<div className="dialog-hint-card-title">{AUDIO_CHORD_SOURCE_HINT_TITLE}</div>
<div className="dialog-hint-card-title">{t('dialog.recommendedSource')}</div>
<div className="dialog-hint-card-text">
{isKGOneEnabled ? AUDIO_CHORD_SOURCE_HINT_KGONE_TEXT : AUDIO_CHORD_SOURCE_HINT_LOCAL_TEXT}
{isKGOneEnabled ? t('dialog.sourceHint.kgone') : t('dialog.sourceHint.local')}
</div>
</div>
<div className="dialog-slider-group">
<div className="dialog-slider-header">
<label className="dialog-slider-label" htmlFor="dialog-chord-sensitivity">Sensitivity</label>
<label className="dialog-slider-label" htmlFor="dialog-chord-sensitivity">{t('dialog.label.sensitivity')}</label>
<span className="dialog-slider-value">{chordDetectionOptions.sensitivity}</span>
</div>
<input
@@ -380,7 +374,7 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
</div>
<div className="dialog-slider-group">
<div className="dialog-slider-header">
<label className="dialog-slider-label" htmlFor="dialog-chord-stability">Stability</label>
<label className="dialog-slider-label" htmlFor="dialog-chord-stability">{t('dialog.label.stability')}</label>
<span className="dialog-slider-value">{chordDetectionOptions.stability}</span>
</div>
<input
@@ -396,7 +390,7 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
</div>
<div className="dialog-slider-group">
<div className="dialog-slider-header">
<label className="dialog-slider-label" htmlFor="dialog-chord-no-chord-threshold">No-Chord Threshold</label>
<label className="dialog-slider-label" htmlFor="dialog-chord-no-chord-threshold">{t('dialog.label.noChordThreshold')}</label>
<span className="dialog-slider-value">{chordDetectionOptions.noChordThreshold}</span>
</div>
<input
@@ -417,7 +411,7 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
checked={chordDetectionOptions.enableSevenths}
onChange={(e) => updateChordDetectionOption('enableSevenths', e.target.checked)}
/>
<span>Chord Detail: Enable sevenths</span>
<span>{t('dialog.label.enableSevenths')}</span>
</label>
</div>
)}
@@ -425,7 +419,7 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
<div className="dialog-chord-detection-form">
<div className="dialog-slider-group">
<div className="dialog-slider-header">
<label className="dialog-slider-label" htmlFor="dialog-midi-short-note-suppression">Short Notes</label>
<label className="dialog-slider-label" htmlFor="dialog-midi-short-note-suppression">{t('dialog.label.shortNotes')}</label>
</div>
<select
id="dialog-midi-short-note-suppression"
@@ -434,14 +428,14 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
onChange={(e) => updateMidiChordDetectionOption('shortNoteSuppression', e.target.value as MidiChordDetectionOptionsResult['shortNoteSuppression'])}
autoFocus
>
<option value="low">Low suppression</option>
<option value="medium">Medium suppression</option>
<option value="high">High suppression</option>
<option value="low">{t('dialog.option.suppressionLow')}</option>
<option value="medium">{t('dialog.option.suppressionMedium')}</option>
<option value="high">{t('dialog.option.suppressionHigh')}</option>
</select>
</div>
<div className="dialog-slider-group">
<div className="dialog-slider-header">
<label className="dialog-slider-label" htmlFor="dialog-midi-harmonic-focus">Harmonic Focus</label>
<label className="dialog-slider-label" htmlFor="dialog-midi-harmonic-focus">{t('dialog.label.harmonicFocus')}</label>
</div>
<select
id="dialog-midi-harmonic-focus"
@@ -449,8 +443,8 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
value={midiChordDetectionOptions.harmonicFocus}
onChange={(e) => updateMidiChordDetectionOption('harmonicFocus', e.target.value as MidiChordDetectionOptionsResult['harmonicFocus'])}
>
<option value="balanced">Balanced</option>
<option value="favor-sustained-notes">Favor sustained notes</option>
<option value="balanced">{t('dialog.option.harmonicBalanced')}</option>
<option value="favor-sustained-notes">{t('dialog.option.harmonicSustained')}</option>
</select>
</div>
<label className="dialog-checkbox-row" htmlFor="dialog-midi-enable-sevenths">
@@ -460,7 +454,7 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
checked={midiChordDetectionOptions.enableSevenths}
onChange={(e) => updateMidiChordDetectionOption('enableSevenths', e.target.checked)}
/>
<span>Chord Detail: Enable sevenths</span>
<span>{t('dialog.label.enableSevenths')}</span>
</label>
</div>
)}
@@ -468,7 +462,7 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
<div className="dialog-chord-detection-form">
<div className="dialog-slider-group">
<div className="dialog-slider-header">
<label className="dialog-slider-label" htmlFor="dialog-tempo-min-tempo">Minimum BPM</label>
<label className="dialog-slider-label" htmlFor="dialog-tempo-min-tempo">{t('dialog.label.minimumBpm')}</label>
<span className="dialog-slider-value">{tempoDetectionOptions.minTempo}</span>
</div>
<input
@@ -485,7 +479,7 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
</div>
<div className="dialog-slider-group">
<div className="dialog-slider-header">
<label className="dialog-slider-label" htmlFor="dialog-tempo-max-tempo">Maximum BPM</label>
<label className="dialog-slider-label" htmlFor="dialog-tempo-max-tempo">{t('dialog.label.maximumBpm')}</label>
<span className="dialog-slider-value">{tempoDetectionOptions.maxTempo}</span>
</div>
<input
@@ -511,7 +505,7 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
onChange={(e) => setAutoAlignRegionToBeat(e.target.checked)}
autoFocus
/>
<span>Auto-align region to beat</span>
<span>{t('dialog.label.autoAlignRegionToBeat')}</span>
</label>
</div>
)}
@@ -522,7 +516,7 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
className="dialog-btn dialog-btn-cancel"
onClick={handleCancel}
>
{(dialog.options as ConfirmOptions | PromptOptions | undefined)?.cancelLabel ?? 'Cancel'}
{(dialog.options as ConfirmOptions | PromptOptions | undefined)?.cancelLabel ?? t('dialog.cancel')}
</button>
)}
{isChoice ? (
@@ -553,7 +547,14 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
onClick={handleConfirm}
autoFocus={!isPrompt && !isTimeSig && !isChordDetection && !isMidiChordDetection && !isTempoDetection && !isTempoApply}
>
{isAlert ? 'OK' : ((dialog.options as ConfirmOptions | PromptOptions | undefined)?.confirmLabel ?? (isPrompt || isTimeSig ? 'OK' : (isChordDetection || isMidiChordDetection || isTempoDetection) ? 'Detect' : 'Yes'))}
{isAlert
? t('dialog.ok')
: ((dialog.options as ConfirmOptions | PromptOptions | undefined)?.confirmLabel
?? (isPrompt || isTimeSig
? t('dialog.ok')
: (isChordDetection || isMidiChordDetection || isTempoDetection)
? t('dialog.ok')
: t('settings.yes')))}
</button>
)}
</div>
+13 -9
View File
@@ -2,6 +2,7 @@ import React, { useCallback, useRef, useState } from 'react';
import './FileImportModal.css';
import { FaTimes } from 'react-icons/fa';
import { showAlert } from '../../util/dialogUtil';
import { useI18n } from '../../i18n/useI18n';
interface FileImportModalProps {
isVisible: boolean;
@@ -17,9 +18,12 @@ const FileImportModal: React.FC<FileImportModalProps> = ({
onClose,
onFileImport,
acceptedTypes = ['.json'],
title = 'Import Project',
description = 'Drag and drop your project file here'
title,
description,
}) => {
const { t } = useI18n();
const resolvedTitle = title ?? t('toolbar.importProject.title');
const resolvedDescription = description ?? t('toolbar.importProject.description');
const [isDragOver, setIsDragOver] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const mouseDownOnOverlay = useRef(false);
@@ -65,7 +69,7 @@ const FileImportModal: React.FC<FileImportModalProps> = ({
onFileImport(file);
startClose();
} else {
await showAlert(`Invalid file type. Please select a file with one of these extensions: ${acceptedTypes.join(', ')}`);
await showAlert(t('fileImport.invalidType', { extensions: acceptedTypes.join(', ') }));
}
}
}, [acceptedTypes, onFileImport, startClose]);
@@ -101,11 +105,11 @@ const FileImportModal: React.FC<FileImportModalProps> = ({
>
<div className={`file-import-modal${isClosing ? ' file-import-modal-closing' : ''}`}>
<div className="file-import-header">
<h3 className="file-import-title">{title}</h3>
<h3 className="file-import-title">{resolvedTitle}</h3>
<button
className="file-import-close-btn"
onClick={startClose}
aria-label="Close import modal"
aria-label={t('fileImport.close')}
>
<FaTimes />
</button>
@@ -120,17 +124,17 @@ const FileImportModal: React.FC<FileImportModalProps> = ({
>
<div className="file-import-drop-content">
<div className="file-import-icon">📁</div>
<p className="file-import-description">{description}</p>
<p className="file-import-description">{resolvedDescription}</p>
<p className="file-import-formats">
Supported formats: {acceptedTypes.join(', ')}
{t('fileImport.supportedFormats', { formats: acceptedTypes.join(', ') })}
</p>
<div className="file-import-divider">
<span>or</span>
<span>{t('fileImport.or')}</span>
</div>
<label className="file-import-browse-btn">
Browse Files
{t('fileImport.browse')}
<input
type="file"
accept={acceptedTypes.join(',')}
+2 -2
View File
@@ -1,12 +1,13 @@
import React from 'react';
import './LoadingOverlay.css';
import { translate } from '../../i18n/translate';
interface LoadingOverlayProps {
visible: boolean;
message?: string;
}
const LoadingOverlay: React.FC<LoadingOverlayProps> = ({ visible, message = 'Loading ...' }) => {
const LoadingOverlay: React.FC<LoadingOverlayProps> = ({ visible, message = translate('app.loading') }) => {
if (!visible) return null;
return (
@@ -21,4 +22,3 @@ const LoadingOverlay: React.FC<LoadingOverlayProps> = ({ visible, message = 'Loa
export default LoadingOverlay;
@@ -44,6 +44,7 @@ import { isModifierKeyPressed } from '../../util/osUtil';
import { parseChordSymbol } from '../../util/chordUtil';
import { showAlert } from '../../util/dialogUtil';
import { getSortedKeySignatureRegions, getSortedTempoRegions } from '../../util/globalTrackUtil';
import { useI18n } from '../../i18n/useI18n';
interface GlobalEventListTabProps {
globalTracks: KGGlobalTrack[];
@@ -97,13 +98,6 @@ interface GlobalEditingCell {
value: string;
}
const ADD_GLOBAL_ITEM_OPTIONS: Array<{ label: string; value: AddGlobalItemType }> = [
{ label: 'Marker', value: 'marker' },
{ label: 'Tempo', value: 'tempo' },
{ label: 'Key Signature', value: 'key-signature' },
{ label: 'Chord', value: 'chord' },
];
const GLOBAL_TYPE_ORDER: Record<GlobalRowData['type'], number> = {
marker: 0,
tempo: 1,
@@ -113,16 +107,16 @@ const GLOBAL_TYPE_ORDER: Record<GlobalRowData['type'], number> = {
const CANONICAL_KEY_SIGNATURES = Object.keys(KEY_SIGNATURE_MAP) as KeySignature[];
const getRowStatus = (row: GlobalRowData): string => {
const getRowStatus = (row: GlobalRowData, t: (key: string, params?: Record<string, string | number>) => string): string => {
switch (row.type) {
case 'marker':
return 'Marker';
return t('eventList.global.status.marker');
case 'tempo':
return 'Tempo';
return t('eventList.global.status.tempo');
case 'key-signature':
return 'Key Signature';
return t('eventList.global.status.keySignature');
case 'chord':
return 'Chord';
return t('eventList.global.status.chord');
}
};
@@ -147,16 +141,22 @@ const findRegionRowType = (region: KGGlobalRegion): GlobalRowData['type'] | null
return null;
};
const buildValueValidationMessage = (type: GlobalRowData['type']): string => {
const buildValueValidationMessage = (
type: GlobalRowData['type'],
t: (key: string, params?: Record<string, string | number>) => string
): string => {
switch (type) {
case 'marker':
return 'Please enter a marker label. Expected a non-empty text label. Example: Intro';
return t('eventList.global.validation.marker');
case 'tempo':
return `Please enter a BPM value using digits only. Expected a whole number between ${TIME_CONSTANTS.MIN_BPM + 1} and ${TIME_CONSTANTS.MAX_BPM - 1}. Example: 128`;
return t('eventList.global.validation.tempo', {
min: TIME_CONSTANTS.MIN_BPM + 1,
max: TIME_CONSTANTS.MAX_BPM - 1,
});
case 'key-signature':
return 'Please enter one exact key signature name. Expected a canonical value such as "C major" or "F# minor". Example: F# minor';
return t('eventList.global.validation.keySignature');
case 'chord':
return 'Please enter a valid chord symbol. Expected a chord representation the app can parse. Example: Bm7b5';
return t('eventList.global.validation.chord');
}
};
@@ -167,6 +167,7 @@ const GlobalEventListTab: React.FC<GlobalEventListTabProps> = ({
playheadPosition,
refreshProjectState,
}) => {
const { t } = useI18n();
const [showMarkers, setShowMarkers] = useState(true);
const [showTempo, setShowTempo] = useState(true);
const [showKeySignature, setShowKeySignature] = useState(true);
@@ -178,6 +179,13 @@ const GlobalEventListTab: React.FC<GlobalEventListTabProps> = ({
const suppressBlurCommitRef = useRef(false);
const pendingSingleClickSelectionRef = useRef<number | null>(null);
const addGlobalItemOptions = useMemo<Array<{ label: string; value: AddGlobalItemType }>>(() => ([
{ label: t('eventList.global.addType.marker'), value: 'marker' },
{ label: t('eventList.global.addType.tempo'), value: 'tempo' },
{ label: t('eventList.global.addType.keySignature'), value: 'key-signature' },
{ label: t('eventList.global.addType.chord'), value: 'chord' },
]), [t]);
const markerTrack = globalTracks.find(track => track.getType() === GlobalTrackType.Marker) ?? null;
const tempoTrack = globalTracks.find(track => track.getType() === GlobalTrackType.Tempo) ?? null;
const signatureTrack = globalTracks.find(track => track.getType() === GlobalTrackType.Signature) ?? null;
@@ -366,7 +374,7 @@ const GlobalEventListTab: React.FC<GlobalEventListTabProps> = ({
try {
if (editingCell.column === 'position') {
if (isDeltaEdit) {
const parsed = parseMidiEventPositionDelta(trimmedValue, timeSignature, MIDI_EVENT_TICKS_PER_BEAT);
const parsed = parseMidiEventPositionDelta(trimmedValue, timeSignature, MIDI_EVENT_TICKS_PER_BEAT);
if ('error' in parsed) {
await showAlert(parsed.error);
return;
@@ -375,7 +383,7 @@ const GlobalEventListTab: React.FC<GlobalEventListTabProps> = ({
for (const targetRow of targetRows) {
const nextBeat = targetRow.absoluteStartBeat + parsed.deltaBeats;
if (nextBeat < 0) {
await showAlert('Please enter a position at or after the start of the project. Expected a non-negative location. Example: 1 1 0');
await showAlert(t('eventList.global.validation.position'));
return;
}
}
@@ -404,7 +412,7 @@ const GlobalEventListTab: React.FC<GlobalEventListTabProps> = ({
return;
}
if (parsed.absoluteBeat < 0) {
await showAlert('Please enter a position at or after the start of the project. Expected a non-negative location. Example: 1 1 0');
await showAlert(t('eventList.global.validation.position'));
return;
}
@@ -432,7 +440,7 @@ const GlobalEventListTab: React.FC<GlobalEventListTabProps> = ({
if (row.type === 'marker') {
const normalized = trimmedValue.replace(/\r?\n/g, ' ').trim();
if (!normalized) {
await showAlert(buildValueValidationMessage('marker'));
await showAlert(buildValueValidationMessage('marker', t));
return;
}
@@ -443,7 +451,7 @@ const GlobalEventListTab: React.FC<GlobalEventListTabProps> = ({
}
} else if (row.type === 'tempo') {
if (!/^\d+$/.test(trimmedValue)) {
await showAlert(buildValueValidationMessage('tempo'));
await showAlert(buildValueValidationMessage('tempo', t));
return;
}
const nextBpm = parseInt(trimmedValue, 10);
@@ -452,7 +460,7 @@ const GlobalEventListTab: React.FC<GlobalEventListTabProps> = ({
|| nextBpm <= TIME_CONSTANTS.MIN_BPM
|| nextBpm >= TIME_CONSTANTS.MAX_BPM
) {
await showAlert(buildValueValidationMessage('tempo'));
await showAlert(buildValueValidationMessage('tempo', t));
return;
}
@@ -463,7 +471,7 @@ const GlobalEventListTab: React.FC<GlobalEventListTabProps> = ({
}
} else if (row.type === 'key-signature') {
if (!CANONICAL_KEY_SIGNATURES.includes(trimmedValue as KeySignature)) {
await showAlert(buildValueValidationMessage('key-signature'));
await showAlert(buildValueValidationMessage('key-signature', t));
return;
}
const keySignature = trimmedValue as KeySignature;
@@ -473,7 +481,7 @@ const GlobalEventListTab: React.FC<GlobalEventListTabProps> = ({
}
}
} else if (parseChordSymbol(trimmedValue) === null) {
await showAlert(buildValueValidationMessage('chord'));
await showAlert(buildValueValidationMessage('chord', t));
return;
} else {
for (const targetRow of targetRows) {
@@ -494,7 +502,7 @@ const GlobalEventListTab: React.FC<GlobalEventListTabProps> = ({
for (const targetRow of targetRows) {
if (targetRow.durationBeats + parsed.deltaBeats <= 0) {
await showAlert('Please enter a positive length. Expected a duration greater than zero. Example: 4 0');
await showAlert(t('eventList.global.validation.length'));
return;
}
}
@@ -527,7 +535,7 @@ const GlobalEventListTab: React.FC<GlobalEventListTabProps> = ({
return;
}
if (parsed.duration <= 0) {
await showAlert('Please enter a positive length. Expected a duration greater than zero. Example: 4 0');
await showAlert(t('eventList.global.validation.length'));
return;
}
@@ -752,11 +760,11 @@ const GlobalEventListTab: React.FC<GlobalEventListTabProps> = ({
return (
<>
<div className="event-list-tabs" role="tablist" aria-label="Global event filters">
<button className={`event-list-tab${showMarkers ? ' active' : ''}`} aria-pressed={showMarkers} type="button" onClick={() => setShowMarkers(value => !value)}>Marker</button>
<button className={`event-list-tab${showTempo ? ' active' : ''}`} aria-pressed={showTempo} type="button" onClick={() => setShowTempo(value => !value)}>Tempo</button>
<button className={`event-list-tab${showKeySignature ? ' active' : ''}`} aria-pressed={showKeySignature} type="button" onClick={() => setShowKeySignature(value => !value)}>Key Sig.</button>
<button className={`event-list-tab${showChords ? ' active' : ''}`} aria-pressed={showChords} type="button" onClick={() => setShowChords(value => !value)}>Chord</button>
<div className="event-list-tabs" role="tablist" aria-label={t('eventList.global.filters')}>
<button className={`event-list-tab${showMarkers ? ' active' : ''}`} aria-pressed={showMarkers} type="button" onClick={() => setShowMarkers(value => !value)}>{t('eventList.global.filter.marker')}</button>
<button className={`event-list-tab${showTempo ? ' active' : ''}`} aria-pressed={showTempo} type="button" onClick={() => setShowTempo(value => !value)}>{t('eventList.global.filter.tempo')}</button>
<button className={`event-list-tab${showKeySignature ? ' active' : ''}`} aria-pressed={showKeySignature} type="button" onClick={() => setShowKeySignature(value => !value)}>{t('eventList.global.filter.keySignature')}</button>
<button className={`event-list-tab${showChords ? ' active' : ''}`} aria-pressed={showChords} type="button" onClick={() => setShowChords(value => !value)}>{t('eventList.global.filter.chord')}</button>
</div>
<div className="event-list-toolbar">
@@ -765,12 +773,12 @@ const GlobalEventListTab: React.FC<GlobalEventListTabProps> = ({
className="event-list-add-button"
title={
addGlobalItemType === 'marker'
? 'Add marker region at playhead'
? t('eventList.global.add.markerTitle')
: addGlobalItemType === 'tempo'
? 'Add tempo region at playhead'
? t('eventList.global.add.tempoTitle')
: addGlobalItemType === 'key-signature'
? 'Add key signature region at playhead'
: 'Add chord region at playhead'
? t('eventList.global.add.keySignatureTitle')
: t('eventList.global.add.chordTitle')
}
type="button"
onClick={handleAddGlobalItem}
@@ -778,10 +786,10 @@ const GlobalEventListTab: React.FC<GlobalEventListTabProps> = ({
<FaPlus />
</button>
<KGDropdown
options={ADD_GLOBAL_ITEM_OPTIONS}
options={addGlobalItemOptions}
value={addGlobalItemType}
onChange={(value) => setAddGlobalItemType(value as AddGlobalItemType)}
label="Add"
label={t('eventList.global.add.label')}
buttonClassName="event-list-type-button"
showValueAsLabel
/>
@@ -790,7 +798,7 @@ const GlobalEventListTab: React.FC<GlobalEventListTabProps> = ({
<div className="event-list-toolbar-group event-list-toolbar-group-right">
<button
className="event-list-delete-button"
title="Delete visible selected rows"
title={t('eventList.deleteVisibleSelectedRows')}
type="button"
onClick={handleDeleteSelectedRows}
disabled={visibleSelectedRows.length === 0}
@@ -804,16 +812,16 @@ const GlobalEventListTab: React.FC<GlobalEventListTabProps> = ({
<table className="event-list-table">
<thead>
<tr>
<th>Position</th>
<th>Status</th>
<th>Val</th>
<th>Length/Info</th>
<th>{t('eventList.table.position')}</th>
<th>{t('eventList.table.status')}</th>
<th>{t('eventList.table.val')}</th>
<th>{t('eventList.table.lengthInfo')}</th>
</tr>
</thead>
<tbody>
{globalRows.map((row, index) => {
const positionText = formatMidiEventPosition(row.absoluteStartBeat, timeSignature, MIDI_EVENT_TICKS_PER_BEAT);
const statusText = getRowStatus(row);
const statusText = getRowStatus(row, t);
const valText = getRowValue(row);
const lengthText = formatMidiEventLength(row.durationBeats, MIDI_EVENT_TICKS_PER_BEAT);
const isEditingPosition = editingCell?.rowId === row.id && editingCell.column === 'position';
@@ -35,6 +35,7 @@ import { UpdateControllerEventPropertiesCommand } from '../../core/commands/note
import { UpdateNotePropertiesCommand } from '../../core/commands/note/UpdateNotePropertiesCommand';
import { UpdatePitchBendPropertiesCommand } from '../../core/commands/note/UpdatePitchBendPropertiesCommand';
import { showAlert } from '../../util/dialogUtil';
import { useI18n } from '../../i18n/useI18n';
interface RegionEventListTabProps {
activeMidiRegion: KGMidiRegion | null;
@@ -74,12 +75,6 @@ interface EditingCell {
value: string;
}
const ADD_EVENT_TYPE_OPTIONS = [
{ label: 'Note', value: 'note' },
{ label: 'Pitch Bend', value: 'pitch-bend' },
{ label: 'Controller', value: 'controller' },
] as const;
const parseVelocityInput = (raw: string): { velocity: number } | { error: string } => {
const trimmed = raw.trim();
if (!/^\d+$/.test(trimmed)) {
@@ -182,6 +177,7 @@ const parseControllerValueDeltaInput = (raw: string): { delta: number } | { erro
};
const RegionEventListTab: React.FC<RegionEventListTabProps> = ({ activeMidiRegion, parentTrack }) => {
const { t } = useI18n();
const {
selectedNoteIds,
selectedPitchBendIds,
@@ -253,6 +249,11 @@ const RegionEventListTab: React.FC<RegionEventListTabProps> = ({ activeMidiRegio
const selectedControllerEventIdSet = new Set(selectedControllerEventIds);
const selectedEventIdSet = new Set([...selectedNoteIds, ...selectedPitchBendIds, ...selectedControllerEventIds]);
const visibleSelectedRows = eventRows.filter(row => selectedEventIdSet.has(row.id));
const addEventTypeOptions = [
{ label: t('eventList.region.addType.note'), value: 'note' },
{ label: t('eventList.region.addType.pitchBend'), value: 'pitch-bend' },
{ label: t('eventList.region.addType.controller'), value: 'controller' },
] as const;
useEffect(() => {
if (editingCell) {
@@ -941,15 +942,15 @@ const RegionEventListTab: React.FC<RegionEventListTabProps> = ({ activeMidiRegio
return (
<>
<div className="event-list-tabs" role="tablist" aria-label="Region event filters">
<button className={`event-list-tab${showNotes ? ' active' : ''}`} type="button" onClick={() => setShowNotes(value => !value)}>Notes</button>
<button className={`event-list-tab${showPitchBends ? ' active' : ''}`} type="button" onClick={() => setShowPitchBends(value => !value)}>Pitch Bends</button>
<button className={`event-list-tab${showControllers ? ' active' : ''}`} type="button" onClick={() => setShowControllers(value => !value)}>Controller</button>
<div className="event-list-tabs" role="tablist" aria-label={t('eventList.region.filters')}>
<button className={`event-list-tab${showNotes ? ' active' : ''}`} type="button" onClick={() => setShowNotes(value => !value)}>{t('eventList.region.filter.notes')}</button>
<button className={`event-list-tab${showPitchBends ? ' active' : ''}`} type="button" onClick={() => setShowPitchBends(value => !value)}>{t('eventList.region.filter.pitchBends')}</button>
<button className={`event-list-tab${showControllers ? ' active' : ''}`} type="button" onClick={() => setShowControllers(value => !value)}>{t('eventList.region.filter.controller')}</button>
</div>
{!activeMidiRegion ? (
<div className="event-list-empty-state">
Please select a MIDI region, or open one in the Piano Roll, to view its event list.
{t('eventList.region.empty')}
</div>
) : (
<>
@@ -957,17 +958,23 @@ const RegionEventListTab: React.FC<RegionEventListTabProps> = ({ activeMidiRegio
<div className="event-list-toolbar-group">
<button
className="event-list-add-button"
title={addEventType === 'note' ? 'Add note at playhead' : addEventType === 'pitch-bend' ? 'Add pitch bend at playhead' : 'Add controller event at playhead'}
title={
addEventType === 'note'
? t('eventList.region.add.noteTitle')
: addEventType === 'pitch-bend'
? t('eventList.region.add.pitchBendTitle')
: t('eventList.region.add.controllerTitle')
}
type="button"
onClick={handleAddEvent}
>
<FaPlus />
</button>
<KGDropdown
options={[...ADD_EVENT_TYPE_OPTIONS]}
options={[...addEventTypeOptions]}
value={addEventType}
onChange={(value) => setAddEventType(value as AddEventType)}
label="Note"
label={t('eventList.region.add.label')}
buttonClassName="event-list-type-button"
showValueAsLabel
/>
@@ -975,28 +982,28 @@ const RegionEventListTab: React.FC<RegionEventListTabProps> = ({ activeMidiRegio
<div className="event-list-toolbar-group event-list-toolbar-group-right">
<KGDropdown
options={KGPianoRollState.QUANT_POS_OPTIONS}
options={KGPianoRollState.QUANT_POS_OPTIONS.map(option => ({ label: t(option.labelKey), value: option.value }))}
value={quantPosition}
onChange={(value) => {
setQuantPosition(value);
quantizeSelectedNotes(value);
}}
label="Qua. Pos."
label={t('pianoRoll.quantizePositionCompact')}
buttonClassName="event-list-quant-button"
/>
<KGDropdown
options={KGPianoRollState.QUANT_LEN_OPTIONS}
options={KGPianoRollState.QUANT_LEN_OPTIONS.map(option => ({ label: t(option.labelKey), value: option.value }))}
value={quantLength}
onChange={(value) => {
setQuantLength(value);
quantizeSelectedNoteLengths(value);
}}
label="Qua. Len."
label={t('pianoRoll.quantizeLengthCompact')}
buttonClassName="event-list-quant-button"
/>
<button
className="event-list-delete-button"
title="Delete visible selected rows"
title={t('eventList.deleteVisibleSelectedRows')}
type="button"
onClick={handleDeleteSelectedRows}
disabled={visibleSelectedRows.length === 0}
@@ -1010,18 +1017,22 @@ const RegionEventListTab: React.FC<RegionEventListTabProps> = ({ activeMidiRegio
<table className="event-list-table">
<thead>
<tr>
<th>Position</th>
<th>Status</th>
<th>Num</th>
<th>Val</th>
<th>Length/Info</th>
<th>{t('eventList.table.position')}</th>
<th>{t('eventList.table.status')}</th>
<th>{t('eventList.table.num')}</th>
<th>{t('eventList.table.val')}</th>
<th>{t('eventList.table.lengthInfo')}</th>
</tr>
</thead>
<tbody>
{eventRows.map((row, index) => {
const absoluteBeat = row.type === 'note' ? row.absoluteStartBeat : row.absoluteBeat;
const positionText = formatMidiEventPosition(absoluteBeat, timeSignature, MIDI_EVENT_TICKS_PER_BEAT);
const statusText = row.type === 'note' ? 'Note' : row.type === 'pitch-bend' ? 'Pitch Bend' : 'Controller';
const statusText = row.type === 'note'
? t('eventList.region.status.note')
: row.type === 'pitch-bend'
? t('eventList.region.status.pitchBend')
: t('eventList.region.status.controller');
const numText = row.type === 'note'
? pitchToNoteNameString(row.note.getPitch())
: row.type === 'controller'
@@ -29,6 +29,7 @@ import {
import { isModifierKeyPressed } from '../../util/osUtil';
import { showAlert } from '../../util/dialogUtil';
import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
import { useI18n } from '../../i18n/useI18n';
interface TrackEventListTabProps {
selectedTrack: KGMidiTrack | KGAudioTrack | null;
@@ -119,6 +120,7 @@ const findPreviousPanValue = (points: KGTrackAutomationPoint[], beat: number): n
};
const TrackEventListTab: React.FC<TrackEventListTabProps> = ({ selectedTrack }) => {
const { t } = useI18n();
const {
tracks,
playheadPosition,
@@ -164,12 +166,12 @@ const TrackEventListTab: React.FC<TrackEventListTabProps> = ({ selectedTrack })
const availableAddOptions = useMemo(() => {
const options: Array<{ label: string; value: AddTrackItemType }> = [];
if (selectedTrack instanceof KGMidiTrack) {
options.push({ label: 'MIDI Region', value: 'midi-region' });
options.push({ label: t('eventList.track.addType.midiRegion'), value: 'midi-region' });
}
options.push({ label: 'Volume', value: 'volume' });
options.push({ label: 'Pan', value: 'pan' });
options.push({ label: t('eventList.track.filter.volume'), value: 'volume' });
options.push({ label: t('eventList.track.filter.pan'), value: 'pan' });
return options;
}, [selectedTrack]);
}, [selectedTrack, t]);
const liveSelectedTrack = useMemo(() => {
if (!selectedTrack) {
@@ -659,15 +661,15 @@ const TrackEventListTab: React.FC<TrackEventListTabProps> = ({ selectedTrack })
return (
<>
<div className="event-list-tabs" role="tablist" aria-label="Track list modes">
<button className={`event-list-tab${showRegions ? ' active' : ''}`} aria-pressed={showRegions} type="button" onClick={() => setShowRegions(value => !value)}>Regions</button>
<button className={`event-list-tab${showVolume ? ' active' : ''}`} aria-pressed={showVolume} type="button" onClick={() => setShowVolume(value => !value)}>Volume</button>
<button className={`event-list-tab${showPan ? ' active' : ''}`} aria-pressed={showPan} type="button" onClick={() => setShowPan(value => !value)}>Pan</button>
<div className="event-list-tabs" role="tablist" aria-label={t('eventList.track.filters')}>
<button className={`event-list-tab${showRegions ? ' active' : ''}`} aria-pressed={showRegions} type="button" onClick={() => setShowRegions(value => !value)}>{t('eventList.track.filter.regions')}</button>
<button className={`event-list-tab${showVolume ? ' active' : ''}`} aria-pressed={showVolume} type="button" onClick={() => setShowVolume(value => !value)}>{t('eventList.track.filter.volume')}</button>
<button className={`event-list-tab${showPan ? ' active' : ''}`} aria-pressed={showPan} type="button" onClick={() => setShowPan(value => !value)}>{t('eventList.track.filter.pan')}</button>
</div>
{!liveSelectedTrack ? (
<div className="event-list-empty-state">
Please select a track to view regions and track automation.
{t('eventList.track.empty')}
</div>
) : (
<>
@@ -675,7 +677,13 @@ const TrackEventListTab: React.FC<TrackEventListTabProps> = ({ selectedTrack })
<div className="event-list-toolbar-group">
<button
className="event-list-add-button"
title={addTrackItemType === 'midi-region' ? 'Add MIDI region at playhead' : addTrackItemType === 'volume' ? 'Add volume automation point at playhead' : 'Add pan automation point at playhead'}
title={
addTrackItemType === 'midi-region'
? t('eventList.track.add.midiRegionTitle')
: addTrackItemType === 'volume'
? t('eventList.track.add.volumeTitle')
: t('eventList.track.add.panTitle')
}
type="button"
onClick={handleAddTrackItem}
>
@@ -685,7 +693,7 @@ const TrackEventListTab: React.FC<TrackEventListTabProps> = ({ selectedTrack })
options={availableAddOptions}
value={addTrackItemType}
onChange={(value) => setAddTrackItemType(value as AddTrackItemType)}
label="Add"
label={t('eventList.track.add.label')}
buttonClassName="event-list-type-button"
showValueAsLabel
/>
@@ -694,7 +702,7 @@ const TrackEventListTab: React.FC<TrackEventListTabProps> = ({ selectedTrack })
<div className="event-list-toolbar-group event-list-toolbar-group-right">
<button
className="event-list-delete-button"
title="Delete visible selected rows"
title={t('eventList.deleteVisibleSelectedRows')}
type="button"
onClick={handleDeleteSelectedRows}
disabled={visibleSelectedRows.length === 0}
@@ -708,16 +716,20 @@ const TrackEventListTab: React.FC<TrackEventListTabProps> = ({ selectedTrack })
<table className="event-list-table">
<thead>
<tr>
<th>Position</th>
<th>Status</th>
<th>Val</th>
<th>Length/Info</th>
<th>{t('eventList.table.position')}</th>
<th>{t('eventList.table.status')}</th>
<th>{t('eventList.table.val')}</th>
<th>{t('eventList.table.lengthInfo')}</th>
</tr>
</thead>
<tbody>
{trackRows.map((row, index) => {
const positionText = formatMidiEventPosition(row.type === 'region' ? row.absoluteStartBeat : row.absoluteBeat, timeSignature, MIDI_EVENT_TICKS_PER_BEAT);
const statusText = row.type === 'region' ? row.statusLabel : row.automationType === 'volume' ? 'Volume' : 'Pan';
const statusText = row.type === 'region'
? (row.statusLabel === 'Audio' ? t('eventList.track.status.audio') : t('eventList.track.status.midi'))
: row.automationType === 'volume'
? t('eventList.track.status.volume')
: t('eventList.track.status.pan');
const valText = row.type === 'region' ? row.region.getName() : formatTrackAutomationValue(row.automationType, row.point.getValue());
const infoText = row.type === 'region' ? formatMidiEventLength(row.durationBeats, MIDI_EVENT_TICKS_PER_BEAT) : formatTrackAutomationInfo(row.automationType, row.point.getValue());
const isEditingPosition = editingCell?.rowId === row.id && editingCell.column === 'position';
@@ -4,19 +4,13 @@ import GlobalChordLane from './GlobalChordLane';
import GlobalKeySignatureLane from './GlobalKeySignatureLane';
import GlobalMarkerLane from './GlobalMarkerLane';
import GlobalTempoLane from './GlobalTempoLane';
import { useI18n } from '../../i18n/useI18n';
interface GlobalTrackDefinition {
id: 'marker' | 'tempo' | 'signature' | 'chord';
label: string;
}
const GLOBAL_TRACKS: GlobalTrackDefinition[] = [
{ id: 'marker', label: 'Marker' },
{ id: 'tempo', label: 'Tempo' },
{ id: 'signature', label: 'Key Signature' },
{ id: 'chord', label: 'Chord' },
];
interface MainContentGlobalTracksSectionProps {
visible: boolean;
onAddMarker: () => void;
@@ -40,8 +34,15 @@ const MainContentGlobalTracksSection: React.FC<MainContentGlobalTracksSectionPro
keySignatureLaneProps,
chordLaneProps,
}) => {
const { t } = useI18n();
const [shouldRender, setShouldRender] = useState(visible);
const [isAnimated, setIsAnimated] = useState(false);
const globalTracks: GlobalTrackDefinition[] = [
{ id: 'marker', label: t('globalTracks.marker') },
{ id: 'tempo', label: t('globalTracks.tempo') },
{ id: 'signature', label: t('globalTracks.signature') },
{ id: 'chord', label: t('globalTracks.chord') },
];
useEffect(() => {
if (!visible) {
@@ -71,9 +72,9 @@ const MainContentGlobalTracksSection: React.FC<MainContentGlobalTracksSectionPro
return (
<div
className="global-tracks-section"
aria-label="Global tracks"
aria-label={t('globalTracks.label')}
aria-hidden={!visible}
style={{ ['--global-track-count' as string]: String(GLOBAL_TRACKS.length) }}
style={{ ['--global-track-count' as string]: String(globalTracks.length) }}
>
<div
className={`global-tracks-info-shell${isAnimated ? ' expanded' : ' collapsed'}`}
@@ -84,14 +85,14 @@ const MainContentGlobalTracksSection: React.FC<MainContentGlobalTracksSectionPro
}}
>
<div className="global-tracks-info">
{GLOBAL_TRACKS.map(track => (
{globalTracks.map(track => (
<div key={track.id} className="global-track-info-row">
<span className="global-track-name">{track.label}</span>
<button
type="button"
className="global-track-add-button"
aria-label={`Add ${track.label} global track item`}
title={`Add ${track.label} global track item`}
aria-label={t('globalTracks.addItem', { label: track.label })}
title={t('globalTracks.addItem', { label: track.label })}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
+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;
+9 -7
View File
@@ -1,6 +1,7 @@
import React from 'react';
import { FaTimes } from 'react-icons/fa';
import type { SettingsSection } from './SettingsPanel';
import { useI18n } from '../../i18n/useI18n';
interface SettingsSidebarProps {
activeSection: SettingsSection;
@@ -13,22 +14,23 @@ const SettingsSidebar: React.FC<SettingsSidebarProps> = ({
onSectionChange,
onClose
}) => {
const { t } = useI18n();
const sections = [
{ id: 'general' as SettingsSection, label: 'General' },
{ id: 'audio_io' as SettingsSection, label: 'Audio I/O' },
{ id: 'behavior' as SettingsSection, label: 'Behavior' },
{ id: 'templates' as SettingsSection, label: 'Templates' },
{ id: 'chord_guide' as SettingsSection, label: 'Chord Guide' }
{ id: 'general' as SettingsSection, label: t('settings.sidebar.general') },
{ id: 'audio_io' as SettingsSection, label: t('settings.sidebar.audioIo') },
{ id: 'behavior' as SettingsSection, label: t('settings.sidebar.behavior') },
{ id: 'templates' as SettingsSection, label: t('settings.sidebar.templates') },
{ id: 'chord_guide' as SettingsSection, label: t('settings.sidebar.chordGuide') }
];
return (
<div className="settings-sidebar">
<div className="settings-sidebar-header">
<h2>Settings</h2>
<h2>{t('settings.sidebar.title')}</h2>
<button
className="settings-close-btn"
onClick={onClose}
title="Close Settings"
title={t('settings.sidebar.close')}
>
<FaTimes />
</button>
@@ -2,6 +2,8 @@ import React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import GeneralSettings from './GeneralSettings';
import { I18nContext } from '../../../i18n/I18nProvider';
import { translate } from '../../../i18n/translate';
const { localSeparatorModelCacheMock } = vi.hoisted(() => ({
localSeparatorModelCacheMock: {
@@ -11,6 +13,7 @@ const { localSeparatorModelCacheMock } = vi.hoisted(() => ({
}));
const configState = new Map<string, unknown>([
['general.language', 'auto'],
['general.llm_provider', 'local_browser'],
['general.persist_api_keys_non_localhost', false],
['general.openai.api_key', ''],
@@ -86,7 +89,23 @@ vi.mock('../../../util/local-separator/modelCache', () => ({
}));
describe('GeneralSettings', () => {
const renderSettings = () => render(
<I18nContext.Provider
value={{
languageSetting: 'auto',
resolvedLocale: 'en_us',
setLanguageSetting: async (value) => {
await configManagerMock.set('general.language', value);
},
t: (key, params) => translate(key, params, 'en_us'),
}}
>
<GeneralSettings />
</I18nContext.Provider>,
);
beforeEach(() => {
configState.set('general.language', 'auto');
configState.set('general.local_browser.context_length', 65536);
configManagerMock.get.mockClear();
configManagerMock.set.mockClear();
@@ -111,7 +130,7 @@ describe('GeneralSettings', () => {
});
it('renders the local context length selector and VRAM hint', async () => {
render(<GeneralSettings />);
renderSettings();
expect(await screen.findByText('Gemma 4 E4B Local Runtime')).toBeTruthy();
expect(screen.getByLabelText('Context Length')).toBeTruthy();
@@ -119,14 +138,14 @@ describe('GeneralSettings', () => {
});
it('initializes the local context length from config', async () => {
render(<GeneralSettings />);
renderSettings();
const select = await screen.findByLabelText('Context Length');
expect((select as HTMLSelectElement).value).toBe('65536');
});
it('persists local context length changes', async () => {
render(<GeneralSettings />);
renderSettings();
const select = await screen.findByLabelText('Context Length');
fireEvent.change(select, { target: { value: '131072' } });
@@ -137,7 +156,7 @@ describe('GeneralSettings', () => {
});
it('renders and persists local runtime download URLs', async () => {
render(<GeneralSettings />);
renderSettings();
expect(await screen.findByDisplayValue('https://huggingface.co/notabilia/gemma-4-E4B-it-litert-lm/resolve/main/gemma-4-E4B-it-web.task')).toBeTruthy();
expect(screen.getByDisplayValue('https://huggingface.co/notabilia/uvr5-models/resolve/main/UVR-MDX-NET-Inst_HQ_3.onnx')).toBeTruthy();
@@ -176,7 +195,7 @@ describe('GeneralSettings', () => {
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(false);
render(<GeneralSettings />);
renderSettings();
expect(await screen.findByText('UVR5 Web Runtime')).toBeTruthy();
@@ -219,10 +238,24 @@ describe('GeneralSettings', () => {
reason: 'This host may not support the local browser runtime reliably because cross-origin isolation or SharedArrayBuffer is unavailable. COOP/COEP headers may be missing.',
};
render(<GeneralSettings />);
renderSettings();
expect(await screen.findByText('Gemma 4 E4B Local Runtime')).toBeTruthy();
expect(screen.queryByText(/may not support the local browser runtime reliably/i)).toBeNull();
expect(screen.getByText(/The local model downloads automatically/i)).toBeTruthy();
});
it('renders language first and persists language changes', async () => {
renderSettings();
const languageSelect = await screen.findByLabelText('Language');
expect(languageSelect).toBeTruthy();
expect(screen.getAllByRole('combobox')[0]).toBe(languageSelect);
fireEvent.change(languageSelect, { target: { value: 'zh_cn' } });
await waitFor(() => {
expect(configManagerMock.set).toHaveBeenCalledWith('general.language', 'zh_cn');
});
});
});
@@ -2,6 +2,8 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { ConfigManager } from '../../../core/config/ConfigManager';
import { LocalLLMModelManager, type LocalLLMModelState } from '../../../util/localLLMModelManager';
import { LocalSeparatorModelCache } from '../../../util/local-separator/modelCache';
import { useI18n } from '../../../i18n/useI18n';
import type { LanguageSetting } from '../../../i18n/types';
import {
formatLocalLLMContextLength,
LOCAL_LLM_CONTEXT_LENGTH_OPTIONS,
@@ -18,6 +20,8 @@ import {
} from '../../../util/local-separator/config';
const GeneralSettings: React.FC = () => {
const { t, setLanguageSetting } = useI18n();
const [language, setLanguage] = useState<LanguageSetting>('auto');
const [llmProvider, setLlmProvider] = useState<string>(LOCAL_LLM_PROVIDER_KEY);
const [openaiKey, setOpenaiKey] = useState<string>('');
const [openaiModel, setOpenaiModel] = useState<string>('');
@@ -90,6 +94,7 @@ const GeneralSettings: React.FC = () => {
await configManager.initialize();
}
setLanguage(((configManager.get('general.language') as LanguageSetting | undefined) ?? 'auto'));
setLlmProvider((configManager.get('general.llm_provider') as string) || LOCAL_LLM_PROVIDER_KEY);
setOpenaiKey((configManager.get('general.openai.api_key') as string) || '');
setOpenaiModel((configManager.get('general.openai.model') as string) || '');
@@ -153,6 +158,15 @@ const GeneralSettings: React.FC = () => {
}
};
const handleLanguageChange = async (value: LanguageSetting) => {
setLanguage(value);
try {
await setLanguageSetting(value);
} catch (error) {
console.error('Failed to save language:', error);
}
};
const handleOpenaiKeyChange = (value: string) => {
setOpenaiKey(value);
debouncedSave('general.openai.api_key', value);
@@ -322,45 +336,66 @@ const GeneralSettings: React.FC = () => {
return (
<div className="settings-section">
<div className="settings-section-header">
<h3>General</h3>
<h3>{t('settings.general.title')}</h3>
</div>
<div className="settings-section-content">
<div className="settings-group">
<h4>LLM Provider</h4>
<div className="settings-item">
<label className="settings-label" htmlFor="general-language-select">
{t('settings.general.language.label')}
</label>
<select
id="general-language-select"
className="settings-select"
value={language}
onChange={(e) => void handleLanguageChange(e.target.value as LanguageSetting)}
>
<option value="auto">{t('settings.general.language.auto')}</option>
<option value="en_us">{t('settings.general.language.en_us')}</option>
<option value="zh_cn">{t('settings.general.language.zh_cn')}</option>
</select>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
{t('settings.general.language.help')}
</div>
</div>
</div>
<div className="settings-group">
<h4>{t('settings.general.llmProvider.section')}</h4>
<div className="settings-item">
<label className="settings-label">
LLM Provider
{t('settings.general.llmProvider.label')}
</label>
<select
className="settings-select"
value={llmProvider}
onChange={(e) => handleLlmProviderChange(e.target.value)}
>
<option value={LOCAL_LLM_PROVIDER_KEY}>Local LLM (Browser)</option>
<option value="openai">OpenAI</option>
<option value={LOCAL_LLM_PROVIDER_KEY}>{t('settings.general.llmProvider.local')}</option>
<option value="openai">{t('settings.general.llmProvider.openai')}</option>
{/* <option value="gemini">Gemini</option>
<option value="claude">Claude</option> */}
<option value="claude_openrouter">Claude (via OpenRouter)</option>
<option value="openai_compatible">OpenAI Compatible (e.g. OpenRouter, Ollama)</option>
<option value="claude_openrouter">{t('settings.general.llmProvider.claudeOpenRouter')}</option>
<option value="openai_compatible">{t('settings.general.llmProvider.openaiCompatible')}</option>
</select>
</div>
<div className="settings-item">
<label className="settings-label">
Persist API Keys on Non-Localhost
{t('settings.general.persistKeys.label')}
</label>
<select
className="settings-select"
value={persistApiKeysNonLocalhost ? 'yes' : 'no'}
onChange={(e) => handlePersistApiKeysNonLocalhostChange(e.target.value)}
>
<option value="no">No</option>
<option value="yes">Yes</option>
<option value="no">{t('settings.no')}</option>
<option value="yes">{t('settings.yes')}</option>
</select>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
When enabled, API keys will be saved to browser storage even on non-localhost environments. Warning: This may increase security vulnerability to XSS attacks.
{t('settings.general.persistKeys.help')}
</div>
</div>
</div>
@@ -376,20 +411,20 @@ const GeneralSettings: React.FC = () => {
<div className="settings-item">
<label className="settings-label">
Cached Model Status
{t('settings.general.localRuntime.cachedStatus')}
</label>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
{localModelState.isChecking
? 'Checking local model cache...'
? t('settings.general.localRuntime.cacheChecking')
: localModelState.isCached
? 'Downloaded in browser cache.'
: 'Not downloaded yet.'}
? t('settings.general.localRuntime.cacheDownloaded')
: t('settings.general.localRuntime.cacheMissing')}
</div>
</div>
<div className="settings-item">
<label className="settings-label" htmlFor="local-llm-context-length">
Context Length
{t('settings.general.localRuntime.contextLength')}
</label>
<select
id="local-llm-context-length"
@@ -404,13 +439,13 @@ const GeneralSettings: React.FC = () => {
))}
</select>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
Larger context lengths require more VRAM and may also reduce performance as conversations become longer.
{t('settings.general.localRuntime.contextHelp')}
</div>
</div>
<div className="settings-item">
<label className="settings-label">
Download URL
{t('settings.general.localRuntime.downloadUrl')}
</label>
<input
type="text"
@@ -420,7 +455,7 @@ const GeneralSettings: React.FC = () => {
onChange={(e) => handleLocalModelUrlChange(e.target.value)}
/>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
Changing this URL may break downloads or point to an incompatible model file.{' '}
{t('settings.general.localRuntime.downloadHelp')}{' '}
<a
href="#"
onClick={(e) => {
@@ -429,14 +464,14 @@ const GeneralSettings: React.FC = () => {
}}
style={{ color: '#5a9fd4', textDecoration: 'underline', cursor: 'pointer' }}
>
Restore default
{t('settings.restoreDefault')}
</a>
</div>
</div>
{!localModelState.isCached && !localModelState.isDownloading && localModelState.runtimeSupport.supported && (
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px', marginBottom: '8px' }}>
The local model downloads automatically the next time you chat with `Local LLM (Browser)`.
{t('settings.general.localRuntime.autoDownload')}
</div>
)}
@@ -470,17 +505,17 @@ const GeneralSettings: React.FC = () => {
onClick={() => void handleDeleteLocalModel()}
disabled={localModelState.isDeleting || localModelState.isDownloading || !localModelState.isCached}
>
{localModelState.isDeleting ? 'Deleting...' : 'Delete Cached Model'}
{localModelState.isDeleting ? t('settings.deleting') : t('settings.deleteCachedModel')}
</button>
</div>
</div>
<div className="settings-group">
<h4>UVR5 Web Runtime</h4>
<h4>{t('settings.general.uvr5.section')}</h4>
<div className="settings-item">
<label className="settings-label">
UVR-MDX-NET-Inst_HQ_3 Download URL
{t('settings.general.uvr5.downloadUrl')}
</label>
<input
type="text"
@@ -490,7 +525,7 @@ const GeneralSettings: React.FC = () => {
onChange={(e) => handleUvr5ModelUrlChange(e.target.value)}
/>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
Changing this URL may break downloads or point to an incompatible model file.{' '}
{t('settings.general.modelUrl.help')}{' '}
<a
href="#"
onClick={(e) => {
@@ -501,7 +536,7 @@ const GeneralSettings: React.FC = () => {
}}
style={{ color: '#5a9fd4', textDecoration: 'underline', cursor: 'pointer' }}
>
Restore default
{t('settings.restoreDefault')}
</a>
</div>
</div>
@@ -513,13 +548,13 @@ const GeneralSettings: React.FC = () => {
onClick={() => void handleDeleteUvr5Model()}
disabled={isCheckingUvr5ModelCache || isDeletingUvr5Model || !isUvr5ModelCached}
>
{isDeletingUvr5Model ? 'Deleting...' : 'Delete Cached Model'}
{isDeletingUvr5Model ? t('settings.deleting') : t('settings.deleteCachedModel')}
</button>
</div>
<div className="settings-item">
<label className="settings-label">
htdemucs_4s Download URL
{t('settings.general.htdemucs.downloadUrl')}
</label>
<input
type="text"
@@ -529,7 +564,7 @@ const GeneralSettings: React.FC = () => {
onChange={(e) => handleHtdemucsModelUrlChange(e.target.value)}
/>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
Changing this URL may break downloads or point to an incompatible model file.{' '}
{t('settings.general.modelUrl.help')}{' '}
<a
href="#"
onClick={(e) => {
@@ -540,7 +575,7 @@ const GeneralSettings: React.FC = () => {
}}
style={{ color: '#5a9fd4', textDecoration: 'underline', cursor: 'pointer' }}
>
Restore default
{t('settings.restoreDefault')}
</a>
</div>
</div>
@@ -552,37 +587,37 @@ const GeneralSettings: React.FC = () => {
onClick={() => void handleDeleteHtdemucsModel()}
disabled={isCheckingUvr5ModelCache || isDeletingHtdemucsModel || !isHtdemucsModelCached}
>
{isDeletingHtdemucsModel ? 'Deleting...' : 'Delete Cached Model'}
{isDeletingHtdemucsModel ? t('settings.deleting') : t('settings.deleteCachedModel')}
</button>
</div>
</div>
<div className="settings-group">
<h4>OpenAI</h4>
<h4>{t('settings.general.openai.section')}</h4>
<div className="settings-item">
<label className="settings-label">
Key
{t('settings.general.openai.key')}
</label>
<input
type="password"
className="settings-input"
placeholder="Enter your OpenAI API key"
placeholder={t('settings.general.openai.keyPlaceholder')}
value={openaiKey}
onChange={(e) => handleOpenaiKeyChange(e.target.value)}
/>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
{isLocalEnvironment
? 'Keys are persisted locally (the IndexedDB in your browser).'
? t('settings.general.keys.persisted')
: persistApiKeysNonLocalhost
? 'Keys are persisted locally (the IndexedDB in your browser).'
: 'For security, keys are not persisted on non-local hosts and are kept in-memory for this session.'}
? t('settings.general.keys.persisted')
: t('settings.general.keys.sessionOnly')}
</div>
</div>
<div className="settings-item">
<label className="settings-label">
Model
{t('settings.general.openai.model')}
</label>
<select
className="settings-select"
@@ -601,18 +636,18 @@ const GeneralSettings: React.FC = () => {
<div className="settings-item">
<label className="settings-label">
Flex Mode
{t('settings.general.openai.flexMode')}
</label>
<select
className="settings-select"
value={openaiFlex ? 'yes' : 'no'}
onChange={(e) => handleOpenaiFlexChange(e.target.value)}
>
<option value="no">No</option>
<option value="yes">Yes</option>
<option value="no">{t('settings.no')}</option>
<option value="yes">{t('settings.yes')}</option>
</select>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
Flex Mode uses OpenAI's flexible service tier. Pros: potential cost savings and higher throughput during busy periods. Cons: variable latency and possible queueing/deprioritization. Applies only to the OpenAI provider; no effect for OpenAI Compatible servers.
{t('settings.general.openai.flexHelp')}
</div>
</div>
</div>
@@ -678,31 +713,31 @@ const GeneralSettings: React.FC = () => {
</div> */}
<div className="settings-group">
<h4>Anthropic Claude (via OpenRouter)</h4>
<h4>{t('settings.general.claudeOpenRouter.section')}</h4>
<div className="settings-item">
<label className="settings-label">
Key
{t('settings.general.openai.key')}
</label>
<input
type="password"
className="settings-input"
placeholder="Enter your Claude API key"
placeholder={t('settings.general.claudeOpenRouter.keyPlaceholder')}
value={claudeOpenRouterKey}
onChange={(e) => handleClaudeOpenRouterKeyChange(e.target.value)}
/>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
{isLocalEnvironment
? 'Keys are persisted locally (the IndexedDB in your browser).'
? t('settings.general.keys.persisted')
: persistApiKeysNonLocalhost
? 'Keys are persisted locally (the IndexedDB in your browser).'
: 'For security, keys are not persisted on non-local hosts and are kept in-memory for this session.'}
? t('settings.general.keys.persisted')
: t('settings.general.keys.sessionOnly')}
</div>
</div>
<div className="settings-item">
<label className="settings-label">
Base URL
{t('settings.general.baseUrl')}
</label>
<input
type="text"
@@ -712,13 +747,13 @@ const GeneralSettings: React.FC = () => {
onChange={(e) => handleClaudeOpenRouterBaseUrlChange(e.target.value)}
/>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
This is the base URL for the OpenRouter API. Please do not change this unless you know what you are doing.
{t('settings.general.claudeOpenRouter.baseUrlHelp')}
</div>
</div>
<div className="settings-item">
<label className="settings-label">
Model
{t('settings.general.openai.model')}
</label>
<select
className="settings-select"
@@ -736,41 +771,41 @@ const GeneralSettings: React.FC = () => {
</div>
<div className="settings-group">
<h4>OpenAI Compatible Server</h4>
<h4>{t('settings.general.openaiCompatible.section')}</h4>
<div className="settings-item">
<label className="settings-label">
Key
{t('settings.general.openai.key')}
</label>
<input
type="password"
className="settings-input"
placeholder="Enter your API key"
placeholder={t('settings.general.openaiCompatible.keyPlaceholder')}
value={compatibleKey}
onChange={(e) => handleCompatibleKeyChange(e.target.value)}
/>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
{isLocalEnvironment
? 'Keys are persisted locally (the IndexedDB in your browser).'
? t('settings.general.keys.persisted')
: persistApiKeysNonLocalhost
? 'Keys are persisted locally (the IndexedDB in your browser).'
: 'For security, keys are not persisted on non-local hosts and are kept in-memory for this session.'}
? t('settings.general.keys.persisted')
: t('settings.general.keys.sessionOnly')}
</div>
</div>
<div className="settings-item">
<label className="settings-label">
Base URL
{t('settings.general.baseUrl')}
</label>
<input
type="text"
className="settings-input"
placeholder="e.g. https://openrouter.ai/api/v1"
placeholder={t('settings.general.openaiCompatible.baseUrlPlaceholder')}
value={compatibleBaseUrl}
onChange={(e) => handleCompatibleBaseUrlChange(e.target.value)}
/>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
Quick presets:{' '}
{t('settings.general.openaiCompatible.baseUrlHelp')}{' '}
<a
href="#"
onClick={(e) => {
@@ -819,12 +854,12 @@ const GeneralSettings: React.FC = () => {
<div className="settings-item">
<label className="settings-label">
Model
{t('settings.general.openai.model')}
</label>
<input
type="text"
className="settings-input"
placeholder="e.g. qwen3:30b"
placeholder={t('settings.general.openaiCompatible.modelPlaceholder')}
value={compatibleModel}
onChange={(e) => handleCompatibleModelChange(e.target.value)}
/>
@@ -832,17 +867,17 @@ const GeneralSettings: React.FC = () => {
</div>
<div className="settings-group">
<h4>Soundfont Settings</h4>
<h4>{t('settings.general.soundfont.section')}</h4>
{soundfontServerManaged && (
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px', marginBottom: '8px' }}>
Soundfont configuration is managed by the server (kgone-server.json). Settings are read-only.
{t('settings.general.soundfont.managed')}
</div>
)}
<div className="settings-item">
<label className="settings-label">
Base URL
{t('settings.general.soundfont.baseUrl')}
</label>
<input
type="text"
@@ -853,7 +888,7 @@ const GeneralSettings: React.FC = () => {
disabled={soundfontServerManaged}
/>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
Changing this URL to an incompatible soundfont source may cause some instruments to sound wrong or not play.{' '}
{t('settings.general.soundfont.baseUrlHelp')}{' '}
<a
href="#"
onClick={(e) => {
@@ -862,24 +897,24 @@ const GeneralSettings: React.FC = () => {
}}
style={{ color: '#5a9fd4', textDecoration: 'underline', cursor: 'pointer' }}
>
Restore default
{t('settings.restoreDefault')}
</a>
</div>
</div>
</div>
<div className="settings-group">
<h4>K.G.One Settings</h4>
<h4>{t('settings.general.kgone.section')}</h4>
{kgoneServerManaged && (
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px', marginBottom: '8px' }}>
K.G.One configuration is managed by the server (kgone-server.json). Settings are read-only.
{t('settings.general.kgone.managed')}
</div>
)}
<div className="settings-item">
<label className="settings-label">
Enable K.G.One Integration
{t('settings.general.kgone.enabled')}
</label>
<select
className="settings-select"
@@ -887,14 +922,14 @@ const GeneralSettings: React.FC = () => {
onChange={(e) => handleKgoneEnabledChange(e.target.value === 'true')}
disabled={kgoneServerManaged}
>
<option value="false">Disabled</option>
<option value="true">Enabled</option>
<option value="false">{t('settings.general.kgone.disabled')}</option>
<option value="true">{t('settings.general.kgone.enabledOption')}</option>
</select>
</div>
<div className="settings-item">
<label className="settings-label">
Server Base URL
{t('settings.general.kgone.serverBaseUrl')}
</label>
<input
type="text"
@@ -905,7 +940,7 @@ const GeneralSettings: React.FC = () => {
disabled={kgoneServerManaged}
/>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
Base URL of a running K.G.One Music Studio server. Used for full-song generation, clip generation, and stem separation.
{t('settings.general.kgone.serverBaseUrlHelp')}
</div>
</div>
</div>
+4 -1
View File
@@ -15,6 +15,8 @@ import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
import { showAlert, showConfirm } from '../../util/dialogUtil';
import type { TrackAutomationType } from '../../core/track/KGTrackAutomationPoint';
import { AUDIO_IMPORT_ACCEPTED_TYPES } from '../../util/audioImportUtil';
import { useI18n } from '../../i18n/useI18n';
import { getInstrumentDisplayName } from '../../i18n/instruments';
const UNITY_POS = 750;
const SLIDER_MAX = 1000;
@@ -69,6 +71,7 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
onDrop,
onDragEnd
}) => {
const { t } = useI18n();
const { selectedTrackId, setSelectedTrack, removeTrack, toggleInstrumentSelectionForTrack, importAudioToTrack, tracks: allTracks } = useProjectStore();
const activeTrackAutomationTrackId = useProjectStore(state => state.activeTrackAutomationTrackId);
const activeTrackAutomationType = useProjectStore(state => state.activeTrackAutomationType);
@@ -407,7 +410,7 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
) : (
<img
src={`${import.meta.env.BASE_URL}resources/instruments/${String(FLUIDR3_INSTRUMENT_MAP[currentInstrument as keyof typeof FLUIDR3_INSTRUMENT_MAP]?.image || 'piano.png')}`}
alt={String(FLUIDR3_INSTRUMENT_MAP[currentInstrument as keyof typeof FLUIDR3_INSTRUMENT_MAP]?.displayName || currentInstrument)}
alt={getInstrumentDisplayName(currentInstrument as keyof typeof FLUIDR3_INSTRUMENT_MAP, t)}
width="64"
height="64"
/>