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"
/>
+3 -2
View File
@@ -1,12 +1,13 @@
import type { ChordGuideCustomConfig } from '../ChordGuideTypes';
import { KGConfigStorage } from '../io/KGConfigStorage';
import type { LanguageSetting } from '../../i18n/types';
/**
* Application configuration interface
*/
interface AppConfig {
general: {
language: string;
language: LanguageSetting;
llm_provider: 'local_browser' | 'openai' | 'gemini' | 'claude' | 'claude_openrouter' | 'openai_compatible';
persist_api_keys_non_localhost: boolean;
local_browser: {
@@ -205,7 +206,7 @@ export class ConfigManager {
// Fallback to minimal hardcoded config
this.defaultConfig = {
general: {
language: 'en_us',
language: 'auto',
llm_provider: 'local_browser',
persist_api_keys_non_localhost: false,
openai: {
+47 -6
View File
@@ -1,5 +1,16 @@
import type { ResolvedChordGuideItem } from '../ChordGuideTypes';
export const PIANO_ROLL_NO_SNAP = 'none' as const;
export interface PianoRollOptionDefinition<TValue extends string = string> {
value: TValue;
labelKey: string;
}
export type PianoRollSnapValue = typeof PIANO_ROLL_NO_SNAP | '1/3' | '1/4' | '1/6' | '1/8' | '1/12' | '1/16' | '1/24' | '1/32';
export type PianoRollQuantizePositionValue = '1/3' | '1/4' | '1/6' | '1/8' | '1/12' | '1/16' | '1/24' | '1/32';
export type PianoRollQuantizeLengthValue = '1/1' | '1/2' | '1/3' | '1/4' | '1/6' | '1/8' | '1/12' | '1/16' | '1/24' | '1/32';
/**
* KGPianoRollState - State management for the piano roll
* Implements the singleton pattern for global access
@@ -7,12 +18,42 @@ import type { ResolvedChordGuideItem } from '../ChordGuideTypes';
export class KGPianoRollState {
private static _instance: KGPianoRollState | null = null;
public static SNAP_OPTIONS: string[] = ['NO SNAP', '1/3', '1/4', '1/6', '1/8', '1/12', '1/16', '1/24', '1/32'];
public static QUANT_POS_OPTIONS: string[] = ['1/3', '1/4', '1/6', '1/8', '1/12', '1/16', '1/24', '1/32'];
public static QUANT_LEN_OPTIONS: string[] = ['1/1', '1/2', '1/3', '1/4', '1/6', '1/8', '1/12', '1/16', '1/24', '1/32'];
public static SNAP_OPTIONS: PianoRollOptionDefinition<PianoRollSnapValue>[] = [
{ value: PIANO_ROLL_NO_SNAP, labelKey: 'pianoRoll.snap.none' },
{ value: '1/3', labelKey: 'pianoRoll.quantize.1/3' },
{ value: '1/4', labelKey: 'pianoRoll.quantize.1/4' },
{ value: '1/6', labelKey: 'pianoRoll.quantize.1/6' },
{ value: '1/8', labelKey: 'pianoRoll.quantize.1/8' },
{ value: '1/12', labelKey: 'pianoRoll.quantize.1/12' },
{ value: '1/16', labelKey: 'pianoRoll.quantize.1/16' },
{ value: '1/24', labelKey: 'pianoRoll.quantize.1/24' },
{ value: '1/32', labelKey: 'pianoRoll.quantize.1/32' },
];
public static QUANT_POS_OPTIONS: PianoRollOptionDefinition<PianoRollQuantizePositionValue>[] = [
{ value: '1/3', labelKey: 'pianoRoll.quantize.1/3' },
{ value: '1/4', labelKey: 'pianoRoll.quantize.1/4' },
{ value: '1/6', labelKey: 'pianoRoll.quantize.1/6' },
{ value: '1/8', labelKey: 'pianoRoll.quantize.1/8' },
{ value: '1/12', labelKey: 'pianoRoll.quantize.1/12' },
{ value: '1/16', labelKey: 'pianoRoll.quantize.1/16' },
{ value: '1/24', labelKey: 'pianoRoll.quantize.1/24' },
{ value: '1/32', labelKey: 'pianoRoll.quantize.1/32' },
];
public static QUANT_LEN_OPTIONS: PianoRollOptionDefinition<PianoRollQuantizeLengthValue>[] = [
{ value: '1/1', labelKey: 'pianoRoll.quantize.1/1' },
{ value: '1/2', labelKey: 'pianoRoll.quantize.1/2' },
{ value: '1/3', labelKey: 'pianoRoll.quantize.1/3' },
{ value: '1/4', labelKey: 'pianoRoll.quantize.1/4' },
{ value: '1/6', labelKey: 'pianoRoll.quantize.1/6' },
{ value: '1/8', labelKey: 'pianoRoll.quantize.1/8' },
{ value: '1/12', labelKey: 'pianoRoll.quantize.1/12' },
{ value: '1/16', labelKey: 'pianoRoll.quantize.1/16' },
{ value: '1/24', labelKey: 'pianoRoll.quantize.1/24' },
{ value: '1/32', labelKey: 'pianoRoll.quantize.1/32' },
];
private activeTool: string = "pointer";
private currentSnap: string = "NO SNAP";
private currentSnap: PianoRollSnapValue = PIANO_ROLL_NO_SNAP;
private lastEditedNoteLength: number = 1; // Default to 1 beat
private currentMode: string = "ionian"; // Default mode
private automationViewEnabled: boolean = false;
@@ -51,11 +92,11 @@ export class KGPianoRollState {
this.activeTool = tool;
}
public getCurrentSnap(): string {
public getCurrentSnap(): PianoRollSnapValue {
return this.currentSnap;
}
public setCurrentSnap(snap: string): void {
public setCurrentSnap(snap: PianoRollSnapValue): void {
this.currentSnap = snap;
}
+2 -2
View File
@@ -7,7 +7,7 @@ import { KGTrack } from '../core/track/KGTrack';
import { KGMidiNote } from '../core/midi/KGMidiNote';
import type { MutableRefObject } from 'react';
import { KGCore } from '../core/KGCore';
import { KGPianoRollState } from '../core/state/KGPianoRollState';
import { KGPianoRollState, PIANO_ROLL_NO_SNAP } from '../core/state/KGPianoRollState';
import { KGAudioInterface } from '../core/audio-interface/KGAudioInterface';
import { CreateNoteCommand, DeleteNotesCommand, ResizeNotesCommand, MoveNotesCommand } from '../core/commands';
import { CreateNotesCommand } from '../core/commands/note/CreateNotesCommand';
@@ -171,7 +171,7 @@ export const useNoteOperations = ({
// Calculate the beat number (0-indexed) and apply floor snapping for note creation
const rawBeatNumber = x / beatWidth;
const currentSnap = KGPianoRollState.instance().getCurrentSnap();
const beatNumber = currentSnap === 'NO SNAP'
const beatNumber = currentSnap === PIANO_ROLL_NO_SNAP
? Math.floor(rawBeatNumber) // Snap to 1-beat grid when no snapping is selected
: getSnappedBeatPosition(rawBeatNumber, currentSnap, true); // Use floor snapping for note creation
+88
View File
@@ -0,0 +1,88 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { act, render, screen, waitFor } from '@testing-library/react';
import { I18nProvider } from './I18nProvider';
import { useI18n } from './useI18n';
const configState = new Map<string, unknown>([['general.language', 'auto']]);
const listeners = new Set<(changedKeys: string[]) => void>();
const configManagerMock = {
getIsInitialized: vi.fn(() => true),
initialize: vi.fn().mockResolvedValue(undefined),
get: vi.fn((key: string) => configState.get(key)),
set: vi.fn(async (key: string, value: unknown) => {
configState.set(key, value);
for (const listener of listeners) {
listener([key]);
}
}),
addChangeListener: vi.fn((listener: (changedKeys: string[]) => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
}),
};
vi.mock('../core/config/ConfigManager', () => ({
ConfigManager: {
instance: () => configManagerMock,
},
}));
function TestComponent() {
const { t, resolvedLocale } = useI18n();
return (
<div>
<span data-testid="locale">{resolvedLocale}</span>
<span>{t('settings.sidebar.title')}</span>
</div>
);
}
describe('I18nProvider', () => {
it('resolves auto to English by default', async () => {
Object.defineProperty(window.navigator, 'languages', {
configurable: true,
value: ['en-US'],
});
render(
<I18nProvider>
<TestComponent />
</I18nProvider>,
);
await waitFor(() => {
expect(screen.getByTestId('locale').textContent).toBe('en_us');
expect(screen.getByText('Settings')).toBeTruthy();
});
});
it('resolves auto to Chinese and updates instantly when config changes', async () => {
configState.set('general.language', 'auto');
Object.defineProperty(window.navigator, 'languages', {
configurable: true,
value: ['zh-CN'],
});
render(
<I18nProvider>
<TestComponent />
</I18nProvider>,
);
await waitFor(() => {
expect(screen.getByTestId('locale').textContent).toBe('zh_cn');
expect(screen.getByText('设置')).toBeTruthy();
});
await act(async () => {
await configManagerMock.set('general.language', 'en_us');
});
await waitFor(() => {
expect(screen.getByTestId('locale').textContent).toBe('en_us');
expect(screen.getByText('Settings')).toBeTruthy();
});
});
});
+76
View File
@@ -0,0 +1,76 @@
import React, { createContext, useEffect, useMemo, useState } from 'react';
import { ConfigManager } from '../core/config/ConfigManager';
import { normalizeLanguageSetting, resolveLanguageSetting } from './locale';
import { setCurrentLocale, translate } from './translate';
import type { I18nContextValue, LanguageSetting, ResolvedLocaleCode, TranslationParams } from './types';
const defaultValue: I18nContextValue = {
languageSetting: 'auto',
resolvedLocale: 'en_us',
setLanguageSetting: async () => undefined,
t: (key: string, params?: TranslationParams) => translate(key, params, 'en_us'),
};
export const I18nContext = createContext<I18nContextValue>(defaultValue);
function buildState(configManager: ConfigManager): { languageSetting: LanguageSetting; resolvedLocale: ResolvedLocaleCode } {
const languageSetting = normalizeLanguageSetting(configManager.get('general.language'));
const resolvedLocale = resolveLanguageSetting(languageSetting);
return { languageSetting, resolvedLocale };
}
export const I18nProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [languageSetting, setLanguageSettingState] = useState<LanguageSetting>('auto');
const [resolvedLocale, setResolvedLocaleState] = useState<ResolvedLocaleCode>('en_us');
useEffect(() => {
let mounted = true;
let unsubscribe: (() => void) | undefined;
const configManager = ConfigManager.instance();
const syncFromConfig = () => {
const nextState = buildState(configManager);
setCurrentLocale(nextState.resolvedLocale);
if (!mounted) {
return;
}
setLanguageSettingState(nextState.languageSetting);
setResolvedLocaleState(nextState.resolvedLocale);
};
const initialize = async () => {
if (!configManager.getIsInitialized()) {
await configManager.initialize();
}
syncFromConfig();
unsubscribe = configManager.addChangeListener((changedKeys) => {
if (changedKeys.includes('__all__') || changedKeys.includes('general.language')) {
syncFromConfig();
}
});
};
void initialize();
return () => {
mounted = false;
unsubscribe?.();
};
}, []);
const contextValue = useMemo<I18nContextValue>(() => ({
languageSetting,
resolvedLocale,
setLanguageSetting: async (value: LanguageSetting) => {
await ConfigManager.instance().set('general.language', value);
},
t: (key: string, params?: TranslationParams) => translate(key, params, resolvedLocale),
}), [languageSetting, resolvedLocale]);
return (
<I18nContext.Provider value={contextValue}>
{children}
</I18nContext.Provider>
);
};
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { getEnglishInstrumentDisplayName, getInstrumentDisplayName, getInstrumentGroupLabel } from './instruments';
import { translate } from './translate';
describe('instrument i18n helpers', () => {
it('returns translated instrument display names for supported locales', () => {
expect(getInstrumentDisplayName('acoustic_grand_piano', (key, params) => translate(key, params, 'en_us'))).toBe('Acoustic Grand Piano');
expect(getInstrumentDisplayName('acoustic_grand_piano', (key, params) => translate(key, params, 'zh_cn'))).toBe('原声大钢琴');
});
it('returns translated instrument group labels for supported locales', () => {
expect(getInstrumentGroupLabel('PIANO_AND_KEYBOARDS', (key, params) => translate(key, params, 'en_us'))).toBe('Piano and Keyboards');
expect(getInstrumentGroupLabel('PIANO_AND_KEYBOARDS', (key, params) => translate(key, params, 'zh_cn'))).toBe('钢琴与键盘');
});
it('keeps stable English fallback names available for non-UI callers', () => {
expect(getEnglishInstrumentDisplayName('standard')).toBe('Standard Drum Kit');
expect(getEnglishInstrumentDisplayName('trumpet')).toBe('Trumpet');
});
});
+25
View File
@@ -0,0 +1,25 @@
import { FLUIDR3_INSTRUMENT_MAP, INSTRUMENT_GROUPS } from '../constants/generalMidiConstants';
import type { InstrumentType } from '../core/track/KGMidiTrack';
import type { TranslationParams } from './types';
type TranslateFn = (key: string, params?: TranslationParams) => string;
export type InstrumentGroupKey = keyof typeof INSTRUMENT_GROUPS;
export function getInstrumentDisplayName(instrumentKey: InstrumentType, t: TranslateFn): string {
const instrument = FLUIDR3_INSTRUMENT_MAP[instrumentKey];
if (!instrument) {
return String(instrumentKey);
}
return t(`instrument.name.${instrumentKey}`) || instrument.displayName;
}
export function getInstrumentGroupLabel(groupKey: InstrumentGroupKey, t: TranslateFn): string {
const englishLabel = INSTRUMENT_GROUPS[groupKey];
return t(`instrument.group.${groupKey}`) || englishLabel;
}
export function getEnglishInstrumentDisplayName(instrumentKey: InstrumentType): string {
return FLUIDR3_INSTRUMENT_MAP[instrumentKey]?.displayName || String(instrumentKey);
}
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { normalizeLanguageSetting, resolveLanguageSetting } from './locale';
describe('locale resolution', () => {
it('normalizes supported language settings', () => {
expect(normalizeLanguageSetting('auto')).toBe('auto');
expect(normalizeLanguageSetting('en_us')).toBe('en_us');
expect(normalizeLanguageSetting('zh_cn')).toBe('zh_cn');
expect(normalizeLanguageSetting('unknown')).toBe('auto');
});
it('resolves auto to English for non-Chinese locales', () => {
expect(resolveLanguageSetting('auto', ['en-US'])).toBe('en_us');
});
it('resolves auto to Simplified Chinese for Chinese locales', () => {
expect(resolveLanguageSetting('auto', ['zh-CN'])).toBe('zh_cn');
expect(resolveLanguageSetting('auto', ['zh-SG'])).toBe('zh_cn');
expect(resolveLanguageSetting('auto', ['zh-TW'])).toBe('zh_cn');
expect(resolveLanguageSetting('auto', ['zh-HK'])).toBe('zh_cn');
expect(resolveLanguageSetting('auto', ['zh-MO'])).toBe('zh_cn');
});
it('falls back to English when locale list is empty or unknown', () => {
expect(resolveLanguageSetting('auto', [])).toBe('en_us');
expect(resolveLanguageSetting('auto', ['fr-FR'])).toBe('en_us');
});
it('keeps explicit locale selections', () => {
expect(resolveLanguageSetting('en_us', ['zh-CN'])).toBe('en_us');
expect(resolveLanguageSetting('zh_cn', ['en-US'])).toBe('zh_cn');
});
});
+40
View File
@@ -0,0 +1,40 @@
import type { LanguageSetting, ResolvedLocaleCode } from './types';
const CHINESE_LANGUAGE_PREFIX = 'zh';
const DEFAULT_LOCALE: ResolvedLocaleCode = 'en_us';
export function normalizeLanguageSetting(value: unknown): LanguageSetting {
return value === 'zh_cn' || value === 'en_us' || value === 'auto' ? value : 'auto';
}
export function getBrowserLocales(): string[] {
if (typeof navigator === 'undefined') {
return [];
}
if (Array.isArray(navigator.languages) && navigator.languages.length > 0) {
return navigator.languages.filter((value): value is string => typeof value === 'string' && value.length > 0);
}
return typeof navigator.language === 'string' && navigator.language.length > 0
? [navigator.language]
: [];
}
export function resolveLanguageSetting(
setting: LanguageSetting,
browserLocales: string[] = getBrowserLocales(),
): ResolvedLocaleCode {
if (setting === 'en_us' || setting === 'zh_cn') {
return setting;
}
for (const locale of browserLocales) {
const normalizedLocale = locale.trim().toLowerCase();
if (normalizedLocale === CHINESE_LANGUAGE_PREFIX || normalizedLocale.startsWith(`${CHINESE_LANGUAGE_PREFIX}-`)) {
return 'zh_cn';
}
}
return DEFAULT_LOCALE;
}
+510
View File
@@ -0,0 +1,510 @@
import type { TranslationMessages } from '../types';
export const enUsMessages: TranslationMessages = {
'app.loading': 'Loading ...',
'status.chordGuideCandidate': 'Chord Guide Candidate: {name} - {notes} - {note}',
'settings.sidebar.title': 'Settings',
'settings.sidebar.close': 'Close Settings',
'settings.sidebar.general': 'General',
'settings.sidebar.audioIo': 'Audio I/O',
'settings.sidebar.behavior': 'Behavior',
'settings.sidebar.templates': 'Templates',
'settings.sidebar.chordGuide': 'Chord Guide',
'settings.general.title': 'General',
'settings.general.language.label': 'Language',
'settings.general.language.help': 'Auto uses your system language.',
'settings.general.language.auto': 'Auto',
'settings.general.language.en_us': 'English',
'settings.general.language.zh_cn': 'Simplified Chinese',
'settings.yes': 'Yes',
'settings.no': 'No',
'settings.restoreDefault': 'Restore default',
'settings.deleteCachedModel': 'Delete Cached Model',
'settings.deleting': 'Deleting...',
'settings.general.llmProvider.section': 'LLM Provider',
'settings.general.llmProvider.label': 'LLM Provider',
'settings.general.llmProvider.local': 'Local LLM (Browser)',
'settings.general.llmProvider.openai': 'OpenAI',
'settings.general.llmProvider.claudeOpenRouter': 'Claude (via OpenRouter)',
'settings.general.llmProvider.openaiCompatible': 'OpenAI Compatible (e.g. OpenRouter, Ollama)',
'settings.general.persistKeys.label': 'Persist API Keys on Non-Localhost',
'settings.general.persistKeys.help': 'When enabled, API keys will be saved to browser storage even on non-localhost environments. Warning: This may increase security vulnerability to XSS attacks.',
'settings.general.localRuntime.cachedStatus': 'Cached Model Status',
'settings.general.localRuntime.cacheChecking': 'Checking local model cache...',
'settings.general.localRuntime.cacheDownloaded': 'Downloaded in browser cache.',
'settings.general.localRuntime.cacheMissing': 'Not downloaded yet.',
'settings.general.localRuntime.contextLength': 'Context Length',
'settings.general.localRuntime.contextHelp': 'Larger context lengths require more VRAM and may also reduce performance as conversations become longer.',
'settings.general.localRuntime.downloadUrl': 'Download URL',
'settings.general.localRuntime.downloadHelp': 'Changing this URL may break downloads or point to an incompatible model file.',
'settings.general.localRuntime.autoDownload': 'The local model downloads automatically the next time you chat with `Local LLM (Browser)`.',
'settings.general.uvr5.section': 'UVR5 Web Runtime',
'settings.general.uvr5.downloadUrl': 'UVR-MDX-NET-Inst_HQ_3 Download URL',
'settings.general.htdemucs.downloadUrl': 'htdemucs_4s Download URL',
'settings.general.modelUrl.help': 'Changing this URL may break downloads or point to an incompatible model file.',
'settings.general.openai.section': 'OpenAI',
'settings.general.openai.key': 'Key',
'settings.general.openai.keyPlaceholder': 'Enter your OpenAI API key',
'settings.general.openai.model': 'Model',
'settings.general.openai.flexMode': 'Flex Mode',
'settings.general.openai.flexHelp': '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.',
'settings.general.keys.persisted': 'Keys are persisted locally (the IndexedDB in your browser).',
'settings.general.keys.sessionOnly': 'For security, keys are not persisted on non-local hosts and are kept in-memory for this session.',
'settings.general.claudeOpenRouter.section': 'Anthropic Claude (via OpenRouter)',
'settings.general.claudeOpenRouter.keyPlaceholder': 'Enter your Claude API key',
'settings.general.baseUrl': 'Base URL',
'settings.general.claudeOpenRouter.baseUrlHelp': 'This is the base URL for the OpenRouter API. Please do not change this unless you know what you are doing.',
'settings.general.openaiCompatible.section': 'OpenAI Compatible Server',
'settings.general.openaiCompatible.keyPlaceholder': 'Enter your API key',
'settings.general.openaiCompatible.baseUrlPlaceholder': 'e.g. https://openrouter.ai/api/v1',
'settings.general.openaiCompatible.baseUrlHelp': 'Quick presets:',
'settings.general.openaiCompatible.modelPlaceholder': 'e.g. qwen3:30b',
'settings.general.soundfont.section': 'Soundfont Settings',
'settings.general.soundfont.managed': 'Soundfont configuration is managed by the server (kgone-server.json). Settings are read-only.',
'settings.general.soundfont.baseUrl': 'Base URL',
'settings.general.soundfont.baseUrlHelp': 'Changing this URL to an incompatible soundfont source may cause some instruments to sound wrong or not play.',
'settings.general.kgone.section': 'K.G.One Settings',
'settings.general.kgone.managed': 'K.G.One configuration is managed by the server (kgone-server.json). Settings are read-only.',
'settings.general.kgone.enabled': 'Enable K.G.One Integration',
'settings.general.kgone.disabled': 'Disabled',
'settings.general.kgone.enabledOption': 'Enabled',
'settings.general.kgone.serverBaseUrl': 'Server Base URL',
'settings.general.kgone.serverBaseUrlHelp': 'Base URL of a running K.G.One Music Studio server. Used for full-song generation, clip generation, and stem separation.',
'dialog.title.notice': 'Notice',
'dialog.title.timeSignature': 'Time Signature',
'dialog.title.tempoDetection': 'Tempo Detection',
'dialog.title.applyTempo': 'Apply Tempo',
'dialog.title.chordDetection': 'Chord Detection',
'dialog.title.input': 'Input',
'dialog.title.confirm': 'Confirm',
'dialog.close': 'Close dialog',
'dialog.cancel': 'Cancel',
'dialog.ok': 'OK',
'dialog.experimentalFeature': 'Experimental Feature',
'dialog.recommendedSource': 'Recommended Source Material',
'dialog.chordHint.audio': '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.',
'dialog.chordHint.midi': '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.',
'dialog.chordHint.tempo': '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.',
'dialog.sourceHint.kgone': '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.',
'dialog.sourceHint.local': '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.',
'dialog.label.sensitivity': 'Sensitivity',
'dialog.label.stability': 'Stability',
'dialog.label.noChordThreshold': 'No-Chord Threshold',
'dialog.label.enableSevenths': 'Chord Detail: Enable sevenths',
'dialog.label.shortNotes': 'Short Notes',
'dialog.option.suppressionLow': 'Low suppression',
'dialog.option.suppressionMedium': 'Medium suppression',
'dialog.option.suppressionHigh': 'High suppression',
'dialog.label.harmonicFocus': 'Harmonic Focus',
'dialog.option.harmonicBalanced': 'Balanced',
'dialog.option.harmonicSustained': 'Favor sustained notes',
'dialog.label.minimumBpm': 'Minimum BPM',
'dialog.label.maximumBpm': 'Maximum BPM',
'dialog.label.autoAlignRegionToBeat': 'Auto-align region to beat',
'eventList.title': 'Event List',
'eventList.scopeTabs': 'Event list scopes',
'eventList.scope.region': 'Region',
'eventList.scope.track': 'Track',
'eventList.scope.global': 'Global',
'eventList.deleteVisibleSelectedRows': 'Delete visible selected rows',
'eventList.table.position': 'Position',
'eventList.table.status': 'Status',
'eventList.table.num': 'Num',
'eventList.table.val': 'Val',
'eventList.table.lengthInfo': 'Length/Info',
'eventList.region.filters': 'Region event filters',
'eventList.region.filter.notes': 'Notes',
'eventList.region.filter.pitchBends': 'Pitch Bends',
'eventList.region.filter.controller': 'Controller',
'eventList.region.empty': 'Please select a MIDI region, or open one in the Piano Roll, to view its event list.',
'eventList.region.add.label': 'Add',
'eventList.region.add.noteTitle': 'Add note at playhead',
'eventList.region.add.pitchBendTitle': 'Add pitch bend at playhead',
'eventList.region.add.controllerTitle': 'Add controller event at playhead',
'eventList.region.addType.note': 'Note',
'eventList.region.addType.pitchBend': 'Pitch Bend',
'eventList.region.addType.controller': 'Controller',
'eventList.region.status.note': 'Note',
'eventList.region.status.pitchBend': 'Pitch Bend',
'eventList.region.status.controller': 'Controller',
'eventList.track.filters': 'Track list modes',
'eventList.track.filter.regions': 'Regions',
'eventList.track.filter.volume': 'Volume',
'eventList.track.filter.pan': 'Pan',
'eventList.track.empty': 'Please select a track to view regions and track automation.',
'eventList.track.add.label': 'Add',
'eventList.track.add.midiRegionTitle': 'Add MIDI region at playhead',
'eventList.track.add.volumeTitle': 'Add volume automation point at playhead',
'eventList.track.add.panTitle': 'Add pan automation point at playhead',
'eventList.track.addType.midiRegion': 'MIDI Region',
'eventList.track.status.audio': 'Audio',
'eventList.track.status.midi': 'MIDI',
'eventList.track.status.volume': 'Volume',
'eventList.track.status.pan': 'Pan',
'eventList.global.filters': 'Global event filters',
'eventList.global.filter.marker': 'Marker',
'eventList.global.filter.tempo': 'Tempo',
'eventList.global.filter.keySignature': 'Key Sig.',
'eventList.global.filter.chord': 'Chord',
'eventList.global.add.label': 'Add',
'eventList.global.add.markerTitle': 'Add marker region at playhead',
'eventList.global.add.tempoTitle': 'Add tempo region at playhead',
'eventList.global.add.keySignatureTitle': 'Add key signature region at playhead',
'eventList.global.add.chordTitle': 'Add chord region at playhead',
'eventList.global.addType.marker': 'Marker',
'eventList.global.addType.tempo': 'Tempo',
'eventList.global.addType.keySignature': 'Key Signature',
'eventList.global.addType.chord': 'Chord',
'eventList.global.status.marker': 'Marker',
'eventList.global.status.tempo': 'Tempo',
'eventList.global.status.keySignature': 'Key Signature',
'eventList.global.status.chord': 'Chord',
'eventList.global.validation.marker': 'Please enter a marker label. Expected a non-empty text label. Example: Intro',
'eventList.global.validation.tempo': 'Please enter a BPM value using digits only. Expected a whole number between {min} and {max}. Example: 128',
'eventList.global.validation.keySignature': 'Please enter one exact key signature name. Expected a canonical value such as "C major" or "F# minor". Example: F# minor',
'eventList.global.validation.chord': 'Please enter a valid chord symbol. Expected a chord representation the app can parse. Example: Bm7b5',
'eventList.global.validation.position': 'Please enter a position at or after the start of the project. Expected a non-negative location. Example: 1 1 0',
'eventList.global.validation.length': 'Please enter a positive length. Expected a duration greater than zero. Example: 4 0',
'globalTracks.label': 'Global tracks',
'globalTracks.marker': 'Marker',
'globalTracks.tempo': 'Tempo',
'globalTracks.signature': 'Key Signature',
'globalTracks.chord': 'Chord',
'globalTracks.addItem': 'Add {label} global track item',
'pianoRoll.power.linear': 'Linear',
'pianoRoll.power.sqrtDefault': '√ (default)',
'pianoRoll.power.mild': 'Mild',
'pianoRoll.power.strong': 'Strong',
'pianoRoll.chordGuide.off': 'Chord guide off',
'pianoRoll.chordGuide.tonic': 'Chord guide tonic',
'pianoRoll.chordGuide.subdominant': 'Chord guide subdominant',
'pianoRoll.chordGuide.dominant': 'Chord guide dominant',
'pianoRoll.spectrogramView': 'Spectrogram View',
'pianoRoll.sheetMusicView': 'Sheet Music View',
'pianoRoll.pointerTool': 'Pointer Tool',
'pianoRoll.pencilTool': 'Pencil Tool',
'pianoRoll.toggleAutomationLane': 'Toggle automation lane',
'pianoRoll.automation': 'Automation',
'pianoRoll.automationLane': '{label} automation lane',
'pianoRoll.automationType.pitchBend': 'Pitch Bend',
'pianoRoll.automationType.cc1': 'CC1',
'pianoRoll.automationType.cc2': 'CC2',
'pianoRoll.automationType.cc7': 'CC7',
'pianoRoll.automationType.cc11': 'CC11',
'pianoRoll.automationType.cc64': 'CC64',
'pianoRoll.mode': 'Mode',
'pianoRoll.modeOption.ionian': 'Ionian',
'pianoRoll.modeOption.dorian': 'Dorian',
'pianoRoll.modeOption.phrygian': 'Phrygian',
'pianoRoll.modeOption.lydian': 'Lydian',
'pianoRoll.modeOption.mixolydian': 'Mixolydian',
'pianoRoll.modeOption.aeolian': 'Aeolian',
'pianoRoll.modeOption.locrian': 'Locrian',
'pianoRoll.modeOption.harmonic_minor': 'Harmonic Minor',
'pianoRoll.modeOption.melodic_minor': 'Melodic Minor',
'pianoRoll.modeOption.phrygian_dominant': 'Phrygian Dominant',
'pianoRoll.modeOption.harmonic_major': 'Harmonic Major',
'pianoRoll.chordGuide': 'Chord guide',
'pianoRoll.sheetQuantization': 'Sheet Quant.',
'pianoRoll.snap': 'Snap',
'pianoRoll.snap.none': 'NO SNAP',
'pianoRoll.quantizePositionCompact': 'Qua. Pos.',
'pianoRoll.quantizeLengthCompact': 'Qua. Len.',
'pianoRoll.floor': 'Floor',
'pianoRoll.curve': 'Curve',
'pianoRoll.zoom': 'Zoom',
'pianoRoll.moreOptions': 'More options',
'pianoRoll.quantize.1/1': '1/1',
'pianoRoll.quantize.1/2': '1/2',
'pianoRoll.quantize.1/3': '1/3',
'pianoRoll.quantize.1/4': '1/4',
'pianoRoll.quantize.1/6': '1/6',
'pianoRoll.quantize.1/8': '1/8',
'pianoRoll.quantize.1/12': '1/12',
'pianoRoll.quantize.1/16': '1/16',
'pianoRoll.quantize.1/24': '1/24',
'pianoRoll.quantize.1/32': '1/32',
'pianoRoll.showActiveRegionOnly': 'Show Active Region Only',
'pianoRoll.showEntireTrack': 'Show Entire Track',
'pianoRoll.detectChords': 'Detect chords...',
'pianoRoll.detectingChords': 'Detecting chords...',
'pianoRoll.detectTempo': 'Detect tempo...',
'pianoRoll.detectingTempo': 'Detecting tempo...',
'percussion.short.35': 'Ac.Bass',
'percussion.short.36': 'BassDrum',
'percussion.short.37': 'SideStick',
'percussion.short.38': 'Ac.Snare',
'percussion.short.39': 'HandClap',
'percussion.short.40': 'ElecSnare',
'percussion.short.41': 'LowFloor',
'percussion.short.42': 'ClosedHH',
'percussion.short.43': 'HighFloor',
'percussion.short.44': 'PedalHH',
'percussion.short.45': 'LowTom',
'percussion.short.46': 'OpenHH',
'percussion.short.47': 'LowMidTom',
'percussion.short.48': 'HiMidTom',
'percussion.short.49': 'Crash1',
'percussion.short.50': 'HighTom',
'percussion.short.51': 'Ride1',
'percussion.short.52': 'Chinese',
'percussion.short.53': 'RideBell',
'percussion.short.54': 'Tambourine',
'percussion.short.55': 'Splash',
'percussion.short.56': 'Cowbell',
'percussion.short.57': 'Crash2',
'percussion.short.58': 'Vibraslap',
'percussion.short.59': 'Ride2',
'percussion.short.60': 'HiBongo',
'percussion.short.61': 'LowBongo',
'percussion.short.62': 'MuteHiCon',
'percussion.short.63': 'OpenHiCon',
'percussion.short.64': 'LowConga',
'percussion.short.65': 'HighTimba',
'percussion.short.66': 'LowTimba',
'percussion.short.67': 'HighAgo',
'percussion.short.68': 'LowAgo',
'percussion.short.69': 'Cabasa',
'percussion.short.70': 'Maracas',
'percussion.short.71': 'ShortWhis',
'percussion.short.72': 'LongWhis',
'percussion.short.73': 'ShortGui',
'percussion.short.74': 'LongGui',
'percussion.short.75': 'Claves',
'percussion.short.76': 'HiWood',
'percussion.short.77': 'LowWood',
'percussion.short.78': 'MuteCuica',
'percussion.short.79': 'OpenCuica',
'percussion.short.80': 'MuteTri',
'percussion.short.81': 'OpenTri',
'percussion.full.35': 'Acoustic Bass Drum',
'percussion.full.36': 'Bass Drum 1',
'percussion.full.37': 'Side Stick',
'percussion.full.38': 'Acoustic Snare',
'percussion.full.39': 'Hand Clap',
'percussion.full.40': 'Electric Snare',
'percussion.full.41': 'Low Floor Tom',
'percussion.full.42': 'Closed Hi Hat',
'percussion.full.43': 'High Floor Tom',
'percussion.full.44': 'Pedal Hi-Hat',
'percussion.full.45': 'Low Tom',
'percussion.full.46': 'Open Hi-Hat',
'percussion.full.47': 'Low-Mid Tom',
'percussion.full.48': 'Hi Mid Tom',
'percussion.full.49': 'Crash Cymbal 1',
'percussion.full.50': 'High Tom',
'percussion.full.51': 'Ride Cymbal 1',
'percussion.full.52': 'Chinese Cymbal',
'percussion.full.53': 'Ride Bell',
'percussion.full.54': 'Tambourine',
'percussion.full.55': 'Splash Cymbal',
'percussion.full.56': 'Cowbell',
'percussion.full.57': 'Crash Cymbal 2',
'percussion.full.58': 'Vibraslap',
'percussion.full.59': 'Ride Cymbal 2',
'percussion.full.60': 'Hi Bongo',
'percussion.full.61': 'Low Bongo',
'percussion.full.62': 'Mute Hi Conga',
'percussion.full.63': 'Open Hi Conga',
'percussion.full.64': 'Low Conga',
'percussion.full.65': 'High Timbale',
'percussion.full.66': 'Low Timbale',
'percussion.full.67': 'High Agogo',
'percussion.full.68': 'Low Agogo',
'percussion.full.69': 'Cabasa',
'percussion.full.70': 'Maracas',
'percussion.full.71': 'Short Whistle',
'percussion.full.72': 'Long Whistle',
'percussion.full.73': 'Short Guiro',
'percussion.full.74': 'Long Guiro',
'percussion.full.75': 'Claves',
'percussion.full.76': 'Hi Wood Block',
'percussion.full.77': 'Low Wood Block',
'percussion.full.78': 'Mute Cuica',
'percussion.full.79': 'Open Cuica',
'percussion.full.80': 'Mute Triangle',
'percussion.full.81': 'Open Triangle',
'toolbar.export.kgstudio': 'Export to KGStudio file',
'toolbar.export.midi': 'Export to MIDI file',
'toolbar.export.wav': 'Export to WAV',
'toolbar.export.mp3': 'Export to MP3',
'toolbar.projectName.prompt': 'Enter project name:',
'toolbar.projectName.invalid': 'Invalid project name. Only letters, numbers, spaces, hyphens, underscores, periods, and parentheses are allowed.',
'toolbar.projectName.reserved': '"{name}" is a reserved project name. Please choose a different name.',
'toolbar.projectName.overwrite': 'Project "{name}" already exists. Do you want to overwrite it?',
'toolbar.projectName.renameOrCopy': 'Would you like to rename this project, or save it as a new copy?',
'toolbar.projectName.saveAsCopy': 'Save as Copy',
'toolbar.projectName.rename': 'Rename',
'toolbar.save.error': 'An error occurred while saving: {error}',
'toolbar.load.error': 'An error occurred while loading the project: {error}',
'toolbar.newProject.confirm': 'Are you sure you want to create a new project? Any unsaved changes will be lost.',
'toolbar.project.notFound': 'Project "{name}" not found.',
'toolbar.openProject.confirm': 'Open this project? Any unsaved changes in the current project will be lost.',
'toolbar.status.loadFailed': 'Failed to load project: {error}',
'toolbar.status.newProjectCreated': 'New project "{name}" created',
'toolbar.status.projectLoaded': '{description} loaded successfully',
'toolbar.status.projectExportedKgstudio': 'Project "{name}" exported as KGStudio file',
'toolbar.status.exportProjectError': 'Error exporting project: {error}',
'toolbar.export.failedProject': 'Failed to export project: {error}',
'toolbar.status.projectExportedMidi': 'Project "{name}" exported as MIDI file',
'toolbar.status.exportMidiError': 'Error exporting MIDI: {error}',
'toolbar.export.failedMidi': 'Failed to export project as MIDI: {error}',
'toolbar.status.projectExportedWav': 'Project "{name}" exported as WAV file',
'toolbar.status.exportWavError': 'Error exporting WAV: {error}',
'toolbar.export.failedWav': 'Failed to export project as WAV: {error}',
'toolbar.status.projectExportedMp3': 'Project "{name}" exported as MP3 file',
'toolbar.status.exportMp3Error': 'Error exporting MP3: {error}',
'toolbar.export.failedMp3': 'Failed to export project as MP3: {error}',
'toolbar.status.importFailed': 'Failed to import file: {error}',
'toolbar.import.failedProjectFile': 'Failed to import project file: {error}',
'toolbar.import.corruptedKgstudio': 'The .kgstudio file is corrupted or invalid: {error}',
'toolbar.status.importingMidi': 'Importing MIDI file "{name}"...',
'toolbar.status.failedImportMidi': 'Failed to import MIDI file: {error}',
'toolbar.status.playbackFailedStart': 'Playback failed to start',
'toolbar.status.recordingStoppedCommitted': 'Recording stopped - notes committed',
'toolbar.status.failedStopPlayback': 'Failed to stop playback',
'toolbar.maxBars.prompt': 'Enter new max bars (>= {min}):',
'toolbar.input.invalidNumber': 'Invalid input. Please enter a valid number.',
'toolbar.maxBars.invalid': 'Invalid value. Please enter a number >= {min}.',
'toolbar.status.maxBarsChanged': 'Max bars changed to {value}',
'toolbar.bpm.prompt': 'Enter new BPM ({min}-{max}):',
'toolbar.bpm.invalid': 'Invalid BPM. Please enter a value between {min} and {max}.',
'toolbar.status.bpmChanged': 'BPM changed to {value}',
'toolbar.timeSignature.prompt': 'Set the time signature:',
'toolbar.status.timeSignatureChanged': 'Time signature changed to {value}',
'toolbar.status.keySignatureChanged': 'Key signature changed to {value}',
'toolbar.status.copied': 'Items copied to clipboard',
'toolbar.status.copyNone': 'No items selected to copy',
'toolbar.status.pasted': 'Items pasted from clipboard',
'toolbar.status.pasteFailed': 'Cannot paste - no valid clipboard content or context',
'toolbar.status.deleted': 'Selected regions deleted',
'toolbar.status.deleteNone': 'No regions selected for deletion',
'toolbar.undo.none': 'Nothing to undo',
'toolbar.redo.none': 'Nothing to redo',
'toolbar.status.undid': 'Undid: {description}',
'toolbar.status.redid': 'Redid: {description}',
'toolbar.action': 'action',
'toolbar.status.chatToggled': 'Chat toggled',
'toolbar.status.settingsToggled': 'Settings toggled',
'toolbar.pianoRoll.selectMidiRegion': 'Please select a MIDI region to open the Piano Roll.',
'toolbar.status.recordingStopped': 'Recording stopped',
'toolbar.status.audioRecordingStarted': 'Audio recording started...',
'toolbar.recording.openMidiRegion': 'Please open a MIDI region in the Piano Roll before starting recording.',
'toolbar.recording.selectMidiRegion': 'Please select a MIDI region before starting recording.',
'toolbar.recording.noMidiDevice': 'No MIDI device detected. Please connect a MIDI keyboard and try again.',
'toolbar.status.recordingStarted': 'Recording started...',
'toolbar.button.new': 'New',
'toolbar.button.load': 'Load',
'toolbar.button.save': 'Save',
'toolbar.button.export': 'Export',
'toolbar.button.import': 'Import',
'toolbar.button.settings': 'Settings',
'toolbar.button.undo': 'Undo',
'toolbar.button.redo': 'Redo',
'toolbar.button.select': 'Select',
'toolbar.button.pencil': 'Pencil',
'toolbar.button.splitRegion': 'Split Region at Playhead',
'toolbar.button.mergeRegions': 'Merge Selected MIDI Regions',
'toolbar.button.snapToGrid': 'Snap to Grid',
'toolbar.button.copy': 'Copy',
'toolbar.button.paste': 'Paste',
'toolbar.button.delete': 'Delete',
'toolbar.button.backToBeginning': 'Back to beginning',
'toolbar.button.play': 'Play',
'toolbar.button.pause': 'Pause',
'toolbar.button.stopRecording': 'Stop Recording',
'toolbar.button.record': 'Record',
'toolbar.button.loop': 'Loop',
'toolbar.button.metronome': 'Metronome',
'toolbar.button.piano': 'Piano',
'toolbar.button.kgone': 'K.G.One Music Generator',
'toolbar.button.chat': 'Chat',
'toolbar.button.eventList': 'Event List Editor',
'toolbar.keySignatureChooser': 'Choose key signature, current {value}',
'toolbar.export.label': 'Export',
'toolbar.importProject.title': 'Import Project',
'toolbar.importProject.description': 'Drag and drop your project file here',
'toolbar.openingProject': 'Opening project...',
'fileImport.invalidType': 'Invalid file type. Please select a file with one of these extensions: {extensions}',
'fileImport.close': 'Close import modal',
'fileImport.supportedFormats': 'Supported formats: {formats}',
'fileImport.or': 'or',
'fileImport.browse': 'Browse Files',
'instrument.group.PIANO_AND_KEYBOARDS': 'Piano and Keyboards',
'instrument.group.GUITAR': 'Guitar',
'instrument.group.BASS': 'Bass',
'instrument.group.STRINGS': 'Strings',
'instrument.group.BRASS': 'Brass',
'instrument.group.WOODWIND': 'Woodwind',
'instrument.group.PERCUSSION_KIT': 'Percussion Kit',
'instrument.group.SYNTH': 'Synthesizer',
'instrument.name.acoustic_grand_piano': 'Acoustic Grand Piano',
'instrument.name.bright_acoustic_piano': 'Bright Acoustic Piano',
'instrument.name.electric_grand_piano': 'Electric Grand Piano',
'instrument.name.electric_piano_1': 'Electric Piano 1',
'instrument.name.electric_piano_2': 'Electric Piano 2',
'instrument.name.drawbar_organ': 'Drawbar Organ',
'instrument.name.clavinet': 'Clavinet',
'instrument.name.harpsichord': 'Harpsichord',
'instrument.name.accordion': 'Accordion',
'instrument.name.acoustic_guitar_nylon': 'Acoustic Guitar (nylon)',
'instrument.name.acoustic_guitar_steel': 'Acoustic Guitar (steel)',
'instrument.name.electric_guitar_clean': 'Electric Guitar (clean)',
'instrument.name.overdriven_guitar': 'Electric Guitar (overdrive)',
'instrument.name.distortion_guitar': 'Electric Guitar (distortion)',
'instrument.name.acoustic_bass': 'Acoustic Bass',
'instrument.name.electric_bass_finger': 'Electric Bass (finger)',
'instrument.name.electric_bass_pick': 'Electric Bass (picked)',
'instrument.name.slap_bass_1': 'Slap Bass',
'instrument.name.synth_bass_1': 'Synth Bass 1',
'instrument.name.violin': 'Violin',
'instrument.name.viola': 'Viola',
'instrument.name.cello': 'Cello',
'instrument.name.contrabass': 'Contrabass',
'instrument.name.string_ensemble_1': 'String Ensemble',
'instrument.name.string_ensemble_2': 'String Ensemble 2',
'instrument.name.pizzicato_strings': 'Pizzicato Strings',
'instrument.name.tremolo_strings': 'Tremolo Strings',
'instrument.name.orchestral_harp': 'Orchestral Harp',
'instrument.name.trumpet': 'Trumpet',
'instrument.name.trombone': 'Trombone',
'instrument.name.tuba': 'Tuba',
'instrument.name.french_horn': 'French Horn',
'instrument.name.brass_section': 'Brass Section',
'instrument.name.soprano_sax': 'Soprano Sax',
'instrument.name.alto_sax': 'Alto Sax',
'instrument.name.tenor_sax': 'Tenor Sax',
'instrument.name.baritone_sax': 'Baritone Sax',
'instrument.name.oboe': 'Oboe',
'instrument.name.english_horn': 'English Horn',
'instrument.name.bassoon': 'Bassoon',
'instrument.name.clarinet': 'Clarinet',
'instrument.name.piccolo': 'Piccolo',
'instrument.name.flute': 'Flute',
'instrument.name.shakuhachi': 'Shakuhachi',
'instrument.name.harmonica': 'Harmonica',
'instrument.name.standard': 'Standard Drum Kit',
'instrument.name.orchestra_kit': 'Orchestra Drum Kit',
'instrument.name.marimba': 'Marimba',
'instrument.name.timpani': 'Timpani',
'instrument.name.taiko_drum': 'Taiko Drum',
'instrument.name.woodblock': 'Woodblock',
'instrument.name.lead_1_square': 'Lead 1 (square)',
'instrument.name.lead_2_sawtooth': 'Lead 2 (sawtooth)',
'instrument.name.pad_1_new_age': 'Pad 1 (new age)',
'instrument.name.pad_2_warm': 'Pad 2 (warm)',
'instrument.name.pad_3_polysynth': 'Pad 3 (polysynth)',
'instrument.name.pad_4_choir': 'Pad 4 (choir)',
'instrument.name.pad_5_bowed': 'Pad 5 (bowed glass)',
'instrument.name.pad_6_metallic': 'Pad 6 (metallic)',
'instrument.name.pad_7_halo': 'Pad 7 (halo)',
'instrument.name.pad_8_sweep': 'Pad 8 (sweep)',
'instrument.name.fx_1_rain': 'FX 1 (rain)',
'instrument.name.fx_2_soundtrack': 'FX 2 (soundtrack)',
'instrument.name.fx_3_crystal': 'FX 3 (crystal)',
'instrument.name.fx_4_atmosphere': 'FX 4 (atmosphere)',
'instrument.name.fx_5_brightness': 'FX 5 (brightness)',
'instrument.name.fx_6_goblins': 'FX 6 (goblins)',
'instrument.name.fx_7_echoes': 'FX 7 (echoes)',
'instrument.name.fx_8_scifi': 'FX 8 (sci-fi)',
};
+8
View File
@@ -0,0 +1,8 @@
import type { ResolvedLocaleCode, TranslationMessages } from '../types';
import { enUsMessages } from './en_us';
import { zhCnMessages } from './zh_cn';
export const messagesByLocale: Record<ResolvedLocaleCode, TranslationMessages> = {
en_us: enUsMessages,
zh_cn: zhCnMessages,
};
+510
View File
@@ -0,0 +1,510 @@
import type { TranslationMessages } from '../types';
export const zhCnMessages: TranslationMessages = {
'app.loading': '加载中...',
'status.chordGuideCandidate': '和弦指导候选: {name} - {notes} - {note}',
'settings.sidebar.title': '设置',
'settings.sidebar.close': '关闭设置',
'settings.sidebar.general': '通用',
'settings.sidebar.audioIo': '音频 I/O',
'settings.sidebar.behavior': '行为',
'settings.sidebar.templates': '模板',
'settings.sidebar.chordGuide': '和弦指导',
'settings.general.title': '通用',
'settings.general.language.label': '语言',
'settings.general.language.help': '自动会使用你的系统语言。',
'settings.general.language.auto': '自动',
'settings.general.language.en_us': 'English',
'settings.general.language.zh_cn': '简体中文',
'settings.yes': '是',
'settings.no': '否',
'settings.restoreDefault': '恢复默认',
'settings.deleteCachedModel': '删除已缓存模型',
'settings.deleting': '删除中...',
'settings.general.llmProvider.section': 'LLM 提供方',
'settings.general.llmProvider.label': 'LLM 提供方',
'settings.general.llmProvider.local': '本地 LLM(浏览器)',
'settings.general.llmProvider.openai': 'OpenAI',
'settings.general.llmProvider.claudeOpenRouter': 'Claude(通过 OpenRouter',
'settings.general.llmProvider.openaiCompatible': 'OpenAI 兼容服务(例如 OpenRouter、Ollama',
'settings.general.persistKeys.label': '在非 localhost 环境持久化 API Key',
'settings.general.persistKeys.help': '启用后,即使在非 localhost 环境下,API Key 也会保存到浏览器存储中。警告:这可能增加 XSS 攻击带来的安全风险。',
'settings.general.localRuntime.cachedStatus': '缓存模型状态',
'settings.general.localRuntime.cacheChecking': '正在检查本地模型缓存...',
'settings.general.localRuntime.cacheDownloaded': '已下载到浏览器缓存。',
'settings.general.localRuntime.cacheMissing': '尚未下载。',
'settings.general.localRuntime.contextLength': '上下文长度',
'settings.general.localRuntime.contextHelp': '更大的上下文长度需要更多显存,并且在对话变长后也可能降低性能。',
'settings.general.localRuntime.downloadUrl': '下载地址',
'settings.general.localRuntime.downloadHelp': '修改这个地址可能导致下载失败,或指向不兼容的模型文件。',
'settings.general.localRuntime.autoDownload': '下次你使用 `Local LLM (Browser)` 聊天时,本地模型会自动下载。',
'settings.general.uvr5.section': 'UVR5 Web 运行时',
'settings.general.uvr5.downloadUrl': 'UVR-MDX-NET-Inst_HQ_3 下载地址',
'settings.general.htdemucs.downloadUrl': 'htdemucs_4s 下载地址',
'settings.general.modelUrl.help': '修改这个地址可能导致下载失败,或指向不兼容的模型文件。',
'settings.general.openai.section': 'OpenAI',
'settings.general.openai.key': '密钥',
'settings.general.openai.keyPlaceholder': '输入你的 OpenAI API Key',
'settings.general.openai.model': '模型',
'settings.general.openai.flexMode': 'Flex 模式',
'settings.general.openai.flexHelp': 'Flex 模式使用 OpenAI 的弹性服务层。优点:繁忙时段可能节省成本并提高吞吐。缺点:延迟可能波动,也可能排队或被降优先级。仅对 OpenAI 提供方生效,对 OpenAI 兼容服务无影响。',
'settings.general.keys.persisted': '密钥会保存在本地(浏览器中的 IndexedDB)。',
'settings.general.keys.sessionOnly': '出于安全考虑,在非本地主机环境下密钥不会持久化,只保留在当前会话内存中。',
'settings.general.claudeOpenRouter.section': 'Anthropic Claude(通过 OpenRouter',
'settings.general.claudeOpenRouter.keyPlaceholder': '输入你的 Claude API Key',
'settings.general.baseUrl': '基础 URL',
'settings.general.claudeOpenRouter.baseUrlHelp': '这是 OpenRouter API 的基础 URL。除非你明确知道自己在做什么,否则不要修改。',
'settings.general.openaiCompatible.section': 'OpenAI 兼容服务',
'settings.general.openaiCompatible.keyPlaceholder': '输入你的 API Key',
'settings.general.openaiCompatible.baseUrlPlaceholder': '例如 https://openrouter.ai/api/v1',
'settings.general.openaiCompatible.baseUrlHelp': '快捷预设:',
'settings.general.openaiCompatible.modelPlaceholder': '例如 qwen3:30b',
'settings.general.soundfont.section': 'Soundfont 设置',
'settings.general.soundfont.managed': 'Soundfont 配置由服务器(kgone-server.json)管理,当前设置为只读。',
'settings.general.soundfont.baseUrl': '基础 URL',
'settings.general.soundfont.baseUrlHelp': '如果改成不兼容的 soundfont 源,某些乐器可能发声错误或无法播放。',
'settings.general.kgone.section': 'K.G.One 设置',
'settings.general.kgone.managed': 'K.G.One 配置由服务器(kgone-server.json)管理,当前设置为只读。',
'settings.general.kgone.enabled': '启用 K.G.One 集成',
'settings.general.kgone.disabled': '禁用',
'settings.general.kgone.enabledOption': '启用',
'settings.general.kgone.serverBaseUrl': '服务器基础 URL',
'settings.general.kgone.serverBaseUrlHelp': '正在运行的 K.G.One Music Studio 服务器基础 URL。用于整曲生成、片段生成和分轨。',
'dialog.title.notice': '提示',
'dialog.title.timeSignature': '拍号',
'dialog.title.tempoDetection': '速度检测',
'dialog.title.applyTempo': '应用速度',
'dialog.title.chordDetection': '和弦检测',
'dialog.title.input': '输入',
'dialog.title.confirm': '确认',
'dialog.close': '关闭对话框',
'dialog.cancel': '取消',
'dialog.ok': '确定',
'dialog.experimentalFeature': '实验性功能',
'dialog.recommendedSource': '推荐源素材',
'dialog.chordHint.audio': '和弦分析仍属实验性功能。和声内容、编配密度以及瞬态较强的素材都可能影响准确率。建议先使用默认设置,再逐步微调灵敏度和稳定性,让检测结果更贴近实际乐句。',
'dialog.chordHint.midi': '和弦分析仍属实验性功能。和弦堆叠密度、重叠以及装饰音都会影响结果。建议先使用默认设置,再调整短音抑制和和声关注方式,以匹配该段音乐的和声作用。',
'dialog.chordHint.tempo': '速度分析仍属实验性功能。自由速度、瞬态稀疏以及多层打击乐都可能降低准确率。建议先使用默认 BPM 范围,如果第一次结果不理想,再把分析范围收窄到更可能的速度区间。',
'dialog.sourceHint.kgone': '为了获得更可靠的和弦标签,建议分析已经削弱或移除了人声和打击乐的 stem。如果已启用 K.G.One Music Studio 服务器集成,请使用带有 “Vocal, Drums, Bass, Guitar, Piano, and Others” 的 Separator 模型,并优先分析 Piano 或 Others stem。',
'dialog.sourceHint.local': '为了获得更可靠的和弦标签,建议分析已经削弱或移除了人声和打击乐的 stem。如果你使用本地分轨器,请选择 “Vocal, Drums, Bass, and Others” 模型,并使用 Others stem 进行分析。',
'dialog.label.sensitivity': '灵敏度',
'dialog.label.stability': '稳定性',
'dialog.label.noChordThreshold': '无和弦阈值',
'dialog.label.enableSevenths': '和弦细节:启用七和弦',
'dialog.label.shortNotes': '短音',
'dialog.option.suppressionLow': '低抑制',
'dialog.option.suppressionMedium': '中抑制',
'dialog.option.suppressionHigh': '高抑制',
'dialog.label.harmonicFocus': '和声关注',
'dialog.option.harmonicBalanced': '均衡',
'dialog.option.harmonicSustained': '偏向持续音',
'dialog.label.minimumBpm': '最小 BPM',
'dialog.label.maximumBpm': '最大 BPM',
'dialog.label.autoAlignRegionToBeat': '自动将片段对齐到拍点',
'eventList.title': '事件列表',
'eventList.scopeTabs': '事件列表范围',
'eventList.scope.region': '区域',
'eventList.scope.track': '音轨',
'eventList.scope.global': '全局',
'eventList.deleteVisibleSelectedRows': '删除当前可见的已选行',
'eventList.table.position': '位置',
'eventList.table.status': '状态',
'eventList.table.num': '编号',
'eventList.table.val': '数值',
'eventList.table.lengthInfo': '长度/信息',
'eventList.region.filters': '区域事件筛选',
'eventList.region.filter.notes': '音符',
'eventList.region.filter.pitchBends': '弯音',
'eventList.region.filter.controller': '控制器',
'eventList.region.empty': '请选择一个 MIDI 区域,或先在钢琴卷帘中打开一个区域,再查看事件列表。',
'eventList.region.add.label': '添加',
'eventList.region.add.noteTitle': '在播放头位置添加音符',
'eventList.region.add.pitchBendTitle': '在播放头位置添加弯音',
'eventList.region.add.controllerTitle': '在播放头位置添加控制器事件',
'eventList.region.addType.note': '音符',
'eventList.region.addType.pitchBend': '弯音',
'eventList.region.addType.controller': '控制器',
'eventList.region.status.note': '音符',
'eventList.region.status.pitchBend': '弯音',
'eventList.region.status.controller': '控制器',
'eventList.track.filters': '音轨列表模式',
'eventList.track.filter.regions': '区域',
'eventList.track.filter.volume': '音量',
'eventList.track.filter.pan': '声像',
'eventList.track.empty': '请选择一条音轨以查看区域和音轨自动化。',
'eventList.track.add.label': '添加',
'eventList.track.add.midiRegionTitle': '在播放头位置添加 MIDI 区域',
'eventList.track.add.volumeTitle': '在播放头位置添加音量自动化点',
'eventList.track.add.panTitle': '在播放头位置添加声像自动化点',
'eventList.track.addType.midiRegion': 'MIDI 区域',
'eventList.track.status.audio': '音频',
'eventList.track.status.midi': 'MIDI',
'eventList.track.status.volume': '音量',
'eventList.track.status.pan': '声像',
'eventList.global.filters': '全局事件筛选',
'eventList.global.filter.marker': '标记',
'eventList.global.filter.tempo': '速度',
'eventList.global.filter.keySignature': '调号',
'eventList.global.filter.chord': '和弦',
'eventList.global.add.label': '添加',
'eventList.global.add.markerTitle': '在播放头位置添加标记区段',
'eventList.global.add.tempoTitle': '在播放头位置添加速度区段',
'eventList.global.add.keySignatureTitle': '在播放头位置添加调号区段',
'eventList.global.add.chordTitle': '在播放头位置添加和弦区段',
'eventList.global.addType.marker': '标记',
'eventList.global.addType.tempo': '速度',
'eventList.global.addType.keySignature': '调号',
'eventList.global.addType.chord': '和弦',
'eventList.global.status.marker': '标记',
'eventList.global.status.tempo': '速度',
'eventList.global.status.keySignature': '调号',
'eventList.global.status.chord': '和弦',
'eventList.global.validation.marker': '请输入标记标签。需要非空文本标签。示例:前奏',
'eventList.global.validation.tempo': '请输入仅包含数字的 BPM 值。需要介于 {min} 和 {max} 之间的整数。示例:128',
'eventList.global.validation.keySignature': '请输入一个精确的调号名称。需要规范值,例如 “C major” 或 “F# minor”。示例:F# minor',
'eventList.global.validation.chord': '请输入有效的和弦符号。需要应用可解析的和弦表示。示例:Bm7b5',
'eventList.global.validation.position': '请输入项目起点或之后的位置。需要非负位置。示例:1 1 0',
'eventList.global.validation.length': '请输入正长度。需要大于零的时值。示例:4 0',
'globalTracks.label': '全局轨道',
'globalTracks.marker': '标记',
'globalTracks.tempo': '速度',
'globalTracks.signature': '调号',
'globalTracks.chord': '和弦',
'globalTracks.addItem': '添加 {label} 全局轨道项目',
'pianoRoll.power.linear': '线性',
'pianoRoll.power.sqrtDefault': '√(默认)',
'pianoRoll.power.mild': '轻度',
'pianoRoll.power.strong': '强烈',
'pianoRoll.chordGuide.off': '关闭和弦指导',
'pianoRoll.chordGuide.tonic': '主功能和弦指导',
'pianoRoll.chordGuide.subdominant': '下属功能和弦指导',
'pianoRoll.chordGuide.dominant': '属功能和弦指导',
'pianoRoll.spectrogramView': '频谱视图',
'pianoRoll.sheetMusicView': '五线谱视图',
'pianoRoll.pointerTool': '指针工具',
'pianoRoll.pencilTool': '铅笔工具',
'pianoRoll.toggleAutomationLane': '切换自动化轨',
'pianoRoll.automation': '自动化',
'pianoRoll.automationLane': '{label} 自动化轨',
'pianoRoll.automationType.pitchBend': '弯音',
'pianoRoll.automationType.cc1': 'CC1',
'pianoRoll.automationType.cc2': 'CC2',
'pianoRoll.automationType.cc7': 'CC7',
'pianoRoll.automationType.cc11': 'CC11',
'pianoRoll.automationType.cc64': 'CC64',
'pianoRoll.mode': '调式',
'pianoRoll.modeOption.ionian': '伊奥尼亚',
'pianoRoll.modeOption.dorian': '多利亚',
'pianoRoll.modeOption.phrygian': '弗里吉亚',
'pianoRoll.modeOption.lydian': '利底亚',
'pianoRoll.modeOption.mixolydian': '混合利底亚',
'pianoRoll.modeOption.aeolian': '爱奥利亚',
'pianoRoll.modeOption.locrian': '洛克里亚',
'pianoRoll.modeOption.harmonic_minor': '和声小调',
'pianoRoll.modeOption.melodic_minor': '旋律小调',
'pianoRoll.modeOption.phrygian_dominant': '弗里吉亚属调式',
'pianoRoll.modeOption.harmonic_major': '和声大调',
'pianoRoll.chordGuide': '和弦指导',
'pianoRoll.sheetQuantization': '谱面量化',
'pianoRoll.snap': '吸附',
'pianoRoll.snap.none': '无吸附',
'pianoRoll.quantizePositionCompact': '位置量化',
'pianoRoll.quantizeLengthCompact': '长度量化',
'pianoRoll.floor': '底噪',
'pianoRoll.curve': '曲线',
'pianoRoll.zoom': '缩放',
'pianoRoll.moreOptions': '更多选项',
'pianoRoll.quantize.1/1': '1/1',
'pianoRoll.quantize.1/2': '1/2',
'pianoRoll.quantize.1/3': '1/3',
'pianoRoll.quantize.1/4': '1/4',
'pianoRoll.quantize.1/6': '1/6',
'pianoRoll.quantize.1/8': '1/8',
'pianoRoll.quantize.1/12': '1/12',
'pianoRoll.quantize.1/16': '1/16',
'pianoRoll.quantize.1/24': '1/24',
'pianoRoll.quantize.1/32': '1/32',
'pianoRoll.showActiveRegionOnly': '仅显示当前区域',
'pianoRoll.showEntireTrack': '显示整条音轨',
'pianoRoll.detectChords': '检测和弦...',
'pianoRoll.detectingChords': '正在检测和弦...',
'pianoRoll.detectTempo': '检测速度...',
'pianoRoll.detectingTempo': '正在检测速度...',
'percussion.short.35': '原底鼓',
'percussion.short.36': '底鼓',
'percussion.short.37': '边击',
'percussion.short.38': '原军鼓',
'percussion.short.39': '拍手',
'percussion.short.40': '电军鼓',
'percussion.short.41': '低落地鼓',
'percussion.short.42': '闭镲',
'percussion.short.43': '高落地鼓',
'percussion.short.44': '踏镲',
'percussion.short.45': '低嗵鼓',
'percussion.short.46': '开镲',
'percussion.short.47': '中低嗵鼓',
'percussion.short.48': '中高嗵鼓',
'percussion.short.49': '碎音镲1',
'percussion.short.50': '高嗵鼓',
'percussion.short.51': '叮叮镲1',
'percussion.short.52': '中国镲',
'percussion.short.53': '叮叮镲铃',
'percussion.short.54': '铃鼓',
'percussion.short.55': '飞溅镲',
'percussion.short.56': '牛铃',
'percussion.short.57': '碎音镲2',
'percussion.short.58': '振响器',
'percussion.short.59': '叮叮镲2',
'percussion.short.60': '高邦戈',
'percussion.short.61': '低邦戈',
'percussion.short.62': '闷高康加',
'percussion.short.63': '开高康加',
'percussion.short.64': '低康加',
'percussion.short.65': '高廷巴勒',
'percussion.short.66': '低廷巴勒',
'percussion.short.67': '高阿哥哥',
'percussion.short.68': '低阿哥哥',
'percussion.short.69': '卡巴萨',
'percussion.short.70': '沙锤',
'percussion.short.71': '短哨',
'percussion.short.72': '长哨',
'percussion.short.73': '短刮瓜',
'percussion.short.74': '长刮瓜',
'percussion.short.75': '响棒',
'percussion.short.76': '高木块',
'percussion.short.77': '低木块',
'percussion.short.78': '闷库卡',
'percussion.short.79': '开库卡',
'percussion.short.80': '闷三角铁',
'percussion.short.81': '开三角铁',
'percussion.full.35': '原声底鼓',
'percussion.full.36': '底鼓 1',
'percussion.full.37': '边击',
'percussion.full.38': '原声军鼓',
'percussion.full.39': '拍手',
'percussion.full.40': '电军鼓',
'percussion.full.41': '低落地嗵鼓',
'percussion.full.42': '闭合踩镲',
'percussion.full.43': '高落地嗵鼓',
'percussion.full.44': '踏踩镲',
'percussion.full.45': '低嗵鼓',
'percussion.full.46': '开放踩镲',
'percussion.full.47': '中低嗵鼓',
'percussion.full.48': '中高嗵鼓',
'percussion.full.49': '碎音镲 1',
'percussion.full.50': '高嗵鼓',
'percussion.full.51': '叮叮镲 1',
'percussion.full.52': '中国镲',
'percussion.full.53': '叮叮镲铃',
'percussion.full.54': '铃鼓',
'percussion.full.55': '飞溅镲',
'percussion.full.56': '牛铃',
'percussion.full.57': '碎音镲 2',
'percussion.full.58': '振响器',
'percussion.full.59': '叮叮镲 2',
'percussion.full.60': '高邦戈',
'percussion.full.61': '低邦戈',
'percussion.full.62': '闷高康加',
'percussion.full.63': '开高康加',
'percussion.full.64': '低康加',
'percussion.full.65': '高廷巴勒',
'percussion.full.66': '低廷巴勒',
'percussion.full.67': '高阿哥哥',
'percussion.full.68': '低阿哥哥',
'percussion.full.69': '卡巴萨',
'percussion.full.70': '沙锤',
'percussion.full.71': '短哨',
'percussion.full.72': '长哨',
'percussion.full.73': '短刮瓜',
'percussion.full.74': '长刮瓜',
'percussion.full.75': '响棒',
'percussion.full.76': '高木块',
'percussion.full.77': '低木块',
'percussion.full.78': '闷库卡',
'percussion.full.79': '开库卡',
'percussion.full.80': '闷三角铁',
'percussion.full.81': '开三角铁',
'toolbar.export.kgstudio': '导出为 KGStudio 文件',
'toolbar.export.midi': '导出为 MIDI 文件',
'toolbar.export.wav': '导出为 WAV',
'toolbar.export.mp3': '导出为 MP3',
'toolbar.projectName.prompt': '输入项目名称:',
'toolbar.projectName.invalid': '无效的项目名称。只允许字母、数字、空格、连字符、下划线、句点和括号。',
'toolbar.projectName.reserved': '"{name}" 是保留项目名称。请选择其他名称。',
'toolbar.projectName.overwrite': '项目 “{name}” 已存在。要覆盖它吗?',
'toolbar.projectName.renameOrCopy': '你想重命名这个项目,还是另存为一个副本?',
'toolbar.projectName.saveAsCopy': '另存为副本',
'toolbar.projectName.rename': '重命名',
'toolbar.save.error': '保存时发生错误:{error}',
'toolbar.load.error': '加载项目时发生错误:{error}',
'toolbar.newProject.confirm': '确定要创建新项目吗?当前未保存的更改将会丢失。',
'toolbar.project.notFound': '未找到项目 “{name}”。',
'toolbar.openProject.confirm': '要打开这个项目吗?当前项目中未保存的更改将会丢失。',
'toolbar.status.loadFailed': '加载项目失败:{error}',
'toolbar.status.newProjectCreated': '已创建新项目 “{name}”',
'toolbar.status.projectLoaded': '{description} 加载成功',
'toolbar.status.projectExportedKgstudio': '项目 “{name}” 已导出为 KGStudio 文件',
'toolbar.status.exportProjectError': '导出项目出错:{error}',
'toolbar.export.failedProject': '导出项目失败:{error}',
'toolbar.status.projectExportedMidi': '项目 “{name}” 已导出为 MIDI 文件',
'toolbar.status.exportMidiError': '导出 MIDI 出错:{error}',
'toolbar.export.failedMidi': '导出项目为 MIDI 失败:{error}',
'toolbar.status.projectExportedWav': '项目 “{name}” 已导出为 WAV 文件',
'toolbar.status.exportWavError': '导出 WAV 出错:{error}',
'toolbar.export.failedWav': '导出项目为 WAV 失败:{error}',
'toolbar.status.projectExportedMp3': '项目 “{name}” 已导出为 MP3 文件',
'toolbar.status.exportMp3Error': '导出 MP3 出错:{error}',
'toolbar.export.failedMp3': '导出项目为 MP3 失败:{error}',
'toolbar.status.importFailed': '导入文件失败:{error}',
'toolbar.import.failedProjectFile': '导入项目文件失败:{error}',
'toolbar.import.corruptedKgstudio': '.kgstudio 文件已损坏或无效:{error}',
'toolbar.status.importingMidi': '正在导入 MIDI 文件 “{name}”...',
'toolbar.status.failedImportMidi': '导入 MIDI 文件失败:{error}',
'toolbar.status.playbackFailedStart': '播放启动失败',
'toolbar.status.recordingStoppedCommitted': '录音已停止 - 音符已提交',
'toolbar.status.failedStopPlayback': '停止播放失败',
'toolbar.maxBars.prompt': '输入新的最大小节数(>= {min}):',
'toolbar.input.invalidNumber': '输入无效。请输入有效数字。',
'toolbar.maxBars.invalid': '数值无效。请输入一个 >= {min} 的数字。',
'toolbar.status.maxBarsChanged': '最大小节数已改为 {value}',
'toolbar.bpm.prompt': '输入新的 BPM{min}-{max}):',
'toolbar.bpm.invalid': 'BPM 无效。请输入 {min} 到 {max} 之间的值。',
'toolbar.status.bpmChanged': 'BPM 已改为 {value}',
'toolbar.timeSignature.prompt': '设置拍号:',
'toolbar.status.timeSignatureChanged': '拍号已改为 {value}',
'toolbar.status.keySignatureChanged': '调号已改为 {value}',
'toolbar.status.copied': '项目已复制到剪贴板',
'toolbar.status.copyNone': '没有可复制的已选项目',
'toolbar.status.pasted': '已从剪贴板粘贴项目',
'toolbar.status.pasteFailed': '无法粘贴 - 没有有效的剪贴板内容或上下文',
'toolbar.status.deleted': '已删除所选区域',
'toolbar.status.deleteNone': '没有选中可删除的区域',
'toolbar.undo.none': '没有可撤销的操作',
'toolbar.redo.none': '没有可重做的操作',
'toolbar.status.undid': '已撤销:{description}',
'toolbar.status.redid': '已重做:{description}',
'toolbar.action': '操作',
'toolbar.status.chatToggled': '聊天窗口已切换',
'toolbar.status.settingsToggled': '设置面板已切换',
'toolbar.pianoRoll.selectMidiRegion': '请先选择一个 MIDI 区域再打开钢琴卷帘。',
'toolbar.status.recordingStopped': '录音已停止',
'toolbar.status.audioRecordingStarted': '音频录音已开始...',
'toolbar.recording.openMidiRegion': '开始录音前,请先在钢琴卷帘中打开一个 MIDI 区域。',
'toolbar.recording.selectMidiRegion': '开始录音前,请先选择一个 MIDI 区域。',
'toolbar.recording.noMidiDevice': '未检测到 MIDI 设备。请连接 MIDI 键盘后重试。',
'toolbar.status.recordingStarted': '录音已开始...',
'toolbar.button.new': '新建',
'toolbar.button.load': '打开',
'toolbar.button.save': '保存',
'toolbar.button.export': '导出',
'toolbar.button.import': '导入',
'toolbar.button.settings': '设置',
'toolbar.button.undo': '撤销',
'toolbar.button.redo': '重做',
'toolbar.button.select': '选择',
'toolbar.button.pencil': '铅笔',
'toolbar.button.splitRegion': '在播放头处分割区域',
'toolbar.button.mergeRegions': '合并选中的 MIDI 区域',
'toolbar.button.snapToGrid': '吸附到网格',
'toolbar.button.copy': '复制',
'toolbar.button.paste': '粘贴',
'toolbar.button.delete': '删除',
'toolbar.button.backToBeginning': '回到开头',
'toolbar.button.play': '播放',
'toolbar.button.pause': '暂停',
'toolbar.button.stopRecording': '停止录音',
'toolbar.button.record': '录音',
'toolbar.button.loop': '循环',
'toolbar.button.metronome': '节拍器',
'toolbar.button.piano': '钢琴',
'toolbar.button.kgone': 'K.G.One 音乐生成器',
'toolbar.button.chat': '聊天',
'toolbar.button.eventList': '事件列表编辑器',
'toolbar.keySignatureChooser': '选择调号,当前为 {value}',
'toolbar.export.label': '导出',
'toolbar.importProject.title': '导入项目',
'toolbar.importProject.description': '将项目文件拖放到这里',
'toolbar.openingProject': '正在打开项目...',
'fileImport.invalidType': '文件类型无效。请选择以下扩展名之一的文件:{extensions}',
'fileImport.close': '关闭导入弹窗',
'fileImport.supportedFormats': '支持的格式:{formats}',
'fileImport.or': '或',
'fileImport.browse': '浏览文件',
'instrument.group.PIANO_AND_KEYBOARDS': '钢琴与键盘',
'instrument.group.GUITAR': '吉他',
'instrument.group.BASS': '贝斯',
'instrument.group.STRINGS': '弦乐',
'instrument.group.BRASS': '铜管',
'instrument.group.WOODWIND': '木管',
'instrument.group.PERCUSSION_KIT': '打击乐套件',
'instrument.group.SYNTH': '合成器',
'instrument.name.acoustic_grand_piano': '原声大钢琴',
'instrument.name.bright_acoustic_piano': '明亮原声钢琴',
'instrument.name.electric_grand_piano': '电大钢琴',
'instrument.name.electric_piano_1': '电钢琴 1',
'instrument.name.electric_piano_2': '电钢琴 2',
'instrument.name.drawbar_organ': '击杆风琴',
'instrument.name.clavinet': '击弦电钢琴',
'instrument.name.harpsichord': '羽管键琴',
'instrument.name.accordion': '手风琴',
'instrument.name.acoustic_guitar_nylon': '原声吉他(尼龙弦)',
'instrument.name.acoustic_guitar_steel': '原声吉他(钢弦)',
'instrument.name.electric_guitar_clean': '电吉他(清音)',
'instrument.name.overdriven_guitar': '电吉他(过载)',
'instrument.name.distortion_guitar': '电吉他(失真)',
'instrument.name.acoustic_bass': '原声贝斯',
'instrument.name.electric_bass_finger': '电贝斯(指弹)',
'instrument.name.electric_bass_pick': '电贝斯(拨片)',
'instrument.name.slap_bass_1': '击勾贝斯',
'instrument.name.synth_bass_1': '合成贝斯 1',
'instrument.name.violin': '小提琴',
'instrument.name.viola': '中提琴',
'instrument.name.cello': '大提琴',
'instrument.name.contrabass': '低音提琴',
'instrument.name.string_ensemble_1': '弦乐合奏',
'instrument.name.string_ensemble_2': '弦乐合奏 2',
'instrument.name.pizzicato_strings': '拨奏弦乐',
'instrument.name.tremolo_strings': '震音弦乐',
'instrument.name.orchestral_harp': '管弦竖琴',
'instrument.name.trumpet': '小号',
'instrument.name.trombone': '长号',
'instrument.name.tuba': '大号',
'instrument.name.french_horn': '圆号',
'instrument.name.brass_section': '铜管组',
'instrument.name.soprano_sax': '高音萨克斯',
'instrument.name.alto_sax': '中音萨克斯',
'instrument.name.tenor_sax': '次中音萨克斯',
'instrument.name.baritone_sax': '上低音萨克斯',
'instrument.name.oboe': '双簧管',
'instrument.name.english_horn': '英国管',
'instrument.name.bassoon': '巴松',
'instrument.name.clarinet': '单簧管',
'instrument.name.piccolo': '短笛',
'instrument.name.flute': '长笛',
'instrument.name.shakuhachi': '尺八',
'instrument.name.harmonica': '口琴',
'instrument.name.standard': '标准鼓组',
'instrument.name.orchestra_kit': '管弦打击乐组',
'instrument.name.marimba': '马林巴',
'instrument.name.timpani': '定音鼓',
'instrument.name.taiko_drum': '太鼓',
'instrument.name.woodblock': '木鱼',
'instrument.name.lead_1_square': '主音 1(方波)',
'instrument.name.lead_2_sawtooth': '主音 2(锯齿波)',
'instrument.name.pad_1_new_age': '铺底 1(新世纪)',
'instrument.name.pad_2_warm': '铺底 2(温暖)',
'instrument.name.pad_3_polysynth': '铺底 3(复音合成)',
'instrument.name.pad_4_choir': '铺底 4(合唱)',
'instrument.name.pad_5_bowed': '铺底 5(弓弦玻璃)',
'instrument.name.pad_6_metallic': '铺底 6(金属)',
'instrument.name.pad_7_halo': '铺底 7(光晕)',
'instrument.name.pad_8_sweep': '铺底 8(扫频)',
'instrument.name.fx_1_rain': '特效 1(雨)',
'instrument.name.fx_2_soundtrack': '特效 2(配乐)',
'instrument.name.fx_3_crystal': '特效 3(水晶)',
'instrument.name.fx_4_atmosphere': '特效 4(氛围)',
'instrument.name.fx_5_brightness': '特效 5(明亮)',
'instrument.name.fx_6_goblins': '特效 6(妖精)',
'instrument.name.fx_7_echoes': '特效 7(回声)',
'instrument.name.fx_8_scifi': '特效 8(科幻)',
};
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { getPercussionKeyFullLabel, getPercussionKeyShortLabel, isGmDrumKitInstrument } from './percussion';
import { translate } from './translate';
describe('percussion i18n helpers', () => {
it('returns translated short labels for supported locales', () => {
expect(getPercussionKeyShortLabel(35, (key, params) => translate(key, params, 'en_us'))).toBe('Ac.Bass');
expect(getPercussionKeyShortLabel(35, (key, params) => translate(key, params, 'zh_cn'))).toBe('原底鼓');
});
it('returns translated full labels when available', () => {
expect(getPercussionKeyFullLabel(42, (key, params) => translate(key, params, 'en_us'))).toBe('Closed Hi Hat');
expect(getPercussionKeyFullLabel(42, (key, params) => translate(key, params, 'zh_cn'))).toBe('闭合踩镲');
});
it('falls back to English labels when translation is missing', () => {
expect(getPercussionKeyShortLabel(42, () => '')).toBe('ClosedHH');
expect(getPercussionKeyFullLabel(42, () => '')).toBe('Closed Hi Hat');
});
it('returns null for unknown pitches', () => {
expect(getPercussionKeyShortLabel(10, (key, params) => translate(key, params, 'en_us'))).toBeNull();
expect(getPercussionKeyFullLabel(10, (key, params) => translate(key, params, 'en_us'))).toBeNull();
});
it('keeps drum-kit eligibility explicit', () => {
expect(isGmDrumKitInstrument('standard')).toBe(true);
expect(isGmDrumKitInstrument('orchestra_kit')).toBe(true);
expect(isGmDrumKitInstrument('taiko_drum')).toBe(false);
});
});
+32
View File
@@ -0,0 +1,32 @@
import type { InstrumentType } from '../core/track/KGMidiTrack';
import { midiPercussionKeyMap } from '../util/midiUtil';
import type { TranslationParams } from './types';
type TranslateFn = (key: string, params?: TranslationParams) => string;
const GM_DRUM_KIT_INSTRUMENTS: ReadonlySet<InstrumentType> = new Set([
'standard',
'orchestra_kit',
]);
export function isGmDrumKitInstrument(instrument: InstrumentType): boolean {
return GM_DRUM_KIT_INSTRUMENTS.has(instrument);
}
export function getPercussionKeyShortLabel(pitch: number, t: TranslateFn): string | null {
const drumInfo = midiPercussionKeyMap[pitch];
if (!drumInfo) {
return null;
}
return t(`percussion.short.${pitch}`) || drumInfo.shortName;
}
export function getPercussionKeyFullLabel(pitch: number, t: TranslateFn): string | null {
const drumInfo = midiPercussionKeyMap[pitch];
if (!drumInfo) {
return null;
}
return t(`percussion.full.${pitch}`) || drumInfo.fullName;
}
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { translate } from './translate';
describe('translate', () => {
it('returns translated strings for both locales', () => {
expect(translate('settings.general.language.auto', undefined, 'en_us')).toBe('Auto');
expect(translate('settings.general.language.auto', undefined, 'zh_cn')).toBe('自动');
});
it('falls back to English when a zh-CN key is missing', () => {
expect(translate('toolbar.button.new', undefined, 'zh_cn')).toBe('新建');
expect(translate('nonexistent.key', undefined, 'zh_cn')).toBe('nonexistent.key');
});
it('interpolates params', () => {
expect(translate('toolbar.status.bpmChanged', { value: 120 }, 'en_us')).toBe('BPM changed to 120');
expect(translate('toolbar.status.bpmChanged', { value: 120 }, 'zh_cn')).toBe('BPM 已改为 120');
});
});
+38
View File
@@ -0,0 +1,38 @@
import { messagesByLocale } from './messages';
import type { ResolvedLocaleCode, TranslationMessages, TranslationParams } from './types';
let currentLocale: ResolvedLocaleCode = 'en_us';
function interpolate(template: string, params?: TranslationParams): string {
if (!params) {
return template;
}
return template.replace(/\{(\w+)\}/g, (_match, key: string) => {
const value = params[key];
return value === undefined ? `{${key}}` : String(value);
});
}
export function setCurrentLocale(locale: ResolvedLocaleCode): void {
currentLocale = locale;
}
export function getCurrentLocale(): ResolvedLocaleCode {
return currentLocale;
}
export function getMessagesForLocale(locale: ResolvedLocaleCode): TranslationMessages {
return messagesByLocale[locale];
}
export function translate(
key: string,
params?: TranslationParams,
locale: ResolvedLocaleCode = currentLocale,
): string {
const localeMessages = messagesByLocale[locale];
const englishMessages = messagesByLocale.en_us;
const template = localeMessages[key] ?? englishMessages[key] ?? key;
return interpolate(template, params);
}
+14
View File
@@ -0,0 +1,14 @@
export type LanguageSetting = 'auto' | 'en_us' | 'zh_cn';
export type ResolvedLocaleCode = Exclude<LanguageSetting, 'auto'>;
export type TranslationParams = Record<string, string | number>;
export type TranslationMessages = Record<string, string>;
export interface I18nContextValue {
languageSetting: LanguageSetting;
resolvedLocale: ResolvedLocaleCode;
setLanguageSetting: (value: LanguageSetting) => Promise<void>;
t: (key: string, params?: TranslationParams) => string;
}
+6
View File
@@ -0,0 +1,6 @@
import { useContext } from 'react';
import { I18nContext } from './I18nProvider';
export function useI18n() {
return useContext(I18nContext);
}
+6 -3
View File
@@ -12,6 +12,7 @@ import { ConfigManager } from './core/config/ConfigManager';
import { KGMidiInput } from './core/midi-input/KGMidiInput';
import { KGDebugger } from './core/KGDebugger';
import { enumerateAudioDevices, validateConfiguredAudioDevices } from './util/audioDeviceUtil';
import { I18nProvider } from './i18n/I18nProvider';
const root = createRoot(document.getElementById('root')!);
@@ -169,9 +170,11 @@ window.addEventListener('beforeunload', (event) => {
root.render(
<StrictMode>
<DialogProvider>
<App />
</DialogProvider>
<I18nProvider>
<DialogProvider>
<App />
</DialogProvider>
</I18nProvider>
</StrictMode>,
);
} // end isSecureContext else
@@ -59,6 +59,7 @@ describe('processUserMessage slash commands', () => {
configState.set('general.claude_openrouter.api_key', '');
configState.set('general.openai_compatible.base_url', '');
configState.set('general.openai_compatible.model', '');
configState.set('general.language', 'en_us');
configManagerMock.getIsInitialized.mockReturnValue(true);
configManagerMock.initialize.mockClear();
@@ -99,6 +100,17 @@ describe('processUserMessage slash commands', () => {
expect(result.pseudoAssistantResponse).toContain('welcome_local_llm.md');
});
it('uses the localized welcome asset when zh-CN is selected', async () => {
configState.set('general.language', 'zh_cn');
configState.set('general.llm_provider', 'local_browser');
const result = await processUserMessage('/welcome');
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('chat/welcome_local_llm-zh_cn.md'));
expect(result.metadata).toMatchObject({ command: 'welcome', variant: 'local' });
expect(result.pseudoAssistantResponse).toContain('welcome_local_llm-zh_cn.md');
});
it('uses the new-user welcome for non-local providers without required config', async () => {
configState.set('general.llm_provider', 'openai');
configState.set('general.openai.api_key', '');
@@ -132,6 +144,59 @@ describe('processUserMessage slash commands', () => {
expect(message?.content).toContain('welcome_local_llm.md');
});
it('uses localized welcome assets for addWelcomeMessage under auto + Chinese locale', async () => {
configState.set('general.language', 'auto');
vi.stubGlobal('navigator', {
languages: ['zh-CN', 'en-US'],
language: 'zh-CN',
});
const message = await addWelcomeMessage();
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('chat/welcome_local_llm-zh_cn.md'));
expect(message?.content).toContain('welcome_local_llm-zh_cn.md');
});
it('fetches the localized help guide for /help under zh-CN', async () => {
configState.set('general.language', 'zh_cn');
const result = await processUserMessage('/help');
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('chat/help-zh_cn.md'));
expect(result).toMatchObject({
displayUserMessage: false,
sendToLLM: false,
finalMessageForLLM: null,
metadata: { command: 'help' },
});
expect(result.pseudoAssistantResponse).toContain('chat/help-zh_cn.md');
});
it('falls back to the English help guide when the localized file is missing', async () => {
configState.set('general.language', 'zh_cn');
vi.stubGlobal('fetch', vi.fn(async (input: string | URL | Request) => {
const url = String(input);
if (url.includes('chat/help-zh_cn.md')) {
return {
ok: false,
status: 404,
text: async () => '',
};
}
return {
ok: true,
status: 200,
text: async () => `content:${url}`,
};
}));
const result = await processUserMessage('/help');
expect(fetch).toHaveBeenNthCalledWith(1, expect.stringContaining('chat/help-zh_cn.md'));
expect(fetch).toHaveBeenNthCalledWith(2, expect.stringContaining('chat/help.md'));
expect(result.pseudoAssistantResponse).toContain('chat/help.md');
});
it('fetches the hotkeys guide for /hotkeys', async () => {
const result = await processUserMessage('/hotkeys');
@@ -145,6 +210,15 @@ describe('processUserMessage slash commands', () => {
expect(result.pseudoAssistantResponse).toContain('chat/hotkeys.md');
});
it('fetches the localized hotkeys guide for /hotkeys under zh-CN', async () => {
configState.set('general.language', 'zh_cn');
const result = await processUserMessage('/hotkeys');
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('chat/hotkeys-zh_cn.md'));
expect(result.pseudoAssistantResponse).toContain('chat/hotkeys-zh_cn.md');
});
it('supports /hotkey as an alias of /hotkeys', async () => {
const result = await processUserMessage('/hotkey');
+56 -16
View File
@@ -3,6 +3,8 @@ import { useProjectStore } from '../../stores/projectStore';
import { ConfigManager } from '../../core/config/ConfigManager';
import { SystemPrompts } from '../../agent/core/SystemPrompts';
import { detectLocalLLMRuntimeSupport, LOCAL_LLM_PROVIDER_KEY } from '../localLLMConfig';
import { normalizeLanguageSetting, resolveLanguageSetting } from '../../i18n/locale';
import type { ResolvedLocaleCode } from '../../i18n/types';
export interface UserMessageFilterResult {
// Whether to render the user message bubble (div.message-user)
@@ -59,6 +61,48 @@ function getWelcomeUrl(variant: 'local' | 'new' | 'again'): string {
}
}
function resolveCurrentCommandLocale(configManager: ConfigManager): ResolvedLocaleCode {
const languageSetting = normalizeLanguageSetting(configManager.get('general.language'));
return resolveLanguageSetting(languageSetting);
}
function resolveLocalizedChatMarkdownUrl(baseFileName: string, locale: ResolvedLocaleCode): string {
if (locale === 'en_us') {
return `${import.meta.env.BASE_URL}chat/${baseFileName}`;
}
const extensionIndex = baseFileName.lastIndexOf('.');
const localizedFileName = extensionIndex >= 0
? `${baseFileName.slice(0, extensionIndex)}-${locale}${baseFileName.slice(extensionIndex)}`
: `${baseFileName}-${locale}`;
return `${import.meta.env.BASE_URL}chat/${localizedFileName}`;
}
async function fetchLocalizedChatMarkdown(
configManager: ConfigManager,
baseFileName: string,
): Promise<string> {
const locale = resolveCurrentCommandLocale(configManager);
const localizedUrl = resolveLocalizedChatMarkdownUrl(baseFileName, locale);
const fallbackUrl = `${import.meta.env.BASE_URL}chat/${baseFileName}`;
const urlsToTry = locale === 'en_us' || localizedUrl === fallbackUrl
? [fallbackUrl]
: [localizedUrl, fallbackUrl];
let lastStatus = 'unknown';
for (const url of urlsToTry) {
const resp = await fetch(url);
if (resp.ok) {
return await resp.text();
}
lastStatus = String(resp.status);
}
throw new Error(`Failed to fetch chat markdown ${baseFileName}: ${lastStatus}`);
}
/**
* Process a user message before it is displayed or sent to the LLM.
* Handles slash-commands and returns a structured decision.
@@ -96,12 +140,8 @@ export async function processUserMessage(originalMessage: string): Promise<UserM
}
const variant = getWelcomeVariant(configManager);
const url = getWelcomeUrl(variant);
const resp = await fetch(url);
if (!resp.ok) {
throw new Error(`Failed to fetch ${url}: ${resp.status}`);
}
const md = await resp.text();
const baseFileName = getWelcomeUrl(variant).split('/').pop() ?? 'welcome_new.md';
const md = await fetchLocalizedChatMarkdown(configManager, baseFileName);
return {
displayUserMessage: false,
sendToLLM: false,
@@ -123,12 +163,12 @@ export async function processUserMessage(originalMessage: string): Promise<UserM
case '/help': {
try {
const url = `${import.meta.env.BASE_URL}chat/help.md`;
const resp = await fetch(url);
if (!resp.ok) {
throw new Error(`Failed to fetch ${url}: ${resp.status}`);
const configManager = ConfigManager.instance();
if (!configManager.getIsInitialized()) {
await configManager.initialize();
}
const md = await resp.text();
const md = await fetchLocalizedChatMarkdown(configManager, 'help.md');
return {
displayUserMessage: false,
sendToLLM: false,
@@ -151,12 +191,12 @@ export async function processUserMessage(originalMessage: string): Promise<UserM
case '/hotkeys':
case '/hotkey': {
try {
const url = `${import.meta.env.BASE_URL}chat/hotkeys.md`;
const resp = await fetch(url);
if (!resp.ok) {
throw new Error(`Failed to fetch ${url}: ${resp.status}`);
const configManager = ConfigManager.instance();
if (!configManager.getIsInitialized()) {
await configManager.initialize();
}
const md = await resp.text();
const md = await fetchLocalizedChatMarkdown(configManager, 'hotkeys.md');
return {
displayUserMessage: false,
sendToLLM: false,