From e263b1cc412bdf1a6e43273dc0705bc108451f50 Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Sat, 30 May 2026 23:37:51 -0700 Subject: [PATCH] feat: added French support --- public/chat/help-fr_fr.md | 107 ++++ public/chat/hotkeys-fr_fr.md | 59 +++ public/chat/welcome_again-fr_fr.md | 11 + public/chat/welcome_local_llm-fr_fr.md | 29 ++ public/chat/welcome_new-fr_fr.md | 25 + src/components/ChatBox.test.tsx | 9 +- src/components/EventListPanel.test.tsx | 3 +- src/components/InstrumentSelection.test.tsx | 3 +- src/components/piano-roll/PianoKeys.test.tsx | 3 +- .../PianoRollAutomationLane.test.tsx | 3 +- .../piano-roll/PianoRollToolbar.test.tsx | 3 +- .../sections/GeneralSettings.test.tsx | 29 +- .../settings/sections/GeneralSettings.tsx | 14 +- src/i18n/I18nProvider.test.tsx | 19 + src/i18n/instruments.test.ts | 2 + src/i18n/locale.test.ts | 12 +- src/i18n/locale.ts | 8 +- src/i18n/messages/en_us.ts | 5 +- src/i18n/messages/fr_fr.ts | 484 ++++++++++++++++++ src/i18n/messages/index.ts | 2 + src/i18n/messages/zh_cn.ts | 5 +- src/i18n/messages/zh_hk.ts | 5 +- src/i18n/translate.test.ts | 7 +- src/i18n/types.ts | 2 +- .../messageFilter/UserMessageFilter.test.ts | 42 ++ 25 files changed, 862 insertions(+), 29 deletions(-) create mode 100644 public/chat/help-fr_fr.md create mode 100644 public/chat/hotkeys-fr_fr.md create mode 100644 public/chat/welcome_again-fr_fr.md create mode 100644 public/chat/welcome_local_llm-fr_fr.md create mode 100644 public/chat/welcome_new-fr_fr.md create mode 100644 src/i18n/messages/fr_fr.ts diff --git a/public/chat/help-fr_fr.md b/public/chat/help-fr_fr.md new file mode 100644 index 0000000..18452a8 --- /dev/null +++ b/public/chat/help-fr_fr.md @@ -0,0 +1,107 @@ +## Aide + +Bienvenue dans **l’assistant musical K.G.Studio**, votre agent IA dédié à la composition, à l’arrangement et au travail de production directement dans le navigateur. + +Pour commencer, vous pouvez soit utiliser le **LLM local intégré au navigateur** sans clé API, soit configurer un fournisseur LLM externe. + +--- + +### Utiliser le LLM local (navigateur) sans clé API + +K.G.Studio peut exécuter **Gemma 4 E4B** entièrement dans votre navigateur avec accélération WebGPU. Aucune requête API externe n’est envoyée, aucun coût supplémentaire n’est engagé, et vos données restent sur votre machine. + +1. Dans **Réglages ⚙️ → Général → Fournisseur LLM**, sélectionnez **LLM local (navigateur)**. +2. Le modèle (~2,8 Go) se télécharge automatiquement lors de la première ouverture du chat, puis reste en cache local pour les lancements suivants. +3. Vous pouvez régler la **Longueur de contexte** (32k / 64k / 128k tokens) selon votre matériel. +4. Commencez à dialoguer. + +**Configuration recommandée :** Chrome 113+ ou Edge 113+, contexte sécurisé (HTTPS ou localhost), et GPU avec au moins 8 Go de VRAM ou machine avec au moins 16 Go de mémoire unifiée. + +> Remarque : la qualité du modèle local reste inférieure à celle des grands modèles commerciaux. Pour les tâches complexes, un fournisseur externe donnera généralement de meilleurs résultats. + +--- + +### Configurer un fournisseur LLM externe + +Allez dans **Réglages ⚙️ → Général → Fournisseur LLM**. Selon le fournisseur choisi, vous devrez renseigner la clé API appropriée et, si besoin, une URL de base personnalisée. + +--- + +### Utiliser OpenAI + +1. Récupérez une clé API OpenAI depuis [**OpenAI**](https://platform.openai.com/account/api-keys). +2. Dans **Réglages ⚙️ → Général → Fournisseur LLM**, choisissez **OpenAI**. +3. Saisissez votre clé dans **OpenAI → Clé**. +4. Sélectionnez le modèle voulu dans **OpenAI → Modèle**. Pour un bon compromis coût / performances, `gpt-5.4-mini` est recommandé. +5. Vous pouvez activer **Mode Flex** si vous acceptez une latence plus variable en échange d’un coût potentiellement réduit. + +--- + +### Utiliser OpenRouter + +OpenRouter donne accès à de nombreux modèles via une API unique, y compris certaines options gratuites. + +1. Créez une clé API sur [**OpenRouter**](https://openrouter.ai/keys). +2. Dans **Réglages ⚙️ → Général → Fournisseur LLM**, choisissez **Serveur compatible OpenAI**. +3. Saisissez votre clé dans **Serveur compatible OpenAI → Clé**. +4. Consultez les modèles disponibles sur la [**page des modèles OpenRouter**](https://openrouter.ai/models). +5. Saisissez le nom du modèle dans **Serveur compatible OpenAI → Modèle**. +6. Saisissez `https://openrouter.ai/api/v1` dans **Serveur compatible OpenAI → URL de base**. + +### Opérations DAW de base + +Commandes du chat : `/clear`, `/welcome`, `/help`, `/hotkeys` + +- Pistes + - Ajouter, renommer et réordonner les pistes depuis le panneau d’infos de piste. + - Changer d’instrument via le bouton instrument ; régler Solo, Mute et Volume. + - Supprimer une piste depuis son menu de réglages. + - **Enregistrement audio** : cliquez sur `Rec` dans la barre d’outils pour enregistrer directement dans une piste audio. + +- Régions + - Créer une région : avec l’outil pointeur, double-cliquez ; ou maintenez `Ctrl/Cmd` et cliquez. Avec l’outil crayon, un simple clic suffit. + - Déplacer / redimensionner : faites glisser le corps de région pour déplacer, ou les bords pour redimensionner. + - Ouvrir le Piano Roll via le petit crayon en haut à gauche de la région. + +- Piano Roll + - Outils : Sélection ou Crayon. + - Créer des notes : double-clic ou `Ctrl/Cmd+clic` en mode sélection ; clic simple en mode crayon. + - Sélectionner : clic, `Maj+clic` pour la multisélection, ou glisser pour un lasso. + - Déplacer / redimensionner : faites glisser le corps ou les bords des notes. + - **Vue partition** : bascule entre Piano Roll et notation sur portée depuis la barre d’outils. + - **Voies d’automation** : éditez le pitch bend et les courbes MIDI CC sous la grille piano. + - **Mode spectrogramme** : affichez le spectrogramme d’une région audio comme référence d’édition. + - **Liste des événements** : inspectez et modifiez les notes, pitch bends et contrôleurs de la région MIDI active. + +- Système de pistes globales + - Quatre pistes permanentes en haut de la timeline : **Repère**, **Tempo**, **Armure** et **Accord**. + - Elles pilotent le timing, les suggestions d’accords, l’affichage de la partition et certains résultats d’analyse. + +- Analyse audio + - **Détecter les accords** : ouvre l’analyse harmonique d’une région audio et alimente la piste globale des accords. + - **Détecter le tempo** : analyse une région audio pour en extraire le BPM et peut réaligner le tempo du projet. + +- Générateur musical K.G.One + - Cliquez sur le bouton **✦** dans la barre d’outils. + - **Morceau complet** : génération d’un titre complet à partir d’une description et, si besoin, de paroles. + - **Clip** : génération de clips instrumentaux courts et de boucles MIDI. + - **Séparation de stems (navigateur)** : séparation locale d’une région audio en stems, sans serveur. + +- Snap et quantification + - Réglez le snap depuis le menu `NO SNAP`. + - Quantifiez avec `Qua. Pos.` et `Qua. Len.`. + +- Lecture + - Retour au début ; Lecture / Pause depuis la barre d’outils. + - Placez la tête de lecture en cliquant sur les numéros de mesure. + - Changez BPM, chiffrage de mesure et armure depuis la barre d’outils. + +--- + +### Remarque + +Par sécurité, lorsque K.G.Studio tourne hors d’un hôte local, les clés API ne sont pas conservées dans IndexedDB par défaut. Vous pouvez activer leur persistance dans les réglages, mais ce n’est pas recommandé sur un environnement partagé. + +### Avertissement + +K.G.Studio n’héberge aucun des modèles mentionnés et n’est affilié à aucun fournisseur de modèles. Toutes les données restent sur votre appareil ; vous êtes responsable des informations envoyées à des services tiers. diff --git a/public/chat/hotkeys-fr_fr.md b/public/chat/hotkeys-fr_fr.md new file mode 100644 index 0000000..8d0f431 --- /dev/null +++ b/public/chat/hotkeys-fr_fr.md @@ -0,0 +1,59 @@ +## Raccourcis + +Ce guide récapitule les raccourcis clavier et les actions avec modificateurs actuellement disponibles dans K.G.Studio. + +### Création selon la plateforme + +- Sur **macOS**, utilisez `Cmd+clic` pour créer une région, une note ou un point d’automation avec l’outil pointeur. +- Sur **Windows/Linux**, utilisez `Ctrl+clic` pour créer une région, une note ou un point d’automation avec l’outil pointeur. +- Dans la timeline principale, un double-clic dans une zone vide crée une région lorsque l’outil pointeur est actif. +- Dans le Piano Roll et les voies d’automation, l’outil crayon permet aussi de créer avec un simple clic. + +### Raccourcis principaux + +- `Espace` — Lecture / Arrêt +- `C` — Activer / désactiver la boucle +- `R` — Démarrer / arrêter l’enregistrement +- `Cmd/Ctrl+Z` — Annuler +- `Cmd/Ctrl+Shift+Z` — Rétablir +- `Cmd/Ctrl+C` — Copier les éléments sélectionnés +- `Cmd/Ctrl+V` — Coller +- `Cmd/Ctrl+S` — Enregistrer le projet +- `Cmd/Ctrl+A` — Sélectionner toutes les notes de la région MIDI active +- `Cmd/Ctrl+T` — Couper la région sélectionnée à la tête de lecture +- `Cmd/Ctrl+J` — Fusionner les régions MIDI sélectionnées + +### Fenêtre de région / Piano Roll + +- `E` — Ouvrir le Piano Roll pour une région MIDI sélectionnée, ou l’éditeur de forme d’onde pour une région audio sélectionnée. Si l’éditeur est déjà ouvert, `E` le ferme. +- `N` — Ouvrir le Piano Roll en vue partition pour une région MIDI sélectionnée. +- `Suppr` / `Retour arrière` — Supprimer les régions, notes ou points d’automation sélectionnés. +- `Esc` — Fermer la fenêtre du Piano Roll. + +### Sélection + +- `Maj+clic` — Ajouter à la sélection. +- Lasso dans une zone vide — Sélection rectangulaire de régions ou de points d’automation. +- `Maj+lasso` dans la fenêtre Piano Roll — Basculer la sélection des éléments contenus dans la zone. + +### Raccourcis du Piano Roll + +- `G` — Faire défiler les modes du guide d’accords +- `Tab` — Aller à la suggestion d’accord suivante +- `Maj+Tab` — Revenir à la suggestion d’accord ou au renversement précédent + +### Raccourcis de snap + +- `1` — Aucun snap +- `2` — Snap sur `1/4` +- `3` — Snap sur `1/8` +- `4` — Snap sur `1/16` + +### Raccourcis de quantification + +- `5` — Quantification de position sur `1/4` +- `6` — Quantification de position sur `1/8` +- `7` — Quantification de position sur `1/16` +- `8` — Quantification de durée sur `1/4` +- `9` — Quantification de durée sur `1/8` +- `0` — Quantification de durée sur `1/16` diff --git a/public/chat/welcome_again-fr_fr.md b/public/chat/welcome_again-fr_fr.md new file mode 100644 index 0000000..54a3940 --- /dev/null +++ b/public/chat/welcome_again-fr_fr.md @@ -0,0 +1,11 @@ +## Bon retour + +Ravi de vous revoir. Votre fournisseur LLM semble déjà configuré, vous pouvez commencer à dialoguer immédiatement. + +Conseils : +- Utilisez `/clear` à tout moment pour réinitialiser le chat. +- Tapez `/welcome` pour réafficher ce message. +- Tapez `/help` pour ouvrir l’aide. +- Tapez `/hotkeys` pour afficher le guide des raccourcis. + +Bonne composition avec l’assistant musical K.G.Studio. diff --git a/public/chat/welcome_local_llm-fr_fr.md b/public/chat/welcome_local_llm-fr_fr.md new file mode 100644 index 0000000..87bff2f --- /dev/null +++ b/public/chat/welcome_local_llm-fr_fr.md @@ -0,0 +1,29 @@ +## Mode LLM local + +Bienvenue dans **l’assistant musical K.G.Studio** en mode LLM local. + +- Aucune API externe n’est nécessaire. Tout s’exécute directement dans votre navigateur, sans coût API supplémentaire. +- Ce mode utilise **Gemma 4 E4B** via **LiteRT-LM** avec accélération **WebGPU**. +- Configuration matérielle recommandée : GPU avec au moins **8 Go de VRAM** ou système avec au moins **16 Go de mémoire unifiée**. +- Les performances restent plus limitées que celles des grands modèles hébergés dans le cloud, surtout pour les tâches longues, complexes ou très itératives. + +### Flux de travail recommandé +- Gardez des demandes courtes et ciblées. +- Faites progresser le modèle étape par étape vers le résultat final. +- Travaillez sur de petites régions musicales plutôt que sur des arrangements de morceau complet. +- Préférez des consignes d’arrangement simples lorsque c’est possible. +- Ouvrez une nouvelle conversation pour chaque tâche autonome. + +Exemple : +- Pour de meilleurs résultats, découpez les demandes complexes en plusieurs étapes. +- Si vous voulez que le modèle écrive une progression d’accords à partir de la musique actuelle, commencez par : `Please read the current music.` +- Une fois cette étape terminée, poursuivez avec : `Please write a chord progression based on it.` +- Ce flux incrémental est généralement plus fiable en mode LLM local. +- Gardez la région MIDI que vous souhaitez modifier sélectionnée pendant tout le flux de travail. +- Il n’est pas nécessaire de changer de sélection vers une autre région uniquement pour `read_music`, car `read_music` peut lire la musique sur l’ensemble des pistes. + +### Utiliser plutôt un LLM externe +- Si vous souhaitez utiliser un modèle plus grand dans le cloud ou auto-hébergé, ouvrez **Réglages -> Général -> Fournisseur LLM** et quittez **LLM local (navigateur)**. +- Pour un modèle cloud, vous pouvez utiliser **OpenAI**, ou **Serveur compatible OpenAI** avec un fournisseur comme OpenRouter. +- Pour un modèle auto-hébergé, choisissez **Serveur compatible OpenAI** puis renseignez l’**URL de base** et le **Modèle** de votre serveur. +- Après changement de fournisseur, démarrez une nouvelle conversation pour repartir proprement avec le nouveau modèle. diff --git a/public/chat/welcome_new-fr_fr.md b/public/chat/welcome_new-fr_fr.md new file mode 100644 index 0000000..9a64870 --- /dev/null +++ b/public/chat/welcome_new-fr_fr.md @@ -0,0 +1,25 @@ +## Bienvenue + +Bienvenue dans **l’assistant musical K.G.Studio**, votre partenaire IA dans le navigateur pour la composition et l’arrangement. + +### Démarrage rapide +**Configurer l’assistant musical K.G.Studio** +- [Cliquez ici pour obtenir une clé API OpenRouter gratuite](https://openrouter.ai/keys) ; un compte OpenRouter peut être nécessaire. +- Dans **Réglages ⚙️ → Général → Fournisseur LLM**, sélectionnez **Serveur compatible OpenAI**. +- Dans **Serveur compatible OpenAI → Clé**, collez votre clé. Remarque : hors localhost, la clé n’est pas conservée après rechargement. +- Dans **Serveur compatible OpenAI → Modèle**, saisissez `qwen/qwen3-30b-a3b:free`. +- Dans **Serveur compatible OpenAI → URL de base**, saisissez `https://openrouter.ai/api/v1`. + +*(Vous pouvez aussi utiliser l’API officielle OpenAI ou tout autre service compatible OpenAI.)* + +**Opérations DAW de base** +- Double-cliquez sur une piste, ou faites `Ctrl/Cmd+clic`, pour créer une région. +- Faites glisser les bords d’une région pour la redimensionner ; faites glisser son corps pour la déplacer. +- Cliquez sur le petit crayon en haut à gauche d’une région pour ouvrir le Piano Roll. +- Dans le Piano Roll, double-cliquez ou faites `Ctrl/Cmd+clic` pour créer une note. +- Cliquez pour sélectionner ; `Maj+clic` pour la multisélection ; glissez pour une sélection rectangulaire. +- Faites glisser les bords d’une note pour modifier sa durée, ou son corps pour la déplacer. +- Utilisez le snap dans la barre d’outils du Piano Roll pour quantifier sur la grille. + +### Avertissement +K.G.Studio n’est affilié à aucun fournisseur LLM. Toutes les données sont stockées localement sur votre appareil ; vous restez responsable des données envoyées à des services tiers. diff --git a/src/components/ChatBox.test.tsx b/src/components/ChatBox.test.tsx index 2265cb6..e9a1f56 100644 --- a/src/components/ChatBox.test.tsx +++ b/src/components/ChatBox.test.tsx @@ -3,6 +3,7 @@ import { beforeAll, describe, expect, it, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; import ChatBox from './ChatBox'; import { I18nContext } from '../i18n/I18nProvider'; +import type { ResolvedLocaleCode } from '../i18n/types'; import { translate } from '../i18n/translate'; vi.mock('./chat', () => ({ @@ -113,7 +114,7 @@ vi.mock('./common/KGDropdown', () => ({ default: () => null, })); -function renderWithLocale(resolvedLocale: 'en_us' | 'zh_cn') { +function renderWithLocale(resolvedLocale: ResolvedLocaleCode) { return render( { expect(screen.getByRole('heading', { level: 3, name: 'K.G.Studio 音乐创作助手' })).toBeTruthy(); }); + + it('renders the French assistant title under fr_fr', () => { + renderWithLocale('fr_fr'); + + expect(screen.getByRole('heading', { level: 3, name: 'Assistant musical K.G.Studio' })).toBeTruthy(); + }); }); diff --git a/src/components/EventListPanel.test.tsx b/src/components/EventListPanel.test.tsx index 89aefac..ae503e9 100644 --- a/src/components/EventListPanel.test.tsx +++ b/src/components/EventListPanel.test.tsx @@ -27,6 +27,7 @@ import { } from '../test/utils/mock-data'; import { showAlert } from '../util/dialogUtil'; import { I18nContext } from '../i18n/I18nProvider'; +import type { ResolvedLocaleCode } from '../i18n/types'; import { translate } from '../i18n/translate'; const clickDropdownOption = (label: string) => { @@ -187,7 +188,7 @@ vi.mock('../core/KGCore', () => ({ })); describe('EventListPanel', () => { - const renderWithLocale = (locale: 'en_us' | 'zh_cn' = 'en_us') => render( + const renderWithLocale = (locale: ResolvedLocaleCode = 'en_us') => render( ({ useProjectStore: () => storeState, })); -function renderWithLocale(resolvedLocale: 'en_us' | 'zh_cn') { +function renderWithLocale(resolvedLocale: ResolvedLocaleCode) { return render( void; @@ -52,7 +53,7 @@ vi.mock('../../core/midi-input/KGMidiInput', () => ({ function renderWithLocale( ui: React.ReactElement, - resolvedLocale: 'en_us' | 'zh_cn' = 'en_us', + resolvedLocale: ResolvedLocaleCode = 'en_us', ) { return render( { - const renderWithLocale = (ui: React.ReactElement, locale: 'en_us' | 'zh_cn' = 'en_us') => render( + const renderWithLocale = (ui: React.ReactElement, locale: ResolvedLocaleCode = 'en_us') => render( ({ @@ -42,7 +43,7 @@ vi.mock('../../core/KGCore', () => ({ })); describe('PianoRollToolbar', () => { - const renderWithLocale = (ui: React.ReactElement, locale: 'en_us' | 'zh_cn' = 'en_us') => render( + const renderWithLocale = (ui: React.ReactElement, locale: ResolvedLocaleCode = 'en_us') => render( ({ })); describe('GeneralSettings', () => { - const renderSettings = () => render( + const renderSettings = (locale: 'en_us' | 'zh_cn' = 'en_us') => render( { await configManagerMock.set('general.language', value); }, - t: (key, params) => translate(key, params, 'en_us'), + t: (key, params) => translate(key, params, locale), }} > @@ -137,6 +137,24 @@ describe('GeneralSettings', () => { expect(screen.getByText(/require more VRAM/i)).toBeTruthy(); }); + it('uses native language names in the language dropdown', async () => { + renderSettings(); + + const select = await screen.findByLabelText('Language'); + const options = Array.from((select as HTMLSelectElement).options).map((option) => option.text); + + expect(options).toEqual(['Auto', 'English', 'Français', '简体中文', '繁體中文']); + }); + + it('localizes the auto label while keeping language names native', async () => { + renderSettings('zh_cn'); + + const select = await screen.findByLabelText('语言'); + const options = Array.from((select as HTMLSelectElement).options).map((option) => option.text); + + expect(options).toEqual(['自动', 'English', 'Français', '简体中文', '繁體中文']); + }); + it('initializes the local context length from config', async () => { renderSettings(); @@ -251,11 +269,12 @@ describe('GeneralSettings', () => { const languageSelect = await screen.findByLabelText('Language'); expect(languageSelect).toBeTruthy(); expect(screen.getAllByRole('combobox')[0]).toBe(languageSelect); + expect(screen.getByRole('option', { name: 'Français' })).toBeTruthy(); - fireEvent.change(languageSelect, { target: { value: 'zh_cn' } }); + fireEvent.change(languageSelect, { target: { value: 'fr_fr' } }); await waitFor(() => { - expect(configManagerMock.set).toHaveBeenCalledWith('general.language', 'zh_cn'); + expect(configManagerMock.set).toHaveBeenCalledWith('general.language', 'fr_fr'); }); }); }); diff --git a/src/components/settings/sections/GeneralSettings.tsx b/src/components/settings/sections/GeneralSettings.tsx index 0cec24a..de235ee 100644 --- a/src/components/settings/sections/GeneralSettings.tsx +++ b/src/components/settings/sections/GeneralSettings.tsx @@ -19,6 +19,13 @@ import { LOCAL_SEPARATOR_MODEL_IDS, } from '../../../util/local-separator/config'; +const LANGUAGE_OPTION_LABELS: Record, string> = { + en_us: 'English', + fr_fr: 'Français', + zh_cn: '简体中文', + zh_hk: '繁體中文', +}; + const GeneralSettings: React.FC = () => { const { t, setLanguageSetting } = useI18n(); const [language, setLanguage] = useState('auto'); @@ -352,9 +359,10 @@ const GeneralSettings: React.FC = () => { onChange={(e) => void handleLanguageChange(e.target.value as LanguageSetting)} > - - - + + + +
{t('settings.general.language.help')} diff --git a/src/i18n/I18nProvider.test.tsx b/src/i18n/I18nProvider.test.tsx index c427947..e97ef75 100644 --- a/src/i18n/I18nProvider.test.tsx +++ b/src/i18n/I18nProvider.test.tsx @@ -85,4 +85,23 @@ describe('I18nProvider', () => { expect(screen.getByText('Settings')).toBeTruthy(); }); }); + + it('resolves auto to French for browser French locales', async () => { + configState.set('general.language', 'auto'); + Object.defineProperty(window.navigator, 'languages', { + configurable: true, + value: ['fr-FR'], + }); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('locale').textContent).toBe('fr_fr'); + expect(screen.getByText('Réglages')).toBeTruthy(); + }); + }); }); diff --git a/src/i18n/instruments.test.ts b/src/i18n/instruments.test.ts index c73b281..fba676a 100644 --- a/src/i18n/instruments.test.ts +++ b/src/i18n/instruments.test.ts @@ -5,11 +5,13 @@ import { translate } from './translate'; describe('instrument i18n helpers', () => { it('returns translated instrument display names for supported locales', () => { expect(getInstrumentDisplayName('acoustic_grand_piano', (key, params) => translate(key, params, 'en_us'))).toBe('Acoustic Grand Piano'); + expect(getInstrumentDisplayName('acoustic_grand_piano', (key, params) => translate(key, params, 'fr_fr'))).toBe('Piano à queue acoustique'); expect(getInstrumentDisplayName('acoustic_grand_piano', (key, params) => translate(key, params, 'zh_cn'))).toBe('原声大钢琴'); }); it('returns translated instrument group labels for supported locales', () => { expect(getInstrumentGroupLabel('PIANO_AND_KEYBOARDS', (key, params) => translate(key, params, 'en_us'))).toBe('Piano and Keyboards'); + expect(getInstrumentGroupLabel('PIANO_AND_KEYBOARDS', (key, params) => translate(key, params, 'fr_fr'))).toBe('Pianos et claviers'); expect(getInstrumentGroupLabel('PIANO_AND_KEYBOARDS', (key, params) => translate(key, params, 'zh_cn'))).toBe('钢琴与键盘'); }); diff --git a/src/i18n/locale.test.ts b/src/i18n/locale.test.ts index d54335f..6759df4 100644 --- a/src/i18n/locale.test.ts +++ b/src/i18n/locale.test.ts @@ -5,15 +5,22 @@ describe('locale resolution', () => { it('normalizes supported language settings', () => { expect(normalizeLanguageSetting('auto')).toBe('auto'); expect(normalizeLanguageSetting('en_us')).toBe('en_us'); + expect(normalizeLanguageSetting('fr_fr')).toBe('fr_fr'); expect(normalizeLanguageSetting('zh_cn')).toBe('zh_cn'); expect(normalizeLanguageSetting('zh_hk')).toBe('zh_hk'); expect(normalizeLanguageSetting('unknown')).toBe('auto'); }); - it('resolves auto to English for non-Chinese locales', () => { + it('resolves auto to English for non-Chinese and non-French locales', () => { expect(resolveLanguageSetting('auto', ['en-US'])).toBe('en_us'); }); + it('resolves auto to French for French-speaking locales', () => { + expect(resolveLanguageSetting('auto', ['fr'])).toBe('fr_fr'); + expect(resolveLanguageSetting('auto', ['fr-FR'])).toBe('fr_fr'); + expect(resolveLanguageSetting('auto', ['fr-CA'])).toBe('fr_fr'); + }); + it('resolves auto to Simplified Chinese for Simplified Chinese locales', () => { expect(resolveLanguageSetting('auto', ['zh-CN'])).toBe('zh_cn'); expect(resolveLanguageSetting('auto', ['zh-SG'])).toBe('zh_cn'); @@ -32,11 +39,12 @@ describe('locale resolution', () => { it('falls back to English when locale list is empty or unknown', () => { expect(resolveLanguageSetting('auto', [])).toBe('en_us'); - expect(resolveLanguageSetting('auto', ['fr-FR'])).toBe('en_us'); + expect(resolveLanguageSetting('auto', ['de-DE'])).toBe('en_us'); }); it('keeps explicit locale selections', () => { expect(resolveLanguageSetting('en_us', ['zh-CN'])).toBe('en_us'); + expect(resolveLanguageSetting('fr_fr', ['en-US'])).toBe('fr_fr'); expect(resolveLanguageSetting('zh_cn', ['en-US'])).toBe('zh_cn'); expect(resolveLanguageSetting('zh_hk', ['en-US'])).toBe('zh_hk'); expect(resolveLanguageSetting('zh_hk', ['zh-CN'])).toBe('zh_hk'); diff --git a/src/i18n/locale.ts b/src/i18n/locale.ts index e2db223..184a7b4 100644 --- a/src/i18n/locale.ts +++ b/src/i18n/locale.ts @@ -1,13 +1,14 @@ import type { LanguageSetting, ResolvedLocaleCode } from './types'; const CHINESE_LANGUAGE_PREFIX = 'zh'; +const FRENCH_LANGUAGE_PREFIX = 'fr'; const DEFAULT_LOCALE: ResolvedLocaleCode = 'en_us'; // BCP 47 subtags that indicate Traditional Chinese (Hant script or HK/TW/MO regions) const TRADITIONAL_CHINESE_SUBTAGS = new Set(['hk', 'tw', 'mo', 'hant']); export function normalizeLanguageSetting(value: unknown): LanguageSetting { - return value === 'zh_cn' || value === 'en_us' || value === 'zh_hk' || value === 'auto' ? value : 'auto'; + return value === 'zh_cn' || value === 'en_us' || value === 'zh_hk' || value === 'fr_fr' || value === 'auto' ? value : 'auto'; } export function getBrowserLocales(): string[] { @@ -28,7 +29,7 @@ export function resolveLanguageSetting( setting: LanguageSetting, browserLocales: string[] = getBrowserLocales(), ): ResolvedLocaleCode { - if (setting === 'en_us' || setting === 'zh_cn' || setting === 'zh_hk') { + if (setting === 'en_us' || setting === 'zh_cn' || setting === 'zh_hk' || setting === 'fr_fr') { return setting; } @@ -42,6 +43,9 @@ export function resolveLanguageSetting( const isTraditional = subtags.some((tag) => TRADITIONAL_CHINESE_SUBTAGS.has(tag)); return isTraditional ? 'zh_hk' : 'zh_cn'; } + if (normalizedLocale === FRENCH_LANGUAGE_PREFIX || normalizedLocale.startsWith(`${FRENCH_LANGUAGE_PREFIX}-`)) { + return 'fr_fr'; + } } return DEFAULT_LOCALE; diff --git a/src/i18n/messages/en_us.ts b/src/i18n/messages/en_us.ts index 2fc9eb9..fbc2f18 100644 --- a/src/i18n/messages/en_us.ts +++ b/src/i18n/messages/en_us.ts @@ -17,8 +17,9 @@ export const enUsMessages: TranslationMessages = { 'settings.general.language.help': 'Auto uses your system language.', 'settings.general.language.auto': 'Auto', 'settings.general.language.en_us': 'English', - 'settings.general.language.zh_cn': 'Simplified Chinese', - 'settings.general.language.zh_hk': 'Traditional Chinese', + 'settings.general.language.fr_fr': 'Français', + 'settings.general.language.zh_cn': '简体中文', + 'settings.general.language.zh_hk': '繁體中文', 'settings.yes': 'Yes', 'settings.no': 'No', 'settings.restoreDefault': 'Restore default', diff --git a/src/i18n/messages/fr_fr.ts b/src/i18n/messages/fr_fr.ts new file mode 100644 index 0000000..82aab5e --- /dev/null +++ b/src/i18n/messages/fr_fr.ts @@ -0,0 +1,484 @@ +import type { TranslationMessages } from '../types'; +import { enUsMessages } from './en_us'; + +export const frFrMessages: TranslationMessages = { + ...enUsMessages, + 'app.loading': 'Chargement...', + 'assistant.displayName': 'Assistant musical K.G.Studio', + 'assistant.welcomeFallback': 'Bienvenue dans l’assistant musical K.G.Studio.', + 'status.chordGuideCandidate': 'Suggestion du guide d’accords : {name} - {notes} - {note}', + 'settings.sidebar.title': 'Réglages', + 'settings.sidebar.close': 'Fermer les réglages', + 'settings.sidebar.general': 'Général', + 'settings.sidebar.audioIo': 'Entrées/Sorties audio', + 'settings.sidebar.behavior': 'Comportement', + 'settings.sidebar.templates': 'Modèles', + 'settings.sidebar.chordGuide': 'Guide d’accords', + 'settings.general.title': 'Général', + 'settings.general.language.label': 'Langue', + 'settings.general.language.help': 'Le mode Auto utilise la langue du système.', + 'settings.general.language.auto': 'Auto', + 'settings.yes': 'Oui', + 'settings.no': 'Non', + 'settings.restoreDefault': 'Rétablir la valeur par défaut', + 'settings.deleteCachedModel': 'Supprimer le modèle en cache', + 'settings.deleting': 'Suppression...', + 'settings.general.llmProvider.section': 'Fournisseur LLM', + 'settings.general.llmProvider.label': 'Fournisseur LLM', + 'settings.general.llmProvider.local': 'LLM local (navigateur)', + 'settings.general.llmProvider.claudeOpenRouter': 'Claude (via OpenRouter)', + 'settings.general.llmProvider.openaiCompatible': 'Serveur compatible OpenAI (ex. OpenRouter, Ollama)', + 'settings.general.persistKeys.label': 'Conserver les clés API hors localhost', + 'settings.general.persistKeys.help': 'Si cette option est activée, les clés API sont enregistrées dans le stockage du navigateur même hors localhost. Attention : cela peut augmenter le risque lié aux attaques XSS.', + 'settings.general.localRuntime.cachedStatus': 'État du modèle en cache', + 'settings.general.localRuntime.cacheChecking': 'Vérification du cache du modèle local...', + 'settings.general.localRuntime.cacheDownloaded': 'Téléchargé dans le cache du navigateur.', + 'settings.general.localRuntime.cacheMissing': 'Pas encore téléchargé.', + 'settings.general.localRuntime.contextLength': 'Longueur de contexte', + 'settings.general.localRuntime.contextHelp': 'Des contextes plus longs demandent davantage de VRAM et peuvent aussi réduire les performances lorsque la conversation devient longue.', + 'settings.general.localRuntime.downloadUrl': 'URL de téléchargement', + 'settings.general.localRuntime.downloadHelp': 'Modifier cette URL peut empêcher le téléchargement ou pointer vers un fichier modèle incompatible.', + 'settings.general.localRuntime.autoDownload': 'Le modèle local sera téléchargé automatiquement lors de votre prochain chat avec `LLM local (navigateur)`.', + 'settings.general.uvr5.section': 'Runtime Web UVR5', + 'settings.general.uvr5.downloadUrl': 'URL de téléchargement UVR-MDX-NET-Inst_HQ_3', + 'settings.general.htdemucs.downloadUrl': 'URL de téléchargement htdemucs_4s', + 'settings.general.modelUrl.help': 'Modifier cette URL peut empêcher le téléchargement ou pointer vers un fichier modèle incompatible.', + 'settings.general.openai.key': 'Clé', + 'settings.general.openai.keyPlaceholder': 'Saisissez votre clé API OpenAI', + 'settings.general.openai.model': 'Modèle', + 'settings.general.openai.flexMode': 'Mode Flex', + 'settings.general.openai.flexHelp': 'Le mode Flex utilise le palier de service flexible d’OpenAI. Avantages : économies possibles et meilleur débit pendant les périodes chargées. Inconvénients : latence variable, mise en file d’attente ou baisse de priorité possibles. S’applique uniquement au fournisseur OpenAI ; sans effet pour les serveurs compatibles OpenAI.', + 'settings.general.keys.persisted': 'Les clés sont conservées localement (IndexedDB du navigateur).', + 'settings.general.keys.sessionOnly': 'Par sécurité, les clés ne sont pas conservées sur les hôtes non locaux et restent uniquement en mémoire pour cette session.', + 'settings.general.claudeOpenRouter.section': 'Anthropic Claude (via OpenRouter)', + 'settings.general.claudeOpenRouter.keyPlaceholder': 'Saisissez votre clé API Claude', + 'settings.general.baseUrl': 'URL de base', + 'settings.general.claudeOpenRouter.baseUrlHelp': 'Il s’agit de l’URL de base de l’API OpenRouter. Ne la modifiez pas sauf si vous savez précisément ce que vous faites.', + 'settings.general.openaiCompatible.section': 'Serveur compatible OpenAI', + 'settings.general.openaiCompatible.keyPlaceholder': 'Saisissez votre clé API', + 'settings.general.openaiCompatible.baseUrlPlaceholder': 'ex. https://openrouter.ai/api/v1', + 'settings.general.openaiCompatible.baseUrlHelp': 'Préréglages rapides :', + 'settings.general.openaiCompatible.modelPlaceholder': 'ex. qwen3:30b', + 'settings.general.soundfont.section': 'Réglages des soundfonts', + 'settings.general.soundfont.managed': 'La configuration des soundfonts est gérée par le serveur (kgone-server.json). Ces réglages sont en lecture seule.', + 'settings.general.soundfont.baseUrl': 'URL de base', + 'settings.general.soundfont.baseUrlHelp': 'Une source de soundfont incompatible peut provoquer des timbres incorrects ou empêcher certains instruments de jouer.', + 'settings.general.kgone.section': 'Réglages K.G.One', + 'settings.general.kgone.managed': 'La configuration K.G.One est gérée par le serveur (kgone-server.json). Ces réglages sont en lecture seule.', + 'settings.general.kgone.enabled': 'Activer l’intégration K.G.One', + 'settings.general.kgone.disabled': 'Désactivé', + 'settings.general.kgone.enabledOption': 'Activé', + 'settings.general.kgone.serverBaseUrl': 'URL de base du serveur', + 'settings.general.kgone.serverBaseUrlHelp': 'URL de base d’un serveur K.G.One Music Studio en cours d’exécution. Utilisée pour la génération de morceau complet, de clip et la séparation de stems.', + 'dialog.title.notice': 'Information', + 'dialog.title.timeSignature': 'Chiffrage de mesure', + 'dialog.title.tempoDetection': 'Détection du tempo', + 'dialog.title.applyTempo': 'Appliquer le tempo', + 'dialog.title.chordDetection': 'Détection d’accords', + 'dialog.title.input': 'Saisie', + 'dialog.title.confirm': 'Confirmation', + 'dialog.close': 'Fermer la boîte de dialogue', + 'dialog.cancel': 'Annuler', + 'dialog.experimentalFeature': 'Fonction expérimentale', + 'dialog.recommendedSource': 'Source recommandée', + 'dialog.chordHint.audio': 'L’analyse d’accords reste expérimentale. Le contenu harmonique, la densité de l’arrangement et les matériaux riches en transitoires peuvent affecter la précision. Pour de meilleurs résultats, commencez avec les réglages par défaut puis affinez la sensibilité et la stabilité jusqu’à ce que l’harmonie détectée corresponde à la phrase musicale.', + 'dialog.chordHint.midi': 'L’analyse d’accords reste expérimentale. La densité des voicings, les chevauchements et les notes d’ornement peuvent influencer le résultat. Pour des étiquettes d’accord plus fiables, partez des réglages par défaut puis ajustez la suppression des notes courtes et le focus harmonique selon le rôle musical du passage.', + 'dialog.chordHint.tempo': 'L’analyse du tempo reste expérimentale. Le rubato, les transitoires clairsemées et les couches de percussions peuvent réduire la précision. Commencez avec la plage de BPM par défaut, puis resserrez la fenêtre d’analyse vers la zone de tempo la plus plausible si le premier résultat n’est pas convaincant musicalement.', + 'dialog.sourceHint.kgone': 'Pour des étiquettes d’accord plus fiables, analysez de préférence un stem où les voix et les percussions ont été atténuées ou retirées. Si l’intégration du serveur K.G.One Music Studio est disponible, lancez Separator avec le modèle "Vocal, Drums, Bass, Guitar, Piano, and Others" et utilisez de préférence le stem Piano ou Others.', + 'dialog.sourceHint.local': 'Pour des étiquettes d’accord plus fiables, analysez de préférence un stem où les voix et les percussions ont été atténuées ou retirées. Si vous utilisez le séparateur local, choisissez le modèle "Vocal, Drums, Bass, and Others" et analysez le stem Others.', + 'dialog.label.sensitivity': 'Sensibilité', + 'dialog.label.stability': 'Stabilité', + 'dialog.label.noChordThreshold': 'Seuil sans accord', + 'dialog.label.enableSevenths': 'Détail harmonique : activer les accords de septième', + 'dialog.label.shortNotes': 'Notes courtes', + 'dialog.option.suppressionLow': 'Suppression faible', + 'dialog.option.suppressionMedium': 'Suppression moyenne', + 'dialog.option.suppressionHigh': 'Suppression forte', + 'dialog.label.harmonicFocus': 'Focus harmonique', + 'dialog.option.harmonicBalanced': 'Équilibré', + 'dialog.option.harmonicSustained': 'Privilégier les notes tenues', + 'dialog.label.minimumBpm': 'BPM minimum', + 'dialog.label.maximumBpm': 'BPM maximum', + 'dialog.label.autoAlignRegionToBeat': 'Aligner automatiquement la région sur le temps', + 'dialog.message.chordDetection': 'Ajustez les réglages de détection d’accords audio avant le traitement.', + 'dialog.message.tempoDetection': 'Ajustez les réglages de détection du tempo audio avant le traitement.', + 'dialog.message.tempoApply': 'Tempo détecté : {bpm} BPM. Choisissez comment l’appliquer.\n\nMettre à jour le tempo courant modifie le tempo actif à l’emplacement de ce clip. Insérer un changement de tempo ajoute une nouvelle région de tempo à la mesure la plus proche avant le début du clip.', + 'dialog.action.updateCurrentTempo': 'Mettre à jour le tempo courant', + 'dialog.action.insertTempoChange': 'Insérer un changement de tempo', + 'eventList.title': 'Liste des événements', + 'eventList.scopeTabs': 'Portées de la liste d’événements', + 'eventList.scope.region': 'Région', + 'eventList.scope.track': 'Piste', + 'eventList.scope.global': 'Global', + 'eventList.deleteVisibleSelectedRows': 'Supprimer les lignes visibles sélectionnées', + 'eventList.table.position': 'Position', + 'eventList.table.status': 'État', + 'eventList.table.lengthInfo': 'Durée/Infos', + 'eventList.region.filters': 'Filtres des événements de région', + 'eventList.region.filter.notes': 'Notes', + 'eventList.region.filter.pitchBends': 'Pitch bends', + 'eventList.region.filter.controller': 'Contrôleurs', + 'eventList.region.empty': 'Sélectionnez une région MIDI, ou ouvrez-en une dans le Piano Roll, pour afficher sa liste d’événements.', + 'eventList.region.add.label': 'Ajouter', + 'eventList.region.add.noteTitle': 'Ajouter une note à la tête de lecture', + 'eventList.region.add.pitchBendTitle': 'Ajouter un pitch bend à la tête de lecture', + 'eventList.region.add.controllerTitle': 'Ajouter un événement de contrôleur à la tête de lecture', + 'eventList.region.addType.note': 'Note', + 'eventList.region.addType.pitchBend': 'Pitch bend', + 'eventList.region.addType.controller': 'Contrôleur', + 'eventList.track.filters': 'Modes de la liste de piste', + 'eventList.track.filter.regions': 'Régions', + 'eventList.track.filter.volume': 'Volume', + 'eventList.track.filter.pan': 'Panoramique', + 'eventList.track.empty': 'Sélectionnez une piste pour afficher les régions et l’automation de piste.', + 'eventList.track.add.midiRegionTitle': 'Ajouter une région MIDI à la tête de lecture', + 'eventList.track.add.volumeTitle': 'Ajouter un point d’automation de volume à la tête de lecture', + 'eventList.track.add.panTitle': 'Ajouter un point d’automation de panoramique à la tête de lecture', + 'eventList.track.addType.midiRegion': 'Région MIDI', + 'eventList.track.status.volume': 'Volume', + 'eventList.track.status.pan': 'Panoramique', + 'eventList.global.filters': 'Filtres des événements globaux', + 'eventList.global.filter.marker': 'Repère', + 'eventList.global.filter.tempo': 'Tempo', + 'eventList.global.filter.keySignature': 'Armure', + 'eventList.global.filter.chord': 'Accord', + 'eventList.global.add.markerTitle': 'Ajouter une région de repère à la tête de lecture', + 'eventList.global.add.tempoTitle': 'Ajouter une région de tempo à la tête de lecture', + 'eventList.global.add.keySignatureTitle': 'Ajouter une région d’armure à la tête de lecture', + 'eventList.global.add.chordTitle': 'Ajouter une région d’accord à la tête de lecture', + 'eventList.global.addType.marker': 'Repère', + 'eventList.global.addType.tempo': 'Tempo', + 'eventList.global.addType.keySignature': 'Armure', + 'eventList.global.addType.chord': 'Accord', + 'eventList.global.status.marker': 'Repère', + 'eventList.global.status.keySignature': 'Armure', + 'eventList.global.status.chord': 'Accord', + 'eventList.global.validation.marker': 'Veuillez saisir un libellé de repère. Un texte non vide est attendu. Exemple : Intro', + 'eventList.global.validation.tempo': 'Veuillez saisir une valeur BPM en chiffres uniquement. Un entier entre {min} et {max} est attendu. Exemple : 128', + 'eventList.global.validation.keySignature': 'Veuillez saisir un nom d’armure exact. Une valeur canonique telle que "Do majeur" ou "Fa# mineur" est attendue. Exemple : Fa# mineur', + 'eventList.global.validation.chord': 'Veuillez saisir un symbole d’accord valide. Une écriture d’accord que l’application peut analyser est attendue. Exemple : Bm7b5', + 'eventList.global.validation.position': 'Veuillez saisir une position au début du projet ou après. Une position non négative est attendue. Exemple : 1 1 0', + 'eventList.global.validation.length': 'Veuillez saisir une durée positive. Une valeur supérieure à zéro est attendue. Exemple : 4 0', + 'globalTracks.label': 'Pistes globales', + 'globalTracks.marker': 'Repère', + 'globalTracks.signature': 'Armure', + 'globalTracks.chord': 'Accord', + 'globalTracks.addItem': 'Ajouter un élément global {label}', + 'pianoRoll.power.sqrtDefault': '√ (par défaut)', + 'pianoRoll.chordGuide.off': 'Guide d’accords désactivé', + 'pianoRoll.chordGuide.tonic': 'Guide d’accords tonique', + 'pianoRoll.chordGuide.subdominant': 'Guide d’accords sous-dominante', + 'pianoRoll.chordGuide.dominant': 'Guide d’accords dominante', + 'pianoRoll.spectrogramView': 'Vue spectrogramme', + 'pianoRoll.sheetMusicView': 'Vue partition', + 'pianoRoll.pointerTool': 'Outil pointeur', + 'pianoRoll.pencilTool': 'Outil crayon', + 'pianoRoll.toggleAutomationLane': 'Afficher/masquer la voie d’automation', + 'pianoRoll.automation': 'Automation', + 'pianoRoll.automationLane': 'Voie d’automation {label}', + 'pianoRoll.automationType.pitchBend': 'Pitch bend', + 'pianoRoll.chordGuide': 'Guide d’accords', + 'pianoRoll.sheetQuantization': 'Quantif. partition', + 'pianoRoll.snap': 'Snap', + 'pianoRoll.snap.none': 'SANS SNAP', + 'pianoRoll.quantizePositionCompact': 'Quant. pos.', + 'pianoRoll.quantizeLengthCompact': 'Quant. dur.', + 'pianoRoll.floor': 'Plancher', + 'pianoRoll.curve': 'Courbe', + 'pianoRoll.zoom': 'Zoom', + 'pianoRoll.moreOptions': 'Plus d’options', + 'pianoRoll.showActiveRegionOnly': 'Afficher seulement la région active', + 'pianoRoll.showEntireTrack': 'Afficher toute la piste', + 'pianoRoll.detectChords': 'Détecter les accords...', + 'pianoRoll.detectingChords': 'Détection des accords...', + 'pianoRoll.detectTempo': 'Détecter le tempo...', + 'pianoRoll.detectingTempo': 'Détection du tempo...', + 'toolbar.projectName.prompt': 'Saisissez le nom du projet :', + 'toolbar.projectName.invalid': 'Nom de projet invalide. Seules les lettres, les chiffres, les espaces, les tirets, les underscores, les points et les parenthèses sont autorisés.', + 'toolbar.projectName.reserved': '"{name}" est un nom de projet réservé. Choisissez-en un autre.', + 'toolbar.projectName.overwrite': 'Le projet "{name}" existe déjà. Voulez-vous l’écraser ?', + 'toolbar.projectName.renameOrCopy': 'Souhaitez-vous renommer ce projet ou l’enregistrer comme nouvelle copie ?', + 'toolbar.projectName.saveAsCopy': 'Enregistrer comme copie', + 'toolbar.projectName.rename': 'Renommer', + 'toolbar.save.error': 'Une erreur est survenue lors de l’enregistrement : {error}', + 'toolbar.load.error': 'Une erreur est survenue lors du chargement du projet : {error}', + 'toolbar.newProject.confirm': 'Créer un nouveau projet ? Toutes les modifications non enregistrées seront perdues.', + 'toolbar.project.notFound': 'Projet "{name}" introuvable.', + 'toolbar.openProject.confirm': 'Ouvrir ce projet ? Toutes les modifications non enregistrées du projet actuel seront perdues.', + 'toolbar.status.loadFailed': 'Échec du chargement du projet : {error}', + 'toolbar.status.newProjectCreated': 'Nouveau projet "{name}" créé', + 'toolbar.status.projectLoaded': '{description} chargé avec succès', + 'toolbar.status.projectExportedKgstudio': 'Projet "{name}" exporté au format KGStudio', + 'toolbar.status.exportProjectError': 'Erreur lors de l’export du projet : {error}', + 'toolbar.export.failedProject': 'Échec de l’export du projet : {error}', + 'toolbar.status.projectExportedMidi': 'Projet "{name}" exporté au format MIDI', + 'toolbar.status.exportMidiError': 'Erreur lors de l’export MIDI : {error}', + 'toolbar.export.failedMidi': 'Échec de l’export du projet en MIDI : {error}', + 'toolbar.status.projectExportedWav': 'Projet "{name}" exporté en WAV', + 'toolbar.status.exportWavError': 'Erreur lors de l’export WAV : {error}', + 'toolbar.export.failedWav': 'Échec de l’export du projet en WAV : {error}', + 'toolbar.status.projectExportedMp3': 'Projet "{name}" exporté en MP3', + 'toolbar.status.exportMp3Error': 'Erreur lors de l’export MP3 : {error}', + 'toolbar.export.failedMp3': 'Échec de l’export du projet en MP3 : {error}', + 'toolbar.status.importFailed': 'Échec de l’import du fichier : {error}', + 'toolbar.import.failedProjectFile': 'Échec de l’import du fichier projet : {error}', + 'toolbar.import.corruptedKgstudio': 'Le fichier .kgstudio est corrompu ou invalide : {error}', + 'toolbar.status.importingMidi': 'Import du fichier MIDI "{name}"...', + 'toolbar.status.failedImportMidi': 'Échec de l’import MIDI : {error}', + 'toolbar.status.playbackFailedStart': 'Impossible de démarrer la lecture', + 'toolbar.status.recordingStoppedCommitted': 'Enregistrement arrêté, notes validées', + 'toolbar.status.failedStopPlayback': 'Impossible d’arrêter la lecture', + 'toolbar.maxBars.prompt': 'Saisissez le nouveau nombre maximal de mesures (>= {min}) :', + 'toolbar.input.invalidNumber': 'Entrée invalide. Veuillez saisir un nombre valide.', + 'toolbar.maxBars.invalid': 'Valeur invalide. Veuillez saisir un nombre >= {min}.', + 'toolbar.status.maxBarsChanged': 'Nombre maximal de mesures défini sur {value}', + 'toolbar.bpm.prompt': 'Saisissez le nouveau BPM ({min}-{max}) :', + 'toolbar.bpm.invalid': 'BPM invalide. Veuillez saisir une valeur entre {min} et {max}.', + 'toolbar.status.bpmChanged': 'BPM changé à {value}', + 'toolbar.timeSignature.prompt': 'Définir le chiffrage de mesure :', + 'toolbar.status.timeSignatureChanged': 'Chiffrage de mesure changé à {value}', + 'toolbar.status.keySignatureChanged': 'Armure changée à {value}', + 'toolbar.status.copied': 'Éléments copiés dans le presse-papiers', + 'toolbar.status.copyNone': 'Aucun élément sélectionné à copier', + 'toolbar.status.pasted': 'Éléments collés depuis le presse-papiers', + 'toolbar.status.pasteFailed': 'Impossible de coller : contenu ou contexte du presse-papiers invalide', + 'toolbar.status.deleted': 'Régions sélectionnées supprimées', + 'toolbar.status.deleteNone': 'Aucune région sélectionnée à supprimer', + 'toolbar.undo.none': 'Rien à annuler', + 'toolbar.redo.none': 'Rien à rétablir', + 'toolbar.status.undid': 'Action annulée : {description}', + 'toolbar.status.redid': 'Action rétablie : {description}', + 'toolbar.status.chatToggled': 'Chat affiché/masqué', + 'toolbar.status.settingsToggled': 'Réglages affichés/masqués', + 'toolbar.pianoRoll.selectMidiRegion': 'Sélectionnez une région MIDI pour ouvrir le Piano Roll.', + 'toolbar.status.recordingStopped': 'Enregistrement arrêté', + 'toolbar.status.audioRecordingStarted': 'Enregistrement audio démarré...', + 'toolbar.recording.openMidiRegion': 'Ouvrez une région MIDI dans le Piano Roll avant de lancer l’enregistrement.', + 'toolbar.recording.selectMidiRegion': 'Sélectionnez une région MIDI avant de lancer l’enregistrement.', + 'toolbar.recording.noMidiDevice': 'Aucun périphérique MIDI détecté. Connectez un clavier MIDI puis réessayez.', + 'toolbar.status.recordingStarted': 'Enregistrement démarré...', + 'toolbar.button.new': 'Nouveau', + 'toolbar.button.load': 'Ouvrir', + 'toolbar.button.save': 'Enregistrer', + 'toolbar.button.export': 'Exporter', + 'toolbar.button.import': 'Importer', + 'toolbar.button.settings': 'Réglages', + 'toolbar.button.undo': 'Annuler', + 'toolbar.button.redo': 'Rétablir', + 'toolbar.button.select': 'Sélection', + 'toolbar.button.pencil': 'Crayon', + 'toolbar.button.splitRegion': 'Couper la région à la tête de lecture', + 'toolbar.button.mergeRegions': 'Fusionner les régions MIDI sélectionnées', + 'toolbar.button.snapToGrid': 'Aligner à la grille', + 'toolbar.button.copy': 'Copier', + 'toolbar.button.paste': 'Coller', + 'toolbar.button.delete': 'Supprimer', + 'toolbar.button.backToBeginning': 'Retour au début', + 'toolbar.button.play': 'Lecture', + 'toolbar.button.pause': 'Pause', + 'toolbar.button.stopRecording': 'Arrêter l’enregistrement', + 'toolbar.button.record': 'Enregistrer', + 'toolbar.button.loop': 'Boucle', + 'toolbar.button.metronome': 'Métronome', + 'toolbar.button.piano': 'Piano', + 'toolbar.button.kgone': 'Générateur musical K.G.One', + 'toolbar.button.chat': 'Chat', + 'toolbar.button.eventList': 'Éditeur de liste d’événements', + 'toolbar.keySignatureChooser': 'Choisir l’armure, actuelle : {value}', + 'toolbar.export.label': 'Exporter', + 'toolbar.importProject.title': 'Importer un projet', + 'toolbar.importProject.description': 'Glissez-déposez votre fichier projet ici', + 'toolbar.openingProject': 'Ouverture du projet...', + 'instrument.group.PIANO_AND_KEYBOARDS': 'Pianos et claviers', + 'instrument.group.GUITAR': 'Guitare', + 'instrument.group.BASS': 'Basse', + 'instrument.group.STRINGS': 'Cordes', + 'instrument.group.BRASS': 'Cuivres', + 'instrument.group.WOODWIND': 'Bois', + 'instrument.group.PERCUSSION_KIT': 'Batterie et percussions', + 'instrument.group.SYNTH': 'Synthétiseur', + 'instrument.name.acoustic_grand_piano': 'Piano à queue acoustique', + 'instrument.name.bright_acoustic_piano': 'Piano acoustique brillant', + 'instrument.name.electric_grand_piano': 'Piano à queue électrique', + 'instrument.name.electric_piano_1': 'Piano électrique 1', + 'instrument.name.electric_piano_2': 'Piano électrique 2', + 'instrument.name.drawbar_organ': 'Orgue à tirettes', + 'instrument.name.clavinet': 'Clavinet', + 'instrument.name.harpsichord': 'Clavecin', + 'instrument.name.accordion': 'Accordéon', + 'instrument.name.acoustic_guitar_nylon': 'Guitare acoustique (nylon)', + 'instrument.name.acoustic_guitar_steel': 'Guitare acoustique (acier)', + 'instrument.name.electric_guitar_clean': 'Guitare électrique (clair)', + 'instrument.name.overdriven_guitar': 'Guitare électrique (overdrive)', + 'instrument.name.distortion_guitar': 'Guitare électrique (distorsion)', + 'instrument.name.acoustic_bass': 'Basse acoustique', + 'instrument.name.electric_bass_finger': 'Basse électrique (doigts)', + 'instrument.name.electric_bass_pick': 'Basse électrique (médiator)', + 'instrument.name.slap_bass_1': 'Basse slap', + 'instrument.name.synth_bass_1': 'Basse synthé 1', + 'instrument.name.violin': 'Violon', + 'instrument.name.viola': 'Alto', + 'instrument.name.cello': 'Violoncelle', + 'instrument.name.contrabass': 'Contrebasse', + 'instrument.name.string_ensemble_1': 'Ensemble de cordes', + 'instrument.name.string_ensemble_2': 'Ensemble de cordes 2', + 'instrument.name.pizzicato_strings': 'Cordes pizzicato', + 'instrument.name.tremolo_strings': 'Cordes trémolo', + 'instrument.name.orchestral_harp': 'Harpe orchestrale', + 'instrument.name.trumpet': 'Trompette', + 'instrument.name.trombone': 'Trombone', + 'instrument.name.tuba': 'Tuba', + 'instrument.name.french_horn': 'Cor français', + 'instrument.name.brass_section': 'Section de cuivres', + 'instrument.name.soprano_sax': 'Saxophone soprano', + 'instrument.name.alto_sax': 'Saxophone alto', + 'instrument.name.tenor_sax': 'Saxophone ténor', + 'instrument.name.baritone_sax': 'Saxophone baryton', + 'instrument.name.oboe': 'Hautbois', + 'instrument.name.english_horn': 'Cor anglais', + 'instrument.name.bassoon': 'Basson', + 'instrument.name.clarinet': 'Clarinette', + 'instrument.name.piccolo': 'Piccolo', + 'instrument.name.flute': 'Flûte', + 'instrument.name.shakuhachi': 'Shakuhachi', + 'instrument.name.harmonica': 'Harmonica', + 'instrument.name.standard': 'Kit de batterie standard', + 'instrument.name.orchestra_kit': 'Kit de percussions orchestrales', + 'instrument.name.marimba': 'Marimba', + 'instrument.name.timpani': 'Timbales', + 'instrument.name.taiko_drum': 'Tambour taiko', + 'instrument.name.woodblock': 'Wood-block', + 'instrument.name.lead_1_square': 'Lead 1 (carré)', + 'instrument.name.lead_2_sawtooth': 'Lead 2 (dent de scie)', + 'instrument.name.pad_1_new_age': 'Pad 1 (new age)', + 'instrument.name.pad_2_warm': 'Pad 2 (chaud)', + 'instrument.name.pad_3_polysynth': 'Pad 3 (polysynth)', + 'instrument.name.pad_4_choir': 'Pad 4 (chœur)', + 'instrument.name.pad_5_bowed': 'Pad 5 (verre frotté)', + 'instrument.name.pad_6_metallic': 'Pad 6 (métallique)', + 'instrument.name.pad_7_halo': 'Pad 7 (halo)', + 'instrument.name.pad_8_sweep': 'Pad 8 (balayage)', + 'instrument.name.fx_1_rain': 'FX 1 (pluie)', + 'instrument.name.fx_2_soundtrack': 'FX 2 (bande-son)', + 'instrument.name.fx_3_crystal': 'FX 3 (cristal)', + 'instrument.name.fx_4_atmosphere': 'FX 4 (atmosphère)', + 'instrument.name.fx_5_brightness': 'FX 5 (brillance)', + 'instrument.name.fx_6_goblins': 'FX 6 (gobelins)', + 'instrument.name.fx_7_echoes': 'FX 7 (échos)', + 'instrument.name.fx_8_scifi': 'FX 8 (science-fiction)', + 'kgone.panel.title.server': 'Générateur musical K.G.One', + 'kgone.panel.title.local': 'Générateur musical', + 'kgone.tab.fullSong': 'Morceau complet', + 'kgone.tab.separator': 'Séparateur', + 'kgone.tab.requiresServer': 'Nécessite l’intégration du serveur K.G.One Music Studio', + 'kgone.shared.advancedSettings': 'Réglages avancés', + 'kgone.shared.caption': 'Description', + 'kgone.shared.lyrics': 'Paroles', + 'kgone.shared.instrumental': 'Instrumental (sans voix)', + 'kgone.shared.inferenceSteps': 'Étapes d’inférence', + 'kgone.shared.guidanceScale': 'Échelle de guidage', + 'kgone.shared.useRandomSeed': 'Utiliser une seed aléatoire', + 'kgone.shared.seed': 'Seed', + 'kgone.shared.thinking': 'Réflexion (génération de métadonnées CoT)', + 'kgone.shared.selectedRegion': 'Région sélectionnée', + 'kgone.shared.track': 'Piste', + 'kgone.shared.btn.loadingModel': 'Chargement du modèle...', + 'kgone.shared.btn.generating': 'Génération...', + 'kgone.shared.btn.processing': 'Traitement...', + 'kgone.shared.btn.downloading': 'Téléchargement...', + 'kgone.shared.btn.preparingUpload': 'Préparation de l’envoi...', + 'kgone.shared.btn.importing': 'Import...', + 'kgone.shared.hint.loadingModel': 'Chargement du modèle : cela peut prendre plus de 60 secondes, veuillez patienter...', + 'kgone.shared.hint.submitting': 'Envoi de la demande de génération...', + 'kgone.shared.hint.downloadingAudio': 'Téléchargement de l’audio...', + 'kgone.clip.field.prompt': 'Prompt', + 'kgone.clip.field.promptHint': 'Décrivez le clip avec des tags séparés par des virgules : famille d’instrument, sous-type, timbre, effets, nombre de mesures, BPM, tonalité.', + 'kgone.clip.field.negativePrompt': 'Prompt négatif', + 'kgone.clip.field.bars': 'Mesures', + 'kgone.clip.field.note': 'Note', + 'kgone.clip.field.scale': 'Mode', + 'kgone.clip.field.scaleMajor': 'Majeur', + 'kgone.clip.field.scaleMinor': 'Mineur', + 'kgone.clip.field.steps': 'Étapes', + 'kgone.clip.field.seedLabel': 'Seed (-1 = aléatoire)', + 'kgone.clip.field.samplerType': 'Type de sampler', + 'kgone.clip.hint.generating': 'Génération du clip...', + 'kgone.clip.hint.drag': 'Glissez le lecteur ci-dessus vers une piste pour importer le clip. Déposez-le sur une piste audio pour l’importer comme région WAV (recommandé), ou sur une piste MIDI pour l’importer comme région MIDI. Remarque : le MIDI est retranscrit depuis l’audio et peut ne pas être parfaitement exact.', + 'kgone.clip.btn.generate': 'Générer le clip', + 'kgone.fullSong.field.captionHint': 'Décrivez en langage naturel le style, l’ambiance, le tempo, l’instrumentation et la structure du morceau.', + 'kgone.fullSong.field.lyricsHint': 'Utilisez les balises [Intro], [Verse], [Chorus], [Bridge] pour marquer les sections.', + 'kgone.fullSong.hint.generating': 'Génération...', + 'kgone.fullSong.hint.generatingProgress': 'Génération... {pct}% — {stage}', + 'kgone.fullSong.hint.generatingProgressNoStage': 'Génération... {pct}%', + 'kgone.fullSong.hint.drag': 'Glissez le lecteur ci-dessus vers une piste audio pour importer le morceau. Le dépôt sur une piste MIDI n’est pas pris en charge pour la génération de morceau complet.', + 'kgone.fullSong.btn.generate': 'Générer le morceau', + 'kgone.remix.field.captionHint': 'Décrivez le style, l’ambiance et l’instrumentation visés pour le remix.', + 'kgone.remix.field.lyricsHint': 'Laissez vide pour conserver les paroles d’origine, ou saisissez-en de nouvelles. Utilisez les balises [Verse], [Chorus], [Bridge].', + 'kgone.remix.field.coverStrength': 'Intensité de reprise', + 'kgone.remix.field.coverStrengthHint': '0 = créatif, 1 = fidèle à la structure source', + 'kgone.remix.field.noiseStrength': 'Intensité du bruit', + 'kgone.remix.field.noiseStrengthHint': '0 = transfert de style pur, 0,1 à 0,25 recommandé', + 'kgone.remix.hint.submitting': 'Envoi de la demande de remix...', + 'kgone.remix.hint.generating': 'Génération du remix...', + 'kgone.remix.hint.generatingProgress': 'Génération du remix... {pct}% — {stage}', + 'kgone.remix.hint.generatingProgressNoStage': 'Génération du remix... {pct}%', + 'kgone.remix.hint.drag': 'Glissez le lecteur ci-dessus vers une piste audio pour importer le remix.', + 'kgone.remix.hint.noRegion': 'Sélectionnez une région audio sur la timeline pour la remixer. Seules les régions audio sont prises en charge ; les régions MIDI ne peuvent pas être remixées.', + 'kgone.remix.btn.generate': 'Générer le remix', + 'kgone.remix.btn.processingRemix': 'Traitement du remix...', + 'kgone.remix.btn.importAligned': 'Importer aligné sur la source', + 'kgone.repaint.field.repaintRange': 'Plage de repaint', + 'kgone.repaint.field.loopModeOff': 'Le mode boucle est désactivé. Activez le bouton boucle dans la barre d’outils et définissez une plage de boucle pour fixer la fenêtre de repaint.', + 'kgone.repaint.field.start': 'Début', + 'kgone.repaint.field.end': 'Fin', + 'kgone.repaint.field.untilEnd': 'jusqu’à la fin de l’audio', + 'kgone.repaint.field.captionHint': 'Décrivez le style cible et l’instrumentation de la section repeinte.', + 'kgone.repaint.field.lyricsHint': 'Laissez vide pour conserver les paroles d’origine, ou saisissez-en de nouvelles pour la section repeinte.', + 'kgone.repaint.field.repaintStrength': 'Intensité du repaint', + 'kgone.repaint.field.repaintStrengthHint': '0 = préserver l’original, 1 = régénération complète', + 'kgone.repaint.hint.submitting': 'Envoi de la demande de repaint...', + 'kgone.repaint.hint.generating': 'Génération du repaint...', + 'kgone.repaint.hint.generatingProgress': 'Génération du repaint... {pct}% — {stage}', + 'kgone.repaint.hint.generatingProgressNoStage': 'Génération du repaint... {pct}%', + 'kgone.repaint.hint.drag': 'Glissez le lecteur ci-dessus vers une piste audio pour importer le repaint.', + 'kgone.repaint.hint.noRegion': 'Sélectionnez une région audio sur la timeline pour la repeindre. Seules les régions audio sont prises en charge ; les régions MIDI ne peuvent pas être repeintes.', + 'kgone.repaint.btn.generate': 'Générer le repaint', + 'kgone.repaint.btn.processingRepaint': 'Traitement du repaint...', + 'kgone.repaint.btn.importAligned': 'Importer aligné sur la source', + 'kgone.separator.field.separationModel': 'Modèle de séparation', + 'kgone.separator.field.chunkDuration': 'Durée optionnelle des segments audio (secondes)', + 'kgone.separator.field.chunkDurationPlaceholder': 'Laisser vide pour traiter toute la région', + 'kgone.separator.field.modelOverlap': 'Recouvrement du modèle', + 'kgone.separator.hint.drag': 'Glissez chaque lecteur de stem ci-dessus vers une piste audio pour l’importer. Le dépôt sur une piste MIDI n’est pas pris en charge pour la séparation de stems.', + 'kgone.separator.hint.noRegion.download': 'Téléchargez {model}, puis sélectionnez une région audio sur la timeline pour en extraire les stems.', + 'kgone.separator.hint.noRegion.select': 'Sélectionnez une région audio sur la timeline pour en extraire les stems. Seules les régions audio sont prises en charge ; les régions MIDI ne peuvent pas être séparées.', + 'kgone.separator.btn.separateStems': 'Séparer les stems', + 'kgone.separator.btn.importAllStems': 'Importer tous les stems sur la timeline', + 'kgone.separator.btn.server.polling': 'Séparation des stems...', + 'kgone.separator.btn.local.loadingModel': 'Préparation du modèle local...', + 'kgone.separator.btn.local.generating': 'Préparation de l’audio...', + 'kgone.separator.btn.local.polling': 'Séparation locale en cours...', + 'kgone.separator.btn.local.downloading': 'Finalisation...', + 'kgone.separator.local.title': 'Mode séparateur local', + 'kgone.separator.local.description': 'Si K.G.One Music Studio n’est pas disponible, le mode séparateur local offre une solution intégrée pour extraire des stems directement dans votre navigateur. Deux modèles locaux sont disponibles : Vocal and Instrument (Medium Accuracy), et Vocal, Drums, Bass, and Others. L’état de téléchargement ci-dessous correspond au modèle actuellement sélectionné. Vocal and Instrument (Medium Accuracy) prend généralement plus de temps à traiter que Vocal, Drums, Bass, and Others, et la durée totale dépendra tout de même de votre matériel. Si le traitement bascule sur le CPU, la page peut devenir temporairement moins réactive pendant la séparation.', + 'kgone.separator.local.learnMore': 'En savoir plus sur l’intégration du serveur K.G.One Music Studio.', + 'kgone.separator.local.provider': 'Fournisseur : ', + 'kgone.separator.local.model': 'Modèle : ', + 'kgone.separator.local.downloaded': 'téléchargé', + 'kgone.separator.local.notDownloaded': 'non téléchargé', + 'kgone.separator.local.checkingCache': 'Vérification du cache du modèle local...', + 'kgone.separator.local.btn.download': 'Télécharger le modèle sélectionné', + 'kgone.separator.local.btn.downloading': 'Téléchargement du modèle...', + 'kgone.separator.local.btn.redownload': 'Retélécharger le modèle', + 'kgone.separator.local.btn.redownloading': 'Retéléchargement...', + 'kgone.separator.local.btn.deleteCache': 'Supprimer le modèle en cache', + 'kgone.separator.local.btn.deleting': 'Suppression...', + 'kgone.separator.local.progress.downloading': 'Téléchargement de {name}...', + 'kgone.separator.local.progress.downloadingWithSize': 'Téléchargement de {name}... {received} / {total} MB', + 'kgone.separator.local.progress.downloadingMbOnly': 'Téléchargement de {name}... {received} MB', + 'kgone.separator.local.progress.ready': '{name} est prêt.', + 'kgone.separator.local.progress.preparingRuntime': 'Préparation de la session ONNX Runtime...', + 'kgone.separator.local.progress.readingAudio': 'Lecture du fichier audio...', + 'kgone.separator.local.progress.running': 'Séparation dans le navigateur en cours...', + 'kgone.separator.local.progress.complete': 'Séparation terminée.', +}; diff --git a/src/i18n/messages/index.ts b/src/i18n/messages/index.ts index 71e41b6..126e351 100644 --- a/src/i18n/messages/index.ts +++ b/src/i18n/messages/index.ts @@ -1,10 +1,12 @@ import type { ResolvedLocaleCode, TranslationMessages } from '../types'; import { enUsMessages } from './en_us'; +import { frFrMessages } from './fr_fr'; import { zhCnMessages } from './zh_cn'; import { zhHkMessages } from './zh_hk'; export const messagesByLocale: Record = { en_us: enUsMessages, + fr_fr: frFrMessages, zh_cn: zhCnMessages, zh_hk: zhHkMessages, }; diff --git a/src/i18n/messages/zh_cn.ts b/src/i18n/messages/zh_cn.ts index c54921b..e44f117 100644 --- a/src/i18n/messages/zh_cn.ts +++ b/src/i18n/messages/zh_cn.ts @@ -1,6 +1,8 @@ import type { TranslationMessages } from '../types'; +import { enUsMessages } from './en_us'; export const zhCnMessages: TranslationMessages = { + ...enUsMessages, 'app.loading': '加载中...', 'assistant.displayName': 'K.G.Studio 音乐创作助手', 'assistant.welcomeFallback': '欢迎使用 K.G.Studio 音乐创作助手。', @@ -16,9 +18,6 @@ export const zhCnMessages: TranslationMessages = { 'settings.general.language.label': '语言', 'settings.general.language.help': '自动会使用你的系统语言。', 'settings.general.language.auto': '自动', - 'settings.general.language.en_us': 'English', - 'settings.general.language.zh_cn': '简体中文', - 'settings.general.language.zh_hk': '繁体中文', 'settings.yes': '是', 'settings.no': '否', 'settings.restoreDefault': '恢复默认', diff --git a/src/i18n/messages/zh_hk.ts b/src/i18n/messages/zh_hk.ts index 65cdd62..27063d7 100644 --- a/src/i18n/messages/zh_hk.ts +++ b/src/i18n/messages/zh_hk.ts @@ -1,6 +1,8 @@ import type { TranslationMessages } from '../types'; +import { enUsMessages } from './en_us'; export const zhHkMessages: TranslationMessages = { + ...enUsMessages, 'app.loading': '加載中...', 'assistant.displayName': 'K.G.Studio 音樂創作助手', 'assistant.welcomeFallback': '歡迎使用 K.G.Studio 音樂創作助手。', @@ -16,9 +18,6 @@ export const zhHkMessages: TranslationMessages = { 'settings.general.language.label': '語言', 'settings.general.language.help': '自動會使用你的系統語言。', 'settings.general.language.auto': '自動', - 'settings.general.language.en_us': 'English', - 'settings.general.language.zh_cn': '簡體中文', - 'settings.general.language.zh_hk': '繁體中文', 'settings.yes': '是', 'settings.no': '否', 'settings.restoreDefault': '恢復默認', diff --git a/src/i18n/translate.test.ts b/src/i18n/translate.test.ts index 658eb52..fdb7cf7 100644 --- a/src/i18n/translate.test.ts +++ b/src/i18n/translate.test.ts @@ -2,18 +2,21 @@ import { describe, expect, it } from 'vitest'; import { translate } from './translate'; describe('translate', () => { - it('returns translated strings for both locales', () => { + it('returns translated strings for supported locales', () => { expect(translate('settings.general.language.auto', undefined, 'en_us')).toBe('Auto'); + expect(translate('settings.general.language.auto', undefined, 'fr_fr')).toBe('Auto'); expect(translate('settings.general.language.auto', undefined, 'zh_cn')).toBe('自动'); }); - it('falls back to English when a zh-CN key is missing', () => { + it('falls back to English when a locale key is missing', () => { expect(translate('toolbar.button.new', undefined, 'zh_cn')).toBe('新建'); + expect(translate('nonexistent.key', undefined, 'fr_fr')).toBe('nonexistent.key'); expect(translate('nonexistent.key', undefined, 'zh_cn')).toBe('nonexistent.key'); }); it('interpolates params', () => { expect(translate('toolbar.status.bpmChanged', { value: 120 }, 'en_us')).toBe('BPM changed to 120'); + expect(translate('toolbar.status.bpmChanged', { value: 120 }, 'fr_fr')).toBe('BPM changé à 120'); expect(translate('toolbar.status.bpmChanged', { value: 120 }, 'zh_cn')).toBe('BPM 已改为 120'); }); }); diff --git a/src/i18n/types.ts b/src/i18n/types.ts index 35d577c..8954478 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -1,4 +1,4 @@ -export type LanguageSetting = 'auto' | 'en_us' | 'zh_cn' | 'zh_hk'; +export type LanguageSetting = 'auto' | 'en_us' | 'zh_cn' | 'zh_hk' | 'fr_fr'; export type ResolvedLocaleCode = Exclude; diff --git a/src/util/messageFilter/UserMessageFilter.test.ts b/src/util/messageFilter/UserMessageFilter.test.ts index 467b6e7..c3a2f04 100644 --- a/src/util/messageFilter/UserMessageFilter.test.ts +++ b/src/util/messageFilter/UserMessageFilter.test.ts @@ -111,6 +111,17 @@ describe('processUserMessage slash commands', () => { expect(result.pseudoAssistantResponse).toContain('welcome_local_llm-zh_cn.md'); }); + it('uses the localized welcome asset when fr-FR is selected', async () => { + configState.set('general.language', 'fr_fr'); + configState.set('general.llm_provider', 'local_browser'); + + const result = await processUserMessage('/welcome'); + + expect(fetch).toHaveBeenCalledWith(expect.stringContaining('chat/welcome_local_llm-fr_fr.md')); + expect(result.metadata).toMatchObject({ command: 'welcome', variant: 'local' }); + expect(result.pseudoAssistantResponse).toContain('welcome_local_llm-fr_fr.md'); + }); + it('falls back to a localized welcome string when welcome markdown fetch fails', async () => { configState.set('general.language', 'zh_cn'); configState.set('general.llm_provider', 'local_browser'); @@ -170,6 +181,19 @@ describe('processUserMessage slash commands', () => { expect(message?.content).toContain('welcome_local_llm-zh_cn.md'); }); + it('uses localized welcome assets for addWelcomeMessage under auto + French locale', async () => { + configState.set('general.language', 'auto'); + vi.stubGlobal('navigator', { + languages: ['fr-FR', 'en-US'], + language: 'fr-FR', + }); + + const message = await addWelcomeMessage(); + + expect(fetch).toHaveBeenCalledWith(expect.stringContaining('chat/welcome_local_llm-fr_fr.md')); + expect(message?.content).toContain('welcome_local_llm-fr_fr.md'); + }); + it('fetches the localized help guide for /help under zh-CN', async () => { configState.set('general.language', 'zh_cn'); @@ -185,6 +209,15 @@ describe('processUserMessage slash commands', () => { expect(result.pseudoAssistantResponse).toContain('chat/help-zh_cn.md'); }); + it('fetches the localized help guide for /help under fr-FR', async () => { + configState.set('general.language', 'fr_fr'); + + const result = await processUserMessage('/help'); + + expect(fetch).toHaveBeenCalledWith(expect.stringContaining('chat/help-fr_fr.md')); + expect(result.pseudoAssistantResponse).toContain('chat/help-fr_fr.md'); + }); + it('falls back to the English help guide when the localized file is missing', async () => { configState.set('general.language', 'zh_cn'); vi.stubGlobal('fetch', vi.fn(async (input: string | URL | Request) => { @@ -223,6 +256,15 @@ describe('processUserMessage slash commands', () => { expect(result.pseudoAssistantResponse).toContain('chat/hotkeys.md'); }); + it('fetches the localized hotkeys guide for /hotkeys under fr-FR', async () => { + configState.set('general.language', 'fr_fr'); + + const result = await processUserMessage('/hotkeys'); + + expect(fetch).toHaveBeenCalledWith(expect.stringContaining('chat/hotkeys-fr_fr.md')); + expect(result.pseudoAssistantResponse).toContain('chat/hotkeys-fr_fr.md'); + }); + it('fetches the localized hotkeys guide for /hotkeys under zh-CN', async () => { configState.set('general.language', 'zh_cn');