feat: added i18n support; added Simplified Chinese support
This commit is contained in:
@@ -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,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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(',')}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user