From 59dea65b0daf30daf9010b5d75557070c78a3ecb Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Sun, 31 May 2026 00:21:44 -0700 Subject: [PATCH] feat: added translations for 4 tabs in Settings screen --- .../sections/AudioIOSettings.test.tsx | 11 +-- .../settings/sections/AudioIOSettings.tsx | 48 +++++------ .../settings/sections/BehaviorSettings.tsx | 82 ++++++++++--------- .../sections/ChordGuideSettings.test.tsx | 37 +++++---- .../settings/sections/ChordGuideSettings.tsx | 44 +++++----- .../settings/sections/TemplatesSettings.tsx | 10 ++- src/i18n/messages/en_us.ts | 74 +++++++++++++++++ src/i18n/messages/fr_fr.ts | 74 +++++++++++++++++ src/i18n/messages/zh_cn.ts | 74 +++++++++++++++++ src/i18n/messages/zh_hk.ts | 74 +++++++++++++++++ 10 files changed, 415 insertions(+), 113 deletions(-) diff --git a/src/components/settings/sections/AudioIOSettings.test.tsx b/src/components/settings/sections/AudioIOSettings.test.tsx index ac83bae..8d2bd05 100644 --- a/src/components/settings/sections/AudioIOSettings.test.tsx +++ b/src/components/settings/sections/AudioIOSettings.test.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { describe, expect, it, vi, beforeEach } from 'vitest'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import AudioIOSettings from './AudioIOSettings'; +import { translate } from '../../../i18n/translate'; const configState = new Map([ ['audio.input_device_id', 'default'], @@ -63,16 +64,16 @@ describe('AudioIOSettings', () => { it('renders input/output selectors and refresh action', async () => { render(); - expect(await screen.findByText('Audio I/O')).toBeTruthy(); - expect(screen.getByLabelText('Audio Input Device')).toBeTruthy(); - expect(screen.getByLabelText('Audio Output Device')).toBeTruthy(); - expect(screen.getByText('Refresh Device List')).toBeTruthy(); + expect(await screen.findByText(translate('settings.audioIo.title', undefined, 'en_us'))).toBeTruthy(); + expect(screen.getByLabelText(translate('settings.audioIo.inputDevice', undefined, 'en_us'))).toBeTruthy(); + expect(screen.getByLabelText(translate('settings.audioIo.outputDevice', undefined, 'en_us'))).toBeTruthy(); + expect(screen.getByText(translate('settings.audioIo.refresh', undefined, 'en_us'))).toBeTruthy(); }); it('persists input device changes', async () => { render(); - const select = await screen.findByLabelText('Audio Input Device'); + const select = await screen.findByLabelText(translate('settings.audioIo.inputDevice', undefined, 'en_us')); fireEvent.change(select, { target: { value: 'mic-1' } }); await waitFor(() => { diff --git a/src/components/settings/sections/AudioIOSettings.tsx b/src/components/settings/sections/AudioIOSettings.tsx index f5b67f4..e999d98 100644 --- a/src/components/settings/sections/AudioIOSettings.tsx +++ b/src/components/settings/sections/AudioIOSettings.tsx @@ -7,8 +7,10 @@ import { supportsAudioContextSinkSelection, type AudioDeviceOption, } from '../../../util/audioDeviceUtil'; +import { useI18n } from '../../../i18n/useI18n'; const AudioIOSettings: React.FC = () => { + const { t } = useI18n(); const [inputDeviceId, setInputDeviceId] = useState('default'); const [outputDeviceId, setOutputDeviceId] = useState('default'); const [inputs, setInputs] = useState([getDefaultAudioDeviceOption('audioinput')]); @@ -67,7 +69,7 @@ const AudioIOSettings: React.FC = () => { ...snapshot.inputs, { deviceId: configuredInputId, - label: 'Previously Selected Input (permission required to verify)', + label: t('settings.audioIo.previousSelectedInput'), kind: 'audioinput' as const, isDefault: false, }, @@ -78,7 +80,7 @@ const AudioIOSettings: React.FC = () => { ...snapshot.outputs, { deviceId: configuredOutputId, - label: 'Previously Selected Output (permission required to verify)', + label: t('settings.audioIo.previousSelectedOutput'), kind: 'audiooutput' as const, isDefault: false, }, @@ -91,7 +93,7 @@ const AudioIOSettings: React.FC = () => { if (!hasInput && configuredInputId !== 'default' && snapshot.labelsAvailable) { setInputDeviceId('default'); await configManager.set('audio.input_device_id', 'default'); - setDeviceStatus('Previously selected audio input device is unavailable; using System Default.'); + setDeviceStatus(t('settings.audioIo.status.inputUnavailable')); } else { setInputDeviceId(hasInput || !snapshot.labelsAvailable ? configuredInputId : 'default'); } @@ -99,17 +101,17 @@ const AudioIOSettings: React.FC = () => { if (!hasOutput && configuredOutputId !== 'default' && snapshot.labelsAvailable) { setOutputDeviceId('default'); await configManager.set('audio.output_device_id', 'default'); - setDeviceStatus('Previously selected audio output device is unavailable; using System Default.'); + setDeviceStatus(t('settings.audioIo.status.outputUnavailable')); } else { setOutputDeviceId(hasOutput || !snapshot.labelsAvailable ? configuredOutputId : 'default'); } if (showStatus) { - setDeviceStatus(current => current || 'Audio device list refreshed.'); + setDeviceStatus(current => current || t('settings.audioIo.status.refreshed')); } } catch (error) { console.error('Unable to refresh audio devices:', error); - setDeviceStatus('Unable to read audio devices from the browser.'); + setDeviceStatus(t('settings.audioIo.status.readFailed')); } finally { setRefreshing(false); } @@ -119,63 +121,63 @@ const AudioIOSettings: React.FC = () => { setInputDeviceId(value); await configManager.set('audio.input_device_id', value); setDeviceStatus(value === 'default' - ? 'Audio input will use System Default on the next recording session.' - : 'Audio input will change on the next recording session.'); + ? t('settings.audioIo.status.inputDefaultNextSession') + : t('settings.audioIo.status.inputChangedNextSession')); }; const handleOutputDeviceChange = async (value: string) => { setOutputDeviceId(value); await configManager.set('audio.output_device_id', value); setDeviceStatus(value === 'default' - ? 'Audio output will use System Default after refresh.' - : 'Audio output device saved. Refresh the page to apply it in v1.'); + ? t('settings.audioIo.status.outputDefaultAfterRefresh') + : t('settings.audioIo.status.outputSavedRefreshRequired')); }; const handleChooseOutputDevice = async () => { try { const selectedDevice = await promptForAudioOutputDevice(); if (!selectedDevice) { - setDeviceStatus('This browser does not support prompting for audio output devices.'); + setDeviceStatus(t('settings.audioIo.status.outputPromptUnsupported')); return; } setOutputDeviceId(selectedDevice.deviceId); await configManager.set('audio.output_device_id', selectedDevice.deviceId); await refreshDevices(false); - setDeviceStatus('Output device selected. Refresh the page to apply it in v1.'); + setDeviceStatus(t('settings.audioIo.status.outputSelectedRefreshRequired')); } catch (error) { console.error('Unable to choose audio output device:', error); - setDeviceStatus('The browser did not allow selecting a non-default output device.'); + setDeviceStatus(t('settings.audioIo.status.outputSelectionDenied')); } }; return (
-

Audio I/O

+

{t('settings.audioIo.title')}

-

Device Routing

+

{t('settings.audioIo.deviceRouting')}

- Choose the devices KGStudio should use for recording and playback. Input changes apply to the next recording session. Output changes require a page refresh in v1. + {t('settings.audioIo.description')}
- Changes apply to the next audio recording. If the selected device is removed, KGStudio will fall back to System Default. + {t('settings.audioIo.inputHelp')}
{
- Update frequency for the playhead animation during playback. Higher values (60 fps) provide smoother animation but use more CPU. Lower values (10 fps) are more efficient. Changes apply immediately without restart. + {t('settings.behavior.playheadUpdateFrequencyHelp')}
- Controls vertical spectrogram detail across the visible pitch range. Higher values sharpen pitch contours but use more CPU and memory while computing the spectrogram. + {t('settings.behavior.spectrogramHeightResolutionHelp')}
-

Chat Box

+

{t('settings.behavior.chatBox')}

-

Audio

+

{t('settings.behavior.audio')}

{
)}
- Audio scheduling lookahead time (0-500ms). Lower values (10-20ms) reduce MIDI input latency but may cause audio glitches on slower systems. Higher values (100ms+) are better for playback stability. Changes apply immediately without restart. + {t('settings.behavior.lookaheadTimeHelp')}
{
)}
- Playback will start with a short delay after pressing the start button (0-500ms). Increasing this value might help stabilize playback, especially for the first few ticks if the lookahead value is too low. Changes apply immediately without restart. + {t('settings.behavior.playbackDelayHelp')}
- Controls how densely MIDI automation are baked for playback and bounce. Smaller intervals sound smoother but schedule more events. + {t('settings.behavior.midiAutomationInterpolationHelp')}
{
)}
- Timing correction for MIDI recording (0-500ms). If recorded notes appear slightly late compared to where you intended to play them, increase this value to match your MIDI device's input latency. Each note's position is shifted back by this amount when committed. + {t('settings.behavior.midiInputLatencyHelp')}
- Yes includes leading silence from the start of the song up to the first rendered region when bouncing WAV/MP3. No trims that leading silence and starts bounce at the first rendered note or audio region. + {t('settings.behavior.bounceStartsFromBeat1Help')}
- Restart KGStudio (refresh the page) to take effect. Enable this option when KGStudio's audio cannot be captured during screen sharing in video calls (e.g., Zoom, Teams). This creates an additional audio stream that screen capture applications can detect. + {t('settings.behavior.captureAudioForScreenSharingHelp')}
- It is important to make sure when screen sharing in Zoom, the "Share Sound" option is enabled. + {t('settings.behavior.captureAudioForScreenSharingWarning')}
diff --git a/src/components/settings/sections/ChordGuideSettings.test.tsx b/src/components/settings/sections/ChordGuideSettings.test.tsx index d53b92b..7f6f386 100644 --- a/src/components/settings/sections/ChordGuideSettings.test.tsx +++ b/src/components/settings/sections/ChordGuideSettings.test.tsx @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import ChordGuideSettings from './ChordGuideSettings'; import chordGuideDataJson from '../../../../public/resources/modes/chord_guide.json'; import type { ChordGuideData } from '../../../core/ChordGuideTypes'; +import { translate } from '../../../i18n/translate'; const chordGuideData = chordGuideDataJson as ChordGuideData; @@ -41,7 +42,9 @@ vi.mock('../../../core/KGCore', () => ({ })); describe('ChordGuideSettings', () => { - const getGroup = (heading: 'Major Candidate Chords' | 'Minor Candidate Chords') => ( + const majorHeading = translate('settings.chordGuide.group.major', undefined, 'en_us'); + const minorHeading = translate('settings.chordGuide.group.minor', undefined, 'en_us'); + const getGroup = (heading: string) => ( screen.getByText(heading).closest('.settings-group') as HTMLElement ); @@ -67,17 +70,19 @@ describe('ChordGuideSettings', () => { it('renders major and minor groups with base-key guidance', async () => { render(); - expect(await screen.findByText('Major Candidate Chords')).toBeTruthy(); - expect(screen.getByText('Minor Candidate Chords')).toBeTruthy(); - expect(screen.getByText(/relative to C major/i)).toBeTruthy(); - expect(screen.getByText(/relative to A minor/i)).toBeTruthy(); + expect(await screen.findByText(majorHeading)).toBeTruthy(); + expect(screen.getByText(minorHeading)).toBeTruthy(); + expect(screen.getByText(translate('settings.chordGuide.help.major', undefined, 'en_us'))).toBeTruthy(); + expect(screen.getByText(translate('settings.chordGuide.help.minor', undefined, 'en_us'))).toBeTruthy(); + expect(screen.getByText(translate('settings.chordGuide.resetToDefault', undefined, 'en_us'))).toBeTruthy(); + expect(screen.getAllByRole('columnheader', { name: translate('settings.chordGuide.table.chord', undefined, 'en_us') })).toHaveLength(2); }); it('switches T/S/D tabs as a mutex control', async () => { render(); - await screen.findByText('Major Candidate Chords'); - const majorGroup = getGroup('Major Candidate Chords'); + await screen.findByText(majorHeading); + const majorGroup = getGroup(majorHeading); expect(within(majorGroup).getByTitle('C')).toBeTruthy(); fireEvent.click(within(majorGroup).getByRole('button', { name: 'S' })); @@ -89,8 +94,8 @@ describe('ChordGuideSettings', () => { it('adds and deletes rows in the active table', async () => { render(); - await screen.findByText('Major Candidate Chords'); - const addButtons = screen.getAllByTitle(/Add .* chord/); + await screen.findByText(majorHeading); + const addButtons = screen.getAllByTitle(translate('settings.chordGuide.addTitle', { group: majorHeading }, 'en_us')); fireEvent.click(addButtons[0]); await waitFor(() => { @@ -106,7 +111,7 @@ describe('ChordGuideSettings', () => { const rows = screen.getAllByRole('row'); fireEvent.click(rows[1]); - const deleteButtons = screen.getAllByTitle('Delete selected rows'); + const deleteButtons = screen.getAllByTitle(translate('settings.chordGuide.deleteSelectedRows', undefined, 'en_us')); fireEvent.click(deleteButtons[0]); await waitFor(() => { @@ -117,8 +122,8 @@ describe('ChordGuideSettings', () => { it('updates notes and source after a valid chord edit', async () => { render(); - await screen.findByText('Major Candidate Chords'); - const majorGroup = getGroup('Major Candidate Chords'); + await screen.findByText(majorHeading); + const majorGroup = getGroup(majorHeading); fireEvent.click(within(majorGroup).getByRole('button', { name: 'S' })); const chordCell = within(majorGroup).getByTitle('F'); @@ -142,7 +147,7 @@ describe('ChordGuideSettings', () => { it('rejects invalid chord edits', async () => { render(); - await screen.findByText('Major Candidate Chords'); + await screen.findByText(majorHeading); const chordCell = screen.getAllByTitle('C')[0]; fireEvent.doubleClick(chordCell); const input = screen.getByDisplayValue('C'); @@ -150,14 +155,14 @@ describe('ChordGuideSettings', () => { fireEvent.keyDown(input, { key: 'Enter' }); await waitFor(() => { - expect(showAlertMock).toHaveBeenCalledWith(expect.stringContaining('valid chord symbol')); + expect(showAlertMock).toHaveBeenCalledWith(translate('settings.chordGuide.invalidChordSymbol', undefined, 'en_us')); }); }); it('enforces the 128-character note limit', async () => { render(); - await screen.findByText('Major Candidate Chords'); + await screen.findByText(majorHeading); const noteCell = screen.getAllByRole('cell').find((cell) => cell.textContent === chordGuideData.ionian.T[0].note) as HTMLElement; fireEvent.doubleClick(noteCell); const input = screen.getByDisplayValue(chordGuideData.ionian.T[0].note); @@ -194,7 +199,7 @@ describe('ChordGuideSettings', () => { render(); expect((await screen.findAllByTitle('G')).length).toBeGreaterThan(0); - fireEvent.click(screen.getByText('Reset to Default')); + fireEvent.click(screen.getByText(translate('settings.chordGuide.resetToDefault', undefined, 'en_us'))); await waitFor(() => { expect(configManagerMock.set).toHaveBeenCalledWith('chord_guide.custom_items', null); diff --git a/src/components/settings/sections/ChordGuideSettings.tsx b/src/components/settings/sections/ChordGuideSettings.tsx index 9f7fe38..b0b252f 100644 --- a/src/components/settings/sections/ChordGuideSettings.tsx +++ b/src/components/settings/sections/ChordGuideSettings.tsx @@ -19,6 +19,7 @@ import { import { parseChordSymbol } from '../../../util/chordUtil'; import { showAlert } from '../../../util/dialogUtil'; import { isModifierKeyPressed } from '../../../util/osUtil'; +import { useI18n } from '../../../i18n/useI18n'; type FunctionType = 'T' | 'S' | 'D'; type EditableColumn = 'name' | 'note'; @@ -36,16 +37,6 @@ const FUNCTION_BUTTONS: Array<{ value: FunctionType; label: string; title: strin { value: 'D', label: 'D', title: 'Dominant' }, ]; -const GROUP_LABELS: Record = { - major: 'Major Candidate Chords', - minor: 'Minor Candidate Chords', -}; - -const GROUP_HELP: Record = { - major: 'Enter chords relative to C major. The app will transpose them automatically for other major key signatures.', - minor: 'Enter chords relative to A minor. The app will transpose them automatically for other minor key signatures.', -}; - const CONFIG_KEY = 'chord_guide.custom_items'; function cloneModeDefinition(definition: ChordGuideModeDefinition): ChordGuideModeDefinition { @@ -86,6 +77,7 @@ async function loadBundledChordGuideData(): Promise { } const ChordGuideSettings: React.FC = () => { + const { t } = useI18n(); const configManager = ConfigManager.instance(); const [defaultsData, setDefaultsData] = React.useState(null); const [customConfig, setCustomConfig] = React.useState(null); @@ -115,9 +107,9 @@ const ChordGuideSettings: React.FC = () => { void initialize().catch((error) => { console.error('Failed to initialize chord guide settings:', error); - void showAlert('Failed to load chord guide settings.'); + void showAlert(t('settings.chordGuide.loadError')); }); - }, [configManager]); + }, [configManager, t]); React.useEffect(() => { if (!editingCell) { @@ -180,12 +172,12 @@ const ChordGuideSettings: React.FC = () => { const trimmedValue = editingCell.value.trim(); if (column === 'name') { if (!trimmedValue || parseChordSymbol(trimmedValue) === null) { - await showAlert('Please enter a valid chord symbol. Example: Bm7b5'); + await showAlert(t('settings.chordGuide.invalidChordSymbol')); return; } const derived = buildDerivedChordGuideItem(group, { name: trimmedValue, note: row.note }); if (!derived) { - await showAlert('Unable to derive notes for this chord. Please use a supported chord symbol.'); + await showAlert(t('settings.chordGuide.unsupportedChordSymbol')); return; } await updateGroupRows(group, functionType, (rows) => rows.map((candidate, index) => ( @@ -290,14 +282,16 @@ const ChordGuideSettings: React.FC = () => { const rows = getModeDefinition(customConfig, group)[functionType]; const selection = selectedRows[group]; + const groupLabel = t(`settings.chordGuide.group.${group}`); + return (
-

{GROUP_LABELS[group]}

+

{groupLabel}

-
{GROUP_HELP[group]}
+
{t(`settings.chordGuide.help.${group}`)}
-
+
{FUNCTION_BUTTONS.map((button) => (