Merge pull request #47 from KGAudioLab/feat/2026-05-30-i18n-v1
Feat/2026 05 30 i18n v1
This commit is contained in:
+453
@@ -0,0 +1,453 @@
|
||||
[English](./README.md) | Français | [简体中文](./README-zh_cn.md) | [繁體中文](./README-zh_hk.md)
|
||||
|
||||
<div align="center">
|
||||
<img src="./public/logo.png" alt="K.G.Studio Logo" width="160" />
|
||||
</div>
|
||||
|
||||
# K.G.Studio — Une DAW basée sur le navigateur avec assistant IA
|
||||
|
||||
<div align="center">
|
||||
<h3><a href="https://kgaudiolab.github.io/kgstudio"><b>◀ Commencer à utiliser K.G.Studio en ligne dans votre navigateur ▶</b></a></h3>
|
||||
</div>
|
||||
|
||||
## Nouveau !
|
||||
|
||||
> ### ✦ [K.G.One Music Studio est disponible dès maintenant.](https://github.com/KGAudioLab/K.G.One) ✦ <br />
|
||||
> [**K.G.One Music Studio**](https://github.com/KGAudioLab/K.G.One) est une plateforme intégrée, entièrement locale et open source, construite autour de **K.G.Studio** (ce projet). Elle embarque [ACE-Step 1.5](https://github.com/ace-step/ACE-Step-1.5) pour la génération de morceaux complets, [Foundation-1](https://huggingface.co/RoyalCities/Foundation-1) pour la génération de clips audio et de boucles MIDI, ainsi que [python-audio-separator (UVR5)](https://github.com/nomadkaraoke/python-audio-separator) pour la séparation de stems, apportant directement la génération musicale IA accélérée par GPU dans votre flux de production musicale dans le navigateur.
|
||||
|
||||
## Qu'est-ce que K.G.Studio ?
|
||||
|
||||
K.G.Studio est une DAW légère et moderne qui fonctionne entièrement dans le navigateur, avec **K.G.Studio Musician Assistant** en son cœur. Il propose une lecture réaliste des instruments via les samplers Tone.js, un éditeur piano roll, une gestion des pistes et des régions avec annulation/rétablissement complets, la persistance des projets dans OPFS (Origin Private File System), un panneau de configuration personnalisable, et un assistant IA intégré avec exécution d'outils.
|
||||
|
||||
**K.G.Studio Musician Assistant** est un agent d'assistance IA pour l'harmonie, l'arrangement et l'édition de notes, mais pas pour la composition entièrement automatique.
|
||||
|
||||
<div align="center">
|
||||
<img src="./docs/KGOne-Demo-GIF.gif" alt="K.G.One Logo" width="640" />
|
||||
</div>
|
||||
|
||||
> Remarque : les fonctions de génération de morceau complet et de génération de clip audio nécessitent l'intégration de [**K.G.One Music Studio**](https://github.com/KGAudioLab/K.G.One).
|
||||
|
||||
## Dernières mises à jour
|
||||
|
||||
- **2026.05.30** : ajout de la **prise en charge de l'internationalisation (i18n)** — K.G.Studio est désormais disponible en quatre langues : **English**, **Simplified Chinese (简体中文)**, **Traditional Chinese (繁體中文)** et **Français**. La langue active se configure dans **Réglages ⚙️ → Général → Langue**, avec une option `Auto` qui détecte automatiquement la langue de votre navigateur.
|
||||
|
||||
- **2026.05.27** :
|
||||
- Ajout du **système de pistes globales** : introduction de quatre pistes globales, **Marker**, **Tempo**, **Key Signature** et **Chord** (régions d'étendue avec symboles d'accords).
|
||||
- Ajout de la fonction **Audio Chord Detection** : ouvrez la fenêtre piano roll pour une région audio ou MIDI, puis cliquez sur « ... » -> **Detect Chords** pour analyser automatiquement l'enregistrement et remplir la Chord Track à l'aide d'un pipeline FFT sans dépendance, avec sensibilité, stabilité et détection des accords de septième configurables.
|
||||
- Ajout de la fonction **Tempo Detection with Auto-Align Beats** : ouvrez la fenêtre piano roll pour une région audio, puis cliquez sur « ... » -> **Detect Tempo** dans la barre d'outils pour analyser le BPM de l'audio et, si vous le souhaitez, réaligner les régions de la Tempo Track du projet.
|
||||
- Ajout de **Demucs 4S** comme second modèle local de séparation de stems intégré au navigateur. En plus du modèle UVR-MDX-NET à deux stems existant, le modèle `htdemucs_4s` à quatre stems (~172 MB, vocals / drums / bass / others) est désormais disponible, les deux s'exécutant entièrement dans le navigateur via ONNX Runtime WebGPU.
|
||||
|
||||
- **2026.05.15** : ajout de **modèles IA intégrés au navigateur**. Deux modèles IA peuvent désormais fonctionner entièrement dans le navigateur, sans service externe, sans clé API, et sans serveur K.G.One. **K.G.Studio Musician Assistant** gagne un nouveau fournisseur **Local LLM (Browser)** basé sur **Gemma 4 E4B** via LiteRT-LM avec accélération WebGPU ; le modèle est téléchargé une fois puis mis en cache dans OPFS pour des lancements ultérieurs quasi instantanés, avec longueur de contexte configurable (32 k / 64 k / 128 k tokens) et statistiques de performance d'inférence en direct. La **séparation de stems** fonctionne désormais aussi localement grâce à un modèle ONNX **UVR-MDX-NET-Inst_HQ_3** intégré au navigateur avec accélération WebGPU. Ouvrez le panneau **Music Generator** (bouton ✦), téléchargez le modèle une fois, puis séparez voix et accompagnement entièrement sur votre appareil. Les deux fonctions nécessitent un navigateur compatible WebGPU (Chrome 113+ ou Edge 113+) et un contexte sécurisé (HTTPS ou localhost). Matériel recommandé : un GPU avec au moins 8 GB de VRAM ou un système avec au moins 16 GB de mémoire unifiée.
|
||||
|
||||
- **2026.05.10** : ajout de la **vue en notation musicale (sheet music)**. Le piano roll propose désormais un mode complet de notation standard. Basculez entre les vues Piano Roll et Sheet Music à l'aide du bouton dans la barre d'outils du piano roll. En mode partition, les notes sont gravées via VexFlow avec sélection automatique de la clé (sol ou fa) selon l'instrument actif, affichage de l'armure, groupement automatique des crochets, liaisons entre mesures et quantification configurable pour la résolution des valeurs de notes. Activez **Track Scope** pour afficher toutes les régions MIDI de la piste comme une partition continue plutôt qu'une seule région isolée.
|
||||
|
||||
- **2026.05.09** : ajout de **l'enregistrement audio**. Vous pouvez désormais enregistrer directement depuis votre microphone dans une piste audio. Un aperçu de forme d'onde en direct se développe en temps réel pendant l'enregistrement, et la région est ajoutée à la timeline comme région audio standard à l'arrêt. Ajout également de la **sélection des périphériques audio I/O** dans les paramètres afin de choisir votre entrée micro et votre sortie audio préférées.
|
||||
|
||||
- **2026.05.08** : ajout de **l'automation MIDI**. Vous pouvez dessiner et modifier des courbes de pitch bend et de MIDI CC (CC1 Modulation, CC2 Breath, CC7 Volume, CC11 Expression, CC64 Sustain) dans une lane d'automation éditable sous la grille piano. Ajout de **l'automation au niveau de la piste** : chaque piste dispose désormais d'un panneau d'automation dédié où vous pouvez voir et modifier les mêmes courbes directement sur la timeline. Les entrées de contrôleurs MIDI en temps réel (pitch wheel, pédales CC) sont enregistrées et relues avec interpolation par lane. Ajout du **panneau Event List** : une barre latérale à onglets (Notes / Pitch Bend / Controller) pour inspecter et modifier en ligne tous les événements de la région MIDI active. Ajout également de la **sélection multiple de régions** avec lasso et déplacement/redimensionnement de groupe, ainsi que de la **fusion de régions MIDI**.
|
||||
<div align="center">
|
||||
<img src="./public/snapshots/2026-05-08-automations.png" alt="K.G.Studio Logo" width="640" />
|
||||
</div>
|
||||
|
||||
- **2026.05.02** : ajout de la **visualisation spectrogramme des pistes audio**. Les régions audio affichent désormais une superposition de spectrogramme en temps réel dans la grille des pistes. Ajout du **Piano Roll hybrid mode** : ouvrez le piano roll sur une région MIDI pendant que le spectrogramme d'une région audio voisine est affiché comme couche de référence, afin d'éditer les notes MIDI en fonction de la forme visuelle de l'audio. Ajout du **zoom avant/arrière dans le piano roll** avec conservation de la position de la vue pour qu'elle reste ancrée près de la tête de lecture actuelle. Ajout également du **réglage fin de la position des régions** avec petits incréments pour un placement précis. Enfin, ajout de la synchronisation du défilement de la tête de lecture entre composants afin que la grille principale et le piano roll restent synchronisés pendant la lecture.
|
||||
- **2026.04.29** : ajout de **Remix** et **Repaint** au panneau K.G.One Music Generator (alimenté par ACE-Step 1.5). **Remix** vous permet de refaire une région audio existante dans un nouveau style : sélectionnez une région audio, décrivez le style cible et fournissez éventuellement de nouvelles paroles, et ACE-Step réinterprétera le morceau avec l'instrumentation et l'ambiance demandées. **Repaint** vous permet de régénérer de façon chirurgicale une section précise d'un morceau : définissez une plage de boucle sur la timeline pour créer la fenêtre de repaint, puis décrivez le résultat souhaité pour cette section ; le reste du morceau reste inchangé. Les deux outils prennent en charge le même flux d'import que les autres onglets K.G.One : prévisualisation dans le lecteur intégré, glisser-déposer sur une piste, ou clic sur **Import Aligned to Source** pour placer automatiquement le résultat sous la région d'origine dans une nouvelle piste.
|
||||
- **2026.04.24** : ajout de l'intégration [**K.G.One Music Studio**](https://github.com/KGAudioLab/K.G.One) ! Lorsque K.G.Studio se connecte à un serveur K.G.One local, le panneau **K.G.One Music Generator** (bouton baguette magique ✦ dans la barre d'outils) devient disponible avec trois outils alimentés par l'IA : **Full Song Generation** (alimenté par ACE-Step 1.5 pour générer des chansons complètes à partir de prompts textuels), **Clip Generation** (alimenté par Foundation-1 pour générer des clips instrumentaux et des boucles MIDI à partir de texte), et **Stem Separation** (alimenté par python-audio-separator pour séparer n'importe quel audio en voix, instrumentaux, etc.). Les audios et MIDI générés peuvent être prévisualisés instantanément et glissés directement sur vos pistes. K.G.One fonctionne entièrement sur votre propre machine (Windows/Linux, GPU CUDA requis) ; consultez le [dépôt K.G.One](https://github.com/KGAudioLab/K.G.One) pour les instructions d'installation.
|
||||
- **2026.04.11** : migration du stockage des projets depuis IndexedDB vers OPFS (Origin Private File System) avec une structure basée sur des dossiers, afin de mieux gérer les fichiers médias. Ajout de la prise en charge des pistes audio avec import WAV/MP3, lecture, boucle et découpe non destructive des régions. Ajout également de l'export bounce vers WAV/MP3 via rendu hors ligne.
|
||||
- **2026.04.05** : migration de l'agent IA, passant d'un appel d'outils basé sur XML au function calling natif du SDK OpenAI afin d'améliorer la fiabilité et la compatibilité. Ajout de nouvelles options de modèles LLM, y compris la série GPT-5.4.
|
||||
- **2026.01.23** : implémentation de la lecture en boucle sans couture ! Faites glisser sur les numéros de mesure pour définir une plage de boucle, ou activez le mode boucle avec le bouton Loop dans la barre d'outils. La lecture en boucle utilise la fonctionnalité native de boucle de `Tone.js` pour une boucle précise au niveau de l'échantillon et sans coupure.
|
||||
- **2025.12.21** : implémentation de la prise en charge du clavier MIDI ! Vous pouvez désormais connecter un clavier MIDI et l'utiliser pour jouer des sons. Veuillez noter que cette fonction peut ne pas fonctionner de manière optimale dans Safari et certains autres navigateurs ne prenant pas entièrement en charge l'interface Web MIDI.
|
||||
- **2025.12.15** : ajout de l'assistant d'accords intelligent avec guidage harmonique fonctionnel (T/S/D). Survolez les touches du piano pour voir des suggestions d'accords adaptées au contexte et créer des accords complets en un clic !
|
||||
|
||||
## État du projet
|
||||
|
||||
**K.G.Studio est un projet expérimental encore en phase de développement précoce.** Nous explorons les possibilités d'intégrer des agents IA et des LLM dans les flux de production musicale, en construisant essentiellement une expérience de type « Cursor ou Claude Code pour DAW ».
|
||||
|
||||
Ce projet étudie comment la collaboration entre l'IA et l'humain peut enrichir la création musicale, depuis les suggestions harmoniques intelligentes jusqu'aux tâches d'édition automatisées. En tant que plateforme expérimentale, attendez-vous à des changements fréquents, des fonctionnalités en évolution et une instabilité occasionnelle au fur et à mesure que nous repoussons les limites de la production musicale assistée par l'IA.
|
||||
|
||||
## Vidéos de démonstration
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://youtu.be/F1JWjK84zwc" target="_blank">
|
||||
<img src="./public/demo/demo-cover.png" alt="K.G.One Music Studio" width="400"/>
|
||||
</a>
|
||||
<br><b>K.G.One Music Studio</b>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://youtu.be/FXgihfAH2vc" target="_blank">
|
||||
<img src="./public/demo/cover-FXgihfAH2vc.png" alt="Short Demo" width="400"/>
|
||||
</a>
|
||||
<br><b>Démo courte (DAW uniquement)</b>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://youtu.be/vKbWAQRt0r0" target="_blank">
|
||||
<img src="./public/demo/cover-vKbWAQRt0r0.png" alt="Full Demo" width="400"/>
|
||||
</a>
|
||||
<br><b>Démo complète (DAW uniquement)</b>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
## Démarrage rapide
|
||||
|
||||
### Configurer K.G.Studio Musician Assistant
|
||||
|
||||
Il existe deux façons d'utiliser K.G.Studio Musician Assistant : **Local LLM (Browser)**, qui s'exécute entièrement dans votre navigateur sans clé API, sans coût et sans que les données quittent votre appareil, ou un **fournisseur LLM externe** pour obtenir des réponses de meilleure qualité.
|
||||
|
||||
#### Option A : Local LLM (Browser) — Aucune clé API requise ✦
|
||||
|
||||
K.G.Studio peut exécuter **Gemma 4 E4B** directement dans votre navigateur grâce à l'accélération WebGPU. Aucun appel API n'est effectué, aucun coût n'est engagé et vos données ne quittent jamais votre machine.
|
||||
|
||||
- Cliquez ici pour commencer à utiliser l'application en ligne : [K.G.Studio (kgaudiolab.github.io/kgstudio)](https://kgaudiolab.github.io/kgstudio)
|
||||
- Dans **Settings ⚙️ → General → LLM Provider**, sélectionnez **Local LLM (Browser)** (option par défaut).
|
||||
- Le modèle (~2.8 GB) se télécharge automatiquement la première fois que vous ouvrez le chat et est mis en cache dans l'OPFS (Origin Private File System) de votre navigateur pour des lancements ultérieurs quasi instantanés.
|
||||
- Vous pouvez également configurer la **Context Length** (32k / 64k / 128k tokens) ; des valeurs plus élevées nécessitent davantage de VRAM.
|
||||
- Commencez à discuter ! Pas de clé, pas de compte, pas de trafic réseau après le téléchargement initial du modèle.
|
||||
|
||||
**Conditions pour Local LLM :** un navigateur compatible WebGPU (Chrome 113+ ou Edge 113+) exécuté dans un contexte sécurisé (HTTPS ou localhost). Matériel recommandé : un GPU avec au moins 8 GB de VRAM, ou un système avec au moins 16 GB de mémoire unifiée.
|
||||
|
||||
> **Remarque :** la qualité du modèle local ne peut pas être comparée à celle des modèles commerciaux de type GPT ou Claude. Pour des tâches d'édition musicale complexes, un fournisseur externe produira en général de meilleurs résultats.
|
||||
|
||||
#### Option B : Fournisseur LLM externe (meilleure qualité)
|
||||
|
||||
- Cliquez ici pour commencer à utiliser l'application en ligne : [K.G.Studio (kgaudiolab.github.io/kgstudio)](https://kgaudiolab.github.io/kgstudio)
|
||||
- [Cliquez ici pour obtenir une clé API OpenRouter gratuite](https://openrouter.ai/keys) (vous pouvez avoir besoin d'un compte OpenRouter).
|
||||
- Dans **Settings ⚙️ → General → LLM Provider**, sélectionnez **OpenAI Compatible**.
|
||||
- Dans **OpenAI Compatible Server → Key**, collez votre clé. (Remarque : hors localhost, votre clé n'est pas persistée par défaut pour des raisons de sécurité ; vous pouvez activer « Persist API Keys on Non-Localhost » dans les paramètres pour la persister, mais cela peut augmenter le risque XSS.)
|
||||
- Dans **OpenAI Compatible Server → Model**, saisissez `openai/gpt-oss-120b:free`. (Remarque : il s'agit d'un modèle gratuit ; les modèles non gratuits peuvent nécessiter une facturation ; les fournisseurs de modèles gratuits peuvent collecter vos données, consultez la page du modèle pour plus de détails ; ce projet **n'est pas** affilié à OpenRouter ni à aucun fournisseur de modèles.)
|
||||
- Dans **OpenAI Compatible Server → Base URL**, saisissez `https://openrouter.ai/api/v1`.
|
||||
|
||||
**Conseils :**
|
||||
- Vous pouvez également utiliser l'API officielle d'OpenAI, d'autres services compatibles OpenAI, ou un serveur LLM auto-hébergé (par exemple Ollama, vLLM). Notez que la qualité des modèles varie : tous les modèles ne sont pas aussi performants pour les tâches d'édition musicale. Pour l'hébergement local, nous recommandons `qwen3.5-35b-a3b` comme bon équilibre entre qualité de génération et exigences matérielles.
|
||||
- Si vous disposez d'un abonnement actif chez OpenAI ou chez un autre fournisseur LLM, vous pouvez utiliser [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) pour exécuter un serveur proxy local qui achemine les requêtes via votre abonnement existant, sans nécessiter de clé API séparée.
|
||||
|
||||
### Opérations DAW de base
|
||||
- Double-cliquez (ou maintenez Ctrl/Cmd et cliquez) sur une piste pour créer une région.
|
||||
- Faites glisser les bords de la 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 Ctrl/Cmd+clic) pour créer une note.
|
||||
- Cliquez pour sélectionner ; Shift+clic pour la multi-sélection ; glissez pour faire une sélection rectangulaire.
|
||||
- Faites glisser les bords de la note pour la redimensionner ; faites glisser le corps de la note pour déplacer les notes sélectionnées.
|
||||
- Utilisez Snapping dans la barre d'outils du piano roll (en haut à droite) pour quantifier sur la grille.
|
||||
|
||||
### Utiliser K.G.Studio Musician Assistant
|
||||
- Sélectionnez la région musicale sur laquelle vous souhaitez que l'assistant travaille, saisissez votre prompt dans le chat ; appuyez sur Enter pour envoyer, Shift+Enter pour insérer une nouvelle ligne.
|
||||
- L'agent traitera automatiquement votre demande et invoquera des outils pour effectuer des modifications limitées à la région sélectionnée. Une tâche peut nécessiter un ou plusieurs tours pour être accomplie.
|
||||
- Notez que l'IA peut se tromper ; vous devez donc toujours vérifier le résultat et l'ajuster si nécessaire. Vous pouvez également utiliser annulation/rétablissement pour revenir sur les changements.
|
||||
- Cliquez sur le bouton « + » ou utilisez la commande `/clear` pour effacer l'historique du chat.
|
||||
|
||||
### Plus de détails
|
||||
|
||||
Vous trouverez le guide utilisateur détaillé [ici](./docs/USER_GUIDE.md).
|
||||
|
||||
### Points forts
|
||||
- **K.G.Studio Musician Assistant** : discutez avec l'agent IA alimenté par un LLM ; il exécute automatiquement des outils pour effectuer des modifications musicales dans la région sélectionnée.
|
||||
- **LLM intégré au navigateur — aucune clé API requise** : exécutez **Gemma 4 E4B** entièrement dans votre navigateur via WebGPU (LiteRT-LM). Aucun appel API, aucun coût, aucune donnée quittant votre appareil. Le modèle est téléchargé une fois et mis en cache localement.
|
||||
- **Séparation de stems intégrée au navigateur — aucun serveur requis** : séparez n'importe quelle région audio en stems avec **UVR-MDX-NET-Inst_HQ_3** (2 stems : Vocals / Instrumental) ou **Demucs htdemucs_4s** (4 stems : Vocals / Drums / Bass / Others), tous deux entièrement exécutés dans le navigateur via ONNX Runtime WebGPU, sans serveur K.G.One.
|
||||
- **Détection d'accords audio** : ouvrez le piano roll sur une région audio et lancez **Detect Chords** pour analyser automatiquement l'enregistrement et remplir la Chord Track globale avec un pipeline FFT sans dépendance, avec sensibilité, stabilité et détection des accords de septième configurables.
|
||||
- **Détection de tempo avec alignement automatique des temps** : lancez **Detect Tempo** depuis la barre d'outils du piano roll pour analyser le BPM d'une région audio et, si vous le souhaitez, réaligner la Tempo Track du projet.
|
||||
- **Système de pistes globales** : quatre pistes globales persistantes — **Marker**, **Tempo**, **Key Signature** et **Chord** — fournissent une structure à l'échelle du projet à laquelle toutes les fonctions (timing de lecture, détection d'accords, notation musicale) se réfèrent.
|
||||
- **Intégration K.G.One Music Studio** : lorsqu'il est connecté à un serveur local [K.G.One](https://github.com/KGAudioLab/K.G.One), vous débloquez la **Full Song Generation** accélérée par GPU (ACE-Step 1.5), la **Clip & MIDI Loop Generation** (Foundation-1) et des modèles supplémentaires de **Stem Separation**.
|
||||
- **Plusieurs fournisseurs LLM** : OpenAI, Claude / Gemini (via OpenRouter), services compatibles OpenAI (Ollama, vLLM, etc.), ou Local Browser LLM intégré — sans clé requise.
|
||||
- **Édition de pistes et de régions** : ajouter/réordonner des pistes, créer/déplacer/redimensionner des régions, multi-sélection avec lasso, déplacement/redimensionnement de groupe, fusion et scission de régions, avec annulation/rétablissement complet.
|
||||
- **Piano roll** : notes, pitch bend et lanes d'automation MIDI CC ; vue de notation musicale (sheet music) via VexFlow ; superposition de spectrogramme pour la référence audio-vers-MIDI.
|
||||
- **Vrais instruments** : sampler basé sur Tone.js avec soundfonts FluidR3 de haute qualité. Enregistrez directement l'audio depuis votre microphone vers une piste audio.
|
||||
- **Assistant d'accords intelligent** : suggestions d'accords en temps réel basées sur l'harmonie fonctionnelle (T/S/D), avec aperçu visuel et création d'accord en un clic.
|
||||
- **Persistance avec confidentialité** : projets et configuration enregistrés entièrement dans votre navigateur (OPFS / IndexedDB). Aucun serveur propriétaire.
|
||||
|
||||
Pour une vue technique plus approfondie, voir [overview.md](./docs/technical/overview.md).
|
||||
|
||||
## Premiers pas
|
||||
|
||||
### Ou cloner et exécuter localement :
|
||||
```bash
|
||||
# Assurez-vous d'avoir Node.js >= 20.19.3 installé
|
||||
# Cloner le dépôt
|
||||
git clone https://github.com/KGAudioLab/KGStudio {your-local-path}
|
||||
cd {your-local-path}
|
||||
|
||||
# Installer les dépendances
|
||||
npm install
|
||||
|
||||
# Lancer le serveur de développement
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
K.G.Studio charge ses valeurs par défaut depuis `./public/config.json` (avec une valeur de secours interne) et persiste les modifications utilisateur dans le navigateur via `ConfigManager` + IndexedDB. IndexedDB est la base de données locale propre à votre navigateur, stockée sur votre appareil ; elle ne quitte pas votre machine et est effacée si vous supprimez les données de ce site. Modifiez les paramètres via le panneau Settings intégré à l'application.
|
||||
|
||||
- **General**
|
||||
- Fournisseur LLM : OpenAI, ou compatible OpenAI
|
||||
- Clés API et modèles pour le fournisseur sélectionné
|
||||
- Persist API Keys on Non-Localhost : permet de persister les clés API dans des environnements non localhost (option de sécurité volontaire, non recommandée pour les environnements partagés ou de production)
|
||||
- Base URL compatible OpenAI (pour les passerelles auto-hébergées)
|
||||
- Base URL des soundfonts (CDN pour les échantillons d'instruments)
|
||||
- **Behavior**
|
||||
- Ouverture du chat par défaut au démarrage
|
||||
- **Templates**
|
||||
- Instructions personnalisées utilisées par l'assistant IA
|
||||
|
||||
### Connectivité et confidentialité
|
||||
|
||||
- K.G.Studio fonctionne entièrement côté client. Aucun serveur propriétaire n'est nécessaire pour faire fonctionner l'application.
|
||||
- Les projets et fichiers audio sont stockés dans l'OPFS (Origin Private File System) de votre navigateur ; la configuration est stockée dans IndexedDB. Toutes les données restent sur votre appareil.
|
||||
- L'accès réseau est utilisé uniquement pour :
|
||||
- télécharger les échantillons d'instruments depuis le CDN de soundfonts configuré
|
||||
- communiquer avec le fournisseur LLM que vous avez choisi (par exemple OpenAI ou des services compatibles OpenAI)
|
||||
- En dehors de ces deux cas, l'application fonctionne localement. Si vous bloquez ces points d'accès, l'application se charge toujours ; la lecture des instruments et les fonctions IA ne fonctionneront pas tant que l'accès réseau ne sera pas rétabli.
|
||||
- Pour des raisons de sécurité, lorsque l'application est exécutée depuis un hôte non local, nous ne persistons pas votre clé API dans IndexedDB par défaut (afin de réduire le risque XSS). Vous devrez la saisir à chaque démarrage de K.G.Studio. Pour activer la persistance sur des hôtes non locaux, activez « Persist API Keys on Non-Localhost » dans les paramètres (non recommandé pour les environnements partagés ou de production).
|
||||
|
||||
## Utiliser l'application
|
||||
|
||||
Vous trouverez le guide utilisateur détaillé [ici](./docs/USER_GUIDE.md).
|
||||
|
||||
- Pistes
|
||||
- Ajoutez, renommez et réordonnez les pistes depuis le panneau d'informations des pistes.
|
||||
- Changez d'instrument avec le bouton d'instrument (icône de piano) ; ajustez Solo (S), Mute (M) et Volume.
|
||||
- Supprimez une piste depuis le menu des paramètres de la piste (bouton à droite de l'instrument).
|
||||
|
||||
- Régions
|
||||
- Créer une région : avec l'outil Pointer, double-cliquez ; ou maintenez Ctrl/Cmd et cliquez. Avec l'outil Pencil, cliquez une seule fois.
|
||||
- Déplacer/redimensionner : faites glisser le corps pour déplacer ; faites glisser les bords pour redimensionner.
|
||||
- Ouvrez le Piano Roll via le petit crayon en haut à gauche d'une région.
|
||||
|
||||
- Piano Roll (notes MIDI)
|
||||
- Outils : Select vs Pencil.
|
||||
- Créer des notes : double-cliquez ou Ctrl/Cmd+clic (Select) ; clic simple (Pencil).
|
||||
- Sélectionner des notes : clic ; Shift+clic pour la multi-sélection ; glisser pour la sélection rectangulaire.
|
||||
- Déplacer/redimensionner : faites glisser le corps de la note pour déplacer les notes sélectionnées ; faites glisser les bords pour redimensionner.
|
||||
- **Vue en notation musicale** : basculez entre les vues Piano Roll et Staff Notation depuis la barre d'outils du piano roll. Prend en charge la sélection automatique de la clé, l'affichage de l'armure, le groupement des crochets, les liaisons et la quantification configurable. Activez **Track Scope** pour afficher toutes les régions de la piste comme une partition continue.
|
||||
- **Lanes d'automation** : dessinez et modifiez des courbes de pitch bend et de MIDI CC (Modulation, Breath, Volume, Expression, Sustain) dans la lane éditable sous la grille piano.
|
||||
- **Mode spectrogramme** : affichez le spectrogramme d'une région audio dans le piano roll comme couche de référence pendant l'édition des notes MIDI.
|
||||
- Fermez le piano roll avec X ou ESC.
|
||||
|
||||
- Intelligent Chord Assistant (ajouté le 2025-12-15)
|
||||
- Activez le guide d'accords depuis la barre d'outils du piano roll : sélectionnez la fonction T (Tonic), S (Subdominant) ou D (Dominant).
|
||||
- Survolez n'importe quelle touche pour voir les suggestions d'accords adaptées au contexte, mises en évidence en rouge, en accord avec l'armure et le mode sélectionnés.
|
||||
- Appuyez sur Tab pour faire défiler différents renversements d'accords pour la même fonction harmonique.
|
||||
- Double-cliquez (ou Ctrl/Cmd+clic) sur un accord surligné pour créer toutes les notes d'un coup.
|
||||
- La longueur de l'accord correspond automatiquement à la dernière longueur de note modifiée pour conserver un rythme cohérent.
|
||||
|
||||
- Snapping et quantification
|
||||
- Définissez le snapping depuis le menu NO SNAP (en haut à droite).
|
||||
- Quantifiez le timing avec Qua. Pos. (début) et Qua. Len. (longueur).
|
||||
|
||||
- Lecture et tête de 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 dans la grille principale ; dans Piano Roll, cliquez sur l'en-tête de la timeline (respecte le snapping).
|
||||
- Modifiez le BPM, la signature rythmique et la tonalité via les indicateurs de la barre d'outils.
|
||||
|
||||
## Raccourcis clavier
|
||||
|
||||
- Principal
|
||||
- Lecture/Pause : Space
|
||||
- Annuler / Rétablir : Ctrl/Cmd+Z / Ctrl/Cmd+Shift+Z
|
||||
- Copier / Couper / Coller : Ctrl/Cmd+C / Ctrl/Cmd+X / Ctrl/Cmd+V
|
||||
- Sauvegarder : Ctrl/Cmd+S
|
||||
- Maintenir pour créer une région : Ctrl/Cmd
|
||||
- Piano Roll
|
||||
- Outils : Select (Q), Pencil (W)
|
||||
- Maintenir pour créer une note : Ctrl/Cmd
|
||||
- Snap : 1(None), 2(1/4), 3(1/8), 4(1/16)
|
||||
- Quantification de position : 5(1/4), 6(1/8), 7(1/16)
|
||||
- Quantification de longueur : 8(1/4), 9(1/8), 0(1/16)
|
||||
|
||||
## Assistant IA
|
||||
|
||||
### Utiliser K.G.Studio Musician Assistant
|
||||
|
||||
- Assurez-vous d'avoir suivi la section précédente pour configurer le fournisseur LLM.
|
||||
- Vous trouverez la zone de chat de K.G.Studio Musician Assistant à droite. Si vous ne la voyez pas, cliquez sur le bouton Chat 🗨️ dans la barre d'outils.
|
||||
- Sélectionnez la région sur laquelle vous souhaitez que l'assistant travaille, saisissez votre prompt dans le chat ; appuyez sur Enter pour envoyer, Shift+Enter pour insérer une nouvelle ligne.
|
||||
- L'agent traitera automatiquement votre demande et invoquera des outils pour effectuer des modifications limitées à la région sélectionnée. Une tâche peut nécessiter un ou plusieurs tours pour être accomplie.
|
||||
- Notez que l'IA peut se tromper ; vous devez donc toujours vérifier le résultat et l'ajuster si nécessaire. Vous pouvez aussi utiliser annulation/rétablissement pour revenir en arrière.
|
||||
- Cliquez sur le bouton « + » ou sur la commande `/clear` pour effacer l'historique du chat.
|
||||
|
||||
### Configurer votre fournisseur LLM
|
||||
|
||||
Allez dans **Settings ⚙️ → General → LLM Provider**. Selon le fournisseur choisi, vous devrez fournir la clé API appropriée et, le cas échéant, une base URL personnalisée (pour les services non officiels compatibles OpenAI comme Ollama, OpenRouter, etc.).
|
||||
|
||||
Remarque : en raison des limitations CORS chez certains fournisseurs, Google Gemini et Anthropic Claude ne sont actuellement pris en charge que via OpenRouter.
|
||||
|
||||
### Utiliser les modèles OpenAI
|
||||
|
||||
1. Obtenez une clé API OpenAI auprès de [**OpenAI**](https://platform.openai.com/account/api-keys). Vous devrez peut-être créer un compte et ajouter un moyen de paiement pour générer une clé API.
|
||||
2. Dans **Settings ⚙️ → General → LLM Provider**, sélectionnez **OpenAI** comme fournisseur.
|
||||
3. Saisissez votre clé API dans **OpenAI → Key**.
|
||||
4. Sélectionnez votre modèle préféré dans la liste **OpenAI → Model**. Pour un bon équilibre entre performances et coût, nous recommandons `gpt-5.4-mini`.
|
||||
5. Vous pouvez également choisir d'activer Flex Mode dans **OpenAI → Flex Mode**. Flex Mode offre un tarif réduit, mais peut entraîner des temps de réponse plus lents ou des erreurs côté serveur.
|
||||
|
||||
### Utiliser OpenRouter
|
||||
|
||||
OpenRouter est une plateforme qui fournit un accès unifié à un large éventail de modèles de langage, y compris des options gratuites, provenant de divers fournisseurs. Cela facilite l'expérimentation et la recherche du modèle le mieux adapté à vos besoins.
|
||||
|
||||
1. Obtenez une clé API auprès de [**OpenRouter**](https://openrouter.ai/keys). L'inscription est requise ; pour les modèles payants, un moyen de paiement peut également être nécessaire.
|
||||
2. Dans **Settings ⚙️ → General → LLM Provider**, sélectionnez **OpenAI Compatible** comme fournisseur.
|
||||
3. Saisissez votre clé API dans **OpenAI Compatible Server → Key**.
|
||||
4. Parcourez les modèles disponibles sur la [**page des modèles OpenRouter**](https://openrouter.ai/models). Utilisez le filtre « Prompt Pricing » pour identifier les modèles gratuits.
|
||||
**Remarque :** chaque fournisseur de modèle peut avoir des politiques différentes en matière de conservation des données et de confidentialité. Veuillez les consulter avant utilisation.
|
||||
5. Saisissez le nom du modèle choisi dans **OpenAI Compatible Server → Model**. Les séries recommandées comprennent :
|
||||
- `Anthropic: Claude Sonnet 4.6` (`anthropic/claude-sonnet-4.6`: [Link](https://openrouter.ai/anthropic/claude-sonnet-4.6)) — meilleur équilibre qualité/coût pour la série Claude
|
||||
- `Qwen: Qwen3.5-35B-A3B` (`qwen/qwen3.5-35b-a3b`: [Link](https://openrouter.ai/qwen/qwen3.5-35b-a3b)) — modèle open source recommandé
|
||||
- `Qwen: Qwen3-Next-80B-A3B` (MODÈLE GRATUIT : `qwen/qwen3-next-80b-a3b-instruct:free`: [Link](https://openrouter.ai/qwen/qwen3-next-80b-a3b-instruct:free)) — modèle gratuit recommandé
|
||||
- `OpenAI: GPT-OSS 120B` (MODÈLE GRATUIT : `openai/gpt-oss-120b:free`: [Link](https://openrouter.ai/openai/gpt-oss-120b:free)) — modèle gratuit recommandé
|
||||
- Remarque : les fournisseurs de modèles gratuits peuvent collecter vos données ; consultez la page du modèle avant utilisation
|
||||
- Remarque : la disponibilité des modèles gratuits change fréquemment ; pour les options gratuites les plus récentes, consultez la [page des modèles OpenRouter](https://openrouter.ai/models) et utilisez le filtre **Prompt Pricing**
|
||||
6. Saisissez l'URL de base `https://openrouter.ai/api/v1` dans **OpenAI Compatible Server → Base URL**.
|
||||
|
||||
### À propos de l'agent et des fournisseurs LLM
|
||||
|
||||
Pour des raisons de sécurité, lorsque vous utilisez K.G.Studio depuis un hôte non local, les clés API ne sont pas persistées dans IndexedDB par défaut ; vous devrez donc saisir votre clé API à chaque démarrage de K.G.Studio. Pour activer la persistance sur des hôtes non locaux, activez « Persist API Keys on Non-Localhost » dans les paramètres (non recommandé pour les environnements partagés ou de production).
|
||||
|
||||
K.G.Studio ne fournit ni n'héberge aucun des modèles listés ci-dessus et n'est affilié à aucun fournisseur de modèles. Toutes les données sont stockées localement sur votre appareil ; K.G.Studio ne collecte ni ne transmet vos données. Vous êtes seul responsable de toute donnée que vous fournissez à des fournisseurs de modèles tiers.
|
||||
|
||||
## K.G.One Music Generator
|
||||
|
||||
> **Nécessite l'intégration de [K.G.One Music Studio](https://github.com/KGAudioLab/K.G.One).** Le panneau K.G.One Music Generator n'est disponible que lorsque K.G.Studio est connecté à un serveur K.G.One en fonctionnement. Consultez le [dépôt K.G.One](https://github.com/KGAudioLab/K.G.One) pour les instructions d'installation.
|
||||
|
||||
Le panneau **K.G.One Music Generator** fournit trois outils IA accélérés par GPU pour la génération musicale et le traitement audio. Cliquez sur le bouton **✦ (baguette magique)** dans la barre d'outils pour l'ouvrir. Le panneau est mutuellement exclusif avec la zone de chat AI Assistant : l'ouverture de l'un ferme l'autre.
|
||||
|
||||
> **Remarque :** la première fois que vous utilisez chaque outil, le serveur doit charger le modèle IA correspondant, ce qui peut prendre 60 secondes ou plus selon votre matériel. Changer d'onglet peut également déclencher un rechargement du modèle.
|
||||
|
||||
### Full Song Generation
|
||||
|
||||
Générez une chanson complète de longue durée à partir d'une description textuelle et de paroles facultatives. Alimenté par [ACE-Step 1.5](https://github.com/ace-step/ACE-Step-1.5).
|
||||
|
||||
- Dans l'onglet **Full Song**, saisissez un **Caption** décrivant le style, l'ambiance, le tempo, l'instrumentation et la structure souhaités en langage naturel. Exemple : `Genre: Eurodance, 90s dance-pop, upbeat electronic. Tempo: ~130 BPM. Instrumentation: driving kick drum, eurodance bassline...`
|
||||
- Vous pouvez éventuellement saisir des **Lyrics**. Utilisez les balises `[Intro]`, `[Verse]`, `[Chorus]`, `[Bridge]` pour marquer les sections. Cochez **Instrumental** pour ne pas générer de voix.
|
||||
- Cliquez sur **Generate Song**. Un indicateur de progression affiche en temps réel l'étape de génération et le pourcentage.
|
||||
- Une fois la génération terminée, un lecteur de prévisualisation apparaît. Faites-le glisser sur une **audio track** pour importer le morceau comme région.
|
||||
- Le glisser-déposer sur une piste MIDI n'est pas pris en charge pour la génération de morceau complet.
|
||||
- **Advanced Settings** (dépliable) : Inference Steps, Guidance Scale, Seed, et Thinking (CoT metadata generation).
|
||||
|
||||
### Clip Generation
|
||||
|
||||
Générez de courts clips instrumentaux et des boucles MIDI à partir de descriptions textuelles. Alimenté par [Foundation-1](https://huggingface.co/RoyalCities/Foundation-1).
|
||||
|
||||
- Dans l'onglet **Clip**, saisissez un **Prompt** décrivant le clip à l'aide de tags séparés par des virgules couvrant la famille d'instruments, le sous-type, le timbre, les effets, la longueur, le BPM et la tonalité. Exemple : `Gritty, Acid, Bassline, 303, Synth Lead, FM, Sub, High Reverb, 8 Bars, 140 BPM, E minor`
|
||||
- Vous pouvez éventuellement saisir un **Negative Prompt** pour éloigner la génération de caractéristiques non désirées (par ex. `distortion, noise`).
|
||||
- Sélectionnez **Bars** : 4 ou 8. Le BPM et la tonalité sont préremplis à partir des paramètres de votre projet et peuvent être ajustés dans **Advanced Settings**.
|
||||
- Cliquez sur **Generate Clip**. Une fois terminé, un lecteur de prévisualisation apparaît avec une poignée de glisser à gauche et un bouton de téléchargement à droite.
|
||||
- **Pour importer** : faites glisser le lecteur sur une piste de la timeline.
|
||||
- Déposez-le sur une **audio track** pour l'importer comme région audio WAV (recommandé).
|
||||
- Déposez-le sur une **MIDI track** pour l'importer comme région MIDI. Notez que le MIDI est transcrit depuis l'audio et peut ne pas être parfaitement exact.
|
||||
- **Advanced Settings** (dépliable) : Note, Scale, BPM, Steps, CFG Scale, Seed (`-1` pour aléatoire), Sampler Type, Sigma Min/Max, et CFG Rescale.
|
||||
|
||||
### Stem Separation
|
||||
|
||||
Séparez une région audio existante en stems individuels (par exemple voix, instruments, batterie).
|
||||
|
||||
K.G.Studio prend en charge **deux modes** de séparation de stems :
|
||||
|
||||
#### Mode navigateur local — Aucun serveur requis ✦
|
||||
|
||||
Deux modèles ONNX s'exécutent entièrement dans votre navigateur, sans appels API, sans coût, et sans que vos données quittent votre appareil. Les modèles sont téléchargés une fois puis mis en cache localement.
|
||||
|
||||
- **Vocal and Instrument (Medium Accuracy)** (`UVR-MDX-NET-Inst_HQ_3`, ~64 MB) — séparation en deux stems (Vocals / Instrumental). Alimenté par [UVR-MDX-NET](https://github.com/nomadkaraoke/python-audio-separator).
|
||||
- **Vocal, Drums, Bass, and Others** (`htdemucs_4s`, ~172 MB) — séparation en quatre stems (Vocals / Drums / Bass / Others). Alimenté par [Demucs](https://github.com/facebookresearch/demucs).
|
||||
|
||||
Ouvrez le panneau **Music Generator** (bouton ✦ dans la barre d'outils), sélectionnez un modèle, cliquez une fois sur **Download Selected Model**, puis sur **Separate Stems** : tout s'exécute localement dans votre navigateur.
|
||||
|
||||
**Conditions requises :** un navigateur compatible WebGPU (Chrome 113+ ou Edge 113+) dans un contexte sécurisé (HTTPS ou localhost). L'accélération WebGPU est utilisée lorsque disponible ; sinon, le système revient au CPU (ce qui peut réduire la réactivité de la page pendant le traitement). Matériel recommandé : un GPU avec au moins 8 GB de VRAM, ou un système avec au moins 16 GB de mémoire unifiée.
|
||||
|
||||
#### Mode serveur K.G.One
|
||||
|
||||
Lorsqu'il est connecté à un serveur [K.G.One Music Studio](https://github.com/KGAudioLab/K.G.One), trois modèles supplémentaires accélérés par GPU sont disponibles. Alimenté par [python-audio-separator (UVR5)](https://github.com/nomadkaraoke/python-audio-separator).
|
||||
|
||||
- **Vocal and Instrument (Medium Accuracy)** (`UVR-MDX-NET-Inst_HQ_3`) — séparation rapide en deux stems (vocal / instrumental).
|
||||
- **Vocal and Instrument (High Accuracy)** (`MDX23C-8KFFT-InstVoc_HQ`) — séparation en deux stems de meilleure qualité, plus lente.
|
||||
- **Vocal, Drums, Bass, Guitar, Piano, and Others** (`htdemucs_6s`) — séparation complète en six stems.
|
||||
|
||||
#### Utilisation (dans les deux modes)
|
||||
|
||||
- **Sélectionnez une région audio** sur la timeline avant d'ouvrir cet onglet. La région sélectionnée et le nom de sa piste sont affichés en haut de l'onglet **Separator**. Seules les régions audio sont prises en charge ; les régions MIDI ne peuvent pas être séparées.
|
||||
- Cliquez sur **Separate Stems**. Si la région sélectionnée a un clip start offset ou a été découpée, l'audio est automatiquement tronqué pour correspondre à la plage de la région avant traitement.
|
||||
- Une fois terminé, chaque stem apparaît comme lecteur de prévisualisation étiqueté avec une poignée de glisser. Vous pouvez prévisualiser chaque stem individuellement avant l'import.
|
||||
- **Pour importer les stems :**
|
||||
- Faites glisser chaque lecteur de stem individuellement sur une **audio track** pour le placer où vous le souhaitez.
|
||||
- Ou cliquez sur **Import All Stems to Timeline** pour créer automatiquement une nouvelle piste audio par stem, positionnée juste sous la piste source et alignée sur le même point de départ que la région d'origine. Il s'agit d'une seule opération annulable.
|
||||
|
||||
## Fonctionnalités à venir
|
||||
|
||||
Les priorités peuvent évoluer.
|
||||
|
||||
### 1.0
|
||||
|
||||
- [X] Plus d'instruments
|
||||
- [X] Tests automatisés (tests unitaires, tests d'intégration, etc.)
|
||||
- [X] Assistant d'accords intelligent avec guidage harmonique fonctionnel (T/S/D)
|
||||
- [X] Prise en charge des automations de contrôle de piste (par ex. sustain, volume, pan, etc.)
|
||||
- [X] Prise en charge des événements de contrôle MIDI (par ex. CC, pitch bend, etc.)
|
||||
- [X] Prise en charge des pistes audio WAV
|
||||
- [X] Enregistrement
|
||||
- [X] Event List
|
||||
- [X] Ajout de la prise en charge des modèles open source d'OpenAI (`gpt-oss-20b` et `gpt-oss-120b`)
|
||||
- [X] Notation musicale
|
||||
- [X] Intégration K.G.One Music Studio
|
||||
- [X] Modèles IA intégrés au navigateur (LLM sur l'appareil via Gemma 4 E4B ; séparation de stems sur l'appareil via UVR-MDX-NET-Inst_HQ_3 et htdemucs_4s)
|
||||
- [X] Système de pistes globales (Marker, Tempo, Key Signature, Chord)
|
||||
- [X] Détection d'accords audio (FFT sans dépendance, remplit la Chord Track)
|
||||
- [X] Détection de tempo avec alignement automatique des temps
|
||||
- [X] Visualisation spectrogramme et Piano Roll hybrid mode
|
||||
|
||||
### Après la 1.0
|
||||
|
||||
- [ ] Extension des outils de l'agent IA pour manipuler directement depuis le chat les régions, les pistes et les pistes globales (progressions d'accords, tempo, tonalité)
|
||||
- [ ] Vue Mixer avec panneau dédié, faders par piste, sends, bus de retour et canal master
|
||||
- [ ] EQ et channel strip avec égaliseur paramétrique et compresseur par piste
|
||||
- [ ] Filtres et effets, comme réverbération, délai et autres effets d'insertion natifs WebAudio
|
||||
- [ ] Audio time-stretch / warp pour étirer les régions audio et les adapter au tempo du projet
|
||||
- [ ] Effets MIDI, comme arpégiateur, quantificateur de gamme et mémoire d'accords
|
||||
- [ ] Sortie vers périphérique MIDI virtuel
|
||||
|
||||
## Aide recherchée
|
||||
|
||||
Nous cherchons des contributeurs pour rendre K.G.Studio encore meilleur ! Que vous soyez développeur, musicien ou designer, votre expertise peut faire une réelle différence.
|
||||
|
||||
### Comment vous pouvez aider
|
||||
|
||||
**🎵 Musiciens & producteurs**
|
||||
- Tester la DAW avec de vrais workflows de production musicale
|
||||
- Donner des retours sur la qualité et le réalisme des instruments
|
||||
- Suggérer des fonctionnalités manquantes essentielles à la création musicale
|
||||
- Aider à améliorer la compréhension musicale de l'assistant IA
|
||||
|
||||
**💻 Développeurs**
|
||||
- Implémenter de nouvelles fonctionnalités de notre feuille de route
|
||||
- Corriger des bugs et améliorer les performances
|
||||
- Renforcer l'intégration Web Audio
|
||||
- Travailler sur les capacités de l'assistant IA
|
||||
|
||||
**🎨 Designers UI/UX**
|
||||
- Améliorer l'interface utilisateur et le workflow
|
||||
- Concevoir un meilleur retour visuel pour l'édition musicale
|
||||
- Créer des interactions plus intuitives
|
||||
|
||||
### Participer
|
||||
|
||||
Intéressé par une contribution ? Nous serions ravis d'avoir de vos nouvelles !
|
||||
|
||||
- **Écrivez-nous** : [kgstudio@duck.com](mailto:kgstudio@duck.com)
|
||||
- **Consultez nos Issues** : parcourez les tickets ouverts avec les labels `help wanted` ou `good first issue`
|
||||
- **Rejoignez les Discussions** : partagez vos idées et retours dans GitHub Discussions
|
||||
|
||||
Aucune contribution n'est trop petite : du signalement de bugs à la proposition de nouvelles fonctionnalités, chaque aide fait avancer le projet.
|
||||
|
||||
### Avertissement
|
||||
|
||||
K.G.Studio est un projet expérimental en phase de développement précoce. Nous explorons les possibilités d'intégrer des agents IA et des LLM dans les flux de production musicale, en construisant essentiellement une expérience de type « Cursor ou Claude Code pour DAW ».
|
||||
|
||||
Ce projet étudie comment la collaboration entre l'IA et l'humain peut enrichir la création musicale, depuis les suggestions harmoniques intelligentes jusqu'aux tâches d'édition automatisées. En tant que plateforme expérimentale, attendez-vous à des changements fréquents, des fonctionnalités en évolution et une instabilité occasionnelle à mesure que nous repoussons les limites de la production musicale assistée par l'IA.
|
||||
|
||||
K.G.Studio ne fournit ni n'héberge aucun modèle LLM et n'est affilié à aucun fournisseur de modèles. Toutes les données sont stockées localement sur votre appareil ; K.G.Studio ne collecte ni ne transmet vos données. Vous êtes seul responsable de toute donnée que vous fournissez à des fournisseurs de modèles tiers.
|
||||
|
||||
## Licence
|
||||
|
||||
Ce projet est distribué sous Apache License, Version 2.0, avec des conditions supplémentaires (voir `LICENSE`) :
|
||||
- Aucune demande de brevet à partir de ce logiciel ou de ses ressources
|
||||
- Attribution requise en cas d'utilisation dans des produits publics/commerciaux (« Powered by K.G.Studio »)
|
||||
|
||||
Les notices tierces (FluidR3_GM SoundFont, midi-js-soundfonts, VexFlow, prompt structure notes, Gemma 4 E4B, UVR-MDX-NET-Inst_HQ_3, MediaPipe, Meyda, web-audio-beat-detector, tonal, htdemucs_4s, onnxruntime-web et demucs-web) sont incluses dans `LICENSE`.
|
||||
+455
@@ -0,0 +1,455 @@
|
||||
[English](./README.md) | [Français](./README-fr.md) | 简体中文 | [繁體中文](./README-zh_hk.md)
|
||||
|
||||
<div align="center">
|
||||
<img src="./public/logo.png" alt="K.G.Studio Logo" width="160" />
|
||||
</div>
|
||||
|
||||
# K.G.Studio — 一款基于浏览器的 DAW,并内置 AI 助手
|
||||
|
||||
<div align="center">
|
||||
<h3><a href="https://kgaudiolab.github.io/kgstudio"><b>◀ 立即在浏览器中在线使用 K.G.Studio ▶</b></a></h3>
|
||||
</div>
|
||||
|
||||
## 新动态
|
||||
|
||||
> ### ✦ [K.G.One Music Studio 现已发布。](https://github.com/KGAudioLab/K.G.One) ✦ <br />
|
||||
> [**K.G.One Music Studio**](https://github.com/KGAudioLab/K.G.One) 是一个完全本地、开源的一体化平台,以 **K.G.Studio**(本项目)为核心。它集成了用于整曲生成的 [ACE-Step 1.5](https://github.com/ace-step/ACE-Step-1.5)、用于音频片段和 MIDI loop 生成的 [Foundation-1](https://huggingface.co/RoyalCities/Foundation-1),以及用于分轨的 [python-audio-separator (UVR5)](https://github.com/nomadkaraoke/python-audio-separator),将 GPU 加速的 AI 音乐生成能力直接带入您的浏览器音乐制作工作流。
|
||||
|
||||
## 什么是 K.G.Studio?
|
||||
|
||||
K.G.Studio 是一款轻量、现代化的 DAW,完全运行于浏览器中,并以 **K.G.Studio 音乐创作助手** 为核心。它提供基于 Tone.js sampler 的真实乐器回放、钢琴卷帘编辑器、支持完整撤销/重做的音轨与区域管理、基于 OPFS(Origin Private File System)的项目持久化、可配置的设置面板,以及可执行工具的内置 AI 助手。
|
||||
|
||||
**K.G.Studio 音乐创作助手** 是一个面向和声、编曲与音符编辑的 AI 助手,但并不负责整首作品的全自动作曲。
|
||||
|
||||
<div align="center">
|
||||
<img src="./docs/KGOne-Demo-GIF.gif" alt="K.G.One Logo" width="640" />
|
||||
</div>
|
||||
|
||||
> 注意:整曲生成功能和音频片段生成功能需要集成 [**K.G.One Music Studio**](https://github.com/KGAudioLab/K.G.One)。
|
||||
|
||||
## 最新更新
|
||||
|
||||
- **2026.05.30**: 新增 **国际化(i18n)支持** — K.G.Studio 现已提供四种语言版本:**English**、**简体中文**、**繁體中文** 和 **Français**。可在 **设置 ⚙️ → 通用 → 语言** 中配置首选语言,选择 `Auto` 时将自动检测浏览器语言。
|
||||
|
||||
- **2026.05.27**:
|
||||
- 新增 **全局轨系统**,引入四条全局轨:**Marker**、**Tempo**、**Key Signature** 和 **Chord**(和弦符号跨度区段)。
|
||||
- 新增 **音频和弦检测** 功能。您可以为音频区域或 MIDI 区域打开钢琴卷帘窗口,然后点击“...” -> **Detect Chords**,即可通过零依赖 FFT 流水线自动分析录音内容,并将结果写入 Chord Track;支持灵敏度、稳定性与七和弦检测配置。
|
||||
- 新增 **带自动对齐拍点的速度检测** 功能。您可以为音频区域打开钢琴卷帘窗口,然后在工具栏点击“...” -> **Detect Tempo**,分析音频 BPM,并可选择将项目中的 Tempo Track 区段自动重新对齐。
|
||||
- 新增 **Demucs 4S** 作为第二个本地浏览器内嵌分轨模型。现有的双 stem UVR-MDX-NET 模型之外,又加入了四 stem 的 `htdemucs_4s` 模型(约 172 MB,vocals / drums / bass / others),两者都可通过 ONNX Runtime WebGPU 完全在浏览器中运行。
|
||||
|
||||
- **2026.05.15**: 新增 **浏览器内嵌 AI 模型**。现在有两个 AI 模型可完全在浏览器中运行,无需外部服务、无需 API Key,也无需 K.G.One 服务器。**K.G.Studio 音乐创作助手** 新增 **Local LLM (Browser)** 提供方,由 **Gemma 4 E4B** 驱动,并通过 LiteRT-LM 与 WebGPU 加速运行;模型只需下载一次,随后会缓存在 OPFS 中,后续启动几乎可即时使用,同时支持可配置的上下文长度(32 k / 64 k / 128 k tokens)与实时推理性能统计。**Stem separation** 也支持本地运行,基于浏览器内嵌的 **UVR-MDX-NET-Inst_HQ_3** ONNX 模型并使用 WebGPU 加速。您可以打开 **Music Generator** 面板(✦ 按钮),下载模型一次后,即可完全在本机将人声与伴奏分离。以上两项功能都要求浏览器支持 WebGPU(Chrome 113+ 或 Edge 113+)并运行在安全上下文中(HTTPS 或 localhost)。推荐硬件:至少 8 GB 显存的 GPU,或至少 16 GB 统一内存的系统。
|
||||
|
||||
- **2026.05.10**: 新增 **五线谱视图**。钢琴卷帘现已支持完整的标准乐谱显示模式。您可以通过钢琴卷帘工具栏中的切换按钮在 Piano Roll 和 Sheet Music 视图之间切换。在五线谱模式下,音符会通过 VexFlow 排版,并支持基于当前乐器自动选择谱号(高音或低音)、显示调号、自动符杠分组、跨小节连音线,以及可配置的音值量化。启用 **Track Scope** 后,整条音轨上的所有 MIDI 区域会以连续谱面的形式显示,而不再局限于单个区域。
|
||||
|
||||
- **2026.05.09**: 新增 **音频录音**。您现在可以直接从麦克风录制到音频轨。录音时会实时增长显示波形预览,停止后该录音会作为标准音频区域写入时间线。另新增 **音频 I/O 设备选择**,您可以在设置中选择偏好的麦克风输入设备和音频输出设备。
|
||||
|
||||
- **2026.05.08**: 新增 **MIDI 自动化**。您可以在钢琴网格下方的可编辑自动化区域中绘制和编辑 pitch bend 与 MIDI CC 曲线(CC1 Modulation、CC2 Breath、CC7 Volume、CC11 Expression、CC64 Sustain)。同时新增 **轨道级自动化**:每条音轨现在都有专用自动化面板,您可以直接在时间线上查看和编辑同样的曲线。实时 MIDI 控制器输入(如 pitch wheel、CC 踏板)也支持录制与按轨回放,并带有每条自动化轨的插值处理。另新增 **事件列表面板**,这是一个带标签页的侧边栏(Notes / Pitch Bend / Controller),可用于查看与内联编辑当前 MIDI 区域中的全部事件。还新增了 **区域多选**(支持套索与批量移动/缩放)以及 **合并 MIDI 区域**。
|
||||
<div align="center">
|
||||
<img src="./public/snapshots/2026-05-08-automations.png" alt="K.G.Studio Logo" width="640" />
|
||||
</div>
|
||||
|
||||
- **2026.05.02**: 新增 **音频轨频谱可视化**。音频区域现在会在时间网格中显示实时频谱叠加层。另新增 **Piano Roll hybrid mode**:当您在 MIDI 区域中打开钢琴卷帘时,可将相邻音频区域的频谱作为参考层显示,从而参照音频形状编辑 MIDI 音符。新增 **钢琴卷帘缩放**,并保留当前视口位置,使画面始终锚定在当前播放头附近。还新增 **区域微调位置**,可用小步长推动区域以实现精确摆放;同时新增跨组件的播放头滚动同步,使主网格与钢琴卷帘在播放期间保持联动。
|
||||
- **2026.04.29**: 在 K.G.One Music Generator 面板中新增 **Remix** 和 **Repaint**(由 ACE-Step 1.5 驱动)。**Remix** 可让您用新风格重制现有音频区域,您只需选中音频区域、描述目标风格,并可选填写新歌词,ACE-Step 就会按提示重新演绎歌曲的配器与氛围。**Repaint** 可对歌曲中特定片段进行局部再生成,您可以先在时间线上设置 loop 范围作为重绘窗口,再描述该片段应有的声音效果,其余部分将保持不变。两项功能都支持与其他 K.G.One 标签页相同的导入流程:可在内置播放器中预览、拖拽到音轨上,或点击 **Import Aligned to Source**,自动将结果放置到原始区域下方的新音轨中。
|
||||
- **2026.04.24**: 新增 [**K.G.One Music Studio**](https://github.com/KGAudioLab/K.G.One) 集成。当 K.G.Studio 连接到本地 K.G.One 服务器后,**K.G.One Music Generator** 面板(工具栏中的魔杖按钮 ✦)即可启用,其中包含三项 AI 驱动工具:**Full Song Generation**(由 ACE-Step 1.5 驱动,可根据文本提示生成整首歌曲)、**Clip Generation**(由 Foundation-1 驱动,可根据文本生成乐器片段与 MIDI loop),以及 **Stem Separation**(由 python-audio-separator 驱动,可将任意音频拆分为人声、伴奏等多个 stem)。生成的音频和 MIDI 均可即时预览,并可直接拖拽到您的音轨中。K.G.One 完全运行在您自己的机器上(Windows/Linux,需要 CUDA GPU);详细设置说明请参阅 [K.G.One 仓库](https://github.com/KGAudioLab/K.G.One)。
|
||||
- **2026.04.11**: 将项目存储从 IndexedDB 迁移到 OPFS(Origin Private File System),并采用基于文件夹的结构,以更好地处理媒体文件。新增音频轨支持,包括 WAV/MP3 导入、回放、循环,以及非破坏性区域裁剪。另新增基于离线渲染的 WAV/MP3 导出功能。
|
||||
- **2026.04.05**: 将 AI agent 从基于 XML 的工具调用迁移到原生 OpenAI SDK function calling,以提升可靠性与兼容性。另新增 GPT-5.4 系列等 LLM 模型选项。
|
||||
- **2026.01.23**: 实现无缝循环播放。您可以拖动小节编号设置 loop 范围,或通过工具栏中的 Loop 按钮切换循环模式。循环播放基于 `Tone.js` 的原生 loop 机制,可实现采样级精确、无缝衔接的循环。
|
||||
- **2025.12.21**: 实现 MIDI 键盘支持。您现在可以连接 MIDI 键盘并直接用其演奏声音。请注意,由于 Safari 和部分其他浏览器对 Web MIDI 接口支持不完整,此功能在这些浏览器中可能无法达到最佳效果。
|
||||
- **2025.12.15**: 新增智能和弦助手,支持功能和声指导(T/S/D)。将鼠标悬停在琴键上即可查看上下文相关的和弦建议,并可一键创建完整和弦。
|
||||
|
||||
## 项目状态
|
||||
|
||||
**K.G.Studio 是一个仍处于早期开发阶段的实验性项目。** 我们正在探索如何将 AI agent 与 LLM 融入音乐制作工作流,本质上是在构建一种面向 DAW 的 “Cursor 或 Claude Code” 体验。
|
||||
|
||||
该项目关注 AI 与人类协作如何增强音乐创作过程,从智能和声建议到自动化编辑任务都在探索范围内。作为实验平台,项目会频繁变化,功能将持续演进,并且在我们推进 AI 辅助音乐制作边界的过程中,偶尔出现不稳定情况也是可以预期的。
|
||||
|
||||
## 演示视频
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://youtu.be/F1JWjK84zwc" target="_blank">
|
||||
<img src="./public/demo/demo-cover.png" alt="K.G.One Music Studio" width="400"/>
|
||||
</a>
|
||||
<br><b>K.G.One Music Studio</b>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://youtu.be/FXgihfAH2vc" target="_blank">
|
||||
<img src="./public/demo/cover-FXgihfAH2vc.png" alt="Short Demo" width="400"/>
|
||||
</a>
|
||||
<br><b>短演示(仅 DAW)</b>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://youtu.be/vKbWAQRt0r0" target="_blank">
|
||||
<img src="./public/demo/cover-vKbWAQRt0r0.png" alt="Full Demo" width="400"/>
|
||||
</a>
|
||||
<br><b>完整演示(仅 DAW)</b>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 配置 K.G.Studio 音乐创作助手
|
||||
|
||||
使用 K.G.Studio 音乐创作助手有两种方式:一种是 **Local LLM (Browser)**,完全在浏览器中运行,无需 API Key、无需费用,且数据不会离开您的设备;另一种是使用 **外部 LLM 提供方**,以获得更高质量的回复。
|
||||
|
||||
#### 方案 A:Local LLM (Browser) — 无需 API Key ✦
|
||||
|
||||
K.G.Studio 可以借助 WebGPU 加速,在浏览器中直接运行 **Gemma 4 E4B**。不会产生 API 调用,不会产生费用,您的数据也不会离开本机。
|
||||
|
||||
- 点击这里开始在线使用应用:[K.G.Studio (kgaudiolab.github.io/kgstudio)](https://kgaudiolab.github.io/kgstudio)
|
||||
- 在 **设置 ⚙️ → 通用 → LLM 提供方** 中选择 **本地 LLM(浏览器)**(默认选项)。
|
||||
- 第一次打开聊天时,模型(约 2.8 GB)会自动下载,并缓存在浏览器的 OPFS(Origin Private File System)中,后续启动几乎可即时使用。
|
||||
- 您也可以选择配置 **上下文长度**(32k / 64k / 128k tokens);数值越大,对显存要求越高。
|
||||
- 现在就可以开始聊天。除首次下载模型外,无需 Key、无需账号,也无需持续联网。
|
||||
|
||||
**Local LLM 的要求:** 需要支持 WebGPU 的浏览器(Chrome 113+ 或 Edge 113+),并运行在安全上下文中(HTTPS 或 localhost)。推荐硬件为至少 8 GB 显存的 GPU,或至少 16 GB 统一内存的系统。
|
||||
|
||||
> **注意:** 本地模型的质量无法与 GPT 或 Claude 系列商业模型相比。对于复杂的音乐编辑任务,外部提供方通常会产生更好的结果。
|
||||
|
||||
#### 方案 B:外部 LLM 提供方(更高质量)
|
||||
|
||||
- 点击这里开始在线使用应用:[K.G.Studio (kgaudiolab.github.io/kgstudio)](https://kgaudiolab.github.io/kgstudio)
|
||||
- [点击这里获取免费 OpenRouter API Key](https://openrouter.ai/keys)(您可能需要一个 OpenRouter 账号)。
|
||||
- 在 **设置 ⚙️ → 通用 → LLM 提供方** 中选择 **OpenAI Compatible**。
|
||||
- 在 **OpenAI Compatible Server → 密钥** 中粘贴您的 Key。(注意:在非 localhost 环境中,出于安全原因,您的 Key 默认不会被持久化;您可以在设置中启用 “Persist API Keys on Non-Localhost” 以允许持久化,但这可能增加 XSS 风险。)
|
||||
- 在 **OpenAI Compatible Server → 模型** 中输入 `openai/gpt-oss-120b:free`。(注意:这是一个免费模型;非免费模型可能需要付费;免费模型提供方可能会收集您的数据,请查看模型页面中的说明;本项目与 OpenRouter 或任何模型提供方 **没有关联关系**。)
|
||||
- 在 **OpenAI Compatible Server → 基础 URL** 中输入 `https://openrouter.ai/api/v1`。
|
||||
|
||||
**提示:**
|
||||
- 您也可以使用官方 OpenAI API、其他 OpenAI 兼容服务,或自托管 LLM 服务器(如 Ollama、vLLM)。请注意,不同模型的质量差异较大,并非所有模型都同样适合音乐编辑任务。对于本地部署,我们推荐 `qwen3.5-35b-a3b`,它在生成质量与硬件需求之间取得了较好的平衡。
|
||||
- 如果您已订阅 OpenAI 或其他 LLM 提供方,可以使用 [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) 运行一个本地代理服务器,通过现有订阅转发请求,而无需单独准备 API Key。
|
||||
|
||||
### 基本 DAW 操作
|
||||
|
||||
- 在音轨上双击(或按住 Ctrl/Cmd 并点击)可创建区域。
|
||||
- 拖动区域边缘可调整长度;拖动区域主体可移动。
|
||||
- 点击区域左上角的小铅笔即可打开钢琴卷帘。
|
||||
- 在钢琴卷帘中,双击(或 Ctrl/Cmd+点击)可创建音符。
|
||||
- 单击可选择;Shift+单击可多选;拖拽可框选。
|
||||
- 拖动音符边缘可调整长度;拖动音符主体可移动已选音符。
|
||||
- 使用钢琴卷帘工具栏右上角的 Snapping 功能可将编辑量化到网格。
|
||||
|
||||
### 使用 K.G.Studio 音乐创作助手
|
||||
|
||||
- 选中您希望助手处理的音乐区域,在聊天框中输入提示词;按 Enter 发送,按 Shift+Enter 插入换行。
|
||||
- agent 会自动处理您的请求,并调用工具,在所选区域范围内执行修改。某些任务可能需要一轮或多轮对话才能完成。
|
||||
- 请注意,AI 也可能出错,因此您应始终检查结果,并在必要时自行调整。您也可以通过撤销/重做回退修改。
|
||||
- 点击 “+” 按钮或输入 `/clear` 命令可清空聊天历史。
|
||||
|
||||
### 更多说明
|
||||
|
||||
您可以在[这里](./docs/USER_GUIDE.md)查看详细用户指南。
|
||||
|
||||
### 亮点功能
|
||||
- **K.G.Studio 音乐创作助手**:与由 LLM 驱动的 AI agent 进行对话;它会自动执行工具,对您所选区域内的音乐内容进行编辑。
|
||||
- **浏览器内嵌 LLM,无需 API Key**:通过 WebGPU(LiteRT-LM)在浏览器中直接运行 **Gemma 4 E4B**。无 API 调用、无费用、数据不离开您的设备。模型只需下载一次,随后会缓存在本地。
|
||||
- **浏览器内嵌分轨,无需服务器**:使用 **UVR-MDX-NET-Inst_HQ_3**(2-stem:Vocals / Instrumental)或 **Demucs htdemucs_4s**(4-stem:Vocals / Drums / Bass / Others)将任意音频区域拆分为 stems。两者都完全在浏览器中通过 ONNX Runtime WebGPU 运行,无需 K.G.One 服务器。
|
||||
- **音频和弦检测**:为音频区域打开钢琴卷帘后运行 **Detect Chords**,即可通过零依赖 FFT 流水线自动分析录音并填充全局 Chord Track,同时支持灵敏度、稳定性和七和弦检测等配置。
|
||||
- **带自动对齐拍点的速度检测**:在钢琴卷帘工具栏运行 **Detect Tempo**,即可分析音频区域的 BPM,并可选择自动重新对齐项目的 Tempo Track。
|
||||
- **全局轨系统**:四条持久存在的全局轨 **Marker**、**Tempo**、**Key Signature** 和 **Chord** 共同提供项目级结构,所有功能(播放时序、和弦检测、五线谱显示)都会参考它们。
|
||||
- **K.G.One Music Studio 集成**:连接本地 [K.G.One](https://github.com/KGAudioLab/K.G.One) 服务器后,可解锁 GPU 加速的 **Full Song Generation**(ACE-Step 1.5)、**Clip & MIDI Loop Generation**(Foundation-1)以及更多 **Stem Separation** 模型。
|
||||
- **多种 LLM 提供方**:支持 OpenAI、Claude / Gemini(通过 OpenRouter)、OpenAI 兼容服务(Ollama、vLLM 等),或内置的本地浏览器 LLM,无需 Key。
|
||||
- **音轨与区域编辑**:支持添加/重排音轨、创建/移动/缩放区域、套索多选、批量移动/缩放、合并与拆分区域,以及完整撤销/重做。
|
||||
- **钢琴卷帘**:支持音符、pitch bend 和 MIDI CC 自动化轨;支持基于 VexFlow 的五线谱视图;支持音频到 MIDI 参考用途的频谱叠加层。
|
||||
- **真实乐器**:基于 Tone.js 的 sampler,搭配高质量 FluidR3 soundfonts。您还可以直接从麦克风录制到音频轨。
|
||||
- **智能和弦助手**:基于功能和声(T/S/D)提供实时和弦建议,并支持可视预览与一键创建和弦。
|
||||
- **兼顾持久化与隐私**:项目和配置完全保存在您的浏览器中(OPFS / IndexedDB)。项目本身不依赖第一方服务器。
|
||||
|
||||
如需更深入的技术概览,请参阅 [overview.md](./docs/technical/overview.md)。
|
||||
|
||||
## 开始使用
|
||||
|
||||
### 或者在本地克隆并运行:
|
||||
```bash
|
||||
# 请确保已安装 Node.js >= 20.19.3
|
||||
# 克隆仓库
|
||||
git clone https://github.com/KGAudioLab/KGStudio {your-local-path}
|
||||
cd {your-local-path}
|
||||
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 启动开发服务器
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
K.G.Studio 会从 `./public/config.json` 加载默认配置(内部也提供回退默认值),并通过 `ConfigManager` + IndexedDB 将用户修改持久化到浏览器中。IndexedDB 是浏览器在您设备上的本地数据库;数据不会离开您的机器,如果您清除此站点的数据,它也会被清除。请通过应用内的设置面板修改配置。
|
||||
|
||||
- **通用**
|
||||
- LLM 提供方:OpenAI,或 OpenAI 兼容服务
|
||||
- 当前所选提供方对应的 API Key 与模型
|
||||
- 在非 localhost 环境持久化 API Key:启用后,可在非 localhost 环境中持久化 API Key(属于安全风险自担选项,不建议在共享或生产环境中开启)
|
||||
- OpenAI 兼容服务的基础 URL(适用于自托管网关)
|
||||
- Soundfont 基础 URL(乐器采样 CDN)
|
||||
- **行为**
|
||||
- 启动时默认打开聊天框
|
||||
- **模板**
|
||||
- AI 助手使用的自定义指令
|
||||
|
||||
### 连接性与隐私
|
||||
|
||||
- K.G.Studio 完全在客户端运行。运行应用不需要任何第一方服务器。
|
||||
- 项目和音频文件保存在浏览器的 OPFS(Origin Private File System)中;配置保存在 IndexedDB 中。所有数据都留在您的设备上。
|
||||
- 网络访问仅用于:
|
||||
- 从配置的 soundfont CDN 下载乐器采样
|
||||
- 与您选择的 LLM 提供方通信(例如 OpenAI 或 OpenAI 兼容服务)
|
||||
- 除上述两种情况外,应用均可在本地工作。即使您阻止这些端点,应用仍可加载;但在网络恢复之前,乐器回放和 AI 功能将无法使用。
|
||||
- 出于安全考虑,当应用运行在非本地主机环境中时,我们默认不会将您的 API Key 持久化到 IndexedDB(以降低 XSS 风险)。这意味着您每次启动 K.G.Studio 时都需要重新输入。如需在非本地主机环境中启用持久化,请在设置中开启 “Persist API Keys on Non-Localhost”(不建议在共享或生产环境中启用)。
|
||||
|
||||
## 使用应用
|
||||
|
||||
您可以在[这里](./docs/USER_GUIDE.md)查看详细用户指南。
|
||||
|
||||
- 音轨
|
||||
- 在轨道信息面板中添加、重命名和重排音轨。
|
||||
- 通过乐器按钮(钢琴图标)切换乐器;可调整 Solo(S)、Mute(M)和 Volume。
|
||||
- 通过音轨设置菜单删除音轨(位于乐器按钮右侧)。
|
||||
|
||||
- 区域
|
||||
- 创建区域:使用 Pointer 工具时可双击,或按住 Ctrl/Cmd 再点击;使用 Pencil 工具时单击即可。
|
||||
- 移动/缩放:拖动区域主体可移动,拖动边缘可调整长度。
|
||||
- 通过区域左上角的小铅笔打开 Piano Roll。
|
||||
|
||||
- 钢琴卷帘(MIDI 音符)
|
||||
- 工具:Select 与 Pencil。
|
||||
- 创建音符:在 Select 模式下双击或 Ctrl/Cmd+点击;在 Pencil 模式下单击。
|
||||
- 选择音符:单击;Shift+单击多选;拖拽框选。
|
||||
- 移动/缩放:拖动音符主体可移动已选音符;拖动边缘可调整长度。
|
||||
- **五线谱视图**:可在钢琴卷帘工具栏中切换 Piano Roll 与 Staff Notation 视图。支持自动谱号选择、调号显示、符杠分组、连音线和可配置量化。启用 **Track Scope** 后会将整条音轨上的所有区域连续渲染为谱面。
|
||||
- **自动化轨道**:可在钢琴网格下方的可编辑区域中绘制和编辑 pitch bend 与 MIDI CC 曲线(Modulation、Breath、Volume、Expression、Sustain)。
|
||||
- **频谱模式**:可在钢琴卷帘中查看音频区域的频谱,并将其作为编辑 MIDI 音符时的参考层。
|
||||
- 可通过 X 或 ESC 关闭钢琴卷帘窗口。
|
||||
|
||||
- 智能和弦助手(新增于 2025-12-15)
|
||||
- 在钢琴卷帘工具栏中启用和弦指导:选择 T(Tonic)、S(Subdominant)或 D(Dominant)功能。
|
||||
- 将鼠标悬停在任意琴键上时,会以红色高亮显示上下文相关的和弦建议,并匹配您当前选择的调号与调式。
|
||||
- 按 Tab 可在同一和声功能下循环切换不同和弦转位。
|
||||
- 双击(或 Ctrl/Cmd+点击)高亮和弦可一次性创建整组音符。
|
||||
- 和弦长度会自动匹配您最近编辑的音符长度,以保持节奏一致。
|
||||
|
||||
- 吸附与量化
|
||||
- 在右上角的 NO SNAP 菜单中设置吸附。
|
||||
- 使用 Qua. Pos.(起始)和 Qua. Len.(长度)进行量化。
|
||||
|
||||
- 播放与播放头
|
||||
- 可通过工具栏回到开头,并执行 Play/Pause。
|
||||
- 在主时间网格点击小节编号可设置播放头;在钢琴卷帘中点击顶部时间轴也可设置,并遵守当前吸附设置。
|
||||
- BPM、拍号和调号都可以通过工具栏中的数值进行修改。
|
||||
|
||||
## 键盘快捷键
|
||||
|
||||
- 主界面
|
||||
- 播放/暂停:Space
|
||||
- 撤销 / 重做:Ctrl/Cmd+Z / Ctrl/Cmd+Shift+Z
|
||||
- 复制 / 剪切 / 粘贴:Ctrl/Cmd+C / Ctrl/Cmd+X / Ctrl/Cmd+V
|
||||
- 保存:Ctrl/Cmd+S
|
||||
- 按住以创建区域:Ctrl/Cmd
|
||||
- 钢琴卷帘
|
||||
- 工具:Select(Q)、Pencil(W)
|
||||
- 按住以创建音符:Ctrl/Cmd
|
||||
- 吸附:1(None), 2(1/4), 3(1/8), 4(1/16)
|
||||
- 位置量化:5(1/4), 6(1/8), 7(1/16)
|
||||
- 长度量化:8(1/4), 9(1/8), 0(1/16)
|
||||
|
||||
## AI 助手
|
||||
|
||||
### 使用 K.G.Studio 音乐创作助手
|
||||
|
||||
- 请先按照前文说明完成 LLM 提供方设置。
|
||||
- 您可以在右侧找到 K.G.Studio 音乐创作助手聊天框。如果当前未显示,可点击工具栏中的 Chat 🗨️ 按钮打开。
|
||||
- 选中您希望助手处理的区域,在聊天框中输入提示词;按 Enter 发送,按 Shift+Enter 插入换行。
|
||||
- agent 会自动处理您的请求,并调用工具,在所选区域范围内执行修改。某些任务可能需要一轮或多轮对话才能完成。
|
||||
- 请注意,AI 也可能出错,因此您应始终检查结果,并在必要时自行调整。您也可以通过撤销/重做回退修改。
|
||||
- 点击 “+” 按钮或输入 `/clear` 命令可清空聊天历史。
|
||||
|
||||
### 配置您的 LLM 提供方
|
||||
|
||||
进入 **设置 ⚙️ → 通用 → LLM 提供方**。根据您选择的提供方,您需要填写对应的 API Key,并在需要时填写自定义基础 URL(适用于 Ollama、OpenRouter 等非官方 OpenAI 兼容服务)。
|
||||
|
||||
注意:由于部分提供方存在 CORS 限制,Google Gemini 和 Anthropic Claude 当前仅支持通过 OpenRouter 使用。
|
||||
|
||||
### 使用 OpenAI 模型
|
||||
|
||||
1. 在 [**OpenAI**](https://platform.openai.com/account/api-keys) 获取 OpenAI API Key。您可能需要先注册账号并添加支付方式,才能生成 API Key。
|
||||
2. 在 **设置 ⚙️ → 通用 → LLM 提供方** 中选择 **OpenAI** 作为提供方。
|
||||
3. 在 **OpenAI → 密钥** 中输入您的 API Key。
|
||||
4. 在 **OpenAI → 模型** 下拉中选择您偏好的模型。若希望在性能与成本之间取得较好平衡,我们推荐 `gpt-5.4-mini`。
|
||||
5. 您也可以选择是否在 **OpenAI → Flex 模式** 中启用 Flex Mode。Flex Mode 可以降低价格,但也可能带来更慢的响应时间或更多服务端错误。
|
||||
|
||||
### 使用 OpenRouter
|
||||
|
||||
OpenRouter 是一个统一接入平台,可让您访问来自多个提供方的大量语言模型,其中也包含免费选项,便于比较并找到最适合您需求的模型。
|
||||
|
||||
1. 在 [**OpenRouter**](https://openrouter.ai/keys) 获取 API Key。需要注册;若使用付费模型,可能还需要绑定支付方式。
|
||||
2. 在 **设置 ⚙️ → 通用 → LLM 提供方** 中选择 **OpenAI Compatible** 作为提供方。
|
||||
3. 在 **OpenAI Compatible Server → 密钥** 中输入您的 API Key。
|
||||
4. 在 [**OpenRouter Models Page**](https://openrouter.ai/models) 浏览可用模型。您可以使用 “Prompt Pricing” 筛选免费模型。
|
||||
**注意:** 不同模型提供方的数据保留与隐私政策可能不同,请在使用前自行查看。
|
||||
5. 在 **OpenAI Compatible Server → 模型** 中输入您所选的模型名。推荐系列包括:
|
||||
- `Anthropic: Claude Sonnet 4.6` (`anthropic/claude-sonnet-4.6`: [Link](https://openrouter.ai/anthropic/claude-sonnet-4.6)) — Claude 系列中质量与成本平衡较好的选择
|
||||
- `Qwen: Qwen3.5-35B-A3B` (`qwen/qwen3.5-35b-a3b`: [Link](https://openrouter.ai/qwen/qwen3.5-35b-a3b)) — 推荐的开源模型
|
||||
- `Qwen: Qwen3-Next-80B-A3B`(免费模型:`qwen/qwen3-next-80b-a3b-instruct:free`: [Link](https://openrouter.ai/qwen/qwen3-next-80b-a3b-instruct:free))— 推荐的免费模型
|
||||
- `OpenAI: GPT-OSS 120B`(免费模型:`openai/gpt-oss-120b:free`: [Link](https://openrouter.ai/openai/gpt-oss-120b:free))— 推荐的免费模型
|
||||
- 注意:免费模型提供方可能会收集您的数据,使用前请先查看模型页面说明
|
||||
- 注意:免费模型的可用性变化频繁。如需查看最新免费选项,请访问 [OpenRouter Models Page](https://openrouter.ai/models),并使用 **Prompt Pricing** 过滤当前免费模型
|
||||
6. 在 **OpenAI Compatible Server → 基础 URL** 中填写 `https://openrouter.ai/api/v1`。
|
||||
|
||||
### 关于 agent 与 LLM 提供方
|
||||
|
||||
出于安全考虑,当您从非本地主机环境使用 K.G.Studio 时,API Key 默认不会持久化到 IndexedDB 中;这意味着您每次启动 K.G.Studio 时都需要重新输入。如需在非本地主机环境中启用持久化,请在设置中打开 “Persist API Keys on Non-Localhost”(不建议在共享或生产环境中启用)。
|
||||
|
||||
K.G.Studio 不提供也不托管上述任何模型,也不隶属于任何模型提供方。所有数据都保存在您的本地设备中;K.G.Studio 不会收集或传输您的数据。若您向第三方模型提供方发送任何数据,相关责任由您自行承担。
|
||||
|
||||
## K.G.One Music Generator
|
||||
|
||||
> **需要 [K.G.One Music Studio](https://github.com/KGAudioLab/K.G.One) 集成。** 只有当 K.G.Studio 连接到正在运行的 K.G.One 服务器时,K.G.One Music Generator 面板才会可用。设置说明请参阅 [K.G.One 仓库](https://github.com/KGAudioLab/K.G.One)。
|
||||
|
||||
**K.G.One Music Generator** 面板提供三项经 GPU 加速的 AI 工具,用于音乐生成与音频处理。点击工具栏中的 **✦(魔杖)** 按钮即可打开。该面板与 AI Assistant 聊天框互斥,打开其中一个时,另一个会自动关闭。
|
||||
|
||||
> **注意:** 您首次使用每个工具时,服务器都需要加载对应 AI 模型,这可能需要 60 秒甚至更久,具体取决于您的硬件。切换标签页时,也可能触发模型重新加载。
|
||||
|
||||
### Full Song Generation
|
||||
|
||||
根据文本描述和可选歌词生成一首完整歌曲。由 [ACE-Step 1.5](https://github.com/ace-step/ACE-Step-1.5) 驱动。
|
||||
|
||||
- 在 **Full Song** 标签页中,于 **Caption** 中输入自然语言描述,说明所需风格、情绪、速度、配器和结构。例如:`Genre: Eurodance, 90s dance-pop, upbeat electronic. Tempo: ~130 BPM. Instrumentation: driving kick drum, eurodance bassline...`
|
||||
- 您也可以填写 **Lyrics**。可使用 `[Intro]`、`[Verse]`、`[Chorus]`、`[Bridge]` 等标签标记段落。若勾选 **Instrumental**,则会完全跳过人声。
|
||||
- 点击 **Generate Song**。界面会实时显示生成阶段与百分比进度。
|
||||
- 生成完成后,会显示预览播放器。您可以将其拖拽到 **audio track**,作为区域导入。
|
||||
- 目前不支持将整曲生成结果拖放到 MIDI track。
|
||||
- **Advanced Settings**(可展开):Inference Steps、Guidance Scale、Seed,以及 Thinking(CoT metadata generation)。
|
||||
|
||||
### Clip Generation
|
||||
|
||||
根据文本描述生成短乐器片段和 MIDI loops。由 [Foundation-1](https://huggingface.co/RoyalCities/Foundation-1) 驱动。
|
||||
|
||||
- 在 **Clip** 标签页中,于 **Prompt** 中输入逗号分隔的标签,描述乐器类别、子类型、音色、效果、长度、BPM 和调性。例如:`Gritty, Acid, Bassline, 303, Synth Lead, FM, Sub, High Reverb, 8 Bars, 140 BPM, E minor`
|
||||
- 您也可以填写 **Negative Prompt**,以避免生成某些不希望出现的特征(例如 `distortion, noise`)。
|
||||
- 选择 **Bars**:4 或 8。BPM 与调号会根据项目设置预填,您也可以在 **Advanced Settings** 中调整。
|
||||
- 点击 **Generate Clip**。生成完成后,会出现一个预览播放器,左侧带拖拽手柄,右侧带下载按钮。
|
||||
- **导入方式**:将播放器拖拽到时间线中的音轨上。
|
||||
- 拖放到 **audio track** 时,会作为 WAV 音频区域导入(推荐)。
|
||||
- 拖放到 **MIDI track** 时,会作为 MIDI 区域导入。请注意,此 MIDI 是从音频转录而来,可能并非完全准确。
|
||||
- **Advanced Settings**(可展开):Note、Scale、BPM、Steps、CFG Scale、Seed(`-1` 表示随机)、Sampler Type、Sigma Min/Max,以及 CFG Rescale。
|
||||
|
||||
### Stem Separation
|
||||
|
||||
将现有音频区域拆分为独立 stems(例如人声、伴奏、鼓组等)。
|
||||
|
||||
K.G.Studio 支持 **两种模式** 的分轨:
|
||||
|
||||
#### 本地浏览器模式 — 无需服务器 ✦
|
||||
|
||||
两个 ONNX 模型可完全在浏览器中运行,无 API 调用、无费用,数据也不会离开您的设备。模型只需下载一次,随后会缓存在本地。
|
||||
|
||||
- **Vocal and Instrument (Medium Accuracy)**(`UVR-MDX-NET-Inst_HQ_3`,约 64 MB)— 双 stem 分离(Vocals / Instrumental)。由 [UVR-MDX-NET](https://github.com/nomadkaraoke/python-audio-separator) 驱动。
|
||||
- **Vocal, Drums, Bass, and Others**(`htdemucs_4s`,约 172 MB)— 四 stem 分离(Vocals / Drums / Bass / Others)。由 [Demucs](https://github.com/facebookresearch/demucs) 驱动。
|
||||
|
||||
打开 **Music Generator** 面板(工具栏中的 ✦ 按钮),选择模型,先点击一次 **Download Selected Model**,然后点击 **Separate Stems**,所有处理都会在您的浏览器中本地完成。
|
||||
|
||||
**要求:** 需要支持 WebGPU 的浏览器(Chrome 113+ 或 Edge 113+),并运行在安全上下文中(HTTPS 或 localhost)。若可用,会优先使用 WebGPU 加速;否则会回退到 CPU(处理期间可能降低页面响应性)。推荐硬件为至少 8 GB 显存的 GPU,或至少 16 GB 统一内存的系统。
|
||||
|
||||
#### K.G.One 服务器模式
|
||||
|
||||
当连接到 [K.G.One Music Studio](https://github.com/KGAudioLab/K.G.One) 服务器后,可使用另外三种 GPU 加速模型。由 [python-audio-separator (UVR5)](https://github.com/nomadkaraoke/python-audio-separator) 驱动。
|
||||
|
||||
- **Vocal and Instrument (Medium Accuracy)**(`UVR-MDX-NET-Inst_HQ_3`)— 快速双 stem 分离(vocal / instrumental)。
|
||||
- **Vocal and Instrument (High Accuracy)**(`MDX23C-8KFFT-InstVoc_HQ`)— 更高质量的双 stem 分离,但更慢。
|
||||
- **Vocal, Drums, Bass, Guitar, Piano, and Others**(`htdemucs_6s`)— 完整六 stem 分离。
|
||||
|
||||
#### 使用方式(两种模式通用)
|
||||
|
||||
- 在打开此标签页之前,请先在时间线上**选中一个音频区域**。**Separator** 标签页顶部会显示当前所选区域及其音轨名称。仅支持音频区域,不支持分离 MIDI 区域。
|
||||
- 点击 **Separate Stems**。如果当前所选区域带有 clip start offset 或已被裁剪,系统会先自动按该区域范围切出对应音频,再进行处理。
|
||||
- 处理完成后,每个 stem 都会以带标签的预览播放器显示,并附带拖拽手柄。您可以在导入前分别试听每个 stem。
|
||||
- **导入 stems:**
|
||||
- 可将每个 stem 播放器分别拖拽到 **audio track** 上,放到您希望的位置。
|
||||
- 或点击 **Import All Stems to Timeline**,系统会自动为每个 stem 新建一条音频轨,并将其放置在源音轨下方,与原始区域的起始拍点对齐。该操作可通过一次撤销完整回退。
|
||||
|
||||
## 即将推出的功能
|
||||
|
||||
功能优先级可能会变化。
|
||||
|
||||
### 1.0
|
||||
|
||||
- [X] 更多乐器
|
||||
- [X] 自动化测试(单元测试、集成测试等)
|
||||
- [X] 带功能和声指导(T/S/D)的智能和弦助手
|
||||
- [X] 支持轨道控制自动化(例如 sustain、volume、pan 等)
|
||||
- [X] 支持 MIDI 控制事件(例如 CC、pitch bend 等)
|
||||
- [X] 支持 WAV 音频轨
|
||||
- [X] 录音
|
||||
- [X] 事件列表
|
||||
- [X] 支持 OpenAI 的开源模型(`gpt-oss-20b` 和 `gpt-oss-120b`)
|
||||
- [X] 五线谱
|
||||
- [X] K.G.One Music Studio 集成
|
||||
- [X] 浏览器内嵌 AI 模型(基于 Gemma 4 E4B 的本机 LLM;基于 UVR-MDX-NET-Inst_HQ_3 与 htdemucs_4s 的本机分轨)
|
||||
- [X] 全局轨系统(Marker、Tempo、Key Signature、Chord)
|
||||
- [X] 音频和弦检测(零依赖 FFT,并将结果写入 Chord Track)
|
||||
- [X] 带自动对齐拍点的速度检测
|
||||
- [X] 频谱可视化与 Piano Roll hybrid mode
|
||||
|
||||
### 1.0 之后
|
||||
|
||||
- [ ] 扩展 AI agent 工具,直接通过聊天操作区域、音轨与全局轨(和弦进行、速度、调性)
|
||||
- [ ] Mixer 视图,提供专用面板显示每轨推子、send、return bus 与 master channel
|
||||
- [ ] EQ 与通道条,为每条音轨提供参数均衡器与压缩器
|
||||
- [ ] 滤波器与效果器,如混响、延迟及其他基于 WebAudio 的原生插入效果
|
||||
- [ ] 音频 time-stretch / warp,使音频区域自动匹配项目速度
|
||||
- [ ] MIDI 效果器,如琶音器、音阶量化器、和弦记忆
|
||||
- [ ] 虚拟 MIDI 设备输出
|
||||
|
||||
## 需要帮助
|
||||
|
||||
我们正在寻找贡献者,一起让 K.G.Studio 变得更好。无论您是开发者、音乐人还是设计师,您的专长都能带来实际价值。
|
||||
|
||||
### 您可以如何参与
|
||||
|
||||
**🎵 音乐人 / 音乐制作人**
|
||||
- 使用真实音乐制作流程测试这款 DAW
|
||||
- 反馈乐器音色质量与真实感
|
||||
- 提出音乐创作中不可或缺但目前缺失的功能
|
||||
- 帮助提升 AI 助手的音乐理解能力
|
||||
|
||||
**💻 开发者**
|
||||
- 根据路线图实现新功能
|
||||
- 修复 bug 并优化性能
|
||||
- 增强 Web Audio 集成
|
||||
- 改进 AI 助手能力
|
||||
|
||||
**🎨 UI/UX 设计师**
|
||||
- 优化界面和工作流
|
||||
- 设计更适合音乐编辑的视觉反馈
|
||||
- 打造更直观的交互方式
|
||||
|
||||
### 参与方式
|
||||
|
||||
如果您有兴趣贡献,我们很愿意听到您的想法。
|
||||
|
||||
- **给我们发邮件**:[kgstudio@duck.com](mailto:kgstudio@duck.com)
|
||||
- **查看 Issues**:浏览带有 `help wanted` 或 `good first issue` 标签的公开问题
|
||||
- **参与讨论**:在 GitHub Discussions 中分享想法与反馈
|
||||
|
||||
每一份贡献都很重要。无论是报告 bug,还是提出新功能建议,都会推动项目继续前进。
|
||||
|
||||
### 免责声明
|
||||
|
||||
K.G.Studio 是一个处于早期开发阶段的实验性项目。我们正在探索如何将 AI agent 与 LLM 融入音乐制作工作流,本质上是在构建一种面向 DAW 的 “Cursor 或 Claude Code” 体验。
|
||||
|
||||
该项目关注 AI 与人类协作如何增强音乐创作过程,从智能和声建议到自动化编辑任务都在探索范围内。作为实验平台,项目会频繁变化,功能将持续演进,并且在我们推进 AI 辅助音乐制作边界的过程中,偶尔出现不稳定情况也是可以预期的。
|
||||
|
||||
K.G.Studio 不提供也不托管任何 LLM 模型,也不隶属于任何模型提供方。所有数据都保存在您的本地设备中;K.G.Studio 不会收集或传输您的数据。若您向第三方模型提供方发送任何数据,相关责任由您自行承担。
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目基于 Apache License, Version 2.0 授权,并附带额外条款(参见 `LICENSE`):
|
||||
- 不得使用本软件或相关素材申请专利
|
||||
- 当用于公开或商业产品时需要署名(“Powered by K.G.Studio”)
|
||||
|
||||
第三方声明(FluidR3_GM SoundFont、midi-js-soundfonts、VexFlow、prompt structure notes、Gemma 4 E4B、UVR-MDX-NET-Inst_HQ_3、MediaPipe、Meyda、web-audio-beat-detector、tonal、htdemucs_4s、onnxruntime-web 和 demucs-web)已包含在 `LICENSE` 中。
|
||||
+453
@@ -0,0 +1,453 @@
|
||||
[English](./README.md) | [Français](./README-fr.md) | [简体中文](./README-zh_cn.md) | 繁體中文
|
||||
|
||||
<div align="center">
|
||||
<img src="./public/logo.png" alt="K.G.Studio Logo" width="160" />
|
||||
</div>
|
||||
|
||||
# K.G.Studio — 一款基於瀏覽器的 DAW,並內建 AI 助手
|
||||
|
||||
<div align="center">
|
||||
<h3><a href="https://kgaudiolab.github.io/kgstudio"><b>◀ 立即在瀏覽器中線上使用 K.G.Studio ▶</b></a></h3>
|
||||
</div>
|
||||
|
||||
## 新動態
|
||||
|
||||
> ### ✦ [K.G.One Music Studio 現已發佈。](https://github.com/KGAudioLab/K.G.One) ✦ <br />
|
||||
> [**K.G.One Music Studio**](https://github.com/KGAudioLab/K.G.One) 是一個完全本地、開源的一體化平台,以 **K.G.Studio**(本專案)為核心。它整合了用於整曲生成的 [ACE-Step 1.5](https://github.com/ace-step/ACE-Step-1.5)、用於音訊片段和 MIDI loop 生成的 [Foundation-1](https://huggingface.co/RoyalCities/Foundation-1),以及用於分軌的 [python-audio-separator (UVR5)](https://github.com/nomadkaraoke/python-audio-separator),將 GPU 加速的 AI 音樂生成能力直接帶入您的瀏覽器音樂製作工作流。
|
||||
|
||||
## 什麼是 K.G.Studio?
|
||||
|
||||
K.G.Studio 是一款輕量、現代化的 DAW,完全執行於瀏覽器中,並以 **K.G.Studio 音樂創作助手** 為核心。它提供基於 Tone.js sampler 的真實樂器回放、Piano Roll 編輯器、支援完整復原/重做的音軌與區域管理、基於 OPFS(Origin Private File System)的專案持久化、可設定的設定面板,以及可執行工具的內建 AI 助手。
|
||||
|
||||
**K.G.Studio 音樂創作助手** 是一個面向和聲、編曲與音符編輯的 AI 助手,但並不負責整首作品的全自動作曲。
|
||||
|
||||
<div align="center">
|
||||
<img src="./docs/KGOne-Demo-GIF.gif" alt="K.G.One Logo" width="640" />
|
||||
</div>
|
||||
|
||||
> 注意:整曲生成功能和音訊片段生成功能需要整合 [**K.G.One Music Studio**](https://github.com/KGAudioLab/K.G.One)。
|
||||
|
||||
## 最新更新
|
||||
|
||||
- **2026.05.30**: 新增 **國際化(i18n)支援** — K.G.Studio 現已提供四種語言版本:**English**、**简体中文**、**繁體中文** 和 **Français**。可在 **設定 ⚙️ → 通用 → 語言** 中設定偏好語言,選擇 `Auto` 時將自動偵測瀏覽器語言。
|
||||
|
||||
- **2026.05.27**:
|
||||
- 新增 **全域軌系統**,引入四條全域軌:**Marker**、**Tempo**、**Key Signature** 和 **Chord**(和弦符號跨度區段)。
|
||||
- 新增 **音訊和弦檢測** 功能。您可以為音訊區域或 MIDI 區域開啟鋼琴卷簾視窗,然後點擊「...」 -> **Detect Chords**,即可透過零依賴 FFT 流水線自動分析錄音內容,並將結果寫入 Chord Track;支援靈敏度、穩定性與七和弦檢測設定。
|
||||
- 新增 **帶自動對齊拍點的速度檢測** 功能。您可以為音訊區域開啟鋼琴卷簾視窗,然後在工具列點擊「...」 -> **Detect Tempo**,分析音訊 BPM,並可選擇將專案中的 Tempo Track 區段自動重新對齊。
|
||||
- 新增 **Demucs 4S** 作為第二個本地瀏覽器內嵌分軌模型。現有的雙 stem UVR-MDX-NET 模型之外,又加入了四 stem 的 `htdemucs_4s` 模型(約 172 MB,vocals / drums / bass / others),兩者都可透過 ONNX Runtime WebGPU 完全在瀏覽器中執行。
|
||||
|
||||
- **2026.05.15**: 新增 **瀏覽器內嵌 AI 模型**。現在有兩個 AI 模型可完全在瀏覽器中執行,無需外部服務、無需 API Key,也無需 K.G.One 伺服器。**K.G.Studio 音樂創作助手** 新增 **Local LLM (Browser)** 提供方,由 **Gemma 4 E4B** 驅動,並透過 LiteRT-LM 與 WebGPU 加速執行;模型只需下載一次,隨後會快取在 OPFS 中,後續啟動幾乎可即時使用,同時支援可設定的上下文長度(32 k / 64 k / 128 k tokens)與即時推理效能統計。**Stem separation** 也支援本地執行,基於瀏覽器內嵌的 **UVR-MDX-NET-Inst_HQ_3** ONNX 模型並使用 WebGPU 加速。您可以開啟 **Music Generator** 面板(✦ 按鈕),下載模型一次後,即可完全在本機將人聲與伴奏分離。以上兩項功能都要求瀏覽器支援 WebGPU(Chrome 113+ 或 Edge 113+)並執行在安全上下文中(HTTPS 或 localhost)。推薦硬體:至少 8 GB 顯存的 GPU,或至少 16 GB 統一記憶體的系統。
|
||||
|
||||
- **2026.05.10**: 新增 **五線譜視圖**。鋼琴卷簾現已支援完整的標準樂譜顯示模式。您可以透過鋼琴卷簾工具列中的切換按鈕在 Piano Roll 和 Sheet Music 視圖之間切換。在五線譜模式下,音符會透過 VexFlow 排版,並支援基於目前樂器自動選擇譜號(高音或低音)、顯示調號、自動符槓分組、跨小節連音線,以及可設定的音值量化。啟用 **Track Scope** 後,整條音軌上的所有 MIDI 區域會以連續譜面的形式顯示,而不再侷限於單個區域。
|
||||
|
||||
- **2026.05.09**: 新增 **音訊錄音**。您現在可以直接從麥克風錄製到音訊軌。錄音時會即時增長顯示波形預覽,停止後該錄音會作為標準音訊區域寫入時間線。另新增 **音訊 I/O 裝置選擇**,您可以在設定中選擇偏好的麥克風輸入裝置和音訊輸出裝置。
|
||||
|
||||
- **2026.05.08**: 新增 **MIDI 自動化**。您可以在鋼琴網格下方的可編輯自動化區域中繪製和編輯 pitch bend 與 MIDI CC 曲線(CC1 Modulation、CC2 Breath、CC7 Volume、CC11 Expression、CC64 Sustain)。同時新增 **軌道級自動化**:每條音軌現在都有專用自動化面板,您可以直接在時間線上查看和編輯同樣的曲線。即時 MIDI 控制器輸入(如 pitch wheel、CC 踏板)也支援錄製與按軌回放,並帶有每條自動化軌的插值處理。另新增 **事件列表面板**,這是一個帶分頁的側邊欄(Notes / Pitch Bend / Controller),可用於查看與內聯編輯目前 MIDI 區域中的全部事件。還新增了 **區域多選**(支援套索與批次移動/縮放)以及 **合併 MIDI 區域**。
|
||||
<div align="center">
|
||||
<img src="./public/snapshots/2026-05-08-automations.png" alt="K.G.Studio Logo" width="640" />
|
||||
</div>
|
||||
|
||||
- **2026.05.02**: 新增 **音訊軌頻譜可視化**。音訊區域現在會在時間網格中顯示即時頻譜疊加層。另新增 **Piano Roll hybrid mode**:當您在 MIDI 區域中開啟鋼琴卷簾時,可將相鄰音訊區域的頻譜作為參考層顯示,從而參照音訊形狀編輯 MIDI 音符。新增 **鋼琴卷簾縮放**,並保留目前視口位置,使畫面始終錨定在目前播放頭附近。還新增 **區域微調位置**,可用小步長推動區域以實現精確擺放;同時新增跨元件的播放頭捲動同步,使主網格與鋼琴卷簾在播放期間保持連動。
|
||||
- **2026.04.29**: 在 K.G.One Music Generator 面板中新增 **Remix** 和 **Repaint**(由 ACE-Step 1.5 驅動)。**Remix** 可讓您用新風格重製現有音訊區域,您只需選取音訊區域、描述目標風格,並可選填寫新歌詞,ACE-Step 就會按提示重新演繹歌曲的配器與氛圍。**Repaint** 可對歌曲中特定片段進行局部再生成,您可以先在時間線上設定 loop 範圍作為重繪視窗,再描述該片段應有的聲音效果,其餘部分將保持不變。兩項功能都支援與其他 K.G.One 分頁相同的匯入流程:可在內建播放器中預覽、拖曳到音軌上,或點擊 **Import Aligned to Source**,自動將結果放置到原始區域下方的新音軌中。
|
||||
- **2026.04.24**: 新增 [**K.G.One Music Studio**](https://github.com/KGAudioLab/K.G.One) 整合。當 K.G.Studio 連線到本地 K.G.One 伺服器後,**K.G.One Music Generator** 面板(工具列中的魔杖按鈕 ✦)即可啟用,其中包含三項 AI 驅動工具:**Full Song Generation**(由 ACE-Step 1.5 驅動,可根據文字提示生成整首歌曲)、**Clip Generation**(由 Foundation-1 驅動,可根據文字生成樂器片段與 MIDI loop),以及 **Stem Separation**(由 python-audio-separator 驅動,可將任意音訊拆分為人聲、伴奏等多個 stem)。生成的音訊和 MIDI 均可即時預覽,並可直接拖曳到您的音軌中。K.G.One 完全執行在您自己的機器上(Windows/Linux,需要 CUDA GPU);詳細設定說明請參閱 [K.G.One 倉庫](https://github.com/KGAudioLab/K.G.One)。
|
||||
- **2026.04.11**: 將專案儲存從 IndexedDB 遷移到 OPFS(Origin Private File System),並採用基於資料夾的結構,以更好地處理媒體檔案。新增音訊軌支援,包括 WAV/MP3 匯入、回放、循環,以及非破壞性區域裁剪。另新增基於離線渲染的 WAV/MP3 匯出功能。
|
||||
- **2026.04.05**: 將 AI agent 從基於 XML 的工具呼叫遷移到原生 OpenAI SDK function calling,以提升可靠性與相容性。另新增 GPT-5.4 系列等 LLM 模型選項。
|
||||
- **2026.01.23**: 實現無縫循環播放。您可以拖動小節編號設定 loop 範圍,或透過工具列中的 Loop 按鈕切換循環模式。循環播放基於 `Tone.js` 的原生 loop 機制,可實現取樣級精確、無縫銜接的循環。
|
||||
- **2025.12.21**: 實現 MIDI 鍵盤支援。您現在可以連接 MIDI 鍵盤並直接用其演奏聲音。請注意,由於 Safari 和部分其他瀏覽器對 Web MIDI 介面支援不完整,此功能在這些瀏覽器中可能無法達到最佳效果。
|
||||
- **2025.12.15**: 新增智慧和弦助手,支援功能和聲指導(T/S/D)。將滑鼠懸停在琴鍵上即可查看與上下文相關的和弦建議,並可一鍵建立完整和弦。
|
||||
|
||||
## 專案狀態
|
||||
|
||||
**K.G.Studio 是一個仍處於早期開發階段的實驗性專案。** 我們正在探索如何將 AI agent 與 LLM 融入音樂製作工作流,本質上是在建構一種面向 DAW 的「Cursor 或 Claude Code」體驗。
|
||||
|
||||
該專案關注 AI 與人類協作如何增強音樂創作過程,從智慧和聲建議到自動化編輯任務都在探索範圍內。作為實驗平台,專案會頻繁變化,功能將持續演進,並且在我們推進 AI 輔助音樂製作邊界的過程中,偶爾出現不穩定情況也是可以預期的。
|
||||
|
||||
## 演示影片
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://youtu.be/F1JWjK84zwc" target="_blank">
|
||||
<img src="./public/demo/demo-cover.png" alt="K.G.One Music Studio" width="400"/>
|
||||
</a>
|
||||
<br><b>K.G.One Music Studio</b>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://youtu.be/FXgihfAH2vc" target="_blank">
|
||||
<img src="./public/demo/cover-FXgihfAH2vc.png" alt="Short Demo" width="400"/>
|
||||
</a>
|
||||
<br><b>短演示(僅 DAW)</b>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://youtu.be/vKbWAQRt0r0" target="_blank">
|
||||
<img src="./public/demo/cover-vKbWAQRt0r0.png" alt="Full Demo" width="400"/>
|
||||
</a>
|
||||
<br><b>完整演示(僅 DAW)</b>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
## 快速開始
|
||||
|
||||
### 設定 K.G.Studio 音樂創作助手
|
||||
|
||||
使用 K.G.Studio 音樂創作助手有兩種方式:一種是 **Local LLM (Browser)**,完全在瀏覽器中執行,無需 API Key、無需費用,且資料不會離開您的裝置;另一種是使用 **外部 LLM 提供方**,以獲得更高品質的回覆。
|
||||
|
||||
#### 方案 A:Local LLM (Browser) — 無需 API Key ✦
|
||||
|
||||
K.G.Studio 可以藉助 WebGPU 加速,在瀏覽器中直接執行 **Gemma 4 E4B**。不會產生 API 呼叫,不會產生費用,您的資料也不會離開本機。
|
||||
|
||||
- 點擊這裡開始線上使用應用:[K.G.Studio (kgaudiolab.github.io/kgstudio)](https://kgaudiolab.github.io/kgstudio)
|
||||
- 在 **設定 ⚙️ → 通用 → LLM 提供方** 中選擇 **本地 LLM(瀏覽器)**(預設選項)。
|
||||
- 第一次開啟聊天時,模型(約 2.8 GB)會自動下載,並快取在瀏覽器的 OPFS(Origin Private File System)中,後續啟動幾乎可即時使用。
|
||||
- 您也可以選擇設定 **上下文長度**(32k / 64k / 128k tokens);數值越大,對顯存要求越高。
|
||||
- 現在就可以開始聊天。除首次下載模型外,無需 Key、無需帳號,也無需持續連網。
|
||||
|
||||
**Local LLM 的要求:** 需要支援 WebGPU 的瀏覽器(Chrome 113+ 或 Edge 113+),並執行在安全上下文中(HTTPS 或 localhost)。推薦硬體為至少 8 GB 顯存的 GPU,或至少 16 GB 統一記憶體的系統。
|
||||
|
||||
> **注意:** 本地模型的品質無法與 GPT 或 Claude 系列商業模型相比。對於複雜的音樂編輯任務,外部提供方通常會產生更好的結果。
|
||||
|
||||
#### 方案 B:外部 LLM 提供方(更高品質)
|
||||
|
||||
- 點擊這裡開始線上使用應用:[K.G.Studio (kgaudiolab.github.io/kgstudio)](https://kgaudiolab.github.io/kgstudio)
|
||||
- [點擊這裡取得免費 OpenRouter API Key](https://openrouter.ai/keys)(您可能需要一個 OpenRouter 帳號)。
|
||||
- 在 **設定 ⚙️ → 通用 → LLM 提供方** 中選擇 **OpenAI Compatible**。
|
||||
- 在 **OpenAI Compatible Server → 密鑰** 中貼上您的 Key。(注意:在非 localhost 環境中,出於安全原因,您的 Key 預設不會被持久化;您可以在設定中啟用「Persist API Keys on Non-Localhost」以允許持久化,但這可能增加 XSS 風險。)
|
||||
- 在 **OpenAI Compatible Server → 模型** 中輸入 `openai/gpt-oss-120b:free`。(注意:這是一個免費模型;非免費模型可能需要付費;免費模型提供方可能會蒐集您的資料,請查看模型頁面中的說明;本專案與 OpenRouter 或任何模型提供方 **沒有關聯關係**。)
|
||||
- 在 **OpenAI Compatible Server → 基礎 URL** 中輸入 `https://openrouter.ai/api/v1`。
|
||||
|
||||
**提示:**
|
||||
- 您也可以使用官方 OpenAI API、其他 OpenAI 相容服務,或自行託管的 LLM 伺服器(如 Ollama、vLLM)。請注意,不同模型的品質差異較大,並非所有模型都同樣適合音樂編輯任務。對於本地部署,我們推薦 `qwen3.5-35b-a3b`,它在生成品質與硬體需求之間取得了較好的平衡。
|
||||
- 如果您已訂閱 OpenAI 或其他 LLM 提供方,可以使用 [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) 執行一個本地代理伺服器,透過現有訂閱轉發請求,而無需另外準備 API Key。
|
||||
|
||||
### 基本 DAW 操作
|
||||
- 在音軌上雙擊(或按住 Ctrl/Cmd 並點擊)可建立區域。
|
||||
- 拖動區域邊緣可調整長度;拖動區域主體可移動。
|
||||
- 點擊區域左上角的小鉛筆即可開啟鋼琴卷簾。
|
||||
- 在鋼琴卷簾中,雙擊(或 Ctrl/Cmd+點擊)可建立音符。
|
||||
- 單擊可選取;Shift+單擊可多選;拖曳可框選。
|
||||
- 拖動音符邊緣可調整長度;拖動音符主體可移動已選音符。
|
||||
- 使用鋼琴卷簾工具列右上角的 Snapping 功能可將編輯量化到網格。
|
||||
|
||||
### 使用 K.G.Studio 音樂創作助手
|
||||
- 選取您希望助手處理的音樂區域,在聊天框中輸入提示詞;按 Enter 傳送,按 Shift+Enter 插入換行。
|
||||
- agent 會自動處理您的請求,並呼叫工具,在所選區域範圍內執行修改。某些任務可能需要一輪或多輪對話才能完成。
|
||||
- 請注意,AI 也可能出錯,因此您應始終檢查結果,並在必要時自行調整。您也可以透過復原/重做回退修改。
|
||||
- 點擊「+」按鈕或輸入 `/clear` 指令可清空聊天歷史。
|
||||
|
||||
### 更多說明
|
||||
|
||||
您可以在[這裡](./docs/USER_GUIDE.md)查看詳細使用者指南。
|
||||
|
||||
### 亮點功能
|
||||
- **K.G.Studio 音樂創作助手**:與由 LLM 驅動的 AI agent 進行對話;它會自動執行工具,對您所選區域內的音樂內容進行編輯。
|
||||
- **瀏覽器內嵌 LLM,無需 API Key**:透過 WebGPU(LiteRT-LM)在瀏覽器中直接執行 **Gemma 4 E4B**。無 API 呼叫、無費用、資料不離開您的裝置。模型只需下載一次,隨後會快取在本地。
|
||||
- **瀏覽器內嵌分軌,無需伺服器**:使用 **UVR-MDX-NET-Inst_HQ_3**(2-stem:Vocals / Instrumental)或 **Demucs htdemucs_4s**(4-stem:Vocals / Drums / Bass / Others)將任意音訊區域拆分為 stems。兩者都完全在瀏覽器中透過 ONNX Runtime WebGPU 執行,無需 K.G.One 伺服器。
|
||||
- **音訊和弦檢測**:為音訊區域開啟 Piano Roll 後執行 **Detect Chords**,即可透過零依賴 FFT 流水線自動分析錄音並填充全域 Chord Track,同時支援靈敏度、穩定性和七和弦檢測等設定。
|
||||
- **帶自動對齊拍點的速度檢測**:在 Piano Roll 工具列執行 **Detect Tempo**,即可分析音訊區域的 BPM,並可選擇自動重新對齊專案的 Tempo Track。
|
||||
- **全域軌系統**:四條持久存在的全域軌 **Marker**、**Tempo**、**Key Signature** 和 **Chord** 共同提供專案級結構,所有功能(播放時序、和弦檢測、五線譜顯示)都會參考它們。
|
||||
- **K.G.One Music Studio 整合**:連接本地 [K.G.One](https://github.com/KGAudioLab/K.G.One) 伺服器後,可解鎖 GPU 加速的 **Full Song Generation**(ACE-Step 1.5)、**Clip & MIDI Loop Generation**(Foundation-1)以及更多 **Stem Separation** 模型。
|
||||
- **多種 LLM 提供方**:支援 OpenAI、Claude / Gemini(透過 OpenRouter)、OpenAI 相容服務(Ollama、vLLM 等),或內建的本地瀏覽器 LLM,無需 Key。
|
||||
- **音軌與區域編輯**:支援新增/重排音軌、建立/移動/縮放區域、套索多選、批次移動/縮放、合併與拆分區域,以及完整復原/重做。
|
||||
- **Piano Roll**:支援音符、pitch bend 和 MIDI CC 自動化軌;支援基於 VexFlow 的五線譜視圖;支援音訊到 MIDI 參考用途的頻譜疊加層。
|
||||
- **真實樂器**:基於 Tone.js 的 sampler,搭配高品質 FluidR3 soundfonts。您還可以直接從麥克風錄製到音訊軌。
|
||||
- **智慧和弦助手**:基於功能和聲(T/S/D)提供即時和弦建議,並支援可視預覽與一鍵建立和弦。
|
||||
- **兼顧持久化與隱私**:專案和設定完全儲存在您的瀏覽器中(OPFS / IndexedDB)。專案本身不依賴第一方伺服器。
|
||||
|
||||
如需更深入的技術概覽,請參閱 [overview.md](./docs/technical/overview.md)。
|
||||
|
||||
## 開始使用
|
||||
|
||||
### 或者在本地複製並執行:
|
||||
```bash
|
||||
# 請確認已安裝 Node.js >= 20.19.3
|
||||
# 複製倉庫
|
||||
git clone https://github.com/KGAudioLab/KGStudio {your-local-path}
|
||||
cd {your-local-path}
|
||||
|
||||
# 安裝依賴
|
||||
npm install
|
||||
|
||||
# 啟動開發伺服器
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 設定
|
||||
|
||||
K.G.Studio 會從 `./public/config.json` 載入預設設定(內部也提供回退預設值),並透過 `ConfigManager` + IndexedDB 將使用者修改持久化到瀏覽器中。IndexedDB 是瀏覽器在您裝置上的本地資料庫;資料不會離開您的機器,如果您清除此站點的資料,它也會被清除。請透過應用程式內的設定面板修改設定。
|
||||
|
||||
- **通用**
|
||||
- LLM 提供方:OpenAI,或 OpenAI 相容服務
|
||||
- 目前所選提供方對應的 API Key 與模型
|
||||
- 在非 localhost 環境持久化 API Key:啟用後,可在非 localhost 環境中持久化 API Key(屬於安全風險自擔選項,不建議在共享或生產環境中開啟)
|
||||
- OpenAI 相容服務的基礎 URL(適用於自託管閘道)
|
||||
- Soundfont 基礎 URL(樂器採樣 CDN)
|
||||
- **行為**
|
||||
- 啟動時預設開啟聊天框
|
||||
- **模板**
|
||||
- AI 助手使用的自訂指令
|
||||
|
||||
### 連線性與隱私
|
||||
|
||||
- K.G.Studio 完全在客戶端執行。執行應用不需要任何第一方伺服器。
|
||||
- 專案和音訊檔案儲存在瀏覽器的 OPFS(Origin Private File System)中;設定儲存在 IndexedDB 中。所有資料都留在您的裝置上。
|
||||
- 網路存取僅用於:
|
||||
- 從設定的 soundfont CDN 下載樂器採樣
|
||||
- 與您選擇的 LLM 提供方通訊(例如 OpenAI 或 OpenAI 相容服務)
|
||||
- 除上述兩種情況外,應用均可在本地工作。即使您阻止這些端點,應用仍可載入;但在網路恢復之前,樂器回放和 AI 功能將無法使用。
|
||||
- 出於安全考量,當應用執行在非本地主機環境中時,我們預設不會將您的 API Key 持久化到 IndexedDB(以降低 XSS 風險)。這表示您每次啟動 K.G.Studio 時都需要重新輸入。如需在非本地主機環境中啟用持久化,請在設定中開啟「Persist API Keys on Non-Localhost」(不建議在共享或生產環境中啟用)。
|
||||
|
||||
## 使用應用
|
||||
|
||||
您可以在[這裡](./docs/USER_GUIDE.md)查看詳細使用者指南。
|
||||
|
||||
- 音軌
|
||||
- 在軌道資訊面板中新增、重新命名和重排音軌。
|
||||
- 透過樂器按鈕(鋼琴圖示)切換樂器;可調整 Solo(S)、Mute(M)和 Volume。
|
||||
- 透過音軌設定選單刪除音軌(位於樂器按鈕右側)。
|
||||
|
||||
- 區域
|
||||
- 建立區域:使用 Pointer 工具時可雙擊,或按住 Ctrl/Cmd 再點擊;使用 Pencil 工具時單擊即可。
|
||||
- 移動/縮放:拖動區域主體可移動,拖動邊緣可調整長度。
|
||||
- 透過區域左上角的小鉛筆開啟 Piano Roll。
|
||||
|
||||
- 鋼琴卷簾(MIDI 音符)
|
||||
- 工具:Select 與 Pencil。
|
||||
- 建立音符:在 Select 模式下雙擊或 Ctrl/Cmd+點擊;在 Pencil 模式下單擊。
|
||||
- 選取音符:單擊;Shift+單擊多選;拖曳框選。
|
||||
- 移動/縮放:拖動音符主體可移動已選音符;拖動邊緣可調整長度。
|
||||
- **五線譜視圖**:可在鋼琴卷簾工具列中切換 Piano Roll 與 Staff Notation 視圖。支援自動譜號選擇、調號顯示、符槓分組、連音線和可設定量化。啟用 **Track Scope** 後會將整條音軌上的所有區域連續渲染為譜面。
|
||||
- **自動化軌道**:可在鋼琴網格下方的可編輯區域中繪製和編輯 pitch bend 與 MIDI CC 曲線(Modulation、Breath、Volume、Expression、Sustain)。
|
||||
- **頻譜模式**:可在鋼琴卷簾中查看音訊區域的頻譜,並將其作為編輯 MIDI 音符時的參考層。
|
||||
- 可透過 X 或 ESC 關閉鋼琴卷簾視窗。
|
||||
|
||||
- 智慧和弦助手(新增於 2025-12-15)
|
||||
- 在鋼琴卷簾工具列中啟用和弦指導:選擇 T(Tonic)、S(Subdominant)或 D(Dominant)功能。
|
||||
- 將滑鼠懸停在任意琴鍵上時,會以紅色高亮顯示與上下文相關的和弦建議,並匹配您目前選擇的調號與調式。
|
||||
- 按 Tab 可在同一和聲功能下循環切換不同和弦轉位。
|
||||
- 雙擊(或 Ctrl/Cmd+點擊)高亮和弦可一次性建立整組音符。
|
||||
- 和弦長度會自動匹配您最近編輯的音符長度,以保持節奏一致。
|
||||
|
||||
- 吸附與量化
|
||||
- 在右上角的 NO SNAP 選單中設定吸附。
|
||||
- 使用 Qua. Pos.(起始)和 Qua. Len.(長度)進行量化。
|
||||
|
||||
- 播放與播放頭
|
||||
- 可透過工具列回到開頭,並執行 Play/Pause。
|
||||
- 在主時間網格點擊小節編號可設定播放頭;在鋼琴卷簾中點擊頂部時間軸也可設定,並遵守目前吸附設定。
|
||||
- BPM、拍號和調號都可以透過工具列中的數值進行修改。
|
||||
|
||||
## 鍵盤快捷鍵
|
||||
|
||||
- 主介面
|
||||
- 播放/暫停:Space
|
||||
- 復原 / 重做:Ctrl/Cmd+Z / Ctrl/Cmd+Shift+Z
|
||||
- 複製 / 剪下 / 貼上:Ctrl/Cmd+C / Ctrl/Cmd+X / Ctrl/Cmd+V
|
||||
- 儲存:Ctrl/Cmd+S
|
||||
- 按住以建立區域:Ctrl/Cmd
|
||||
- 鋼琴卷簾
|
||||
- 工具:Select(Q)、Pencil(W)
|
||||
- 按住以建立音符:Ctrl/Cmd
|
||||
- 吸附:1(None), 2(1/4), 3(1/8), 4(1/16)
|
||||
- 位置量化:5(1/4), 6(1/8), 7(1/16)
|
||||
- 長度量化:8(1/4), 9(1/8), 0(1/16)
|
||||
|
||||
## AI 助手
|
||||
|
||||
### 使用 K.G.Studio 音樂創作助手
|
||||
|
||||
- 請先按照前文說明完成 LLM 提供方設定。
|
||||
- 您可以在右側找到 K.G.Studio 音樂創作助手聊天框。如果目前未顯示,可點擊工具列中的 Chat 🗨️ 按鈕開啟。
|
||||
- 選取您希望助手處理的區域,在聊天框中輸入提示詞;按 Enter 傳送,按 Shift+Enter 插入換行。
|
||||
- agent 會自動處理您的請求,並呼叫工具,在所選區域範圍內執行修改。某些任務可能需要一輪或多輪對話才能完成。
|
||||
- 請注意,AI 也可能出錯,因此您應始終檢查結果,並在必要時自行調整。您也可以透過復原/重做回退修改。
|
||||
- 點擊「+」按鈕或輸入 `/clear` 指令可清空聊天歷史。
|
||||
|
||||
### 設定您的 LLM 提供方
|
||||
|
||||
進入 **設定 ⚙️ → 通用 → LLM 提供方**。根據您選擇的提供方,您需要填寫對應的 API Key,並在需要時填寫自訂基礎 URL(適用於 Ollama、OpenRouter 等非官方 OpenAI 相容服務)。
|
||||
|
||||
注意:由於部分提供方存在 CORS 限制,Google Gemini 和 Anthropic Claude 目前僅支援透過 OpenRouter 使用。
|
||||
|
||||
### 使用 OpenAI 模型
|
||||
|
||||
1. 在 [**OpenAI**](https://platform.openai.com/account/api-keys) 取得 OpenAI API Key。您可能需要先註冊帳號並新增付款方式,才能生成 API Key。
|
||||
2. 在 **設定 ⚙️ → 通用 → LLM 提供方** 中選擇 **OpenAI** 作為提供方。
|
||||
3. 在 **OpenAI → 密鑰** 中輸入您的 API Key。
|
||||
4. 在 **OpenAI → 模型** 下拉中選擇您偏好的模型。若希望在效能與成本之間取得較好平衡,我們推薦 `gpt-5.4-mini`。
|
||||
5. 您也可以選擇是否在 **OpenAI → Flex 模式** 中啟用 Flex Mode。Flex Mode 可以降低價格,但也可能帶來更慢的回應時間或更多伺服器端錯誤。
|
||||
|
||||
### 使用 OpenRouter
|
||||
|
||||
OpenRouter 是一個統一接入平台,可讓您存取來自多個提供方的大量語言模型,其中也包含免費選項,便於比較並找到最適合您需求的模型。
|
||||
|
||||
1. 在 [**OpenRouter**](https://openrouter.ai/keys) 取得 API Key。需要註冊;若使用付費模型,可能還需要綁定付款方式。
|
||||
2. 在 **設定 ⚙️ → 通用 → LLM 提供方** 中選擇 **OpenAI Compatible** 作為提供方。
|
||||
3. 在 **OpenAI Compatible Server → 密鑰** 中輸入您的 API Key。
|
||||
4. 在 [**OpenRouter Models Page**](https://openrouter.ai/models) 瀏覽可用模型。您可以使用「Prompt Pricing」篩選免費模型。
|
||||
**注意:** 不同模型提供方的資料保留與隱私政策可能不同,請在使用前自行查看。
|
||||
5. 在 **OpenAI Compatible Server → 模型** 中輸入您所選的模型名。推薦系列包括:
|
||||
- `Anthropic: Claude Sonnet 4.6` (`anthropic/claude-sonnet-4.6`: [Link](https://openrouter.ai/anthropic/claude-sonnet-4.6)) — Claude 系列中品質與成本平衡較好的選擇
|
||||
- `Qwen: Qwen3.5-35B-A3B` (`qwen/qwen3.5-35b-a3b`: [Link](https://openrouter.ai/qwen/qwen3.5-35b-a3b)) — 推薦的開源模型
|
||||
- `Qwen: Qwen3-Next-80B-A3B`(免費模型:`qwen/qwen3-next-80b-a3b-instruct:free`: [Link](https://openrouter.ai/qwen/qwen3-next-80b-a3b-instruct:free))— 推薦的免費模型
|
||||
- `OpenAI: GPT-OSS 120B`(免費模型:`openai/gpt-oss-120b:free`: [Link](https://openrouter.ai/openai/gpt-oss-120b:free))— 推薦的免費模型
|
||||
- 注意:免費模型提供方可能會蒐集您的資料,使用前請先查看模型頁面說明
|
||||
- 注意:免費模型的可用性變化頻繁。如需查看最新免費選項,請造訪 [OpenRouter Models Page](https://openrouter.ai/models),並使用 **Prompt Pricing** 過濾目前免費模型
|
||||
6. 在 **OpenAI Compatible Server → 基礎 URL** 中填入 `https://openrouter.ai/api/v1`。
|
||||
|
||||
### 關於 agent 與 LLM 提供方
|
||||
|
||||
出於安全考量,當您從非本地主機環境使用 K.G.Studio 時,API Key 預設不會持久化到 IndexedDB 中;這表示您每次啟動 K.G.Studio 時都需要重新輸入。如需在非本地主機環境中啟用持久化,請在設定中開啟「Persist API Keys on Non-Localhost」(不建議在共享或生產環境中啟用)。
|
||||
|
||||
K.G.Studio 不提供也不託管上述任何模型,也不隸屬於任何模型提供方。所有資料都儲存在您的本地裝置中;K.G.Studio 不會蒐集或傳輸您的資料。若您向第三方模型提供方傳送任何資料,相關責任由您自行承擔。
|
||||
|
||||
## K.G.One Music Generator
|
||||
|
||||
> **需要 [K.G.One Music Studio](https://github.com/KGAudioLab/K.G.One) 整合。** 只有當 K.G.Studio 連線到正在執行的 K.G.One 伺服器時,K.G.One Music Generator 面板才會可用。設定說明請參閱 [K.G.One 儲存庫](https://github.com/KGAudioLab/K.G.One)。
|
||||
|
||||
**K.G.One Music Generator** 面板提供三項經 GPU 加速的 AI 工具,用於音樂生成與音訊處理。點擊工具列中的 **✦(魔杖)** 按鈕即可開啟。該面板與 AI Assistant 聊天框互斥,開啟其中一個時,另一個會自動關閉。
|
||||
|
||||
> **注意:** 您首次使用每個工具時,伺服器都需要載入對應 AI 模型,這可能需要 60 秒甚至更久,具體取決於您的硬體。切換分頁時,也可能觸發模型重新載入。
|
||||
|
||||
### Full Song Generation
|
||||
|
||||
根據文字描述和可選歌詞生成一首完整歌曲。由 [ACE-Step 1.5](https://github.com/ace-step/ACE-Step-1.5) 驅動。
|
||||
|
||||
- 在 **Full Song** 分頁中,於 **Caption** 中輸入自然語言描述,說明所需風格、情緒、速度、配器和結構。例如:`Genre: Eurodance, 90s dance-pop, upbeat electronic. Tempo: ~130 BPM. Instrumentation: driving kick drum, eurodance bassline...`
|
||||
- 您也可以填寫 **Lyrics**。可使用 `[Intro]`、`[Verse]`、`[Chorus]`、`[Bridge]` 等標籤標記段落。若勾選 **Instrumental**,則會完全跳過人聲。
|
||||
- 點擊 **Generate Song**。介面會即時顯示生成階段與百分比進度。
|
||||
- 生成完成後,會顯示預覽播放器。您可以將其拖曳到 **audio track**,作為區域匯入。
|
||||
- 目前不支援將整曲生成結果拖放到 MIDI track。
|
||||
- **Advanced Settings**(可展開):Inference Steps、Guidance Scale、Seed,以及 Thinking(CoT metadata generation)。
|
||||
|
||||
### Clip Generation
|
||||
|
||||
根據文字描述生成短樂器片段和 MIDI loops。由 [Foundation-1](https://huggingface.co/RoyalCities/Foundation-1) 驅動。
|
||||
|
||||
- 在 **Clip** 分頁中,於 **Prompt** 中輸入逗號分隔的標籤,描述樂器類別、子類型、音色、效果、長度、BPM 和調性。例如:`Gritty, Acid, Bassline, 303, Synth Lead, FM, Sub, High Reverb, 8 Bars, 140 BPM, E minor`
|
||||
- 您也可以填寫 **Negative Prompt**,以避免生成某些不希望出現的特徵(例如 `distortion, noise`)。
|
||||
- 選擇 **Bars**:4 或 8。BPM 與調號會根據專案設定預填,您也可以在 **Advanced Settings** 中調整。
|
||||
- 點擊 **Generate Clip**。生成完成後,會出現一個預覽播放器,左側帶拖曳手把,右側帶下載按鈕。
|
||||
- **匯入方式**:將播放器拖曳到時間線中的音軌上。
|
||||
- 拖放到 **audio track** 時,會作為 WAV 音訊區域匯入(推薦)。
|
||||
- 拖放到 **MIDI track** 時,會作為 MIDI 區域匯入。請注意,此 MIDI 是從音訊轉錄而來,可能並非完全準確。
|
||||
- **Advanced Settings**(可展開):Note、Scale、BPM、Steps、CFG Scale、Seed(`-1` 表示隨機)、Sampler Type、Sigma Min/Max,以及 CFG Rescale。
|
||||
|
||||
### Stem Separation
|
||||
|
||||
將現有音訊區域拆分為獨立 stems(例如人聲、伴奏、鼓組等)。
|
||||
|
||||
K.G.Studio 支援 **兩種模式** 的分軌:
|
||||
|
||||
#### 本地瀏覽器模式 — 無需伺服器 ✦
|
||||
|
||||
兩個 ONNX 模型可完全在瀏覽器中執行,無 API 呼叫、無費用,資料也不會離開您的裝置。模型只需下載一次,隨後會快取在本地。
|
||||
|
||||
- **Vocal and Instrument (Medium Accuracy)**(`UVR-MDX-NET-Inst_HQ_3`,約 64 MB)— 雙 stem 分離(Vocals / Instrumental)。由 [UVR-MDX-NET](https://github.com/nomadkaraoke/python-audio-separator) 驅動。
|
||||
- **Vocal, Drums, Bass, and Others**(`htdemucs_4s`,約 172 MB)— 四 stem 分離(Vocals / Drums / Bass / Others)。由 [Demucs](https://github.com/facebookresearch/demucs) 驅動。
|
||||
|
||||
開啟 **Music Generator** 面板(工具列中的 ✦ 按鈕),選擇模型,先點擊一次 **Download Selected Model**,然後點擊 **Separate Stems**,所有處理都會在您的瀏覽器中本機完成。
|
||||
|
||||
**要求:** 需要支援 WebGPU 的瀏覽器(Chrome 113+ 或 Edge 113+),並執行在安全上下文中(HTTPS 或 localhost)。若可用,會優先使用 WebGPU 加速;否則會回退到 CPU(處理期間可能降低頁面回應性)。推薦硬體為至少 8 GB 顯存的 GPU,或至少 16 GB 統一記憶體的系統。
|
||||
|
||||
#### K.G.One 伺服器模式
|
||||
|
||||
當連線到 [K.G.One Music Studio](https://github.com/KGAudioLab/K.G.One) 伺服器後,可使用另外三種 GPU 加速模型。由 [python-audio-separator (UVR5)](https://github.com/nomadkaraoke/python-audio-separator) 驅動。
|
||||
|
||||
- **Vocal and Instrument (Medium Accuracy)**(`UVR-MDX-NET-Inst_HQ_3`)— 快速雙 stem 分離(vocal / instrumental)。
|
||||
- **Vocal and Instrument (High Accuracy)**(`MDX23C-8KFFT-InstVoc_HQ`)— 更高品質的雙 stem 分離,但更慢。
|
||||
- **Vocal, Drums, Bass, Guitar, Piano, and Others**(`htdemucs_6s`)— 完整六 stem 分離。
|
||||
|
||||
#### 使用方式(兩種模式通用)
|
||||
|
||||
- 在開啟此分頁之前,請先在時間線上**選取一個音訊區域**。**Separator** 分頁頂部會顯示目前所選區域及其音軌名稱。僅支援音訊區域,不支援分離 MIDI 區域。
|
||||
- 點擊 **Separate Stems**。如果目前所選區域帶有 clip start offset 或已被裁剪,系統會先自動按該區域範圍切出對應音訊,再進行處理。
|
||||
- 處理完成後,每個 stem 都會以帶標籤的預覽播放器顯示,並附帶拖曳手把。您可以在匯入前分別試聽每個 stem。
|
||||
- **匯入 stems:**
|
||||
- 可將每個 stem 播放器分別拖曳到 **audio track** 上,放到您希望的位置。
|
||||
- 或點擊 **Import All Stems to Timeline**,系統會自動為每個 stem 新建一條音訊軌,並將其放置在源音軌下方,與原始區域的起始拍點對齊。該操作可透過一次復原完整回退。
|
||||
|
||||
## 即將推出的功能
|
||||
|
||||
功能優先級可能會變化。
|
||||
|
||||
### 1.0
|
||||
|
||||
- [X] 更多樂器
|
||||
- [X] 自動化測試(單元測試、整合測試等)
|
||||
- [X] 帶功能和聲指導(T/S/D)的智慧和弦助手
|
||||
- [X] 支援軌道控制自動化(例如 sustain、volume、pan 等)
|
||||
- [X] 支援 MIDI 控制事件(例如 CC、pitch bend 等)
|
||||
- [X] 支援 WAV 音訊軌
|
||||
- [X] 錄音
|
||||
- [X] 事件列表
|
||||
- [X] 支援 OpenAI 的開源模型(`gpt-oss-20b` 和 `gpt-oss-120b`)
|
||||
- [X] 五線譜
|
||||
- [X] K.G.One Music Studio 整合
|
||||
- [X] 瀏覽器內嵌 AI 模型(基於 Gemma 4 E4B 的本機 LLM;基於 UVR-MDX-NET-Inst_HQ_3 與 htdemucs_4s 的本機分軌)
|
||||
- [X] 全域軌系統(Marker、Tempo、Key Signature、Chord)
|
||||
- [X] 音訊和弦檢測(零依賴 FFT,並將結果寫入 Chord Track)
|
||||
- [X] 帶自動對齊拍點的速度檢測
|
||||
- [X] 頻譜可視化與 Piano Roll hybrid mode
|
||||
|
||||
### 1.0 之後
|
||||
|
||||
- [ ] 擴展 AI agent 工具,直接透過聊天操作區域、音軌與全域軌(和弦進行、速度、調性)
|
||||
- [ ] Mixer 視圖,提供專用面板顯示每軌推桿、send、return bus 與 master channel
|
||||
- [ ] EQ 與通道條,為每條音軌提供參數均衡器與壓縮器
|
||||
- [ ] 濾波器與效果器,如混響、延遲及其他基於 WebAudio 的原生插入效果
|
||||
- [ ] 音訊 time-stretch / warp,使音訊區域自動匹配專案速度
|
||||
- [ ] MIDI 效果器,如琶音器、音階量化器、和弦記憶
|
||||
- [ ] 虛擬 MIDI 裝置輸出
|
||||
|
||||
## 需要幫助
|
||||
|
||||
我們正在尋找貢獻者,一起讓 K.G.Studio 變得更好。無論您是開發者、音樂人還是設計師,您的專長都能帶來實際價值。
|
||||
|
||||
### 您可以如何參與
|
||||
|
||||
**🎵 音樂人 / 音樂製作人**
|
||||
- 使用真實音樂製作流程測試這款 DAW
|
||||
- 回饋樂器音色品質與真實感
|
||||
- 提出音樂創作中不可或缺但目前缺失的功能
|
||||
- 幫助提升 AI 助手的音樂理解能力
|
||||
|
||||
**💻 開發者**
|
||||
- 根據路線圖實作新功能
|
||||
- 修復 bug 並最佳化效能
|
||||
- 增強 Web Audio 整合
|
||||
- 改進 AI 助手能力
|
||||
|
||||
**🎨 UI/UX 設計師**
|
||||
- 最佳化介面和工作流
|
||||
- 設計更適合音樂編輯的視覺回饋
|
||||
- 打造更直觀的互動方式
|
||||
|
||||
### 參與方式
|
||||
|
||||
如果您有興趣貢獻,我們很願意聽到您的想法。
|
||||
|
||||
- **給我們發郵件**:[kgstudio@duck.com](mailto:kgstudio@duck.com)
|
||||
- **查看 Issues**:瀏覽帶有 `help wanted` 或 `good first issue` 標籤的公開問題
|
||||
- **參與討論**:在 GitHub Discussions 中分享想法與回饋
|
||||
|
||||
每一份貢獻都很重要。無論是回報 bug,還是提出新功能建議,都會推動專案繼續前進。
|
||||
|
||||
### 免責聲明
|
||||
|
||||
K.G.Studio 是一個處於早期開發階段的實驗性專案。我們正在探索如何將 AI agent 與 LLM 融入音樂製作工作流,本質上是在建構一種面向 DAW 的「Cursor 或 Claude Code」體驗。
|
||||
|
||||
該專案關注 AI 與人類協作如何增強音樂創作過程,從智慧和聲建議到自動化編輯任務都在探索範圍內。作為實驗平台,專案會頻繁變化,功能將持續演進,並且在我們推進 AI 輔助音樂製作邊界的過程中,偶爾出現不穩定情況也是可以預期的。
|
||||
|
||||
K.G.Studio 不提供也不託管任何 LLM 模型,也不隸屬於任何模型提供方。所有資料都儲存在您的本地裝置中;K.G.Studio 不會蒐集或傳輸您的資料。若您向第三方模型提供方傳送任何資料,相關責任由您自行承擔。
|
||||
|
||||
## 授權條款
|
||||
|
||||
本專案基於 Apache License, Version 2.0 授權,並附帶額外條款(參見 `LICENSE`):
|
||||
- 不得使用本軟體或相關素材申請專利
|
||||
- 當用於公開或商業產品時需要署名(「Powered by K.G.Studio」)
|
||||
|
||||
第三方聲明(FluidR3_GM SoundFont、midi-js-soundfonts、VexFlow、prompt structure notes、Gemma 4 E4B、UVR-MDX-NET-Inst_HQ_3、MediaPipe、Meyda、web-audio-beat-detector、tonal、htdemucs_4s、onnxruntime-web 和 demucs-web)已包含在 `LICENSE` 中。
|
||||
@@ -1,3 +1,5 @@
|
||||
English | [Français](./README-fr.md) | [简体中文](./README-zh_cn.md) | [繁體中文](./README-zh_hk.md)
|
||||
|
||||
<div align="center">
|
||||
<img src="./public/logo.png" alt="K.G.Studio Logo" width="160" />
|
||||
</div>
|
||||
@@ -27,6 +29,8 @@ K.G.Studio is a lightweight, modern DAW that runs entirely in the browser with *
|
||||
|
||||
## Latest Updates
|
||||
|
||||
- **2026.05.30**: Added **internationalization (i18n) support** — K.G.Studio now ships in four languages: **English**, **Simplified Chinese (简体中文)**, **Traditional Chinese (繁體中文)**, and **French (Français)**. The active language can be configured under **Settings ⚙️ → General → Language**, with an `Auto` option that automatically detects your browser's locale.
|
||||
|
||||
- **2026.05.27**:
|
||||
- Added **Global Track System** — introduce four global tracks: **Marker**, **Tempo**, **Key Signature**, and **Chord** (chord-symbol span regions).
|
||||
- Added **Audio Chord Detection** feature — open piano roll window for an audio or a MIDI region and click "..." -> **Detect Chords** to automatically analyse the recording and populate the Chord Track using a zero-dependency FFT pipeline with configurable sensitivity, stability, and seventh-chord detection.
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,126 @@
|
||||
## 帮助
|
||||
|
||||
欢迎使用 **K.G.Studio 音乐创作助手**。这是您的 AI 音乐创作与编曲助手,可以帮助您构思、编排和修改音乐内容。
|
||||
|
||||
您可以选择使用 **内置本地浏览器 LLM**(无需 API Key),也可以配置外部 LLM 提供方。下面是常用的入门方式。
|
||||
|
||||
---
|
||||
|
||||
### 使用本地 LLM(浏览器)——无需 API Key
|
||||
|
||||
K.G.Studio 可以通过 WebGPU 在浏览器中直接运行 **Gemma 4 E4B**。不会产生外部 API 调用费用,数据也不会离开您的设备。
|
||||
|
||||
1. 进入 **设置 ⚙️ → 通用 → LLM 提供方**,选择 **本地 LLM(浏览器)**(默认选项)。
|
||||
2. 第一次打开聊天时会自动下载模型(约 2.8 GB),后续会缓存在本地浏览器中。
|
||||
3. 可选设置 **上下文长度**(32k / 64k / 128k tokens);越大越占显存。
|
||||
4. 现在就可以开始聊天,无需 Key、账号或持续联网。
|
||||
|
||||
**要求:** Chrome 113+ 或 Edge 113+,安全上下文(HTTPS 或 localhost),以及至少 8 GB 显存的 GPU 或至少 16 GB 统一内存的系统。
|
||||
|
||||
> 注意:本地模型效果无法与 GPT 或 Claude 这类商业模型完全相比。复杂任务通常更适合外部提供方。
|
||||
|
||||
---
|
||||
|
||||
### 配置外部 LLM 提供方
|
||||
|
||||
进入 **设置 ⚙️ → 通用 → LLM 提供方**。根据所选提供方,您需要填写对应的 API Key,以及在需要时填写 Base URL(例如 Ollama、OpenRouter 等 OpenAI 兼容服务)。
|
||||
|
||||
---
|
||||
|
||||
### 使用 OpenAI GPT 系列
|
||||
|
||||
1. 在 [**OpenAI**](https://platform.openai.com/account/api-keys) 获取 API Key。您可能需要先注册账号并添加支付方式。
|
||||
2. 在 **设置 ⚙️ → 通用 → LLM 提供方** 中选择 **OpenAI**。
|
||||
3. 在 **OpenAI → 密钥** 中填入 API Key。
|
||||
4. 在 **OpenAI → 模型** 下拉中选择模型。若希望在成本和效果之间取得平衡,推荐 `gpt-5.4-mini`。
|
||||
5. 可选:在 **OpenAI → Flex 模式** 中启用 Flex Mode。它可能降低成本,但也可能带来更高延迟或更多服务端错误。
|
||||
|
||||
---
|
||||
|
||||
### 使用 OpenRouter
|
||||
|
||||
OpenRouter 提供统一接口,可访问多个语言模型提供方的模型,其中也包含免费选项,适合比较不同模型表现。
|
||||
|
||||
1. 在 [**OpenRouter**](https://openrouter.ai/keys) 获取 API Key。需要注册;使用付费模型时可能还需要绑定支付方式。
|
||||
2. 在 **设置 ⚙️ → 通用 → LLM 提供方** 中选择 **OpenAI 兼容服务**。
|
||||
3. 在 **OpenAI 兼容服务 → 密钥** 中填入 API Key。
|
||||
4. 在 [**OpenRouter Models Page**](https://openrouter.ai/models) 浏览可用模型,并可使用 “Prompt Pricing” 过滤免费模型。
|
||||
**注意:** 各模型提供方的数据保留与隐私策略可能不同,使用前请自行查看。
|
||||
5. 在 **OpenAI 兼容服务 → 模型** 中填入模型名。推荐系列包括:
|
||||
- `Anthropic: Claude Sonnet 4.6`(`anthropic/claude-sonnet-4.6`)—— Claude 系列里质量和成本平衡较好
|
||||
- `Qwen: Qwen3.5-35B-A3B`(`qwen/qwen3.5-35b-a3b`)—— 推荐开源模型
|
||||
- `Qwen: Qwen3-Next-80B-A3B`(免费:`qwen/qwen3-next-80b-a3b-instruct:free`)—— 推荐免费模型
|
||||
- `OpenAI: GPT-OSS 120B`(免费:`openai/gpt-oss-120b:free`)—— 推荐免费模型
|
||||
- 注意:免费模型会经常变化,请以 OpenRouter 模型页中的 **Prompt Pricing** 过滤结果为准
|
||||
- 注意:免费模型提供方可能会收集您的数据,使用前请先查看模型页面说明
|
||||
6. 在 **OpenAI 兼容服务 → 基础 URL** 中填写 `https://openrouter.ai/api/v1`。
|
||||
|
||||
### 基本 DAW 操作
|
||||
|
||||
聊天命令:`/clear`、`/welcome`、`/help`、`/hotkeys`
|
||||
|
||||
- 音轨
|
||||
- 在轨道信息面板中添加、重命名和重排音轨。
|
||||
- 通过乐器按钮(钢琴图标)切换乐器;可调整 Solo(S)、Mute(M)和 Volume。
|
||||
- 通过音轨设置菜单删除音轨(位于乐器按钮右侧)。
|
||||
- **音频录音**:点击工具栏里的 Rec 按钮,可直接从麦克风录到音频轨。
|
||||
|
||||
- 区域
|
||||
- 创建区域:使用 Pointer 工具时可双击,或按住 Ctrl/Cmd 再点击;使用 Pencil 工具时单击即可。
|
||||
- 移动/缩放:拖动区域主体可移动,拖动边缘可调整长度。
|
||||
- 通过区域左上角的小铅笔打开钢琴卷帘窗口。
|
||||
|
||||
- 钢琴卷帘(MIDI 音符)
|
||||
- 工具:Select 与 Pencil。
|
||||
- 创建音符:在 Select 模式下双击或 Ctrl/Cmd+点击;在 Pencil 模式下单击。
|
||||
- 选择音符:单击;Shift+单击多选;拖拽框选。
|
||||
- 移动/缩放:拖动音符主体可移动已选音符;拖动边缘可调整长度。
|
||||
- **五线谱视图**:可在钢琴卷帘工具栏中切换钢琴卷帘与五线谱视图。支持自动谱号、调号显示、连音线和符杠分组。启用 **Track Scope** 后会连续显示整条 MIDI 音轨上的内容。
|
||||
- **自动化轨道**:可在下方自动化区域绘制和编辑 Pitch Bend 与 MIDI CC 曲线(Modulation、Breath、Volume、Expression、Sustain)。
|
||||
- **频谱模式**:可在钢琴卷帘中查看音频区域的频谱,作为 MIDI 编辑参考层。
|
||||
- **事件列表面板**:提供 Notes / Pitch Bend / Controller 标签页,用于查看并内联编辑当前 MIDI 区域中的事件。
|
||||
- 可通过 X 或 ESC 关闭钢琴卷帘窗口。
|
||||
|
||||
- 智能和弦助手
|
||||
- 在钢琴卷帘工具栏中使用 `⊘`、`T`、`S`、`D` 启用和弦指导。
|
||||
- 和弦候选会根据播放头当前位置的有效调号来推断:大调使用 Ionian,小调使用 Aeolian。
|
||||
- 将鼠标悬停在任意琴键上时,会以红色高亮显示上下文相关的和弦建议。
|
||||
- 按 `g` 循环切换和弦指导模式,按 `Tab` 切换到下一个候选和弦,按 `Shift+Tab` 切换到上一个。
|
||||
- 双击(或 Ctrl/Cmd+点击)高亮和弦可一次性创建整组音符。
|
||||
- 和弦长度会自动匹配您最近编辑的音符长度,以保持节奏一致。
|
||||
|
||||
- 全局轨系统
|
||||
- 时间线上方固定有四条轨道:**Marker**、**Tempo**、**Key Signature**、**Chord**。
|
||||
- 创建全局区域:在全局轨中双击或 Ctrl/Cmd+点击;拖动可移动,拖动边缘可调整长度。
|
||||
- 这些轨道会影响播放时序、和弦指导、五线谱调号显示,以及和弦检测结果。
|
||||
|
||||
- 音频分析功能
|
||||
- **Detect Chords**:在音频区域打开钢琴卷帘窗口后,点击 **...** → **Detect Chords**,即可自动分析并把结果写入全局 Chord Track。可配置灵敏度、稳定性和七和弦检测。
|
||||
- **Detect Tempo**:在音频区域打开钢琴卷帘窗口后,点击 **...** → **Detect Tempo**,即可分析 BPM,并可选择自动对齐项目中的 Tempo Track。
|
||||
|
||||
- K.G.One 音乐生成器
|
||||
- 点击工具栏中的 **✦**(魔杖)按钮打开生成器面板。
|
||||
- **Full Song Generation**:根据文本描述和可选歌词生成整首歌曲(需要 K.G.One 服务器)。
|
||||
- **Clip Generation**:根据文本提示生成短音频片段和 MIDI loop(需要 K.G.One 服务器)。
|
||||
- **Stem Separation(浏览器)**:可完全在浏览器中把任意音频区域分离成 stems,无需服务器。支持两种模型:**UVR-MDX-NET-Inst_HQ_3**(2 stem:Vocals / Instrumental,约 64 MB)和 **Demucs htdemucs_4s**(4 stem:Vocals / Drums / Bass / Others,约 172 MB)。在生成器面板中下载模型后,点击 **Separate Stems** 即可。需要 WebGPU(Chrome 113+ / Edge 113+)。
|
||||
|
||||
- 吸附与量化
|
||||
- 在右上角的 NO SNAP 菜单中设置吸附。
|
||||
- 使用 Qua. Pos.(起始)和 Qua. Len.(长度)进行量化。
|
||||
|
||||
- 播放与播放头
|
||||
- 工具栏可回到开头,并执行 Play/Pause。
|
||||
- 在主时间网格点击小节编号可设置播放头;在钢琴卷帘中点击顶部时间轴也可设置,并遵守当前吸附设置。
|
||||
- BPM、拍号和调号都可以通过工具栏中的数值直接调整。
|
||||
|
||||
---
|
||||
|
||||
### 说明
|
||||
|
||||
出于安全考虑,在非本地主机场景下,K.G.Studio 默认不会把 API Key 持久化到 IndexedDB 中(以降低 XSS 风险)。您每次打开 K.G.Studio 时都需要重新输入。若您确实需要在非本地主机场景下持久化,请在设置中启用 “Persist API Keys on Non-Localhost”(不建议在共享或生产环境下启用)。
|
||||
|
||||
### 免责声明
|
||||
|
||||
K.G.Studio 不提供也不托管上述任何模型,也不隶属于任何模型提供方。所有数据默认都保存在您的本地设备中;您向第三方模型提供方发送的任何数据,都由您自行负责。
|
||||
|
||||
祝您使用 K.G.Studio 音乐创作助手创作愉快!
|
||||
@@ -0,0 +1,126 @@
|
||||
## 幫助
|
||||
|
||||
歡迎使用 **K.G.Studio 音樂創作助手**。這是您的 AI 音樂創作與編曲助手,可以幫助您構思、編排和修改音樂內容。
|
||||
|
||||
您可以選擇使用 **內建本地瀏覽器 LLM**(無需 API Key),也可以設定外部 LLM 提供方。下面是常用的入門方式。
|
||||
|
||||
---
|
||||
|
||||
### 使用本地 LLM(瀏覽器)——無需 API Key
|
||||
|
||||
K.G.Studio 可以通過 WebGPU 在瀏覽器中直接運行 **Gemma 4 E4B**。不會產生外部 API 呼叫費用,資料也不會離開您的裝置。
|
||||
|
||||
1. 進入 **設定 ⚙️ → 通用 → LLM 提供方**,選擇 **本地 LLM(瀏覽器)**(預設選項)。
|
||||
2. 第一次開啟聊天時會自動下載模型(約 2.8 GB),後續會快取在本地瀏覽器中。
|
||||
3. 可選設定 **上下文長度**(32k / 64k / 128k tokens);越大越佔顯存。
|
||||
4. 現在就可以開始聊天,無需 Key、帳號或持續聯網。
|
||||
|
||||
**要求:** Chrome 113+ 或 Edge 113+,安全上下文(HTTPS 或 localhost),以及至少 8 GB 顯存的 GPU 或至少 16 GB 統一記憶體的系統。
|
||||
|
||||
> 注意:本地模型效果無法與 GPT 或 Claude 這類商業模型完全相比。複雜任務通常更適合外部提供方。
|
||||
|
||||
---
|
||||
|
||||
### 配置外部 LLM 提供方
|
||||
|
||||
進入 **設定 ⚙️ → 通用 → LLM 提供方**。根據所選提供方,您需要填寫對應的 API Key,以及在需要時填寫 Base URL(例如 Ollama、OpenRouter 等 OpenAI 兼容服務)。
|
||||
|
||||
---
|
||||
|
||||
### 使用 OpenAI GPT 系列
|
||||
|
||||
1. 在 [**OpenAI**](https://platform.openai.com/account/api-keys) 取得 API Key。您可能需要先註冊帳號並新增付款方式。
|
||||
2. 在 **設定 ⚙️ → 通用 → LLM 提供方** 中選擇 **OpenAI**。
|
||||
3. 在 **OpenAI → 密鑰** 中填入 API Key。
|
||||
4. 在 **OpenAI → 模型** 下拉中選擇模型。若希望在成本和效果之間取得平衡,推薦 `gpt-5.4-mini`。
|
||||
5. 可選:在 **OpenAI → Flex 模式** 中啟用 Flex Mode。它可能降低成本,但也可能帶來更高延遲或更多服務端錯誤。
|
||||
|
||||
---
|
||||
|
||||
### 使用 OpenRouter
|
||||
|
||||
OpenRouter 提供統一接口,可訪問多個語言模型提供方的模型,其中也包含免費選項,適合比較不同模型表現。
|
||||
|
||||
1. 在 [**OpenRouter**](https://openrouter.ai/keys) 取得 API Key。需要註冊;使用付費模型時可能還需要綁定付款方式。
|
||||
2. 在 **設定 ⚙️ → 通用 → LLM 提供方** 中選擇 **OpenAI 兼容服務**。
|
||||
3. 在 **OpenAI 兼容服務 → 密鑰** 中填入 API Key。
|
||||
4. 在 [**OpenRouter Models Page**](https://openrouter.ai/models) 瀏覽可用模型,並可使用 "Prompt Pricing" 過濾免費模型。
|
||||
**注意:** 各模型提供方的資料保留與隱私策略可能不同,使用前請自行查看。
|
||||
5. 在 **OpenAI 兼容服務 → 模型** 中填入模型名。推薦系列包括:
|
||||
- `Anthropic: Claude Sonnet 4.6`(`anthropic/claude-sonnet-4.6`)—— Claude 系列裡質量和成本平衡較好
|
||||
- `Qwen: Qwen3.5-35B-A3B`(`qwen/qwen3.5-35b-a3b`)—— 推薦開源模型
|
||||
- `Qwen: Qwen3-Next-80B-A3B`(免費:`qwen/qwen3-next-80b-a3b-instruct:free`)—— 推薦免費模型
|
||||
- `OpenAI: GPT-OSS 120B`(免費:`openai/gpt-oss-120b:free`)—— 推薦免費模型
|
||||
- 注意:免費模型會經常變化,請以 OpenRouter 模型頁中的 **Prompt Pricing** 過濾結果為準
|
||||
- 注意:免費模型提供方可能會收集您的資料,使用前請先查看模型頁面說明
|
||||
6. 在 **OpenAI 兼容服務 → 基礎 URL** 中填寫 `https://openrouter.ai/api/v1`。
|
||||
|
||||
### 基本 DAW 操作
|
||||
|
||||
聊天命令:`/clear`、`/welcome`、`/help`、`/hotkeys`
|
||||
|
||||
- 音軌
|
||||
- 在軌道資訊面板中新增、重命名和重排音軌。
|
||||
- 通過樂器按鈕(鋼琴圖示)切換樂器;可調整 Solo(S)、Mute(M)和 Volume。
|
||||
- 通過音軌設定選單刪除音軌(位於樂器按鈕右側)。
|
||||
- **音訊錄音**:點擊工具列裡的 Rec 按鈕,可直接從麥克風錄到音訊軌。
|
||||
|
||||
- 區域
|
||||
- 建立區域:使用 Pointer 工具時可雙擊,或按住 Ctrl/Cmd 再點擊;使用 Pencil 工具時單擊即可。
|
||||
- 移動/縮放:拖動區域主體可移動,拖動邊緣可調整長度。
|
||||
- 通過區域左上角的小鉛筆開啟 Piano Roll。
|
||||
|
||||
- Piano Roll(MIDI 音符)
|
||||
- 工具:Select 與 Pencil。
|
||||
- 建立音符:在 Select 模式下雙擊或 Ctrl/Cmd+點擊;在 Pencil 模式下單擊。
|
||||
- 選取音符:單擊;Shift+單擊多選;拖曳框選。
|
||||
- 移動/縮放:拖動音符主體可移動已選音符;拖動邊緣可調整長度。
|
||||
- **五線譜視圖**:可在 Piano Roll 工具列中切換 Piano Roll 與五線譜視圖。支援自動譜號、調號顯示、連音線和符槓分組。啟用 **Track Scope** 後會連續顯示整條 MIDI 音軌上的內容。
|
||||
- **自動化軌道**:可在下方自動化區域繪製和編輯 Pitch Bend 與 MIDI CC 曲線(Modulation、Breath、Volume、Expression、Sustain)。
|
||||
- **頻譜模式**:可在 Piano Roll 中查看音訊區域的頻譜,作為 MIDI 編輯參考層。
|
||||
- **事件列表面板**:提供 Notes / Pitch Bend / Controller 標籤頁,用於查看並內聯編輯當前 MIDI 區域中的事件。
|
||||
- 可通過 X 或 ESC 關閉 Piano Roll 視窗。
|
||||
|
||||
- 智能和弦助手
|
||||
- 在 Piano Roll 工具列中使用 `⊘`、`T`、`S`、`D` 啟用和弦指導。
|
||||
- 和弦候選會根據播放頭當前位置的有效調號來推斷:大調使用 Ionian,小調使用 Aeolian。
|
||||
- 將滑鼠懸停在任意琴鍵上時,會以紅色高亮顯示上下文相關的和弦建議。
|
||||
- 按 `g` 循環切換和弦指導模式,按 `Tab` 切換到下一個候選和弦,按 `Shift+Tab` 切換到上一個。
|
||||
- 雙擊(或 Ctrl/Cmd+點擊)高亮和弦可一次性建立整組音符。
|
||||
- 和弦長度會自動匹配您最近編輯的音符長度,以保持節奏一致。
|
||||
|
||||
- 全域軌系統
|
||||
- 時間線上方固定有四條軌道:**Marker**、**Tempo**、**Key Signature**、**Chord**。
|
||||
- 建立全域區域:在全域軌中雙擊或 Ctrl/Cmd+點擊;拖動可移動,拖動邊緣可調整長度。
|
||||
- 這些軌道會影響播放時序、和弦指導、五線譜調號顯示,以及和弦檢測結果。
|
||||
|
||||
- 音訊分析功能
|
||||
- **Detect Chords**:在音訊區域開啟 Piano Roll 後,點擊 **...** → **Detect Chords**,即可自動分析並把結果寫入全域 Chord Track。可設定靈敏度、穩定性和七和弦檢測。
|
||||
- **Detect Tempo**:在音訊區域開啟 Piano Roll 後,點擊 **...** → **Detect Tempo**,即可分析 BPM,並可選擇自動對齊專案中的 Tempo Track。
|
||||
|
||||
- K.G.One 音樂生成器
|
||||
- 點擊工具列中的 **✦**(魔杖)按鈕開啟生成器面板。
|
||||
- **Full Song Generation**:根據文本描述和可選歌詞生成整首歌曲(需要 K.G.One 伺服器)。
|
||||
- **Clip Generation**:根據文本提示生成短音訊片段和 MIDI loop(需要 K.G.One 伺服器)。
|
||||
- **Stem Separation(瀏覽器)**:可完全在瀏覽器中把任意音訊區域分離成 stems,無需伺服器。支援兩種模型:**UVR-MDX-NET-Inst_HQ_3**(2 stem:Vocals / Instrumental,約 64 MB)和 **Demucs htdemucs_4s**(4 stem:Vocals / Drums / Bass / Others,約 172 MB)。在生成器面板中下載模型後,點擊 **Separate Stems** 即可。需要 WebGPU(Chrome 113+ / Edge 113+)。
|
||||
|
||||
- 吸附與量化
|
||||
- 在右上角的 NO SNAP 選單中設定吸附。
|
||||
- 使用 Qua. Pos.(起始)和 Qua. Len.(長度)進行量化。
|
||||
|
||||
- 播放與播放頭
|
||||
- 工具列可回到開頭,並執行 Play/Pause。
|
||||
- 在主時間網格點擊小節編號可設定播放頭;在 Piano Roll 中點擊頂部時間軸也可設定,並遵守目前吸附設定。
|
||||
- BPM、拍號和調號都可以通過工具列中的數值直接調整。
|
||||
|
||||
---
|
||||
|
||||
### 說明
|
||||
|
||||
出於安全考慮,在非本地主機場景下,K.G.Studio 預設不會把 API Key 持久化到 IndexedDB 中(以降低 XSS 風險)。您每次開啟 K.G.Studio 時都需要重新輸入。若您確實需要在非本地主機場景下持久化,請在設定中啟用 "Persist API Keys on Non-Localhost"(不建議在共享或生產環境下啟用)。
|
||||
|
||||
### 免責聲明
|
||||
|
||||
K.G.Studio 不提供也不託管上述任何模型,也不隸屬於任何模型提供方。所有資料預設都儲存在您的本地裝置中;您向第三方模型提供方發送的任何資料,都由您自行負責。
|
||||
|
||||
祝您使用 K.G.Studio 音樂創作助手創作愉快!
|
||||
@@ -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`
|
||||
@@ -0,0 +1,59 @@
|
||||
## 快捷键
|
||||
|
||||
本指南列出了当前在 K.G.Studio 中已经实现的键盘快捷键,以及部分点击时配合修饰键的操作。
|
||||
|
||||
### 平台相关的创建操作
|
||||
|
||||
- 在 **macOS** 上,使用 Pointer 工具时按 `Cmd+click` 可创建区域、音符或自动化点。
|
||||
- 在 **Windows/Linux** 上,使用 Pointer 工具时按 `Ctrl+click` 可创建区域、音符或自动化点。
|
||||
- 在主时间线中,Pointer 工具激活时双击空白处可创建区域。
|
||||
- 在钢琴卷帘与自动化轨中,Pencil 工具支持单击创建。
|
||||
|
||||
### 主快捷键
|
||||
|
||||
- `Space` — 播放 / 停止
|
||||
- `C` — 切换循环
|
||||
- `R` — 开始 / 停止录音
|
||||
- `Cmd/Ctrl+Z` — 撤销
|
||||
- `Cmd/Ctrl+Shift+Z` — 重做
|
||||
- `Cmd/Ctrl+C` — 复制所选项目
|
||||
- `Cmd/Ctrl+V` — 粘贴
|
||||
- `Cmd/Ctrl+S` — 保存项目
|
||||
- `Cmd/Ctrl+A` — 选中当前 MIDI 区域中的全部音符
|
||||
- `Cmd/Ctrl+T` — 在播放头处分割所选区域
|
||||
- `Cmd/Ctrl+J` — 合并选中的 MIDI 区域
|
||||
|
||||
### 区域与钢琴卷帘窗口快捷键
|
||||
|
||||
- `E` — 为选中的 MIDI 区域打开钢琴卷帘窗口,或为选中的音频区域打开波形编辑器;如果编辑器已经打开,`E` 会关闭它。
|
||||
- `N` — 以五线谱视图打开选中的 MIDI 区域。
|
||||
- `Delete` / `Backspace` — 删除选中的区域、音符或自动化点。
|
||||
- `Esc` — 关闭钢琴卷帘窗口。
|
||||
|
||||
### 选择操作
|
||||
|
||||
- `Shift+click` — 加入当前选择。
|
||||
- 在空白区域套索拖拽 — 框选区域或自动化点。
|
||||
- 在钢琴卷帘窗口中 `Shift+套索` — 切换套索范围内项目的选中状态。
|
||||
|
||||
### 钢琴卷帘快捷键
|
||||
|
||||
- `G` — 循环切换和弦指导模式
|
||||
- `Tab` — 跳到下一个和弦候选
|
||||
- `Shift+Tab` — 跳到上一个和弦候选 / 转位
|
||||
|
||||
### 钢琴卷帘吸附快捷键
|
||||
|
||||
- `1` — 不吸附
|
||||
- `2` — 吸附到 `1/4`
|
||||
- `3` — 吸附到 `1/8`
|
||||
- `4` — 吸附到 `1/16`
|
||||
|
||||
### 钢琴卷帘量化快捷键
|
||||
|
||||
- `5` — 起始量化到 `1/4`
|
||||
- `6` — 起始量化到 `1/8`
|
||||
- `7` — 起始量化到 `1/16`
|
||||
- `8` — 长度量化到 `1/4`
|
||||
- `9` — 长度量化到 `1/8`
|
||||
- `0` — 长度量化到 `1/16`
|
||||
@@ -0,0 +1,59 @@
|
||||
## 快捷鍵
|
||||
|
||||
本指南列出了當前在 K.G.Studio 中已經實現的鍵盤快捷鍵,以及部分點擊時配合修飾鍵的操作。
|
||||
|
||||
### 平台相關的建立操作
|
||||
|
||||
- 在 **macOS** 上,使用 Pointer 工具時按 `Cmd+click` 可建立區域、音符或自動化點。
|
||||
- 在 **Windows/Linux** 上,使用 Pointer 工具時按 `Ctrl+click` 可建立區域、音符或自動化點。
|
||||
- 在主時間線中,Pointer 工具啟用時雙擊空白處可建立區域。
|
||||
- 在 Piano Roll 與自動化軌中,Pencil 工具支援單擊建立。
|
||||
|
||||
### 主快捷鍵
|
||||
|
||||
- `Space` — 播放 / 停止
|
||||
- `C` — 切換循環
|
||||
- `R` — 開始 / 停止錄音
|
||||
- `Cmd/Ctrl+Z` — 撤銷
|
||||
- `Cmd/Ctrl+Shift+Z` — 重做
|
||||
- `Cmd/Ctrl+C` — 複製所選專案
|
||||
- `Cmd/Ctrl+V` — 貼上
|
||||
- `Cmd/Ctrl+S` — 儲存專案
|
||||
- `Cmd/Ctrl+A` — 選取目前 MIDI 區域中的全部音符
|
||||
- `Cmd/Ctrl+T` — 在播放頭處分割所選區域
|
||||
- `Cmd/Ctrl+J` — 合併已選取的 MIDI 區域
|
||||
|
||||
### 區域與 Piano Roll 視窗快捷鍵
|
||||
|
||||
- `E` — 為已選取的 MIDI 區域開啟 Piano Roll,或為已選取的音訊區域開啟波形編輯器;如果編輯器已經開啟,`E` 會關閉它。
|
||||
- `N` — 以五線譜視圖開啟已選取的 MIDI 區域。
|
||||
- `Delete` / `Backspace` — 刪除已選取的區域、音符或自動化點。
|
||||
- `Esc` — 關閉 Piano Roll 視窗。
|
||||
|
||||
### 選擇操作
|
||||
|
||||
- `Shift+click` — 加入目前選擇。
|
||||
- 在空白區域套索拖曳 — 框選區域或自動化點。
|
||||
- 在 Piano Roll 視窗中 `Shift+套索` — 切換套索範圍內項目的選取狀態。
|
||||
|
||||
### Piano Roll 快捷鍵
|
||||
|
||||
- `G` — 循環切換和弦指導模式
|
||||
- `Tab` — 跳到下一個和弦候選
|
||||
- `Shift+Tab` — 跳到上一個和弦候選 / 轉位
|
||||
|
||||
### Piano Roll 吸附快捷鍵
|
||||
|
||||
- `1` — 不吸附
|
||||
- `2` — 吸附到 `1/4`
|
||||
- `3` — 吸附到 `1/8`
|
||||
- `4` — 吸附到 `1/16`
|
||||
|
||||
### Piano Roll 量化快捷鍵
|
||||
|
||||
- `5` — 起始量化到 `1/4`
|
||||
- `6` — 起始量化到 `1/8`
|
||||
- `7` — 起始量化到 `1/16`
|
||||
- `8` — 長度量化到 `1/4`
|
||||
- `9` — 長度量化到 `1/8`
|
||||
- `0` — 長度量化到 `1/16`
|
||||
@@ -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.
|
||||
@@ -0,0 +1,11 @@
|
||||
## 再次欢迎
|
||||
|
||||
很高兴再次见到您。检测到您的 LLM 提供方已经配置完成,现在可以直接开始聊天。
|
||||
|
||||
提示:
|
||||
- 随时输入 `/clear` 重置当前聊天。
|
||||
- 输入 `/welcome` 再次查看这条欢迎消息。
|
||||
- 输入 `/help` 查看帮助说明。
|
||||
- 输入 `/hotkeys` 查看快捷键指南。
|
||||
|
||||
祝您在 K.G.Studio 音乐创作助手中创作顺利。
|
||||
@@ -0,0 +1,11 @@
|
||||
## 再次歡迎
|
||||
|
||||
很高興再次見到您。檢測到您的 LLM 提供方已經設定完成,現在可以直接開始聊天。
|
||||
|
||||
提示:
|
||||
- 隨時輸入 `/clear` 重設目前聊天。
|
||||
- 輸入 `/welcome` 再次查看這條歡迎消息。
|
||||
- 輸入 `/help` 查看幫助說明。
|
||||
- 輸入 `/hotkeys` 查看快捷鍵指南。
|
||||
|
||||
祝您在 K.G.Studio 音樂創作助手中創作順利。
|
||||
@@ -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.
|
||||
@@ -0,0 +1,29 @@
|
||||
## 本地 LLM 模式
|
||||
|
||||
欢迎使用 **K.G.Studio 音乐创作助手** 的本地 LLM 模式。
|
||||
|
||||
- 无需外部 API 调用。所有内容都直接在您的浏览器中运行,不会产生额外 API 成本。
|
||||
- 此模式通过 **LiteRT-LM** 和 **WebGPU** 加速运行 **Gemma 4 E4B**。
|
||||
- 推荐硬件:至少 **8 GB 显存**的 GPU,或至少 **16 GB 统一内存**的系统。
|
||||
- 相比更大的云端模型,本地模式在复杂规划、长链路编辑和多步骤任务上能力会更有限。
|
||||
|
||||
### 推荐工作方式
|
||||
- 保持请求小而明确。
|
||||
- 分步骤引导模型逐步完成目标。
|
||||
- 尽量针对较小的音乐区域工作,而不是一次处理整首歌。
|
||||
- 优先选择更简单的音乐编排。
|
||||
- 每个独立任务尽量开启新的对话。
|
||||
|
||||
示例:
|
||||
- 对较复杂的请求,建议拆成多个小步骤。
|
||||
- 例如,如果您想让模型基于当前音乐写一段和弦进行,可以先说:`请读取当前音乐。`
|
||||
- 等这一步完成后,再继续说:`请基于它写一段和弦进行。`
|
||||
- 在本地 LLM 模式下,这种渐进式流程通常更可靠。
|
||||
- 在整个流程中,保持您希望 agent 更新的 MIDI 区域处于选中状态。
|
||||
- 仅仅为了执行 `read_music`,您不需要切换到别的区域,因为 `read_music` 可以读取所有音轨的音乐内容。
|
||||
|
||||
### 改用外部 LLM
|
||||
- 如果您想使用更大的云端模型或自托管模型,请打开 **设置 -> 通用 -> LLM 提供方**,切换到 **本地 LLM(浏览器)** 以外的提供方。
|
||||
- 如果要用云端模型,可以选择 **OpenAI**,或者选择 **OpenAI Compatible** 并填写 OpenRouter 等提供方。
|
||||
- 如果要用自托管模型,也请选择 **OpenAI Compatible**,然后填写您的服务器 **Base URL** 和 **Model**。
|
||||
- 切换提供方后,建议开启一个新的对话,以便聊天会话干净地使用新模型。
|
||||
@@ -0,0 +1,29 @@
|
||||
## 本地 LLM 模式
|
||||
|
||||
歡迎使用 **K.G.Studio 音樂創作助手** 的本地 LLM 模式。
|
||||
|
||||
- 無需外部 API 調用。所有內容都直接在您的瀏覽器中運行,不會產生額外 API 成本。
|
||||
- 此模式通過 **LiteRT-LM** 和 **WebGPU** 加速運行 **Gemma 4 E4B**。
|
||||
- 推薦硬體:至少 **8 GB 顯存**的 GPU,或至少 **16 GB 統一記憶體**的系統。
|
||||
- 相比更大的雲端模型,本地模式在複雜規劃、長鏈路編輯和多步驟任務上能力會更有限。
|
||||
|
||||
### 推薦工作方式
|
||||
- 保持請求小而明確。
|
||||
- 分步驟引導模型逐步完成目標。
|
||||
- 盡量針對較小的音樂區域工作,而不是一次處理整首歌。
|
||||
- 優先選擇更簡單的音樂編排。
|
||||
- 每個獨立任務盡量開啟新的對話。
|
||||
|
||||
示例:
|
||||
- 對較複雜的請求,建議拆成多個小步驟。
|
||||
- 例如,如果您想讓模型基於當前音樂寫一段和弦進行,可以先說:`請讀取當前音樂。`
|
||||
- 等這一步完成後,再繼續說:`請基於它寫一段和弦進行。`
|
||||
- 在本地 LLM 模式下,這種漸進式流程通常更可靠。
|
||||
- 在整個流程中,保持您希望 agent 更新的 MIDI 區域處於選取狀態。
|
||||
- 僅僅為了執行 `read_music`,您不需要切換到別的區域,因為 `read_music` 可以讀取所有音軌的音樂內容。
|
||||
|
||||
### 改用外部 LLM
|
||||
- 如果您想使用更大的雲端模型或自託管模型,請開啟 **設定 -> 通用 -> LLM 提供方**,切換到 **本地 LLM(瀏覽器)** 以外的提供方。
|
||||
- 如果要用雲端模型,可以選擇 **OpenAI**,或者選擇 **OpenAI Compatible** 並填寫 OpenRouter 等提供方。
|
||||
- 如果要用自託管模型,也請選擇 **OpenAI Compatible**,然後填寫您的伺服器 **Base URL** 和 **Model**。
|
||||
- 切換提供方後,建議開啟一個新的對話,以便聊天會話乾淨地使用新模型。
|
||||
@@ -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.
|
||||
@@ -0,0 +1,25 @@
|
||||
## 欢迎
|
||||
|
||||
欢迎使用 **K.G.Studio 音乐创作助手**。这是您在浏览器中的 AI 音乐创作与编曲助手。
|
||||
|
||||
### 快速开始
|
||||
**配置 K.G.Studio 音乐创作助手**
|
||||
- [点击这里获取免费的 OpenRouter API Key](https://openrouter.ai/keys)(您可能需要一个 OpenRouter 账号)。
|
||||
- 进入 **设置 ⚙️ → 通用 → LLM 提供方**,选择 **OpenAI 兼容服务**。
|
||||
- 在 **OpenAI 兼容服务 → 密钥** 中粘贴您的 Key。注意:在非 localhost 环境下,Key 默认不会持久化,刷新后需要重新输入,请妥善保管。
|
||||
- 在 **OpenAI 兼容服务 → 模型** 中输入 `qwen/qwen3-30b-a3b:free`。注意:非免费模型可能需要计费。
|
||||
- 在 **OpenAI 兼容服务 → 基础 URL** 中输入 `https://openrouter.ai/api/v1`。
|
||||
|
||||
*(或者,您也可以使用官方 OpenAI API,或者任何 OpenAI 兼容服务。)*
|
||||
|
||||
**基本 DAW 操作**
|
||||
- 在音轨上双击(或按住 Ctrl/Cmd 再点击)可创建区域。
|
||||
- 拖动区域边缘可调整长度;拖动区域主体可移动。
|
||||
- 点击区域左上角的小铅笔可打开钢琴卷帘窗口。
|
||||
- 在钢琴卷帘中,双击(或 Ctrl/Cmd+点击)可创建音符。
|
||||
- 单击选择;Shift+单击多选;拖拽框选。
|
||||
- 拖动音符边缘可调整长度;拖动音符主体可移动所选音符。
|
||||
- 使用钢琴卷帘工具栏右上角的 Snapping 控制网格量化。
|
||||
|
||||
### 免责声明
|
||||
K.G.Studio 与任何 LLM 提供方均无隶属关系。所有数据默认都保存在您的本地设备中;您发送给第三方服务的数据由您自行负责。
|
||||
@@ -0,0 +1,25 @@
|
||||
## 歡迎
|
||||
|
||||
歡迎使用 **K.G.Studio 音樂創作助手**。這是您在瀏覽器中的 AI 音樂創作與編曲助手。
|
||||
|
||||
### 快速開始
|
||||
**配置 K.G.Studio 音樂創作助手**
|
||||
- [點擊這裡獲取免費的 OpenRouter API Key](https://openrouter.ai/keys)(您可能需要一個 OpenRouter 帳號)。
|
||||
- 進入 **設定 ⚙️ → 通用 → LLM 提供方**,選擇 **OpenAI 兼容服務**。
|
||||
- 在 **OpenAI 兼容服務 → 密鑰** 中貼上您的 Key。注意:在非 localhost 環境下,Key 預設不會持久化,重新整理後需要重新輸入,請妥善保管。
|
||||
- 在 **OpenAI 兼容服務 → 模型** 中輸入 `qwen/qwen3-30b-a3b:free`。注意:非免費模型可能需要計費。
|
||||
- 在 **OpenAI 兼容服務 → 基礎 URL** 中輸入 `https://openrouter.ai/api/v1`。
|
||||
|
||||
*(或者,您也可以使用官方 OpenAI API,或者任何 OpenAI 兼容服務。)*
|
||||
|
||||
**基本 DAW 操作**
|
||||
- 在音軌上雙擊(或按住 Ctrl/Cmd 再點擊)可建立區域。
|
||||
- 拖動區域邊緣可調整長度;拖動區域主體可移動。
|
||||
- 點擊區域左上角的小鉛筆可開啟 Piano Roll。
|
||||
- 在 Piano Roll 中,雙擊(或 Ctrl/Cmd+點擊)可建立音符。
|
||||
- 單擊選取;Shift+單擊多選;拖曳框選。
|
||||
- 拖動音符邊緣可調整長度;拖動音符主體可移動所選音符。
|
||||
- 使用 Piano Roll 工具列右上角的 Snapping 控制網格量化。
|
||||
|
||||
### 免責聲明
|
||||
K.G.Studio 與任何 LLM 提供方均無隸屬關係。所有資料預設都儲存在您的本地裝置中;您發送給第三方服務的資料由您自行負責。
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"general": {
|
||||
"language": "en_us",
|
||||
"language": "auto",
|
||||
"llm_provider": "local_browser",
|
||||
"persist_api_keys_non_localhost": false,
|
||||
"openai": {
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import React from 'react';
|
||||
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', () => ({
|
||||
UserMessage: ({ content }: { content: string }) => <div>{content}</div>,
|
||||
AssistantMessage: ({ content }: { content: string }) => <div>{content}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../agent/core/AgentCore', () => ({
|
||||
AgentCore: {
|
||||
instance: () => ({
|
||||
setLLMProvider: vi.fn(),
|
||||
getLLMProvider: vi.fn(),
|
||||
abortCurrentRequest: vi.fn(),
|
||||
getAgentState: vi.fn(() => ({ getMessages: vi.fn(() => []) })),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../agent/llm/LLMProvider', () => ({
|
||||
OpenAICompatibleLLMProvider: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../agent/llm/LocalBrowserLLMProvider', () => ({
|
||||
LocalBrowserLLMProvider: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../core/config/ConfigManager', () => ({
|
||||
ConfigManager: {
|
||||
instance: () => ({
|
||||
getIsInitialized: () => true,
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
get: (key: string) => {
|
||||
if (key === 'general.llm_provider') {
|
||||
return 'openai';
|
||||
}
|
||||
return '';
|
||||
},
|
||||
addChangeListener: () => () => undefined,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../stores/projectStore', () => ({
|
||||
useProjectStore: {
|
||||
getState: () => ({
|
||||
setStatus: vi.fn(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../agent/core/SystemPrompts', () => ({
|
||||
SystemPrompts: {
|
||||
getSystemPromptWithContext: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../util/chatUtil', () => ({
|
||||
clearChatHistoryAndUI: vi.fn(),
|
||||
registerClearChatUICallback: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../util/messageFilter/UserMessageFilter', () => ({
|
||||
processUserMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/useStreamProcessor', () => ({
|
||||
useStreamProcessor: () => ({
|
||||
abortController: null,
|
||||
processStream: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/chatMessageUtils', () => ({
|
||||
createMessage: vi.fn(),
|
||||
addWelcomeMessage: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
vi.mock('../util/timeUtil', () => ({
|
||||
formatLocalDateTime: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../util/miscUtil', () => ({
|
||||
downloadBlob: vi.fn(),
|
||||
buildTimestampSuffix: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../util/localLLMModelManager', () => ({
|
||||
LocalLLMModelManager: {
|
||||
getState: () => ({
|
||||
runtimeSupport: { supported: true, reason: null },
|
||||
isCached: false,
|
||||
isDownloading: false,
|
||||
isChecking: false,
|
||||
progressText: '',
|
||||
progressPercent: 0,
|
||||
error: null,
|
||||
}),
|
||||
subscribe: () => () => undefined,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../util/localLLMConfig', () => ({
|
||||
LOCAL_LLM_DISPLAY_NAME: 'Gemma 4 E4B',
|
||||
LOCAL_LLM_PROVIDER_KEY: 'local_browser',
|
||||
}));
|
||||
|
||||
vi.mock('./common/KGDropdown', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
function renderWithLocale(resolvedLocale: ResolvedLocaleCode) {
|
||||
return render(
|
||||
<I18nContext.Provider
|
||||
value={{
|
||||
languageSetting: resolvedLocale,
|
||||
resolvedLocale,
|
||||
setLanguageSetting: async () => undefined,
|
||||
t: (key, params) => translate(key, params, resolvedLocale),
|
||||
}}
|
||||
>
|
||||
<ChatBox isVisible={true} />
|
||||
</I18nContext.Provider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('ChatBox', () => {
|
||||
beforeAll(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
});
|
||||
|
||||
it('renders the English assistant title under en_us', () => {
|
||||
renderWithLocale('en_us');
|
||||
|
||||
expect(screen.getByRole('heading', { level: 3, name: 'K.G.Studio Musician Assistant' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders the Chinese assistant title under zh_cn', () => {
|
||||
renderWithLocale('zh_cn');
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import { downloadBlob, buildTimestampSuffix } from '../util/miscUtil';
|
||||
import { LocalLLMModelManager, type LocalLLMModelState } from '../util/localLLMModelManager';
|
||||
import { LOCAL_LLM_DISPLAY_NAME, LOCAL_LLM_PROVIDER_KEY } from '../util/localLLMConfig';
|
||||
import KGDropdown from './common/KGDropdown';
|
||||
import { useI18n } from '../i18n/useI18n';
|
||||
|
||||
import type { ChatMessage } from '../types/projectTypes';
|
||||
|
||||
@@ -63,6 +64,7 @@ interface ChatBoxProps {
|
||||
}
|
||||
|
||||
const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
const { t } = useI18n();
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
@@ -345,7 +347,7 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
return (
|
||||
<div className={`chatbox ${isVisible ? '' : 'is-hidden'}`}>
|
||||
<div className="chatbox-header">
|
||||
<h3>K.G.Studio Musician Assistant</h3>
|
||||
<h3>{t('assistant.displayName')}</h3>
|
||||
<div className="chatbox-actions">
|
||||
{isProcessing && (
|
||||
<button
|
||||
|
||||
@@ -26,6 +26,9 @@ import {
|
||||
createMockMidiTrack,
|
||||
} 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) => {
|
||||
const option = Array.from(document.querySelectorAll('.quant-option'))
|
||||
@@ -185,6 +188,19 @@ vi.mock('../core/KGCore', () => ({
|
||||
}));
|
||||
|
||||
describe('EventListPanel', () => {
|
||||
const renderWithLocale = (locale: ResolvedLocaleCode = '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 +476,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 +570,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,79 @@
|
||||
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 type { ResolvedLocaleCode } from '../i18n/types';
|
||||
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: ResolvedLocaleCode) {
|
||||
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;
|
||||
|
||||
|
||||
|
||||
@@ -304,24 +304,6 @@
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.kgone-btn-generate-accent {
|
||||
background-color: #4a5fa0;
|
||||
border: none;
|
||||
color: #e0e0e0;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.kgone-btn-generate-accent:hover:not(:disabled) {
|
||||
background-color: #5a70b8;
|
||||
}
|
||||
|
||||
.kgone-btn-generate-accent:disabled {
|
||||
background-color: #3a3a3a;
|
||||
color: #666;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.kgone-btn-generate.dialog-btn {
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
+228
-252
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,7 @@ import {
|
||||
CHORD_REGION_IMPORT_REGION_NAME,
|
||||
resolveChordRegionImportSelection,
|
||||
} from '../util/chordRegionImportUtil';
|
||||
import { useI18n } from '../i18n/useI18n';
|
||||
|
||||
interface MainContentProps {
|
||||
onTrackClick?: () => void;
|
||||
@@ -34,6 +35,7 @@ interface MainContentProps {
|
||||
const MainContent: React.FC<MainContentProps> = ({
|
||||
onTrackClick = () => {},
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
tracks,
|
||||
globalTracks,
|
||||
@@ -338,8 +340,8 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
className="track-header-button"
|
||||
aria-label="Create track"
|
||||
title="Create track"
|
||||
aria-label={t('mainContent.createTrack')}
|
||||
title={t('mainContent.createTrack')}
|
||||
onClick={openCreateTrackModal}
|
||||
>
|
||||
<FaPlus />
|
||||
@@ -347,8 +349,8 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
className={`track-header-button${showGlobalTracksMock ? ' active' : ''}`}
|
||||
aria-label="Show global tracks"
|
||||
title="Show global tracks"
|
||||
aria-label={t('mainContent.showGlobalTracks')}
|
||||
title={t('mainContent.showGlobalTracks')}
|
||||
onClick={handleToggleGlobalTracksMock}
|
||||
>
|
||||
<FaSquareArrowUpRight />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import StatusBar from './StatusBar';
|
||||
import StatusBar, { formatChordGuideCandidateStatus } from './StatusBar';
|
||||
import { KGPianoRollState } from '../core/state/KGPianoRollState';
|
||||
import { KGCore } from '../core/KGCore';
|
||||
import type { ChordGuideData } from '../core/ChordGuideTypes';
|
||||
@@ -56,9 +56,7 @@ describe('StatusBar', () => {
|
||||
KGPianoRollState.instance().setCurrentHoveredChordGuideCandidate(hoveredCandidate);
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText('Chord Guide Candidate: Dm7 — D F A C — Core ii-V-I predominant sonority.')
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText(formatChordGuideCandidateStatus(hoveredCandidate))).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
KGPianoRollState.instance().setCurrentHoveredChordGuideCandidate(null);
|
||||
@@ -76,8 +74,6 @@ describe('StatusBar', () => {
|
||||
KGPianoRollState.instance().setCurrentHoveredChordGuideCandidate(transposedCandidate);
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText('Chord Guide Candidate: C — D F# A — Most stable tonic triad.')
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText(formatChordGuideCandidateStatus(transposedCandidate))).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -103,7 +103,7 @@ describe('DialogProvider chord detection dialog', () => {
|
||||
fireEvent.change(screen.getByLabelText('Stability'), { target: { value: '81' } });
|
||||
fireEvent.change(screen.getByLabelText('No-Chord Threshold'), { target: { value: '24' } });
|
||||
fireEvent.click(screen.getByLabelText('Chord Detail: Enable sevenths'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Detect' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'OK' }));
|
||||
finishDialogCloseAnimation();
|
||||
|
||||
await waitFor(() => expect(resolved).toEqual({
|
||||
@@ -228,7 +228,7 @@ describe('DialogProvider tempo detection dialog', () => {
|
||||
fireEvent.change(screen.getByLabelText('Maximum BPM'), { target: { value: '154' } });
|
||||
expect(screen.getByText('96')).toBeInTheDocument();
|
||||
expect(screen.getByText('154')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Detect' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'OK' }));
|
||||
finishDialogCloseAnimation();
|
||||
|
||||
await waitFor(() => expect(resolved).toEqual({
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import { KGProjectStorage, type ProjectMeta } from '../../core/io/KGProjectStora
|
||||
import { isValidProjectName } from '../../util/projectNameUtil';
|
||||
import './OpenProjectModal.css';
|
||||
import { showAlert, showConfirm, showPrompt } from '../../util/dialogUtil';
|
||||
import { useI18n } from '../../i18n/useI18n';
|
||||
import { translate } from '../../i18n/translate';
|
||||
|
||||
interface OpenProjectModalProps {
|
||||
onClose: () => void;
|
||||
@@ -34,7 +36,7 @@ const matchesFilter = (projectName: string, filter: string): boolean => {
|
||||
};
|
||||
|
||||
const formatDate = (timestamp: number): string => {
|
||||
if (timestamp === 0) return 'Unknown';
|
||||
if (timestamp === 0) return translate('openProjectModal.date.unknown');
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
@@ -52,6 +54,7 @@ const OpenProjectModal: React.FC<OpenProjectModalProps> = ({
|
||||
currentProjectName,
|
||||
onCreateNewProject
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [projects, setProjects] = useState<ProjectMeta[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [filter, setFilter] = useState('');
|
||||
@@ -145,12 +148,12 @@ const OpenProjectModal: React.FC<OpenProjectModalProps> = ({
|
||||
|
||||
const handleDuplicate = async (e: React.MouseEvent, projectName: string) => {
|
||||
e.stopPropagation();
|
||||
const newName = await showPrompt('Enter a name for the duplicated project:', projectName);
|
||||
const newName = await showPrompt(t('openProjectModal.dialog.duplicateName'), projectName);
|
||||
if (!newName || newName.trim() === '') return;
|
||||
|
||||
const trimmed = newName.trim();
|
||||
if (!isValidProjectName(trimmed)) {
|
||||
await showAlert('Invalid project name. Only letters, numbers, spaces, hyphens, underscores, periods, and parentheses are allowed.');
|
||||
await showAlert(t('openProjectModal.dialog.invalidName'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -165,7 +168,7 @@ const OpenProjectModal: React.FC<OpenProjectModalProps> = ({
|
||||
startClose();
|
||||
} catch (error) {
|
||||
console.error('Error duplicating project:', error);
|
||||
await showAlert(`Failed to duplicate project: ${error}`);
|
||||
await showAlert(t('openProjectModal.dialog.duplicateError', { error: String(error) }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -177,7 +180,7 @@ const OpenProjectModal: React.FC<OpenProjectModalProps> = ({
|
||||
await fetchProjects(viewMode);
|
||||
} catch (error) {
|
||||
console.error('Error deleting project:', error);
|
||||
await showAlert(`Failed to delete project: ${error}`);
|
||||
await showAlert(t('openProjectModal.dialog.deleteError', { error: String(error) }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -189,14 +192,14 @@ const OpenProjectModal: React.FC<OpenProjectModalProps> = ({
|
||||
await fetchProjects(viewMode);
|
||||
} catch (error) {
|
||||
console.error('Error restoring project:', error);
|
||||
await showAlert(`Failed to restore project: ${error}`);
|
||||
await showAlert(t('openProjectModal.dialog.restoreError', { error: String(error) }));
|
||||
}
|
||||
};
|
||||
|
||||
const handlePermanentDelete = async (e: React.MouseEvent, projectName: string) => {
|
||||
e.stopPropagation();
|
||||
const confirmed = await showConfirm(
|
||||
`Are you sure you want to permanently delete "${projectName}"?\n\nThis operation cannot be undone.`
|
||||
t('openProjectModal.dialog.confirmPermanentDelete', { name: projectName })
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
@@ -210,7 +213,7 @@ const OpenProjectModal: React.FC<OpenProjectModalProps> = ({
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error permanently deleting project:', error);
|
||||
await showAlert(`Failed to permanently delete project: ${error}`);
|
||||
await showAlert(t('openProjectModal.dialog.permanentDeleteError', { error: String(error) }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -243,8 +246,8 @@ const OpenProjectModal: React.FC<OpenProjectModalProps> = ({
|
||||
>
|
||||
<div className={`open-project-panel${isClosing ? ' open-project-panel-closing' : ''}`}>
|
||||
<div className="open-project-header">
|
||||
<h3 className="open-project-title">Open Project</h3>
|
||||
<button className="open-project-close-btn" onClick={startClose} aria-label="Close">
|
||||
<h3 className="open-project-title">{t('openProjectModal.title')}</h3>
|
||||
<button className="open-project-close-btn" onClick={startClose} aria-label={t('openProjectModal.close')}>
|
||||
<FaTimes />
|
||||
</button>
|
||||
</div>
|
||||
@@ -253,7 +256,7 @@ const OpenProjectModal: React.FC<OpenProjectModalProps> = ({
|
||||
<div className="open-project-filter-row">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter projects..."
|
||||
placeholder={t('openProjectModal.filter.placeholder')}
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
autoFocus
|
||||
@@ -263,13 +266,13 @@ const OpenProjectModal: React.FC<OpenProjectModalProps> = ({
|
||||
className={`open-project-toggle-btn ${viewMode === 'projects' ? 'active' : ''}`}
|
||||
onClick={() => handleViewModeChange('projects')}
|
||||
>
|
||||
Projects
|
||||
{t('openProjectModal.viewToggle.projects')}
|
||||
</button>
|
||||
<button
|
||||
className={`open-project-toggle-btn ${viewMode === 'trash' ? 'active' : ''}`}
|
||||
onClick={() => handleViewModeChange('trash')}
|
||||
>
|
||||
Trash
|
||||
{t('openProjectModal.viewToggle.trash')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -277,7 +280,7 @@ const OpenProjectModal: React.FC<OpenProjectModalProps> = ({
|
||||
|
||||
{viewMode === 'trash' && (
|
||||
<div className="open-project-trash-hint">
|
||||
Deleted projects are automatically removed after 30 days.
|
||||
{t('openProjectModal.trash.hint')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -286,29 +289,29 @@ const OpenProjectModal: React.FC<OpenProjectModalProps> = ({
|
||||
className={`open-project-sort-col open-project-sort-col-name ${sortField === 'name' ? 'active' : ''}`}
|
||||
onClick={() => handleSortClick('name')}
|
||||
>
|
||||
Name <SortIcon field="name" />
|
||||
{t('openProjectModal.sort.name')} <SortIcon field="name" />
|
||||
</span>
|
||||
<span
|
||||
className={`open-project-sort-col open-project-sort-col-created ${sortField === 'createdAt' ? 'active' : ''}`}
|
||||
onClick={() => handleSortClick('createdAt')}
|
||||
>
|
||||
Created <SortIcon field="createdAt" />
|
||||
{t('openProjectModal.sort.created')} <SortIcon field="createdAt" />
|
||||
</span>
|
||||
<span
|
||||
className={`open-project-sort-col open-project-sort-col-updated ${sortField === 'updatedAt' ? 'active' : ''}`}
|
||||
onClick={() => handleSortClick('updatedAt')}
|
||||
>
|
||||
Updated <SortIcon field="updatedAt" />
|
||||
{t('openProjectModal.sort.updated')} <SortIcon field="updatedAt" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="open-project-loading">Loading projects...</div>
|
||||
<div className="open-project-loading">{t('openProjectModal.loading')}</div>
|
||||
) : filteredProjects.length === 0 ? (
|
||||
<div className="open-project-empty">
|
||||
{projects.length === 0
|
||||
? (viewMode === 'trash' ? 'Trash is empty' : 'No saved projects')
|
||||
: 'No projects match the filter'}
|
||||
? (viewMode === 'trash' ? t('openProjectModal.empty.trash') : t('openProjectModal.empty.noProjects'))
|
||||
: t('openProjectModal.empty.noMatch')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="open-project-list">
|
||||
@@ -321,9 +324,9 @@ const OpenProjectModal: React.FC<OpenProjectModalProps> = ({
|
||||
<div className="open-project-item-info">
|
||||
<div className="open-project-item-name">{project.name}</div>
|
||||
<div className="open-project-item-meta">
|
||||
Created {formatDate(project.createdAt)} · Updated {formatDate(project.updatedAt)}
|
||||
{t('openProjectModal.meta.created', { date: formatDate(project.createdAt) })} · {t('openProjectModal.meta.updated', { date: formatDate(project.updatedAt) })}
|
||||
{viewMode === 'trash' && project.deletedAt && (
|
||||
<> · Deleted {formatDate(project.deletedAt)}</>
|
||||
<> · {t('openProjectModal.meta.deleted', { date: formatDate(project.deletedAt) })}</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -334,19 +337,19 @@ const OpenProjectModal: React.FC<OpenProjectModalProps> = ({
|
||||
className="open-project-item-open-btn"
|
||||
onClick={(e) => { e.stopPropagation(); handleOpen(project.name); }}
|
||||
>
|
||||
Open
|
||||
{t('openProjectModal.action.open')}
|
||||
</button>
|
||||
<button
|
||||
className="open-project-item-action-btn open-project-item-duplicate-btn"
|
||||
onClick={(e) => handleDuplicate(e, project.name)}
|
||||
title="Duplicate"
|
||||
title={t('openProjectModal.action.duplicate')}
|
||||
>
|
||||
<FaCopy />
|
||||
</button>
|
||||
<button
|
||||
className="open-project-item-action-btn open-project-item-delete-btn"
|
||||
onClick={(e) => handleSoftDelete(e, project.name)}
|
||||
title="Delete"
|
||||
title={t('openProjectModal.action.delete')}
|
||||
>
|
||||
<FaTrash />
|
||||
</button>
|
||||
@@ -356,14 +359,14 @@ const OpenProjectModal: React.FC<OpenProjectModalProps> = ({
|
||||
<button
|
||||
className="open-project-item-action-btn open-project-item-restore-btn"
|
||||
onClick={(e) => handleRestore(e, project.name)}
|
||||
title="Restore"
|
||||
title={t('openProjectModal.action.restore')}
|
||||
>
|
||||
<FaUndo />
|
||||
</button>
|
||||
<button
|
||||
className="open-project-item-action-btn open-project-item-permdelete-btn"
|
||||
onClick={(e) => handlePermanentDelete(e, project.name)}
|
||||
title="Delete permanently"
|
||||
title={t('openProjectModal.action.deletePermanently')}
|
||||
>
|
||||
<FaTrash />
|
||||
</button>
|
||||
|
||||
@@ -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,9 @@ 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 type { ResolvedLocaleCode } from '../../i18n/types';
|
||||
import { translate } from '../../i18n/translate';
|
||||
|
||||
type TestLiveNoteActivityListener = (...args: [{ pitch: number; isNoteOn: boolean }]) => void;
|
||||
|
||||
@@ -48,6 +51,24 @@ vi.mock('../../core/midi-input/KGMidiInput', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
function renderWithLocale(
|
||||
ui: React.ReactElement,
|
||||
resolvedLocale: ResolvedLocaleCode = '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 +87,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 +102,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 +124,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 +136,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 +148,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 +175,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';
|
||||
@@ -63,6 +69,7 @@ import {
|
||||
type PendingModeSwitchRequest,
|
||||
} from './pianoRollViewport';
|
||||
import { getNextChordGuideSelection, resolveChordGuideContext, type ChordGuideFunction } from './chordGuideUtil';
|
||||
import { useI18n } from '../../i18n/useI18n';
|
||||
|
||||
interface PianoRollProps {
|
||||
onClose: () => void;
|
||||
@@ -95,6 +102,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
const isAudioOnly = isAudioWaveform || isSpectrogram;
|
||||
const isHybrid = currentMode === 'hybrid';
|
||||
const { maxBars, tracks, updateTrack, timeSignature, showChatBox, showKGOnePanel, showEventListPanel, showInstrumentSelection, keySignature, selectedMode, setSelectedMode, playheadPosition, isPlaying, autoScrollEnabled, bpm, pianoRollScrollRequest, selectedNoteIds, automationRedrawVersion, refreshProjectState, setBpm } = useProjectStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
// Tool state for piano roll
|
||||
const [activeTool, setActiveTool] = useState<'pointer' | 'pencil'>('pointer');
|
||||
@@ -121,11 +129,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');
|
||||
@@ -504,7 +512,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
|
||||
const detectionOptions = audioRegion
|
||||
? await showChordDetectionOptions(
|
||||
'Tune audio chord detection settings before processing.',
|
||||
t('dialog.message.chordDetection'),
|
||||
DEFAULT_AUDIO_CHORD_DETECTION_OPTIONS,
|
||||
)
|
||||
: await showMidiChordDetectionOptions(
|
||||
@@ -622,7 +630,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
}
|
||||
|
||||
const detectionOptions = await showTempoDetectionOptions(
|
||||
'Tune audio tempo detection settings before processing.',
|
||||
t('dialog.message.tempoDetection'),
|
||||
DEFAULT_AUDIO_TEMPO_DETECTION_OPTIONS,
|
||||
);
|
||||
if (!detectionOptions) {
|
||||
@@ -653,8 +661,8 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
const applyResult = await showTempoApply(
|
||||
buildDetectedTempoChoiceMessage(result.bpm),
|
||||
[
|
||||
{ label: 'Update Current Tempo', value: DETECTED_TEMPO_ACTION_UPDATE_CURRENT },
|
||||
{ label: 'Insert Tempo Change', value: DETECTED_TEMPO_ACTION_INSERT_REGION },
|
||||
{ label: t('dialog.action.updateCurrentTempo'), value: DETECTED_TEMPO_ACTION_UPDATE_CURRENT },
|
||||
{ label: t('dialog.action.insertTempoChange'), value: DETECTED_TEMPO_ACTION_INSERT_REGION },
|
||||
],
|
||||
);
|
||||
if (!applyResult) {
|
||||
@@ -713,8 +721,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 +983,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 +1418,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 +1464,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 +1478,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,11 @@
|
||||
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 type { ResolvedLocaleCode } from '../../i18n/types';
|
||||
import { translate } from '../../i18n/translate';
|
||||
|
||||
const coreMock = {
|
||||
selectedItems: [] as Array<{ getId(): string }>,
|
||||
@@ -45,6 +48,19 @@ import PianoRollAutomationLane from './PianoRollAutomationLane';
|
||||
import { getControllerNumberForAutomationType } from './pianoRollAutomation';
|
||||
|
||||
describe('PianoRollAutomationLane', () => {
|
||||
const renderWithLocale = (ui: React.ReactElement, locale: ResolvedLocaleCode = '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 +92,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 +122,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 +160,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 +204,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 +240,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 +269,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 +302,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 +324,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,9 @@ 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 type { ResolvedLocaleCode } from '../../i18n/types';
|
||||
import { translate } from '../../i18n/translate';
|
||||
|
||||
vi.mock('../common', () => ({
|
||||
KGDropdown: ({
|
||||
@@ -34,11 +37,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: ResolvedLocaleCode = '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 +69,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 +83,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 +106,7 @@ describe('PianoRollToolbar', () => {
|
||||
it('changes the automation type from the dropdown', () => {
|
||||
const onAutomationTypeChange = vi.fn();
|
||||
|
||||
render(
|
||||
renderWithLocale(
|
||||
<PianoRollToolbar
|
||||
{...baseProps}
|
||||
mode="hybrid"
|
||||
@@ -107,7 +124,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 +143,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 +164,7 @@ describe('PianoRollToolbar', () => {
|
||||
});
|
||||
|
||||
it('hides automation controls in spectrogram mode', () => {
|
||||
render(
|
||||
renderWithLocale(
|
||||
<PianoRollToolbar
|
||||
{...baseProps}
|
||||
mode="spectrogram"
|
||||
@@ -162,7 +179,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 +196,7 @@ describe('PianoRollToolbar', () => {
|
||||
});
|
||||
|
||||
it('hides the spectrogram toggle in hybrid mode', () => {
|
||||
render(
|
||||
renderWithLocale(
|
||||
<PianoRollToolbar
|
||||
{...baseProps}
|
||||
mode="hybrid"
|
||||
@@ -193,7 +210,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 +228,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 +246,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 +264,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 +284,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 +303,7 @@ describe('PianoRollToolbar', () => {
|
||||
});
|
||||
|
||||
it('does not show the detect tempo action without the audio callback', () => {
|
||||
render(
|
||||
renderWithLocale(
|
||||
<PianoRollToolbar
|
||||
{...baseProps}
|
||||
mode="midi-edit"
|
||||
@@ -299,7 +316,7 @@ describe('PianoRollToolbar', () => {
|
||||
});
|
||||
|
||||
it('shows only the sheet controls when sheet mode is enabled', () => {
|
||||
render(
|
||||
renderWithLocale(
|
||||
<PianoRollToolbar
|
||||
{...baseProps}
|
||||
sheetMusicViewEnabled={true}
|
||||
@@ -316,7 +333,7 @@ describe('PianoRollToolbar', () => {
|
||||
});
|
||||
|
||||
it('shows the zoom button outside sheet mode', () => {
|
||||
render(
|
||||
renderWithLocale(
|
||||
<PianoRollToolbar
|
||||
{...baseProps}
|
||||
sheetMusicViewEnabled={false}
|
||||
@@ -324,13 +341,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 +361,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,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<string, unknown>([
|
||||
['audio.input_device_id', 'default'],
|
||||
@@ -63,16 +64,16 @@ describe('AudioIOSettings', () => {
|
||||
it('renders input/output selectors and refresh action', async () => {
|
||||
render(<AudioIOSettings />);
|
||||
|
||||
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(<AudioIOSettings />);
|
||||
|
||||
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(() => {
|
||||
|
||||
@@ -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<string>('default');
|
||||
const [outputDeviceId, setOutputDeviceId] = useState<string>('default');
|
||||
const [inputs, setInputs] = useState<AudioDeviceOption[]>([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 (
|
||||
<div className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<h3>Audio I/O</h3>
|
||||
<h3>{t('settings.audioIo.title')}</h3>
|
||||
</div>
|
||||
|
||||
<div className="settings-section-content">
|
||||
<div className="settings-group">
|
||||
<div className="settings-group-header">
|
||||
<h4>Device Routing</h4>
|
||||
<h4>{t('settings.audioIo.deviceRouting')}</h4>
|
||||
<button
|
||||
type="button"
|
||||
className="settings-btn"
|
||||
onClick={() => void refreshDevices()}
|
||||
disabled={refreshing}
|
||||
>
|
||||
{refreshing ? 'Refreshing…' : 'Refresh Device List'}
|
||||
{refreshing ? t('settings.audioIo.refreshing') : t('settings.audioIo.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="settings-description">
|
||||
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')}
|
||||
</div>
|
||||
|
||||
<div className="settings-item">
|
||||
<label className="settings-label" htmlFor="audio-input-device-select">
|
||||
Audio Input Device
|
||||
{t('settings.audioIo.inputDevice')}
|
||||
</label>
|
||||
<select
|
||||
id="audio-input-device-select"
|
||||
@@ -191,13 +193,13 @@ const AudioIOSettings: React.FC = () => {
|
||||
))}
|
||||
</select>
|
||||
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
|
||||
Changes apply to the next audio recording. If the selected device is removed, KGStudio will fall back to System Default.
|
||||
{t('settings.audioIo.inputHelp')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-item">
|
||||
<label className="settings-label" htmlFor="audio-output-device-select">
|
||||
Audio Output Device
|
||||
{t('settings.audioIo.outputDevice')}
|
||||
</label>
|
||||
<select
|
||||
id="audio-output-device-select"
|
||||
@@ -215,16 +217,16 @@ const AudioIOSettings: React.FC = () => {
|
||||
{supportsOutputPrompt && (
|
||||
<div style={{ marginTop: '10px' }}>
|
||||
<button type="button" className="settings-btn" onClick={() => void handleChooseOutputDevice()}>
|
||||
Choose Output Device…
|
||||
{t('settings.audioIo.chooseOutputDevice')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
|
||||
Output-device changes require refresh in v1. Browser support for non-default output routing is limited, so KGStudio will continue on System Default when unsupported.
|
||||
{t('settings.audioIo.outputHelp')}
|
||||
</div>
|
||||
{!supportsOutputSink && (
|
||||
<div className="settings-help" style={{ fontSize: '12px', color: '#d0a56b', marginTop: '6px' }}>
|
||||
This browser does not expose reliable live Web Audio sink switching. Non-default output selection is best-effort and may remain on System Default.
|
||||
{t('settings.audioIo.outputSinkUnsupported')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,8 +5,10 @@ import {
|
||||
normalizeSpectrogramHeightResolution,
|
||||
type SpectrogramHeightResolution,
|
||||
} from '../../../util/spectrogramUtil';
|
||||
import { useI18n } from '../../../i18n/useI18n';
|
||||
|
||||
const BehaviorSettings: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const [playheadUpdateFrequency, setPlayheadUpdateFrequency] = useState<number>(30);
|
||||
const [spectrogramHeightResolution, setSpectrogramHeightResolution] = useState<SpectrogramHeightResolution>(3);
|
||||
const [chatboxDefaultOpen, setChatboxDefaultOpen] = useState<boolean>(true);
|
||||
@@ -78,11 +80,11 @@ const BehaviorSettings: React.FC = () => {
|
||||
|
||||
// Validate the input
|
||||
if (isNaN(numValueMs)) {
|
||||
errors.push('Lookahead time must be a valid number');
|
||||
errors.push(t('settings.behavior.validation.lookahead.invalidNumber'));
|
||||
} else if (numValueSeconds < 0) {
|
||||
errors.push('Lookahead time must be between 0 and 0.5 seconds (0-500ms)');
|
||||
errors.push(t('settings.behavior.validation.lookahead.range'));
|
||||
} else if (numValueSeconds > 0.5) {
|
||||
errors.push('Lookahead time must be between 0 and 0.5 seconds (0-500ms)');
|
||||
errors.push(t('settings.behavior.validation.lookahead.range'));
|
||||
}
|
||||
|
||||
setLookaheadValidationErrors(errors);
|
||||
@@ -108,11 +110,11 @@ const BehaviorSettings: React.FC = () => {
|
||||
|
||||
// Validate the input
|
||||
if (isNaN(numValueMs)) {
|
||||
errors.push('Playback delay must be a valid number');
|
||||
errors.push(t('settings.behavior.validation.playbackDelay.invalidNumber'));
|
||||
} else if (numValueSeconds < 0) {
|
||||
errors.push('Playback delay must be between 0 and 0.5 seconds (0-500ms)');
|
||||
errors.push(t('settings.behavior.validation.playbackDelay.range'));
|
||||
} else if (numValueSeconds > 0.5) {
|
||||
errors.push('Playback delay must be between 0 and 0.5 seconds (0-500ms)');
|
||||
errors.push(t('settings.behavior.validation.playbackDelay.range'));
|
||||
}
|
||||
|
||||
setPlaybackDelayValidationErrors(errors);
|
||||
@@ -138,11 +140,11 @@ const BehaviorSettings: React.FC = () => {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (isNaN(numValueMs)) {
|
||||
errors.push('MIDI input latency must be a valid number');
|
||||
errors.push(t('settings.behavior.validation.recordingOffset.invalidNumber'));
|
||||
} else if (numValueSeconds < 0) {
|
||||
errors.push('MIDI input latency must be between 0 and 0.5 seconds (0-500ms)');
|
||||
errors.push(t('settings.behavior.validation.recordingOffset.range'));
|
||||
} else if (numValueSeconds > 0.5) {
|
||||
errors.push('MIDI input latency must be between 0 and 0.5 seconds (0-500ms)');
|
||||
errors.push(t('settings.behavior.validation.recordingOffset.range'));
|
||||
}
|
||||
|
||||
setRecordingOffsetValidationErrors(errors);
|
||||
@@ -169,16 +171,16 @@ const BehaviorSettings: React.FC = () => {
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<h3>Behavior</h3>
|
||||
<h3>{t('settings.behavior.title')}</h3>
|
||||
</div>
|
||||
|
||||
<div className="settings-section-content">
|
||||
<div className="settings-group">
|
||||
<h4>Editor</h4>
|
||||
<h4>{t('settings.behavior.editor')}</h4>
|
||||
|
||||
<div className="settings-item">
|
||||
<label className="settings-label">
|
||||
Playhead Update Frequency (fps)
|
||||
{t('settings.behavior.playheadUpdateFrequency')}
|
||||
</label>
|
||||
<select
|
||||
className="settings-select"
|
||||
@@ -190,13 +192,13 @@ const BehaviorSettings: React.FC = () => {
|
||||
<option value="60">60</option>
|
||||
</select>
|
||||
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
|
||||
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')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-item">
|
||||
<label className="settings-label">
|
||||
Spectrogram Height Resolution
|
||||
{t('settings.behavior.spectrogramHeightResolution')}
|
||||
</label>
|
||||
<select
|
||||
className="settings-select"
|
||||
@@ -208,35 +210,35 @@ const BehaviorSettings: React.FC = () => {
|
||||
<option value="5">5x</option>
|
||||
</select>
|
||||
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
|
||||
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')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-group">
|
||||
<h4>Chat Box</h4>
|
||||
<h4>{t('settings.behavior.chatBox')}</h4>
|
||||
|
||||
<div className="settings-item">
|
||||
<label className="settings-label">
|
||||
Open at Start Up
|
||||
{t('settings.behavior.openAtStartup')}
|
||||
</label>
|
||||
<select
|
||||
className="settings-select"
|
||||
value={chatboxDefaultOpen ? 'yes' : 'no'}
|
||||
onChange={(e) => handleChatboxDefaultOpenChange(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>
|
||||
</div>
|
||||
|
||||
<div className="settings-group">
|
||||
<h4>Audio</h4>
|
||||
<h4>{t('settings.behavior.audio')}</h4>
|
||||
|
||||
<div className="settings-item">
|
||||
<label className="settings-label">
|
||||
Lookahead Time (ms)
|
||||
{t('settings.behavior.lookaheadTime')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
@@ -257,13 +259,13 @@ const BehaviorSettings: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
|
||||
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')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-item">
|
||||
<label className="settings-label">
|
||||
Playback Delay (ms)
|
||||
{t('settings.behavior.playbackDelay')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
@@ -284,31 +286,31 @@ const BehaviorSettings: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
|
||||
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')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-item">
|
||||
<label className="settings-label">
|
||||
MIDI Automation Interpolation
|
||||
{t('settings.behavior.midiAutomationInterpolation')}
|
||||
</label>
|
||||
<select
|
||||
className="settings-select"
|
||||
value={midiAutomationInterpolationIntervalMs}
|
||||
onChange={(e) => handleMidiAutomationInterpolationIntervalChange(e.target.value)}
|
||||
>
|
||||
<option value="20">Low-end (20 ms)</option>
|
||||
<option value="10">Balanced (10 ms)</option>
|
||||
<option value="5">High quality (5 ms)</option>
|
||||
<option value="20">{t('settings.behavior.midiAutomationInterpolation.low')}</option>
|
||||
<option value="10">{t('settings.behavior.midiAutomationInterpolation.balanced')}</option>
|
||||
<option value="5">{t('settings.behavior.midiAutomationInterpolation.high')}</option>
|
||||
</select>
|
||||
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
|
||||
Controls how densely MIDI automation are baked for playback and bounce. Smaller intervals sound smoother but schedule more events.
|
||||
{t('settings.behavior.midiAutomationInterpolationHelp')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-item">
|
||||
<label className="settings-label">
|
||||
MIDI Input Latency (ms)
|
||||
{t('settings.behavior.midiInputLatency')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
@@ -329,43 +331,43 @@ const BehaviorSettings: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
|
||||
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')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-item">
|
||||
<label className="settings-label">
|
||||
Bounce Starts From Beat 1
|
||||
{t('settings.behavior.bounceStartsFromBeat1')}
|
||||
</label>
|
||||
<select
|
||||
className="settings-select"
|
||||
value={bounceStartsFromBeat1 ? 'yes' : 'no'}
|
||||
onChange={(e) => handleBounceStartsFromBeat1Change(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' }}>
|
||||
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')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-item">
|
||||
<label className="settings-label">
|
||||
Capture Audio for Screen Sharing
|
||||
{t('settings.behavior.captureAudioForScreenSharing')}
|
||||
</label>
|
||||
<select
|
||||
className="settings-select"
|
||||
value={enableAudioCapture ? 'yes' : 'no'}
|
||||
onChange={(e) => handleEnableAudioCaptureChange(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' }}>
|
||||
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')}
|
||||
<br />
|
||||
<b>It is important to make sure when screen sharing in Zoom, the "Share Sound" option is enabled.</b>
|
||||
<b>{t('settings.behavior.captureAudioForScreenSharingWarning')}</b>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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(<ChordGuideSettings />);
|
||||
|
||||
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(<ChordGuideSettings />);
|
||||
|
||||
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(<ChordGuideSettings />);
|
||||
|
||||
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(<ChordGuideSettings />);
|
||||
|
||||
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(<ChordGuideSettings />);
|
||||
|
||||
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(<ChordGuideSettings />);
|
||||
|
||||
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(<ChordGuideSettings />);
|
||||
|
||||
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);
|
||||
|
||||
@@ -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<ChordGuideGroupKey, string> = {
|
||||
major: 'Major Candidate Chords',
|
||||
minor: 'Minor Candidate Chords',
|
||||
};
|
||||
|
||||
const GROUP_HELP: Record<ChordGuideGroupKey, string> = {
|
||||
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<ChordGuideData> {
|
||||
}
|
||||
|
||||
const ChordGuideSettings: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const configManager = ConfigManager.instance();
|
||||
const [defaultsData, setDefaultsData] = React.useState<ChordGuideData | null>(null);
|
||||
const [customConfig, setCustomConfig] = React.useState<ChordGuideCustomConfig | null>(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 (
|
||||
<div className="settings-group" key={group}>
|
||||
<div className="settings-group-header">
|
||||
<h4>{GROUP_LABELS[group]}</h4>
|
||||
<h4>{groupLabel}</h4>
|
||||
</div>
|
||||
<div className="settings-description">{GROUP_HELP[group]}</div>
|
||||
<div className="settings-description">{t(`settings.chordGuide.help.${group}`)}</div>
|
||||
|
||||
<div className="event-list-tabs" role="tablist" aria-label={`${GROUP_LABELS[group]} functions`}>
|
||||
<div className="event-list-tabs" role="tablist" aria-label={t('settings.chordGuide.functionsAria', { group: groupLabel })}>
|
||||
{FUNCTION_BUTTONS.map((button) => (
|
||||
<button
|
||||
key={button.value}
|
||||
@@ -319,7 +313,7 @@ const ChordGuideSettings: React.FC = () => {
|
||||
<div className="event-list-toolbar-group">
|
||||
<button
|
||||
className="event-list-add-button settings-chord-guide-add-button"
|
||||
title={`Add ${GROUP_LABELS[group]} chord`}
|
||||
title={t('settings.chordGuide.addTitle', { group: groupLabel })}
|
||||
type="button"
|
||||
onClick={() => { void handleAddRow(group); }}
|
||||
>
|
||||
@@ -329,7 +323,7 @@ const ChordGuideSettings: React.FC = () => {
|
||||
<div className="event-list-toolbar-group event-list-toolbar-group-right">
|
||||
<button
|
||||
className="event-list-delete-button"
|
||||
title="Delete selected rows"
|
||||
title={t('settings.chordGuide.deleteSelectedRows')}
|
||||
type="button"
|
||||
onClick={() => { void handleDeleteRows(group); }}
|
||||
disabled={selection.size === 0}
|
||||
@@ -343,10 +337,10 @@ const ChordGuideSettings: React.FC = () => {
|
||||
<table className="event-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Chord</th>
|
||||
<th>Notes</th>
|
||||
<th>Source</th>
|
||||
<th>Note</th>
|
||||
<th>{t('settings.chordGuide.table.chord')}</th>
|
||||
<th>{t('settings.chordGuide.table.notes')}</th>
|
||||
<th>{t('settings.chordGuide.table.source')}</th>
|
||||
<th>{t('settings.chordGuide.table.note')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -415,13 +409,13 @@ const ChordGuideSettings: React.FC = () => {
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<h3>Chord Guide</h3>
|
||||
<h3>{t('settings.chordGuide.title')}</h3>
|
||||
</div>
|
||||
|
||||
<div className="settings-section-content">
|
||||
<div className="settings-help-links settings-chord-guide-reset-row">
|
||||
<button className="settings-help" type="button" onClick={() => { void handleResetToDefault(); }}>
|
||||
Reset to Default
|
||||
{t('settings.chordGuide.resetToDefault')}
|
||||
</button>
|
||||
</div>
|
||||
{renderGroup('major')}
|
||||
|
||||
@@ -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 = (locale: 'en_us' | 'zh_cn' = 'en_us') => render(
|
||||
<I18nContext.Provider
|
||||
value={{
|
||||
languageSetting: 'auto',
|
||||
resolvedLocale: locale,
|
||||
setLanguageSetting: async (value) => {
|
||||
await configManagerMock.set('general.language', value);
|
||||
},
|
||||
t: (key, params) => translate(key, params, locale),
|
||||
}}
|
||||
>
|
||||
<GeneralSettings />
|
||||
</I18nContext.Provider>,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
configState.set('general.language', 'auto');
|
||||
configState.set('general.local_browser.context_length', 65536);
|
||||
configManagerMock.get.mockClear();
|
||||
configManagerMock.set.mockClear();
|
||||
@@ -111,22 +130,40 @@ 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();
|
||||
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 () => {
|
||||
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 +174,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 +213,7 @@ describe('GeneralSettings', () => {
|
||||
.mockResolvedValueOnce(false)
|
||||
.mockResolvedValueOnce(false);
|
||||
|
||||
render(<GeneralSettings />);
|
||||
renderSettings();
|
||||
|
||||
expect(await screen.findByText('UVR5 Web Runtime')).toBeTruthy();
|
||||
|
||||
@@ -219,10 +256,25 @@ 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);
|
||||
expect(screen.getByRole('option', { name: 'Français' })).toBeTruthy();
|
||||
|
||||
fireEvent.change(languageSelect, { target: { value: 'fr_fr' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(configManagerMock.set).toHaveBeenCalledWith('general.language', 'fr_fr');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
@@ -17,7 +19,16 @@ import {
|
||||
LOCAL_SEPARATOR_MODEL_IDS,
|
||||
} from '../../../util/local-separator/config';
|
||||
|
||||
const LANGUAGE_OPTION_LABELS: Record<Exclude<LanguageSetting, 'auto'>, string> = {
|
||||
en_us: 'English',
|
||||
fr_fr: 'Français',
|
||||
zh_cn: '简体中文',
|
||||
zh_hk: '繁體中文',
|
||||
};
|
||||
|
||||
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 +101,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 +165,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 +343,68 @@ 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">{LANGUAGE_OPTION_LABELS.en_us}</option>
|
||||
<option value="fr_fr">{LANGUAGE_OPTION_LABELS.fr_fr}</option>
|
||||
<option value="zh_cn">{LANGUAGE_OPTION_LABELS.zh_cn}</option>
|
||||
<option value="zh_hk">{LANGUAGE_OPTION_LABELS.zh_hk}</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 +420,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 +448,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 +464,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 +473,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 +514,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 +534,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 +545,7 @@ const GeneralSettings: React.FC = () => {
|
||||
}}
|
||||
style={{ color: '#5a9fd4', textDecoration: 'underline', cursor: 'pointer' }}
|
||||
>
|
||||
Restore default
|
||||
{t('settings.restoreDefault')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -513,13 +557,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 +573,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 +584,7 @@ const GeneralSettings: React.FC = () => {
|
||||
}}
|
||||
style={{ color: '#5a9fd4', textDecoration: 'underline', cursor: 'pointer' }}
|
||||
>
|
||||
Restore default
|
||||
{t('settings.restoreDefault')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -552,37 +596,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 +645,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 +722,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 +756,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 +780,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 +863,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 +876,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 +897,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 +906,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 +931,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 +949,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>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { ConfigManager } from '../../../core/config/ConfigManager';
|
||||
import { useI18n } from '../../../i18n/useI18n';
|
||||
|
||||
const TemplatesSettings: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const [customInstructions, setCustomInstructions] = useState<string>('');
|
||||
|
||||
const configManager = ConfigManager.instance();
|
||||
@@ -42,17 +44,17 @@ const TemplatesSettings: React.FC = () => {
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<h3>Templates</h3>
|
||||
<h3>{t('settings.templates.title')}</h3>
|
||||
</div>
|
||||
|
||||
<div className="settings-section-content">
|
||||
<div className="settings-group">
|
||||
<h4>Custom Instructions</h4>
|
||||
<h4>{t('settings.templates.customInstructions')}</h4>
|
||||
|
||||
<div className="settings-item">
|
||||
<textarea
|
||||
className="settings-textarea"
|
||||
placeholder="Please input your custom instructions for the K.G.Studio Musician Assistant"
|
||||
placeholder={t('settings.templates.placeholder')}
|
||||
rows={8}
|
||||
value={customInstructions}
|
||||
onChange={(e) => handleCustomInstructionsChange(e.target.value)}
|
||||
@@ -64,4 +66,4 @@ const TemplatesSettings: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplatesSettings;
|
||||
export default TemplatesSettings;
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
@@ -499,20 +502,20 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
||||
<button
|
||||
className={`automation${automationActive ? ' active' : ''}`}
|
||||
onClick={handleAutomationButtonClick}
|
||||
title="Track automation"
|
||||
aria-label="Track automation"
|
||||
title={t('track.controls.automationButton')}
|
||||
aria-label={t('track.controls.automationButton')}
|
||||
>
|
||||
A
|
||||
</button>
|
||||
<div style={{ position: 'absolute', top: 0, left: 'calc(100% + 6px)', zIndex: 10000 }}>
|
||||
<KGDropdown
|
||||
options={[
|
||||
{ label: 'Volume', value: 'volume' },
|
||||
{ label: 'Pan', value: 'pan' },
|
||||
{ label: t('track.controls.automation.volume'), value: 'volume' },
|
||||
{ label: t('track.controls.automation.pan'), value: 'pan' },
|
||||
]}
|
||||
value={activeTrackAutomationType ?? ''}
|
||||
onChange={handleAutomationTypeSelect}
|
||||
label="Automation"
|
||||
label={t('track.controls.automationDropdown')}
|
||||
hideButton={true}
|
||||
isOpen={showAutomationDropdown}
|
||||
onToggle={setShowAutomationDropdown}
|
||||
@@ -537,10 +540,10 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
||||
</button>
|
||||
<div style={{ position: 'absolute', top: '100%', left: 0, zIndex: 10000 }}>
|
||||
<KGDropdown
|
||||
options={['Delete Track']}
|
||||
options={[{ label: t('track.controls.settings.deleteTrack'), value: 'Delete Track' }]}
|
||||
value={''}
|
||||
onChange={handleSettingsAction}
|
||||
label="Settings"
|
||||
label={t('track.controls.settingsDropdown')}
|
||||
hideButton={true}
|
||||
isOpen={showSettingsDropdown}
|
||||
onToggle={setShowSettingsDropdown}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { ChordGuideCustomConfig } from '../ChordGuideTypes';
|
||||
import { KGConfigStorage } from '../io/KGConfigStorage';
|
||||
import type { LanguageSetting } from '../../i18n/types';
|
||||
|
||||
/**
|
||||
* Application configuration interface
|
||||
*/
|
||||
interface AppConfig {
|
||||
general: {
|
||||
language: string;
|
||||
language: LanguageSetting;
|
||||
llm_provider: 'local_browser' | 'openai' | 'gemini' | 'claude' | 'claude_openrouter' | 'openai_compatible';
|
||||
persist_api_keys_non_localhost: boolean;
|
||||
local_browser: {
|
||||
@@ -205,7 +206,7 @@ export class ConfigManager {
|
||||
// Fallback to minimal hardcoded config
|
||||
this.defaultConfig = {
|
||||
general: {
|
||||
language: 'en_us',
|
||||
language: 'auto',
|
||||
llm_provider: 'local_browser',
|
||||
persist_api_keys_non_localhost: false,
|
||||
openai: {
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import type { ResolvedChordGuideItem } from '../ChordGuideTypes';
|
||||
|
||||
export const PIANO_ROLL_NO_SNAP = 'none' as const;
|
||||
|
||||
export interface PianoRollOptionDefinition<TValue extends string = string> {
|
||||
value: TValue;
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
export type PianoRollSnapValue = typeof PIANO_ROLL_NO_SNAP | '1/3' | '1/4' | '1/6' | '1/8' | '1/12' | '1/16' | '1/24' | '1/32';
|
||||
export type PianoRollQuantizePositionValue = '1/3' | '1/4' | '1/6' | '1/8' | '1/12' | '1/16' | '1/24' | '1/32';
|
||||
export type PianoRollQuantizeLengthValue = '1/1' | '1/2' | '1/3' | '1/4' | '1/6' | '1/8' | '1/12' | '1/16' | '1/24' | '1/32';
|
||||
|
||||
/**
|
||||
* KGPianoRollState - State management for the piano roll
|
||||
* Implements the singleton pattern for global access
|
||||
@@ -7,12 +18,42 @@ import type { ResolvedChordGuideItem } from '../ChordGuideTypes';
|
||||
export class KGPianoRollState {
|
||||
private static _instance: KGPianoRollState | null = null;
|
||||
|
||||
public static SNAP_OPTIONS: string[] = ['NO SNAP', '1/3', '1/4', '1/6', '1/8', '1/12', '1/16', '1/24', '1/32'];
|
||||
public static QUANT_POS_OPTIONS: string[] = ['1/3', '1/4', '1/6', '1/8', '1/12', '1/16', '1/24', '1/32'];
|
||||
public static QUANT_LEN_OPTIONS: string[] = ['1/1', '1/2', '1/3', '1/4', '1/6', '1/8', '1/12', '1/16', '1/24', '1/32'];
|
||||
public static SNAP_OPTIONS: PianoRollOptionDefinition<PianoRollSnapValue>[] = [
|
||||
{ value: PIANO_ROLL_NO_SNAP, labelKey: 'pianoRoll.snap.none' },
|
||||
{ value: '1/3', labelKey: 'pianoRoll.quantize.1/3' },
|
||||
{ value: '1/4', labelKey: 'pianoRoll.quantize.1/4' },
|
||||
{ value: '1/6', labelKey: 'pianoRoll.quantize.1/6' },
|
||||
{ value: '1/8', labelKey: 'pianoRoll.quantize.1/8' },
|
||||
{ value: '1/12', labelKey: 'pianoRoll.quantize.1/12' },
|
||||
{ value: '1/16', labelKey: 'pianoRoll.quantize.1/16' },
|
||||
{ value: '1/24', labelKey: 'pianoRoll.quantize.1/24' },
|
||||
{ value: '1/32', labelKey: 'pianoRoll.quantize.1/32' },
|
||||
];
|
||||
public static QUANT_POS_OPTIONS: PianoRollOptionDefinition<PianoRollQuantizePositionValue>[] = [
|
||||
{ value: '1/3', labelKey: 'pianoRoll.quantize.1/3' },
|
||||
{ value: '1/4', labelKey: 'pianoRoll.quantize.1/4' },
|
||||
{ value: '1/6', labelKey: 'pianoRoll.quantize.1/6' },
|
||||
{ value: '1/8', labelKey: 'pianoRoll.quantize.1/8' },
|
||||
{ value: '1/12', labelKey: 'pianoRoll.quantize.1/12' },
|
||||
{ value: '1/16', labelKey: 'pianoRoll.quantize.1/16' },
|
||||
{ value: '1/24', labelKey: 'pianoRoll.quantize.1/24' },
|
||||
{ value: '1/32', labelKey: 'pianoRoll.quantize.1/32' },
|
||||
];
|
||||
public static QUANT_LEN_OPTIONS: PianoRollOptionDefinition<PianoRollQuantizeLengthValue>[] = [
|
||||
{ value: '1/1', labelKey: 'pianoRoll.quantize.1/1' },
|
||||
{ value: '1/2', labelKey: 'pianoRoll.quantize.1/2' },
|
||||
{ value: '1/3', labelKey: 'pianoRoll.quantize.1/3' },
|
||||
{ value: '1/4', labelKey: 'pianoRoll.quantize.1/4' },
|
||||
{ value: '1/6', labelKey: 'pianoRoll.quantize.1/6' },
|
||||
{ value: '1/8', labelKey: 'pianoRoll.quantize.1/8' },
|
||||
{ value: '1/12', labelKey: 'pianoRoll.quantize.1/12' },
|
||||
{ value: '1/16', labelKey: 'pianoRoll.quantize.1/16' },
|
||||
{ value: '1/24', labelKey: 'pianoRoll.quantize.1/24' },
|
||||
{ value: '1/32', labelKey: 'pianoRoll.quantize.1/32' },
|
||||
];
|
||||
|
||||
private activeTool: string = "pointer";
|
||||
private currentSnap: string = "NO SNAP";
|
||||
private currentSnap: PianoRollSnapValue = PIANO_ROLL_NO_SNAP;
|
||||
private lastEditedNoteLength: number = 1; // Default to 1 beat
|
||||
private currentMode: string = "ionian"; // Default mode
|
||||
private automationViewEnabled: boolean = false;
|
||||
@@ -51,11 +92,11 @@ export class KGPianoRollState {
|
||||
this.activeTool = tool;
|
||||
}
|
||||
|
||||
public getCurrentSnap(): string {
|
||||
public getCurrentSnap(): PianoRollSnapValue {
|
||||
return this.currentSnap;
|
||||
}
|
||||
|
||||
public setCurrentSnap(snap: string): void {
|
||||
public setCurrentSnap(snap: PianoRollSnapValue): void {
|
||||
this.currentSnap = snap;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { KGTrack } from '../core/track/KGTrack';
|
||||
import { KGMidiNote } from '../core/midi/KGMidiNote';
|
||||
import type { MutableRefObject } from 'react';
|
||||
import { KGCore } from '../core/KGCore';
|
||||
import { KGPianoRollState } from '../core/state/KGPianoRollState';
|
||||
import { KGPianoRollState, PIANO_ROLL_NO_SNAP } from '../core/state/KGPianoRollState';
|
||||
import { KGAudioInterface } from '../core/audio-interface/KGAudioInterface';
|
||||
import { CreateNoteCommand, DeleteNotesCommand, ResizeNotesCommand, MoveNotesCommand } from '../core/commands';
|
||||
import { CreateNotesCommand } from '../core/commands/note/CreateNotesCommand';
|
||||
@@ -171,7 +171,7 @@ export const useNoteOperations = ({
|
||||
// Calculate the beat number (0-indexed) and apply floor snapping for note creation
|
||||
const rawBeatNumber = x / beatWidth;
|
||||
const currentSnap = KGPianoRollState.instance().getCurrentSnap();
|
||||
const beatNumber = currentSnap === 'NO SNAP'
|
||||
const beatNumber = currentSnap === PIANO_ROLL_NO_SNAP
|
||||
? Math.floor(rawBeatNumber) // Snap to 1-beat grid when no snapping is selected
|
||||
: getSnappedBeatPosition(rawBeatNumber, currentSnap, true); // Use floor snapping for note creation
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import { I18nProvider } from './I18nProvider';
|
||||
import { useI18n } from './useI18n';
|
||||
|
||||
const configState = new Map<string, unknown>([['general.language', 'auto']]);
|
||||
const listeners = new Set<(changedKeys: string[]) => void>();
|
||||
|
||||
const configManagerMock = {
|
||||
getIsInitialized: vi.fn(() => true),
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
get: vi.fn((key: string) => configState.get(key)),
|
||||
set: vi.fn(async (key: string, value: unknown) => {
|
||||
configState.set(key, value);
|
||||
for (const listener of listeners) {
|
||||
listener([key]);
|
||||
}
|
||||
}),
|
||||
addChangeListener: vi.fn((listener: (changedKeys: string[]) => void) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mock('../core/config/ConfigManager', () => ({
|
||||
ConfigManager: {
|
||||
instance: () => configManagerMock,
|
||||
},
|
||||
}));
|
||||
|
||||
function TestComponent() {
|
||||
const { t, resolvedLocale } = useI18n();
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="locale">{resolvedLocale}</span>
|
||||
<span>{t('settings.sidebar.title')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('I18nProvider', () => {
|
||||
it('resolves auto to English by default', async () => {
|
||||
Object.defineProperty(window.navigator, 'languages', {
|
||||
configurable: true,
|
||||
value: ['en-US'],
|
||||
});
|
||||
|
||||
render(
|
||||
<I18nProvider>
|
||||
<TestComponent />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('locale').textContent).toBe('en_us');
|
||||
expect(screen.getByText('Settings')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves auto to Chinese and updates instantly when config changes', async () => {
|
||||
configState.set('general.language', 'auto');
|
||||
Object.defineProperty(window.navigator, 'languages', {
|
||||
configurable: true,
|
||||
value: ['zh-CN'],
|
||||
});
|
||||
|
||||
render(
|
||||
<I18nProvider>
|
||||
<TestComponent />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('locale').textContent).toBe('zh_cn');
|
||||
expect(screen.getByText('设置')).toBeTruthy();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await configManagerMock.set('general.language', 'en_us');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('locale').textContent).toBe('en_us');
|
||||
expect(screen.getByText('Settings')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
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(
|
||||
<I18nProvider>
|
||||
<TestComponent />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('locale').textContent).toBe('fr_fr');
|
||||
expect(screen.getByText('Réglages')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import React, { createContext, useEffect, useMemo, useState } from 'react';
|
||||
import { ConfigManager } from '../core/config/ConfigManager';
|
||||
import { normalizeLanguageSetting, resolveLanguageSetting } from './locale';
|
||||
import { setCurrentLocale, translate } from './translate';
|
||||
import type { I18nContextValue, LanguageSetting, ResolvedLocaleCode, TranslationParams } from './types';
|
||||
|
||||
const defaultValue: I18nContextValue = {
|
||||
languageSetting: 'auto',
|
||||
resolvedLocale: 'en_us',
|
||||
setLanguageSetting: async () => undefined,
|
||||
t: (key: string, params?: TranslationParams) => translate(key, params, 'en_us'),
|
||||
};
|
||||
|
||||
export const I18nContext = createContext<I18nContextValue>(defaultValue);
|
||||
|
||||
function buildState(configManager: ConfigManager): { languageSetting: LanguageSetting; resolvedLocale: ResolvedLocaleCode } {
|
||||
const languageSetting = normalizeLanguageSetting(configManager.get('general.language'));
|
||||
const resolvedLocale = resolveLanguageSetting(languageSetting);
|
||||
return { languageSetting, resolvedLocale };
|
||||
}
|
||||
|
||||
export const I18nProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [languageSetting, setLanguageSettingState] = useState<LanguageSetting>('auto');
|
||||
const [resolvedLocale, setResolvedLocaleState] = useState<ResolvedLocaleCode>('en_us');
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
const configManager = ConfigManager.instance();
|
||||
|
||||
const syncFromConfig = () => {
|
||||
const nextState = buildState(configManager);
|
||||
setCurrentLocale(nextState.resolvedLocale);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setLanguageSettingState(nextState.languageSetting);
|
||||
setResolvedLocaleState(nextState.resolvedLocale);
|
||||
};
|
||||
|
||||
const initialize = async () => {
|
||||
if (!configManager.getIsInitialized()) {
|
||||
await configManager.initialize();
|
||||
}
|
||||
|
||||
syncFromConfig();
|
||||
unsubscribe = configManager.addChangeListener((changedKeys) => {
|
||||
if (changedKeys.includes('__all__') || changedKeys.includes('general.language')) {
|
||||
syncFromConfig();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
void initialize();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const contextValue = useMemo<I18nContextValue>(() => ({
|
||||
languageSetting,
|
||||
resolvedLocale,
|
||||
setLanguageSetting: async (value: LanguageSetting) => {
|
||||
await ConfigManager.instance().set('general.language', value);
|
||||
},
|
||||
t: (key: string, params?: TranslationParams) => translate(key, params, resolvedLocale),
|
||||
}), [languageSetting, resolvedLocale]);
|
||||
|
||||
return (
|
||||
<I18nContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</I18nContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getEnglishInstrumentDisplayName, getInstrumentDisplayName, getInstrumentGroupLabel } from './instruments';
|
||||
import { translate } from './translate';
|
||||
|
||||
describe('instrument i18n helpers', () => {
|
||||
it('returns translated instrument display names for supported locales', () => {
|
||||
expect(getInstrumentDisplayName('acoustic_grand_piano', (key, params) => translate(key, params, 'en_us'))).toBe('Acoustic Grand Piano');
|
||||
expect(getInstrumentDisplayName('acoustic_grand_piano', (key, params) => translate(key, params, '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('钢琴与键盘');
|
||||
});
|
||||
|
||||
it('keeps stable English fallback names available for non-UI callers', () => {
|
||||
expect(getEnglishInstrumentDisplayName('standard')).toBe('Standard Drum Kit');
|
||||
expect(getEnglishInstrumentDisplayName('trumpet')).toBe('Trumpet');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { FLUIDR3_INSTRUMENT_MAP, INSTRUMENT_GROUPS } from '../constants/generalMidiConstants';
|
||||
import type { InstrumentType } from '../core/track/KGMidiTrack';
|
||||
import type { TranslationParams } from './types';
|
||||
|
||||
type TranslateFn = (key: string, params?: TranslationParams) => string;
|
||||
|
||||
export type InstrumentGroupKey = keyof typeof INSTRUMENT_GROUPS;
|
||||
|
||||
export function getInstrumentDisplayName(instrumentKey: InstrumentType, t: TranslateFn): string {
|
||||
const instrument = FLUIDR3_INSTRUMENT_MAP[instrumentKey];
|
||||
if (!instrument) {
|
||||
return String(instrumentKey);
|
||||
}
|
||||
|
||||
return t(`instrument.name.${instrumentKey}`) || instrument.displayName;
|
||||
}
|
||||
|
||||
export function getInstrumentGroupLabel(groupKey: InstrumentGroupKey, t: TranslateFn): string {
|
||||
const englishLabel = INSTRUMENT_GROUPS[groupKey];
|
||||
return t(`instrument.group.${groupKey}`) || englishLabel;
|
||||
}
|
||||
|
||||
export function getEnglishInstrumentDisplayName(instrumentKey: InstrumentType): string {
|
||||
return FLUIDR3_INSTRUMENT_MAP[instrumentKey]?.displayName || String(instrumentKey);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { normalizeLanguageSetting, resolveLanguageSetting } from './locale';
|
||||
|
||||
describe('locale resolution', () => {
|
||||
it('normalizes supported language settings', () => {
|
||||
expect(normalizeLanguageSetting('auto')).toBe('auto');
|
||||
expect(normalizeLanguageSetting('en_us')).toBe('en_us');
|
||||
expect(normalizeLanguageSetting('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 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');
|
||||
expect(resolveLanguageSetting('auto', ['zh-Hans'])).toBe('zh_cn');
|
||||
expect(resolveLanguageSetting('auto', ['zh'])).toBe('zh_cn');
|
||||
});
|
||||
|
||||
it('resolves auto to Traditional Chinese for Traditional Chinese locales', () => {
|
||||
expect(resolveLanguageSetting('auto', ['zh-HK'])).toBe('zh_hk');
|
||||
expect(resolveLanguageSetting('auto', ['zh-TW'])).toBe('zh_hk');
|
||||
expect(resolveLanguageSetting('auto', ['zh-MO'])).toBe('zh_hk');
|
||||
expect(resolveLanguageSetting('auto', ['zh-Hant'])).toBe('zh_hk');
|
||||
expect(resolveLanguageSetting('auto', ['zh-Hant-HK'])).toBe('zh_hk');
|
||||
expect(resolveLanguageSetting('auto', ['zh-Hant-TW'])).toBe('zh_hk');
|
||||
});
|
||||
|
||||
it('falls back to English when locale list is empty or unknown', () => {
|
||||
expect(resolveLanguageSetting('auto', [])).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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
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 === 'fr_fr' || value === 'auto' ? value : 'auto';
|
||||
}
|
||||
|
||||
export function getBrowserLocales(): string[] {
|
||||
if (typeof navigator === 'undefined') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (Array.isArray(navigator.languages) && navigator.languages.length > 0) {
|
||||
return navigator.languages.filter((value): value is string => typeof value === 'string' && value.length > 0);
|
||||
}
|
||||
|
||||
return typeof navigator.language === 'string' && navigator.language.length > 0
|
||||
? [navigator.language]
|
||||
: [];
|
||||
}
|
||||
|
||||
export function resolveLanguageSetting(
|
||||
setting: LanguageSetting,
|
||||
browserLocales: string[] = getBrowserLocales(),
|
||||
): ResolvedLocaleCode {
|
||||
if (setting === 'en_us' || setting === 'zh_cn' || setting === 'zh_hk' || setting === 'fr_fr') {
|
||||
return setting;
|
||||
}
|
||||
|
||||
for (const locale of browserLocales) {
|
||||
const normalizedLocale = locale.trim().toLowerCase();
|
||||
if (normalizedLocale === CHINESE_LANGUAGE_PREFIX) {
|
||||
return 'zh_cn';
|
||||
}
|
||||
if (normalizedLocale.startsWith(`${CHINESE_LANGUAGE_PREFIX}-`)) {
|
||||
const subtags = normalizedLocale.slice(CHINESE_LANGUAGE_PREFIX.length + 1).split('-');
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,781 @@
|
||||
import type { TranslationMessages } from '../types';
|
||||
|
||||
export const enUsMessages: TranslationMessages = {
|
||||
'app.loading': 'Loading ...',
|
||||
'assistant.displayName': 'K.G.Studio Musician Assistant',
|
||||
'assistant.welcomeFallback': 'Welcome to K.G.Studio Musician Assistant.',
|
||||
'mainContent.createTrack': 'Create track',
|
||||
'mainContent.showGlobalTracks': 'Show global tracks',
|
||||
'status.chordGuideCandidate': 'Chord Guide Candidate: {name} - {notes} - {note}',
|
||||
'settings.sidebar.title': 'Settings',
|
||||
'settings.sidebar.close': 'Close Settings',
|
||||
'settings.sidebar.general': 'General',
|
||||
'settings.sidebar.audioIo': 'Audio I/O',
|
||||
'settings.sidebar.behavior': 'Behavior',
|
||||
'settings.sidebar.templates': 'Templates',
|
||||
'settings.sidebar.chordGuide': 'Chord Guide',
|
||||
'settings.general.title': 'General',
|
||||
'settings.general.language.label': 'Language',
|
||||
'settings.general.language.help': 'Auto uses your system language.',
|
||||
'settings.general.language.auto': 'Auto',
|
||||
'settings.general.language.en_us': 'English',
|
||||
'settings.general.language.fr_fr': 'Français',
|
||||
'settings.general.language.zh_cn': '简体中文',
|
||||
'settings.general.language.zh_hk': '繁體中文',
|
||||
'settings.yes': 'Yes',
|
||||
'settings.no': 'No',
|
||||
'settings.restoreDefault': 'Restore default',
|
||||
'settings.deleteCachedModel': 'Delete Cached Model',
|
||||
'settings.deleting': 'Deleting...',
|
||||
'settings.general.llmProvider.section': 'LLM Provider',
|
||||
'settings.general.llmProvider.label': 'LLM Provider',
|
||||
'settings.general.llmProvider.local': 'Local LLM (Browser)',
|
||||
'settings.general.llmProvider.openai': 'OpenAI',
|
||||
'settings.general.llmProvider.claudeOpenRouter': 'Claude (via OpenRouter)',
|
||||
'settings.general.llmProvider.openaiCompatible': 'OpenAI Compatible (e.g. OpenRouter, Ollama)',
|
||||
'settings.general.persistKeys.label': 'Persist API Keys on Non-Localhost',
|
||||
'settings.general.persistKeys.help': 'When enabled, API keys will be saved to browser storage even on non-localhost environments. Warning: This may increase security vulnerability to XSS attacks.',
|
||||
'settings.general.localRuntime.cachedStatus': 'Cached Model Status',
|
||||
'settings.general.localRuntime.cacheChecking': 'Checking local model cache...',
|
||||
'settings.general.localRuntime.cacheDownloaded': 'Downloaded in browser cache.',
|
||||
'settings.general.localRuntime.cacheMissing': 'Not downloaded yet.',
|
||||
'settings.general.localRuntime.contextLength': 'Context Length',
|
||||
'settings.general.localRuntime.contextHelp': 'Larger context lengths require more VRAM and may also reduce performance as conversations become longer.',
|
||||
'settings.general.localRuntime.downloadUrl': 'Download URL',
|
||||
'settings.general.localRuntime.downloadHelp': 'Changing this URL may break downloads or point to an incompatible model file.',
|
||||
'settings.general.localRuntime.autoDownload': 'The local model downloads automatically the next time you chat with `Local LLM (Browser)`.',
|
||||
'settings.general.uvr5.section': 'UVR5 Web Runtime',
|
||||
'settings.general.uvr5.downloadUrl': 'UVR-MDX-NET-Inst_HQ_3 Download URL',
|
||||
'settings.general.htdemucs.downloadUrl': 'htdemucs_4s Download URL',
|
||||
'settings.general.modelUrl.help': 'Changing this URL may break downloads or point to an incompatible model file.',
|
||||
'settings.general.openai.section': 'OpenAI',
|
||||
'settings.general.openai.key': 'Key',
|
||||
'settings.general.openai.keyPlaceholder': 'Enter your OpenAI API key',
|
||||
'settings.general.openai.model': 'Model',
|
||||
'settings.general.openai.flexMode': 'Flex Mode',
|
||||
'settings.general.openai.flexHelp': 'Flex Mode uses OpenAI\'s flexible service tier. Pros: potential cost savings and higher throughput during busy periods. Cons: variable latency and possible queueing/deprioritization. Applies only to the OpenAI provider; no effect for OpenAI Compatible servers.',
|
||||
'settings.general.keys.persisted': 'Keys are persisted locally (the IndexedDB in your browser).',
|
||||
'settings.general.keys.sessionOnly': 'For security, keys are not persisted on non-local hosts and are kept in-memory for this session.',
|
||||
'settings.general.claudeOpenRouter.section': 'Anthropic Claude (via OpenRouter)',
|
||||
'settings.general.claudeOpenRouter.keyPlaceholder': 'Enter your Claude API key',
|
||||
'settings.general.baseUrl': 'Base URL',
|
||||
'settings.general.claudeOpenRouter.baseUrlHelp': 'This is the base URL for the OpenRouter API. Please do not change this unless you know what you are doing.',
|
||||
'settings.general.openaiCompatible.section': 'OpenAI Compatible Server',
|
||||
'settings.general.openaiCompatible.keyPlaceholder': 'Enter your API key',
|
||||
'settings.general.openaiCompatible.baseUrlPlaceholder': 'e.g. https://openrouter.ai/api/v1',
|
||||
'settings.general.openaiCompatible.baseUrlHelp': 'Quick presets:',
|
||||
'settings.general.openaiCompatible.modelPlaceholder': 'e.g. qwen3:30b',
|
||||
'settings.general.soundfont.section': 'Soundfont Settings',
|
||||
'settings.general.soundfont.managed': 'Soundfont configuration is managed by the server (kgone-server.json). Settings are read-only.',
|
||||
'settings.general.soundfont.baseUrl': 'Base URL',
|
||||
'settings.general.soundfont.baseUrlHelp': 'Changing this URL to an incompatible soundfont source may cause some instruments to sound wrong or not play.',
|
||||
'settings.general.kgone.section': 'K.G.One Settings',
|
||||
'settings.general.kgone.managed': 'K.G.One configuration is managed by the server (kgone-server.json). Settings are read-only.',
|
||||
'settings.general.kgone.enabled': 'Enable K.G.One Integration',
|
||||
'settings.general.kgone.disabled': 'Disabled',
|
||||
'settings.general.kgone.enabledOption': 'Enabled',
|
||||
'settings.general.kgone.serverBaseUrl': 'Server Base URL',
|
||||
'settings.general.kgone.serverBaseUrlHelp': 'Base URL of a running K.G.One Music Studio server. Used for full-song generation, clip generation, and stem separation.',
|
||||
'settings.audioIo.title': 'Audio I/O',
|
||||
'settings.audioIo.deviceRouting': 'Device Routing',
|
||||
'settings.audioIo.refresh': 'Refresh Device List',
|
||||
'settings.audioIo.refreshing': 'Refreshing…',
|
||||
'settings.audioIo.description': 'Choose the devices KGStudio should use for recording and playback. Input changes apply to the next recording session. Output changes require a page refresh.',
|
||||
'settings.audioIo.inputDevice': 'Audio Input Device',
|
||||
'settings.audioIo.outputDevice': 'Audio Output Device',
|
||||
'settings.audioIo.inputHelp': 'Changes apply to the next audio recording. If the selected device is removed, KGStudio will fall back to System Default.',
|
||||
'settings.audioIo.outputHelp': 'Output-device changes require a page refresh. Browser support for non-default output routing is limited, so KGStudio will continue on System Default when unsupported.',
|
||||
'settings.audioIo.outputSinkUnsupported': 'This browser does not expose reliable live Web Audio sink switching. Non-default output selection is best-effort and may remain on System Default.',
|
||||
'settings.audioIo.chooseOutputDevice': 'Choose Output Device…',
|
||||
'settings.audioIo.previousSelectedInput': 'Previously Selected Input (permission required to verify)',
|
||||
'settings.audioIo.previousSelectedOutput': 'Previously Selected Output (permission required to verify)',
|
||||
'settings.audioIo.status.inputUnavailable': 'Previously selected audio input device is unavailable; using System Default.',
|
||||
'settings.audioIo.status.outputUnavailable': 'Previously selected audio output device is unavailable; using System Default.',
|
||||
'settings.audioIo.status.refreshed': 'Audio device list refreshed.',
|
||||
'settings.audioIo.status.readFailed': 'Unable to read audio devices from the browser.',
|
||||
'settings.audioIo.status.inputDefaultNextSession': 'Audio input will use System Default on the next recording session.',
|
||||
'settings.audioIo.status.inputChangedNextSession': 'Audio input will change on the next recording session.',
|
||||
'settings.audioIo.status.outputDefaultAfterRefresh': 'Audio output will use System Default after refresh.',
|
||||
'settings.audioIo.status.outputSavedRefreshRequired': 'Audio output device saved. Refresh the page to apply it.',
|
||||
'settings.audioIo.status.outputPromptUnsupported': 'This browser does not support prompting for audio output devices.',
|
||||
'settings.audioIo.status.outputSelectedRefreshRequired': 'Output device selected. Refresh the page to apply it.',
|
||||
'settings.audioIo.status.outputSelectionDenied': 'The browser did not allow selecting a non-default output device.',
|
||||
'settings.behavior.title': 'Behavior',
|
||||
'settings.behavior.editor': 'Editor',
|
||||
'settings.behavior.playheadUpdateFrequency': 'Playhead Update Frequency (fps)',
|
||||
'settings.behavior.playheadUpdateFrequencyHelp': '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.',
|
||||
'settings.behavior.spectrogramHeightResolution': 'Spectrogram Height Resolution',
|
||||
'settings.behavior.spectrogramHeightResolutionHelp': 'Controls vertical spectrogram detail across the visible pitch range. Higher values sharpen pitch contours but use more CPU and memory while computing the spectrogram.',
|
||||
'settings.behavior.chatBox': 'Chat Box',
|
||||
'settings.behavior.openAtStartup': 'Open at Start Up',
|
||||
'settings.behavior.audio': 'Audio',
|
||||
'settings.behavior.lookaheadTime': 'Lookahead Time (ms)',
|
||||
'settings.behavior.lookaheadTimeHelp': '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.',
|
||||
'settings.behavior.playbackDelay': 'Playback Delay (ms)',
|
||||
'settings.behavior.playbackDelayHelp': '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.',
|
||||
'settings.behavior.midiAutomationInterpolation': 'MIDI Automation Interpolation',
|
||||
'settings.behavior.midiAutomationInterpolation.low': 'Low-end (20 ms)',
|
||||
'settings.behavior.midiAutomationInterpolation.balanced': 'Balanced (10 ms)',
|
||||
'settings.behavior.midiAutomationInterpolation.high': 'High quality (5 ms)',
|
||||
'settings.behavior.midiAutomationInterpolationHelp': 'Controls how densely MIDI automation are baked for playback and bounce. Smaller intervals sound smoother but schedule more events.',
|
||||
'settings.behavior.midiInputLatency': 'MIDI Input Latency (ms)',
|
||||
'settings.behavior.midiInputLatencyHelp': '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.',
|
||||
'settings.behavior.bounceStartsFromBeat1': 'Bounce Starts From Beat 1',
|
||||
'settings.behavior.bounceStartsFromBeat1Help': '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.',
|
||||
'settings.behavior.captureAudioForScreenSharing': 'Capture Audio for Screen Sharing',
|
||||
'settings.behavior.captureAudioForScreenSharingHelp': '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.',
|
||||
'settings.behavior.captureAudioForScreenSharingWarning': 'It is important to make sure when screen sharing in Zoom, the "Share Sound" option is enabled.',
|
||||
'settings.behavior.validation.lookahead.invalidNumber': 'Lookahead time must be a valid number',
|
||||
'settings.behavior.validation.lookahead.range': 'Lookahead time must be between 0 and 0.5 seconds (0-500ms)',
|
||||
'settings.behavior.validation.playbackDelay.invalidNumber': 'Playback delay must be a valid number',
|
||||
'settings.behavior.validation.playbackDelay.range': 'Playback delay must be between 0 and 0.5 seconds (0-500ms)',
|
||||
'settings.behavior.validation.recordingOffset.invalidNumber': 'MIDI input latency must be a valid number',
|
||||
'settings.behavior.validation.recordingOffset.range': 'MIDI input latency must be between 0 and 0.5 seconds (0-500ms)',
|
||||
'settings.templates.title': 'Templates',
|
||||
'settings.templates.customInstructions': 'Custom Instructions',
|
||||
'settings.templates.placeholder': 'Please input your custom instructions for the K.G.Studio Musician Assistant',
|
||||
'settings.chordGuide.title': 'Chord Guide',
|
||||
'settings.chordGuide.resetToDefault': 'Reset to Default',
|
||||
'settings.chordGuide.group.major': 'Major Candidate Chords',
|
||||
'settings.chordGuide.group.minor': 'Minor Candidate Chords',
|
||||
'settings.chordGuide.help.major': 'Enter chords relative to C major. The app will transpose them automatically for other major key signatures.',
|
||||
'settings.chordGuide.help.minor': 'Enter chords relative to A minor. The app will transpose them automatically for other minor key signatures.',
|
||||
'settings.chordGuide.functionsAria': '{group} functions',
|
||||
'settings.chordGuide.addTitle': 'Add {group} chord',
|
||||
'settings.chordGuide.deleteSelectedRows': 'Delete selected rows',
|
||||
'settings.chordGuide.table.chord': 'Chord',
|
||||
'settings.chordGuide.table.notes': 'Notes',
|
||||
'settings.chordGuide.table.source': 'Source',
|
||||
'settings.chordGuide.table.note': 'Note',
|
||||
'settings.chordGuide.loadError': 'Failed to load chord guide settings.',
|
||||
'settings.chordGuide.invalidChordSymbol': 'Please enter a valid chord symbol. Example: Bm7b5',
|
||||
'settings.chordGuide.unsupportedChordSymbol': 'Unable to derive notes for this chord. Please use a supported chord symbol.',
|
||||
'dialog.title.notice': 'Notice',
|
||||
'dialog.title.timeSignature': 'Time Signature',
|
||||
'dialog.title.tempoDetection': 'Tempo Detection',
|
||||
'dialog.title.applyTempo': 'Apply Tempo',
|
||||
'dialog.title.chordDetection': 'Chord Detection',
|
||||
'dialog.title.input': 'Input',
|
||||
'dialog.title.confirm': 'Confirm',
|
||||
'dialog.close': 'Close dialog',
|
||||
'dialog.cancel': 'Cancel',
|
||||
'dialog.ok': 'OK',
|
||||
'dialog.experimentalFeature': 'Experimental Feature',
|
||||
'dialog.recommendedSource': 'Recommended Source Material',
|
||||
'dialog.chordHint.audio': 'Chord analysis is still experimental. Harmonic content, arrangement density, and transient-heavy material can affect accuracy. For more reliable results, start with the default settings, then refine sensitivity and stability until the detected harmony best matches the musical phrasing.',
|
||||
'dialog.chordHint.midi': 'Chord analysis is still experimental. Voicing density, overlaps, and ornamental notes can influence the result. For more reliable chord labels, begin with the default settings, then adjust note suppression and harmonic focus to match the musical role of the passage.',
|
||||
'dialog.chordHint.tempo': 'Tempo analysis is still experimental. Rubato phrasing, sparse transients, and layered percussion can reduce accuracy. Start with the default BPM range, then narrow the analysis window to the most plausible tempo span for the material if the first pass is not musically convincing.',
|
||||
'dialog.sourceHint.kgone': 'For the most dependable chord labels, analyze a stem with vocals and percussion reduced or removed. If K.G.One Music Studio server integration is available, run Separator with the "Vocal, Drums, Bass, Guitar, Piano, and Others" model and use the Piano or Others stem for analysis.',
|
||||
'dialog.sourceHint.local': 'For the most dependable chord labels, analyze a stem with vocals and percussion reduced or removed. If you are using the local separator, choose the "Vocal, Drums, Bass, and Others" model and use the Others stem for analysis.',
|
||||
'dialog.label.sensitivity': 'Sensitivity',
|
||||
'dialog.label.stability': 'Stability',
|
||||
'dialog.label.noChordThreshold': 'No-Chord Threshold',
|
||||
'dialog.label.enableSevenths': 'Chord Detail: Enable sevenths',
|
||||
'dialog.label.shortNotes': 'Short Notes',
|
||||
'dialog.option.suppressionLow': 'Low suppression',
|
||||
'dialog.option.suppressionMedium': 'Medium suppression',
|
||||
'dialog.option.suppressionHigh': 'High suppression',
|
||||
'dialog.label.harmonicFocus': 'Harmonic Focus',
|
||||
'dialog.option.harmonicBalanced': 'Balanced',
|
||||
'dialog.option.harmonicSustained': 'Favor sustained notes',
|
||||
'dialog.label.minimumBpm': 'Minimum BPM',
|
||||
'dialog.label.maximumBpm': 'Maximum BPM',
|
||||
'dialog.label.autoAlignRegionToBeat': 'Auto-align region to beat',
|
||||
'dialog.message.chordDetection': 'Tune audio chord detection settings before processing.',
|
||||
'dialog.message.tempoDetection': 'Tune audio tempo detection settings before processing.',
|
||||
'dialog.message.tempoApply': 'Detected tempo: {bpm} BPM. Choose how to apply it.\n\nUpdate Current Tempo changes the active tempo at this clip location. Insert Tempo Change adds a new tempo region at the nearest bar before the clip starts.',
|
||||
'dialog.action.updateCurrentTempo': 'Update Current Tempo',
|
||||
'dialog.action.insertTempoChange': 'Insert Tempo Change',
|
||||
'eventList.title': 'Event List',
|
||||
'eventList.scopeTabs': 'Event list scopes',
|
||||
'eventList.scope.region': 'Region',
|
||||
'eventList.scope.track': 'Track',
|
||||
'eventList.scope.global': 'Global',
|
||||
'eventList.deleteVisibleSelectedRows': 'Delete visible selected rows',
|
||||
'eventList.table.position': 'Position',
|
||||
'eventList.table.status': 'Status',
|
||||
'eventList.table.num': 'Num',
|
||||
'eventList.table.val': 'Val',
|
||||
'eventList.table.lengthInfo': 'Length/Info',
|
||||
'eventList.region.filters': 'Region event filters',
|
||||
'eventList.region.filter.notes': 'Notes',
|
||||
'eventList.region.filter.pitchBends': 'Pitch Bends',
|
||||
'eventList.region.filter.controller': 'Controller',
|
||||
'eventList.region.empty': 'Please select a MIDI region, or open one in the Piano Roll, to view its event list.',
|
||||
'eventList.region.add.label': 'Add',
|
||||
'eventList.region.add.noteTitle': 'Add note at playhead',
|
||||
'eventList.region.add.pitchBendTitle': 'Add pitch bend at playhead',
|
||||
'eventList.region.add.controllerTitle': 'Add controller event at playhead',
|
||||
'eventList.region.addType.note': 'Note',
|
||||
'eventList.region.addType.pitchBend': 'Pitch Bend',
|
||||
'eventList.region.addType.controller': 'Controller',
|
||||
'eventList.region.status.note': 'Note',
|
||||
'eventList.region.status.pitchBend': 'Pitch Bend',
|
||||
'eventList.region.status.controller': 'Controller',
|
||||
'eventList.track.filters': 'Track list modes',
|
||||
'eventList.track.filter.regions': 'Regions',
|
||||
'eventList.track.filter.volume': 'Volume',
|
||||
'eventList.track.filter.pan': 'Pan',
|
||||
'eventList.track.empty': 'Please select a track to view regions and track automation.',
|
||||
'eventList.track.add.label': 'Add',
|
||||
'eventList.track.add.midiRegionTitle': 'Add MIDI region at playhead',
|
||||
'eventList.track.add.volumeTitle': 'Add volume automation point at playhead',
|
||||
'eventList.track.add.panTitle': 'Add pan automation point at playhead',
|
||||
'eventList.track.addType.midiRegion': 'MIDI Region',
|
||||
'eventList.track.status.audio': 'Audio',
|
||||
'eventList.track.status.midi': 'MIDI',
|
||||
'eventList.track.status.volume': 'Volume',
|
||||
'eventList.track.status.pan': 'Pan',
|
||||
'eventList.global.filters': 'Global event filters',
|
||||
'eventList.global.filter.marker': 'Marker',
|
||||
'eventList.global.filter.tempo': 'Tempo',
|
||||
'eventList.global.filter.keySignature': 'Key Sig.',
|
||||
'eventList.global.filter.chord': 'Chord',
|
||||
'eventList.global.add.label': 'Add',
|
||||
'eventList.global.add.markerTitle': 'Add marker region at playhead',
|
||||
'eventList.global.add.tempoTitle': 'Add tempo region at playhead',
|
||||
'eventList.global.add.keySignatureTitle': 'Add key signature region at playhead',
|
||||
'eventList.global.add.chordTitle': 'Add chord region at playhead',
|
||||
'eventList.global.addType.marker': 'Marker',
|
||||
'eventList.global.addType.tempo': 'Tempo',
|
||||
'eventList.global.addType.keySignature': 'Key Signature',
|
||||
'eventList.global.addType.chord': 'Chord',
|
||||
'eventList.global.status.marker': 'Marker',
|
||||
'eventList.global.status.tempo': 'Tempo',
|
||||
'eventList.global.status.keySignature': 'Key Signature',
|
||||
'eventList.global.status.chord': 'Chord',
|
||||
'eventList.global.validation.marker': 'Please enter a marker label. Expected a non-empty text label. Example: Intro',
|
||||
'eventList.global.validation.tempo': 'Please enter a BPM value using digits only. Expected a whole number between {min} and {max}. Example: 128',
|
||||
'eventList.global.validation.keySignature': 'Please enter one exact key signature name. Expected a canonical value such as "C major" or "F# minor". Example: F# minor',
|
||||
'eventList.global.validation.chord': 'Please enter a valid chord symbol. Expected a chord representation the app can parse. Example: Bm7b5',
|
||||
'eventList.global.validation.position': 'Please enter a position at or after the start of the project. Expected a non-negative location. Example: 1 1 0',
|
||||
'eventList.global.validation.length': 'Please enter a positive length. Expected a duration greater than zero. Example: 4 0',
|
||||
'globalTracks.label': 'Global tracks',
|
||||
'globalTracks.marker': 'Marker',
|
||||
'globalTracks.tempo': 'Tempo',
|
||||
'globalTracks.signature': 'Key Signature',
|
||||
'globalTracks.chord': 'Chord',
|
||||
'globalTracks.addItem': 'Add {label} global track item',
|
||||
'pianoRoll.power.linear': 'Linear',
|
||||
'pianoRoll.power.sqrtDefault': '√ (default)',
|
||||
'pianoRoll.power.mild': 'Mild',
|
||||
'pianoRoll.power.strong': 'Strong',
|
||||
'pianoRoll.chordGuide.off': 'Chord guide off',
|
||||
'pianoRoll.chordGuide.tonic': 'Chord guide tonic',
|
||||
'pianoRoll.chordGuide.subdominant': 'Chord guide subdominant',
|
||||
'pianoRoll.chordGuide.dominant': 'Chord guide dominant',
|
||||
'pianoRoll.spectrogramView': 'Spectrogram View',
|
||||
'pianoRoll.sheetMusicView': 'Sheet Music View',
|
||||
'pianoRoll.pointerTool': 'Pointer Tool',
|
||||
'pianoRoll.pencilTool': 'Pencil Tool',
|
||||
'pianoRoll.toggleAutomationLane': 'Toggle automation lane',
|
||||
'pianoRoll.automation': 'Automation',
|
||||
'pianoRoll.automationLane': '{label} automation lane',
|
||||
'pianoRoll.automationType.pitchBend': 'Pitch Bend',
|
||||
'pianoRoll.automationType.cc1': 'CC1',
|
||||
'pianoRoll.automationType.cc2': 'CC2',
|
||||
'pianoRoll.automationType.cc7': 'CC7',
|
||||
'pianoRoll.automationType.cc11': 'CC11',
|
||||
'pianoRoll.automationType.cc64': 'CC64',
|
||||
'pianoRoll.mode': 'Mode',
|
||||
'pianoRoll.modeOption.ionian': 'Ionian',
|
||||
'pianoRoll.modeOption.dorian': 'Dorian',
|
||||
'pianoRoll.modeOption.phrygian': 'Phrygian',
|
||||
'pianoRoll.modeOption.lydian': 'Lydian',
|
||||
'pianoRoll.modeOption.mixolydian': 'Mixolydian',
|
||||
'pianoRoll.modeOption.aeolian': 'Aeolian',
|
||||
'pianoRoll.modeOption.locrian': 'Locrian',
|
||||
'pianoRoll.modeOption.harmonic_minor': 'Harmonic Minor',
|
||||
'pianoRoll.modeOption.melodic_minor': 'Melodic Minor',
|
||||
'pianoRoll.modeOption.phrygian_dominant': 'Phrygian Dominant',
|
||||
'pianoRoll.modeOption.harmonic_major': 'Harmonic Major',
|
||||
'pianoRoll.chordGuide': 'Chord guide',
|
||||
'pianoRoll.sheetQuantization': 'Sheet Quant.',
|
||||
'pianoRoll.snap': 'Snap',
|
||||
'pianoRoll.snap.none': 'NO SNAP',
|
||||
'pianoRoll.quantizePositionCompact': 'Qua. Pos.',
|
||||
'pianoRoll.quantizeLengthCompact': 'Qua. Len.',
|
||||
'pianoRoll.floor': 'Floor',
|
||||
'pianoRoll.curve': 'Curve',
|
||||
'pianoRoll.zoom': 'Zoom',
|
||||
'pianoRoll.moreOptions': 'More options',
|
||||
'pianoRoll.quantize.1/1': '1/1',
|
||||
'pianoRoll.quantize.1/2': '1/2',
|
||||
'pianoRoll.quantize.1/3': '1/3',
|
||||
'pianoRoll.quantize.1/4': '1/4',
|
||||
'pianoRoll.quantize.1/6': '1/6',
|
||||
'pianoRoll.quantize.1/8': '1/8',
|
||||
'pianoRoll.quantize.1/12': '1/12',
|
||||
'pianoRoll.quantize.1/16': '1/16',
|
||||
'pianoRoll.quantize.1/24': '1/24',
|
||||
'pianoRoll.quantize.1/32': '1/32',
|
||||
'pianoRoll.showActiveRegionOnly': 'Show Active Region Only',
|
||||
'pianoRoll.showEntireTrack': 'Show Entire Track',
|
||||
'pianoRoll.detectChords': 'Detect chords...',
|
||||
'pianoRoll.detectingChords': 'Detecting chords...',
|
||||
'pianoRoll.detectTempo': 'Detect tempo...',
|
||||
'pianoRoll.detectingTempo': 'Detecting tempo...',
|
||||
'percussion.short.35': 'Ac.Bass',
|
||||
'percussion.short.36': 'BassDrum',
|
||||
'percussion.short.37': 'SideStick',
|
||||
'percussion.short.38': 'Ac.Snare',
|
||||
'percussion.short.39': 'HandClap',
|
||||
'percussion.short.40': 'ElecSnare',
|
||||
'percussion.short.41': 'LowFloor',
|
||||
'percussion.short.42': 'ClosedHH',
|
||||
'percussion.short.43': 'HighFloor',
|
||||
'percussion.short.44': 'PedalHH',
|
||||
'percussion.short.45': 'LowTom',
|
||||
'percussion.short.46': 'OpenHH',
|
||||
'percussion.short.47': 'LowMidTom',
|
||||
'percussion.short.48': 'HiMidTom',
|
||||
'percussion.short.49': 'Crash1',
|
||||
'percussion.short.50': 'HighTom',
|
||||
'percussion.short.51': 'Ride1',
|
||||
'percussion.short.52': 'Chinese',
|
||||
'percussion.short.53': 'RideBell',
|
||||
'percussion.short.54': 'Tambourine',
|
||||
'percussion.short.55': 'Splash',
|
||||
'percussion.short.56': 'Cowbell',
|
||||
'percussion.short.57': 'Crash2',
|
||||
'percussion.short.58': 'Vibraslap',
|
||||
'percussion.short.59': 'Ride2',
|
||||
'percussion.short.60': 'HiBongo',
|
||||
'percussion.short.61': 'LowBongo',
|
||||
'percussion.short.62': 'MuteHiCon',
|
||||
'percussion.short.63': 'OpenHiCon',
|
||||
'percussion.short.64': 'LowConga',
|
||||
'percussion.short.65': 'HighTimba',
|
||||
'percussion.short.66': 'LowTimba',
|
||||
'percussion.short.67': 'HighAgo',
|
||||
'percussion.short.68': 'LowAgo',
|
||||
'percussion.short.69': 'Cabasa',
|
||||
'percussion.short.70': 'Maracas',
|
||||
'percussion.short.71': 'ShortWhis',
|
||||
'percussion.short.72': 'LongWhis',
|
||||
'percussion.short.73': 'ShortGui',
|
||||
'percussion.short.74': 'LongGui',
|
||||
'percussion.short.75': 'Claves',
|
||||
'percussion.short.76': 'HiWood',
|
||||
'percussion.short.77': 'LowWood',
|
||||
'percussion.short.78': 'MuteCuica',
|
||||
'percussion.short.79': 'OpenCuica',
|
||||
'percussion.short.80': 'MuteTri',
|
||||
'percussion.short.81': 'OpenTri',
|
||||
'percussion.full.35': 'Acoustic Bass Drum',
|
||||
'percussion.full.36': 'Bass Drum 1',
|
||||
'percussion.full.37': 'Side Stick',
|
||||
'percussion.full.38': 'Acoustic Snare',
|
||||
'percussion.full.39': 'Hand Clap',
|
||||
'percussion.full.40': 'Electric Snare',
|
||||
'percussion.full.41': 'Low Floor Tom',
|
||||
'percussion.full.42': 'Closed Hi Hat',
|
||||
'percussion.full.43': 'High Floor Tom',
|
||||
'percussion.full.44': 'Pedal Hi-Hat',
|
||||
'percussion.full.45': 'Low Tom',
|
||||
'percussion.full.46': 'Open Hi-Hat',
|
||||
'percussion.full.47': 'Low-Mid Tom',
|
||||
'percussion.full.48': 'Hi Mid Tom',
|
||||
'percussion.full.49': 'Crash Cymbal 1',
|
||||
'percussion.full.50': 'High Tom',
|
||||
'percussion.full.51': 'Ride Cymbal 1',
|
||||
'percussion.full.52': 'Chinese Cymbal',
|
||||
'percussion.full.53': 'Ride Bell',
|
||||
'percussion.full.54': 'Tambourine',
|
||||
'percussion.full.55': 'Splash Cymbal',
|
||||
'percussion.full.56': 'Cowbell',
|
||||
'percussion.full.57': 'Crash Cymbal 2',
|
||||
'percussion.full.58': 'Vibraslap',
|
||||
'percussion.full.59': 'Ride Cymbal 2',
|
||||
'percussion.full.60': 'Hi Bongo',
|
||||
'percussion.full.61': 'Low Bongo',
|
||||
'percussion.full.62': 'Mute Hi Conga',
|
||||
'percussion.full.63': 'Open Hi Conga',
|
||||
'percussion.full.64': 'Low Conga',
|
||||
'percussion.full.65': 'High Timbale',
|
||||
'percussion.full.66': 'Low Timbale',
|
||||
'percussion.full.67': 'High Agogo',
|
||||
'percussion.full.68': 'Low Agogo',
|
||||
'percussion.full.69': 'Cabasa',
|
||||
'percussion.full.70': 'Maracas',
|
||||
'percussion.full.71': 'Short Whistle',
|
||||
'percussion.full.72': 'Long Whistle',
|
||||
'percussion.full.73': 'Short Guiro',
|
||||
'percussion.full.74': 'Long Guiro',
|
||||
'percussion.full.75': 'Claves',
|
||||
'percussion.full.76': 'Hi Wood Block',
|
||||
'percussion.full.77': 'Low Wood Block',
|
||||
'percussion.full.78': 'Mute Cuica',
|
||||
'percussion.full.79': 'Open Cuica',
|
||||
'percussion.full.80': 'Mute Triangle',
|
||||
'percussion.full.81': 'Open Triangle',
|
||||
'toolbar.export.kgstudio': 'Export to KGStudio file',
|
||||
'toolbar.export.midi': 'Export to MIDI file',
|
||||
'toolbar.export.wav': 'Export to WAV',
|
||||
'toolbar.export.mp3': 'Export to MP3',
|
||||
'toolbar.projectName.prompt': 'Enter project name:',
|
||||
'toolbar.projectName.invalid': 'Invalid project name. Only letters, numbers, spaces, hyphens, underscores, periods, and parentheses are allowed.',
|
||||
'toolbar.projectName.reserved': '"{name}" is a reserved project name. Please choose a different name.',
|
||||
'toolbar.projectName.overwrite': 'Project "{name}" already exists. Do you want to overwrite it?',
|
||||
'toolbar.projectName.renameOrCopy': 'Would you like to rename this project, or save it as a new copy?',
|
||||
'toolbar.projectName.saveAsCopy': 'Save as Copy',
|
||||
'toolbar.projectName.rename': 'Rename',
|
||||
'toolbar.save.error': 'An error occurred while saving: {error}',
|
||||
'toolbar.load.error': 'An error occurred while loading the project: {error}',
|
||||
'toolbar.newProject.confirm': 'Are you sure you want to create a new project? Any unsaved changes will be lost.',
|
||||
'toolbar.project.notFound': 'Project "{name}" not found.',
|
||||
'toolbar.openProject.confirm': 'Open this project? Any unsaved changes in the current project will be lost.',
|
||||
'toolbar.status.loadFailed': 'Failed to load project: {error}',
|
||||
'toolbar.status.newProjectCreated': 'New project "{name}" created',
|
||||
'toolbar.status.projectLoaded': '{description} loaded successfully',
|
||||
'toolbar.status.projectExportedKgstudio': 'Project "{name}" exported as KGStudio file',
|
||||
'toolbar.status.exportProjectError': 'Error exporting project: {error}',
|
||||
'toolbar.export.failedProject': 'Failed to export project: {error}',
|
||||
'toolbar.status.projectExportedMidi': 'Project "{name}" exported as MIDI file',
|
||||
'toolbar.status.exportMidiError': 'Error exporting MIDI: {error}',
|
||||
'toolbar.export.failedMidi': 'Failed to export project as MIDI: {error}',
|
||||
'toolbar.status.projectExportedWav': 'Project "{name}" exported as WAV file',
|
||||
'toolbar.status.exportWavError': 'Error exporting WAV: {error}',
|
||||
'toolbar.export.failedWav': 'Failed to export project as WAV: {error}',
|
||||
'toolbar.status.projectExportedMp3': 'Project "{name}" exported as MP3 file',
|
||||
'toolbar.status.exportMp3Error': 'Error exporting MP3: {error}',
|
||||
'toolbar.export.failedMp3': 'Failed to export project as MP3: {error}',
|
||||
'toolbar.status.importFailed': 'Failed to import file: {error}',
|
||||
'toolbar.import.failedProjectFile': 'Failed to import project file: {error}',
|
||||
'toolbar.import.corruptedKgstudio': 'The .kgstudio file is corrupted or invalid: {error}',
|
||||
'toolbar.status.importingMidi': 'Importing MIDI file "{name}"...',
|
||||
'toolbar.status.failedImportMidi': 'Failed to import MIDI file: {error}',
|
||||
'toolbar.status.playbackFailedStart': 'Playback failed to start',
|
||||
'toolbar.status.recordingStoppedCommitted': 'Recording stopped - notes committed',
|
||||
'toolbar.status.failedStopPlayback': 'Failed to stop playback',
|
||||
'toolbar.maxBars.prompt': 'Enter new max bars (>= {min}):',
|
||||
'toolbar.input.invalidNumber': 'Invalid input. Please enter a valid number.',
|
||||
'toolbar.maxBars.invalid': 'Invalid value. Please enter a number >= {min}.',
|
||||
'toolbar.status.maxBarsChanged': 'Max bars changed to {value}',
|
||||
'toolbar.bpm.prompt': 'Enter new BPM ({min}-{max}):',
|
||||
'toolbar.bpm.invalid': 'Invalid BPM. Please enter a value between {min} and {max}.',
|
||||
'toolbar.status.bpmChanged': 'BPM changed to {value}',
|
||||
'toolbar.timeSignature.prompt': 'Set the time signature:',
|
||||
'toolbar.status.timeSignatureChanged': 'Time signature changed to {value}',
|
||||
'toolbar.status.keySignatureChanged': 'Key signature changed to {value}',
|
||||
'toolbar.status.copied': 'Items copied to clipboard',
|
||||
'toolbar.status.copyNone': 'No items selected to copy',
|
||||
'toolbar.status.pasted': 'Items pasted from clipboard',
|
||||
'toolbar.status.pasteFailed': 'Cannot paste - no valid clipboard content or context',
|
||||
'toolbar.status.deleted': 'Selected regions deleted',
|
||||
'toolbar.status.deleteNone': 'No regions selected for deletion',
|
||||
'toolbar.undo.none': 'Nothing to undo',
|
||||
'toolbar.redo.none': 'Nothing to redo',
|
||||
'toolbar.status.undid': 'Undid: {description}',
|
||||
'toolbar.status.redid': 'Redid: {description}',
|
||||
'toolbar.action': 'action',
|
||||
'toolbar.status.chatToggled': 'Chat toggled',
|
||||
'toolbar.status.settingsToggled': 'Settings toggled',
|
||||
'toolbar.pianoRoll.selectMidiRegion': 'Please select a MIDI region to open the Piano Roll.',
|
||||
'toolbar.status.recordingStopped': 'Recording stopped',
|
||||
'toolbar.status.audioRecordingStarted': 'Audio recording started...',
|
||||
'toolbar.recording.openMidiRegion': 'Please open a MIDI region in the Piano Roll before starting recording.',
|
||||
'toolbar.recording.selectMidiRegion': 'Please select a MIDI region before starting recording.',
|
||||
'toolbar.recording.noMidiDevice': 'No MIDI device detected. Please connect a MIDI keyboard and try again.',
|
||||
'toolbar.status.recordingStarted': 'Recording started...',
|
||||
'toolbar.button.new': 'New',
|
||||
'toolbar.button.load': 'Load',
|
||||
'toolbar.button.save': 'Save',
|
||||
'toolbar.button.export': 'Export',
|
||||
'toolbar.button.import': 'Import',
|
||||
'toolbar.button.settings': 'Settings',
|
||||
'toolbar.button.undo': 'Undo',
|
||||
'toolbar.button.redo': 'Redo',
|
||||
'toolbar.button.select': 'Select',
|
||||
'toolbar.button.pencil': 'Pencil',
|
||||
'toolbar.button.splitRegion': 'Split Region at Playhead',
|
||||
'toolbar.button.mergeRegions': 'Merge Selected MIDI Regions',
|
||||
'toolbar.button.snapToGrid': 'Snap to Grid',
|
||||
'toolbar.button.copy': 'Copy',
|
||||
'toolbar.button.paste': 'Paste',
|
||||
'toolbar.button.delete': 'Delete',
|
||||
'toolbar.button.backToBeginning': 'Back to beginning',
|
||||
'toolbar.button.play': 'Play',
|
||||
'toolbar.button.pause': 'Pause',
|
||||
'toolbar.button.stopRecording': 'Stop Recording',
|
||||
'toolbar.button.record': 'Record',
|
||||
'toolbar.button.loop': 'Loop',
|
||||
'toolbar.button.metronome': 'Metronome',
|
||||
'toolbar.button.piano': 'Piano',
|
||||
'toolbar.button.kgone': 'K.G.One Music Generator',
|
||||
'toolbar.button.chat': 'Chat',
|
||||
'toolbar.button.eventList': 'Event List Editor',
|
||||
'track.controls.automationButton': 'Track automation',
|
||||
'track.controls.automationDropdown': 'Automation',
|
||||
'track.controls.automation.volume': 'Volume',
|
||||
'track.controls.automation.pan': 'Pan',
|
||||
'track.controls.settingsDropdown': 'Settings',
|
||||
'track.controls.settings.deleteTrack': 'Delete Track',
|
||||
'toolbar.keySignatureChooser': 'Choose key signature, current {value}',
|
||||
'toolbar.export.label': 'Export',
|
||||
'toolbar.importProject.title': 'Import Project',
|
||||
'toolbar.importProject.description': 'Drag and drop your project file here',
|
||||
'toolbar.openingProject': 'Opening project...',
|
||||
'fileImport.invalidType': 'Invalid file type. Please select a file with one of these extensions: {extensions}',
|
||||
'fileImport.close': 'Close import modal',
|
||||
'fileImport.supportedFormats': 'Supported formats: {formats}',
|
||||
'fileImport.or': 'or',
|
||||
'fileImport.browse': 'Browse Files',
|
||||
'instrument.group.PIANO_AND_KEYBOARDS': 'Piano and Keyboards',
|
||||
'instrument.group.GUITAR': 'Guitar',
|
||||
'instrument.group.BASS': 'Bass',
|
||||
'instrument.group.STRINGS': 'Strings',
|
||||
'instrument.group.BRASS': 'Brass',
|
||||
'instrument.group.WOODWIND': 'Woodwind',
|
||||
'instrument.group.PERCUSSION_KIT': 'Percussion Kit',
|
||||
'instrument.group.SYNTH': 'Synthesizer',
|
||||
'instrument.name.acoustic_grand_piano': 'Acoustic Grand Piano',
|
||||
'instrument.name.bright_acoustic_piano': 'Bright Acoustic Piano',
|
||||
'instrument.name.electric_grand_piano': 'Electric Grand Piano',
|
||||
'instrument.name.electric_piano_1': 'Electric Piano 1',
|
||||
'instrument.name.electric_piano_2': 'Electric Piano 2',
|
||||
'instrument.name.drawbar_organ': 'Drawbar Organ',
|
||||
'instrument.name.clavinet': 'Clavinet',
|
||||
'instrument.name.harpsichord': 'Harpsichord',
|
||||
'instrument.name.accordion': 'Accordion',
|
||||
'instrument.name.acoustic_guitar_nylon': 'Acoustic Guitar (nylon)',
|
||||
'instrument.name.acoustic_guitar_steel': 'Acoustic Guitar (steel)',
|
||||
'instrument.name.electric_guitar_clean': 'Electric Guitar (clean)',
|
||||
'instrument.name.overdriven_guitar': 'Electric Guitar (overdrive)',
|
||||
'instrument.name.distortion_guitar': 'Electric Guitar (distortion)',
|
||||
'instrument.name.acoustic_bass': 'Acoustic Bass',
|
||||
'instrument.name.electric_bass_finger': 'Electric Bass (finger)',
|
||||
'instrument.name.electric_bass_pick': 'Electric Bass (picked)',
|
||||
'instrument.name.slap_bass_1': 'Slap Bass',
|
||||
'instrument.name.synth_bass_1': 'Synth Bass 1',
|
||||
'instrument.name.violin': 'Violin',
|
||||
'instrument.name.viola': 'Viola',
|
||||
'instrument.name.cello': 'Cello',
|
||||
'instrument.name.contrabass': 'Contrabass',
|
||||
'instrument.name.string_ensemble_1': 'String Ensemble',
|
||||
'instrument.name.string_ensemble_2': 'String Ensemble 2',
|
||||
'instrument.name.pizzicato_strings': 'Pizzicato Strings',
|
||||
'instrument.name.tremolo_strings': 'Tremolo Strings',
|
||||
'instrument.name.orchestral_harp': 'Orchestral Harp',
|
||||
'instrument.name.trumpet': 'Trumpet',
|
||||
'instrument.name.trombone': 'Trombone',
|
||||
'instrument.name.tuba': 'Tuba',
|
||||
'instrument.name.french_horn': 'French Horn',
|
||||
'instrument.name.brass_section': 'Brass Section',
|
||||
'instrument.name.soprano_sax': 'Soprano Sax',
|
||||
'instrument.name.alto_sax': 'Alto Sax',
|
||||
'instrument.name.tenor_sax': 'Tenor Sax',
|
||||
'instrument.name.baritone_sax': 'Baritone Sax',
|
||||
'instrument.name.oboe': 'Oboe',
|
||||
'instrument.name.english_horn': 'English Horn',
|
||||
'instrument.name.bassoon': 'Bassoon',
|
||||
'instrument.name.clarinet': 'Clarinet',
|
||||
'instrument.name.piccolo': 'Piccolo',
|
||||
'instrument.name.flute': 'Flute',
|
||||
'instrument.name.shakuhachi': 'Shakuhachi',
|
||||
'instrument.name.harmonica': 'Harmonica',
|
||||
'instrument.name.standard': 'Standard Drum Kit',
|
||||
'instrument.name.orchestra_kit': 'Orchestra Drum Kit',
|
||||
'instrument.name.marimba': 'Marimba',
|
||||
'instrument.name.timpani': 'Timpani',
|
||||
'instrument.name.taiko_drum': 'Taiko Drum',
|
||||
'instrument.name.woodblock': 'Woodblock',
|
||||
'instrument.name.lead_1_square': 'Lead 1 (square)',
|
||||
'instrument.name.lead_2_sawtooth': 'Lead 2 (sawtooth)',
|
||||
'instrument.name.pad_1_new_age': 'Pad 1 (new age)',
|
||||
'instrument.name.pad_2_warm': 'Pad 2 (warm)',
|
||||
'instrument.name.pad_3_polysynth': 'Pad 3 (polysynth)',
|
||||
'instrument.name.pad_4_choir': 'Pad 4 (choir)',
|
||||
'instrument.name.pad_5_bowed': 'Pad 5 (bowed glass)',
|
||||
'instrument.name.pad_6_metallic': 'Pad 6 (metallic)',
|
||||
'instrument.name.pad_7_halo': 'Pad 7 (halo)',
|
||||
'instrument.name.pad_8_sweep': 'Pad 8 (sweep)',
|
||||
'instrument.name.fx_1_rain': 'FX 1 (rain)',
|
||||
'instrument.name.fx_2_soundtrack': 'FX 2 (soundtrack)',
|
||||
'instrument.name.fx_3_crystal': 'FX 3 (crystal)',
|
||||
'instrument.name.fx_4_atmosphere': 'FX 4 (atmosphere)',
|
||||
'instrument.name.fx_5_brightness': 'FX 5 (brightness)',
|
||||
'instrument.name.fx_6_goblins': 'FX 6 (goblins)',
|
||||
'instrument.name.fx_7_echoes': 'FX 7 (echoes)',
|
||||
'instrument.name.fx_8_scifi': 'FX 8 (sci-fi)',
|
||||
// ─── K.G.One / Music Generator Panel ─────────────────────────────────────────
|
||||
'kgone.panel.title.server': 'K.G.One Music Generator',
|
||||
'kgone.panel.title.local': 'Music Generator',
|
||||
'kgone.tab.fullSong': 'Full Song',
|
||||
'kgone.tab.remix': 'Remix',
|
||||
'kgone.tab.repaint': 'Repaint',
|
||||
'kgone.tab.separator': 'Separator',
|
||||
'kgone.tab.requiresServer': 'Requires K.G.One Music Studio server integration',
|
||||
// Shared across tabs
|
||||
'kgone.shared.advancedSettings': 'Advanced Settings',
|
||||
'kgone.shared.caption': 'Caption',
|
||||
'kgone.shared.lyrics': 'Lyrics',
|
||||
'kgone.shared.lyricsPlaceholder': '[Verse 1]\nYour lyrics here...\n\n[Chorus]\n...',
|
||||
'kgone.shared.instrumental': 'Instrumental (no vocals)',
|
||||
'kgone.shared.inferenceSteps': 'Inference Steps',
|
||||
'kgone.shared.guidanceScale': 'Guidance Scale',
|
||||
'kgone.shared.useRandomSeed': 'Use Random Seed',
|
||||
'kgone.shared.seed': 'Seed',
|
||||
'kgone.shared.thinking': 'Thinking (CoT metadata generation)',
|
||||
'kgone.shared.selectedRegion': 'Selected Region',
|
||||
'kgone.shared.track': 'Track',
|
||||
'kgone.shared.poweredBy': 'Powered by ',
|
||||
'kgone.shared.btn.loadingModel': 'Loading model...',
|
||||
'kgone.shared.btn.generating': 'Generating...',
|
||||
'kgone.shared.btn.processing': 'Processing...',
|
||||
'kgone.shared.btn.downloading': 'Downloading...',
|
||||
'kgone.shared.btn.preparingUpload': 'Preparing upload...',
|
||||
'kgone.shared.btn.importing': 'Importing...',
|
||||
'kgone.shared.hint.loadingModel': 'Loading model — this can take 60+ seconds, please wait...',
|
||||
'kgone.shared.hint.submitting': 'Submitting generation request...',
|
||||
'kgone.shared.hint.downloadingAudio': 'Downloading audio...',
|
||||
// Clip tab
|
||||
'kgone.clip.field.prompt': 'Prompt',
|
||||
'kgone.clip.field.promptPlaceholder': 'e.g. Gritty, Acid, Bassline, 303, Synth Lead, FM, Sub, Upper Mids, High Phaser, High Reverb, Pitch Bend, 8 Bars, 140 BPM, E minor',
|
||||
'kgone.clip.field.promptHint': 'Describe the clip with comma-separated tags: instrument family, sub-type, timbre, FX, bars, BPM, key.',
|
||||
'kgone.clip.field.negativePrompt': 'Negative Prompt',
|
||||
'kgone.clip.field.negativePromptPlaceholder': 'e.g. distortion, noise',
|
||||
'kgone.clip.field.bars': 'Bars',
|
||||
'kgone.clip.field.bars4': '4 Bars',
|
||||
'kgone.clip.field.bars8': '8 Bars',
|
||||
'kgone.clip.field.note': 'Note',
|
||||
'kgone.clip.field.scale': 'Scale',
|
||||
'kgone.clip.field.scaleMajor': 'Major',
|
||||
'kgone.clip.field.scaleMinor': 'Minor',
|
||||
'kgone.clip.field.bpm': 'BPM',
|
||||
'kgone.clip.field.steps': 'Steps',
|
||||
'kgone.clip.field.cfgScale': 'CFG Scale',
|
||||
'kgone.clip.field.seedLabel': 'Seed (-1 = random)',
|
||||
'kgone.clip.field.samplerType': 'Sampler Type',
|
||||
'kgone.clip.field.sigmaMin': 'Sigma Min',
|
||||
'kgone.clip.field.sigmaMax': 'Sigma Max',
|
||||
'kgone.clip.field.cfgRescale': 'CFG Rescale',
|
||||
'kgone.clip.hint.generating': 'Generating clip...',
|
||||
'kgone.clip.hint.drag': 'Drag the player above to a track to import the clip. Drop onto an <strong>audio track</strong> to import as a WAV region (recommended), or onto a <strong>MIDI track</strong> to import as a MIDI region. Note: MIDI is transcribed from the audio and may not be perfectly accurate.',
|
||||
'kgone.clip.btn.generate': 'Generate Clip',
|
||||
'kgone.clip.poweredBy': 'Foundation-1',
|
||||
// Full Song tab
|
||||
'kgone.fullSong.field.captionPlaceholder': 'e.g. Genre: Eurodance, 90s dance-pop, upbeat electronic. Style: Catchy, energetic... Tempo: ~130 BPM. Instrumentation: driving kick drum, eurodance bassline...',
|
||||
'kgone.fullSong.field.captionHint': 'Describe the song style, mood, tempo, instrumentation, and structure in natural language.',
|
||||
'kgone.fullSong.field.lyricsHint': 'Use [Intro], [Verse], [Chorus], [Bridge] tags to mark sections.',
|
||||
'kgone.fullSong.hint.generating': 'Generating...',
|
||||
'kgone.fullSong.hint.generatingProgress': 'Generating... {pct}% — {stage}',
|
||||
'kgone.fullSong.hint.generatingProgressNoStage': 'Generating... {pct}%',
|
||||
'kgone.fullSong.hint.drag': 'Drag the player above to an <strong>audio track</strong> to import the song. Dropping onto a MIDI track is not supported for full song generation.',
|
||||
'kgone.fullSong.btn.generate': 'Generate Song',
|
||||
'kgone.fullSong.poweredBy': 'ACE-Step 1.5',
|
||||
// Remix tab
|
||||
'kgone.remix.field.captionPlaceholder': 'e.g. Genre: Jazz, Style: Smooth and mellow, Instrumentation: piano, upright bass, brushed drums...',
|
||||
'kgone.remix.field.captionHint': 'Describe the target style, mood, and instrumentation for the remix.',
|
||||
'kgone.remix.field.lyricsHint': 'Leave empty to keep the original lyrics, or provide new ones. Use [Verse], [Chorus], [Bridge] tags.',
|
||||
'kgone.remix.field.coverStrength': 'Cover Strength',
|
||||
'kgone.remix.field.coverStrengthHint': '0 = creative, 1 = faithful to source structure',
|
||||
'kgone.remix.field.noiseStrength': 'Noise Strength',
|
||||
'kgone.remix.field.noiseStrengthHint': '0 = pure style transfer, 0.1–0.25 recommended',
|
||||
'kgone.remix.hint.submitting': 'Submitting remix request...',
|
||||
'kgone.remix.hint.generating': 'Generating remix...',
|
||||
'kgone.remix.hint.generatingProgress': 'Generating remix... {pct}% — {stage}',
|
||||
'kgone.remix.hint.generatingProgressNoStage': 'Generating remix... {pct}%',
|
||||
'kgone.remix.hint.drag': 'Drag the player above to an <strong>audio track</strong> to import the remix.',
|
||||
'kgone.remix.hint.noRegion': 'Select an audio region on the timeline to remix it. Only audio regions are supported — MIDI regions cannot be remixed.',
|
||||
'kgone.remix.btn.generate': 'Generate Remix',
|
||||
'kgone.remix.btn.processingRemix': 'Processing remix...',
|
||||
'kgone.remix.btn.importAligned': 'Import Aligned to Source',
|
||||
'kgone.remix.poweredBy': 'ACE-Step 1.5',
|
||||
// Repaint tab
|
||||
'kgone.repaint.field.repaintRange': 'Repaint Range',
|
||||
'kgone.repaint.field.loopModeOff': 'Loop mode is off. Enable the loop button on the toolbar and set a loop range to define the repaint window.',
|
||||
'kgone.repaint.field.start': 'Start',
|
||||
'kgone.repaint.field.end': 'End',
|
||||
'kgone.repaint.field.untilEnd': 'until end of audio',
|
||||
'kgone.repaint.field.captionPlaceholder': 'e.g. Genre: Jazz, Style: Smooth and mellow, Instrumentation: piano, upright bass, brushed drums...',
|
||||
'kgone.repaint.field.captionHint': 'Describe the target style and instrumentation for the repainted section.',
|
||||
'kgone.repaint.field.lyricsHint': 'Leave empty to keep the original lyrics, or provide new ones for the repainted section.',
|
||||
'kgone.repaint.field.repaintStrength': 'Repaint Strength',
|
||||
'kgone.repaint.field.repaintStrengthHint': '0 = preserve original, 1 = full regeneration',
|
||||
'kgone.repaint.hint.submitting': 'Submitting repaint request...',
|
||||
'kgone.repaint.hint.generating': 'Generating repaint...',
|
||||
'kgone.repaint.hint.generatingProgress': 'Generating repaint... {pct}% — {stage}',
|
||||
'kgone.repaint.hint.generatingProgressNoStage': 'Generating repaint... {pct}%',
|
||||
'kgone.repaint.hint.drag': 'Drag the player above to an <strong>audio track</strong> to import the repaint.',
|
||||
'kgone.repaint.hint.noRegion': 'Select an audio region on the timeline to repaint it. Only audio regions are supported — MIDI regions cannot be repainted.',
|
||||
'kgone.repaint.btn.generate': 'Generate Repaint',
|
||||
'kgone.repaint.btn.processingRepaint': 'Processing repaint...',
|
||||
'kgone.repaint.btn.importAligned': 'Import Aligned to Source',
|
||||
'kgone.repaint.poweredBy': 'ACE-Step 1.5',
|
||||
// Separator tab
|
||||
'kgone.separator.field.separationModel': 'Separation Model',
|
||||
'kgone.separator.field.chunkDuration': 'Optional audio chunk duration (seconds)',
|
||||
'kgone.separator.field.chunkDurationPlaceholder': 'Leave blank to process the full region',
|
||||
'kgone.separator.field.modelOverlap': 'Model overlap',
|
||||
'kgone.separator.hint.drag': 'Drag each stem player above to an <strong>audio track</strong> to import it. Dropping onto a MIDI track is not supported for stem separation.',
|
||||
'kgone.separator.hint.noRegion.download': 'Download {model}, then select an audio region on the timeline to extract stems from it.',
|
||||
'kgone.separator.hint.noRegion.select': 'Select an audio region on the timeline to extract stems from it. Only audio regions are supported — MIDI regions cannot be separated.',
|
||||
'kgone.separator.btn.separateStems': 'Separate Stems',
|
||||
'kgone.separator.btn.importAllStems': 'Import All Stems to Timeline',
|
||||
'kgone.separator.poweredBy': 'UVR5 CLI',
|
||||
'kgone.separator.model.vocalInstMedium': 'Vocal and Instrument (Medium Accuracy)',
|
||||
'kgone.separator.model.vocalInstHigh': 'Vocal and Instrument (High Accuracy)',
|
||||
'kgone.separator.model.6stem': 'Vocal, Drums, Bass, Guitar, Piano, and Others',
|
||||
'kgone.separator.model.htdemucs4s': 'Vocal, Drums, Bass, and Others',
|
||||
'kgone.separator.btn.server.loadingModel': 'Loading model...',
|
||||
'kgone.separator.btn.server.generating': 'Preparing upload...',
|
||||
'kgone.separator.btn.server.polling': 'Separating stems...',
|
||||
'kgone.separator.btn.server.downloading': 'Downloading...',
|
||||
'kgone.separator.btn.local.loadingModel': 'Preparing local model...',
|
||||
'kgone.separator.btn.local.generating': 'Preparing audio...',
|
||||
'kgone.separator.btn.local.polling': 'Separating locally...',
|
||||
'kgone.separator.btn.local.downloading': 'Finalizing...',
|
||||
'kgone.separator.local.title': 'Local Separator Mode',
|
||||
'kgone.separator.local.description': 'If K.G.One Music Studio is unavailable, Local Separator Mode provides a built-in alternative for extracting stems directly in your browser. Two local models are available: Vocal and Instrument (Medium Accuracy), and Vocal, Drums, Bass, and Others. Download status below reflects the currently selected model. Vocal and Instrument (Medium Accuracy) usually takes longer to process than Vocal, Drums, Bass, and Others, and total processing time will still depend on your hardware. If processing falls back to CPU, the page may become temporarily less responsive while separation is running.',
|
||||
'kgone.separator.local.learnMore': 'Learn more about K.G.One Music Studio server integration.',
|
||||
'kgone.separator.local.provider': 'Provider: ',
|
||||
'kgone.separator.local.model': 'Model: ',
|
||||
'kgone.separator.local.downloaded': 'downloaded',
|
||||
'kgone.separator.local.notDownloaded': 'not downloaded',
|
||||
'kgone.separator.local.checkingCache': 'Checking local model cache...',
|
||||
'kgone.separator.local.btn.download': 'Download Selected Model',
|
||||
'kgone.separator.local.btn.downloading': 'Downloading Model...',
|
||||
'kgone.separator.local.btn.redownload': 'Redownload Model',
|
||||
'kgone.separator.local.btn.redownloading': 'Redownloading...',
|
||||
'kgone.separator.local.btn.deleteCache': 'Delete Cached Model',
|
||||
'kgone.separator.local.btn.deleting': 'Deleting...',
|
||||
'kgone.separator.local.progress.downloading': 'Downloading {name}...',
|
||||
'kgone.separator.local.progress.downloadingWithSize': 'Downloading {name}... {received} / {total} MB',
|
||||
'kgone.separator.local.progress.downloadingMbOnly': 'Downloading {name}... {received} MB',
|
||||
'kgone.separator.local.progress.ready': '{name} is ready.',
|
||||
'kgone.separator.local.progress.preparingRuntime': 'Preparing ONNX Runtime session...',
|
||||
'kgone.separator.local.progress.readingAudio': 'Reading audio file...',
|
||||
'kgone.separator.local.progress.running': 'Running browser separation...',
|
||||
'kgone.separator.local.progress.complete': 'Separation complete.',
|
||||
|
||||
'openProjectModal.title': 'Open Project',
|
||||
'openProjectModal.close': 'Close',
|
||||
'openProjectModal.loading': 'Loading projects...',
|
||||
'openProjectModal.date.unknown': 'Unknown',
|
||||
'openProjectModal.filter.placeholder': 'Filter projects...',
|
||||
'openProjectModal.viewToggle.projects': 'Projects',
|
||||
'openProjectModal.viewToggle.trash': 'Trash',
|
||||
'openProjectModal.trash.hint': 'Deleted projects are automatically removed after 30 days.',
|
||||
'openProjectModal.sort.name': 'Name',
|
||||
'openProjectModal.sort.created': 'Created',
|
||||
'openProjectModal.sort.updated': 'Updated',
|
||||
'openProjectModal.empty.trash': 'Trash is empty',
|
||||
'openProjectModal.empty.noProjects': 'No saved projects',
|
||||
'openProjectModal.empty.noMatch': 'No projects match the filter',
|
||||
'openProjectModal.meta.created': 'Created {date}',
|
||||
'openProjectModal.meta.updated': 'Updated {date}',
|
||||
'openProjectModal.meta.deleted': 'Deleted {date}',
|
||||
'openProjectModal.action.open': 'Open',
|
||||
'openProjectModal.action.duplicate': 'Duplicate',
|
||||
'openProjectModal.action.delete': 'Delete',
|
||||
'openProjectModal.action.restore': 'Restore',
|
||||
'openProjectModal.action.deletePermanently': 'Delete permanently',
|
||||
'openProjectModal.dialog.duplicateName': 'Enter a name for the duplicated project:',
|
||||
'openProjectModal.dialog.invalidName': 'Invalid project name. Only letters, numbers, spaces, hyphens, underscores, periods, and parentheses are allowed.',
|
||||
'openProjectModal.dialog.duplicateError': 'Failed to duplicate project: {error}',
|
||||
'openProjectModal.dialog.deleteError': 'Failed to delete project: {error}',
|
||||
'openProjectModal.dialog.restoreError': 'Failed to restore project: {error}',
|
||||
'openProjectModal.dialog.confirmPermanentDelete': 'Are you sure you want to permanently delete "{name}"?\n\nThis operation cannot be undone.',
|
||||
'openProjectModal.dialog.permanentDeleteError': 'Failed to permanently delete project: {error}',
|
||||
};
|
||||
@@ -0,0 +1,566 @@
|
||||
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.',
|
||||
'mainContent.createTrack': 'Créer une piste',
|
||||
'mainContent.showGlobalTracks': 'Afficher les pistes globales',
|
||||
'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.',
|
||||
'settings.audioIo.title': 'Entrées/Sorties audio',
|
||||
'settings.audioIo.deviceRouting': 'Routage des appareils',
|
||||
'settings.audioIo.refresh': 'Rafraîchir la liste des appareils',
|
||||
'settings.audioIo.refreshing': 'Rafraîchissement…',
|
||||
'settings.audioIo.description': 'Choisissez les appareils que KGStudio doit utiliser pour l’enregistrement et la lecture. Les changements d’entrée s’appliquent à la prochaine session d’enregistrement. Les changements de sortie demandent un rechargement de la page.',
|
||||
'settings.audioIo.inputDevice': 'Appareil d’entrée audio',
|
||||
'settings.audioIo.outputDevice': 'Appareil de sortie audio',
|
||||
'settings.audioIo.inputHelp': 'Les changements s’appliquent au prochain enregistrement audio. Si l’appareil choisi est retiré, KGStudio reviendra à l’appareil système par défaut.',
|
||||
'settings.audioIo.outputHelp': 'Les changements de sortie demandent un rechargement de la page. La prise en charge du routage vers une sortie non par défaut est limitée dans les navigateurs, donc KGStudio continuera à utiliser la sortie système par défaut lorsqu’elle n’est pas prise en charge.',
|
||||
'settings.audioIo.outputSinkUnsupported': 'Ce navigateur n’expose pas de bascule fiable en direct de la sortie Web Audio. La sélection d’une sortie non par défaut reste un effort au mieux et peut finalement rester sur la sortie système par défaut.',
|
||||
'settings.audioIo.chooseOutputDevice': 'Choisir l’appareil de sortie…',
|
||||
'settings.audioIo.previousSelectedInput': 'Entrée précédemment sélectionnée (autorisation requise pour vérification)',
|
||||
'settings.audioIo.previousSelectedOutput': 'Sortie précédemment sélectionnée (autorisation requise pour vérification)',
|
||||
'settings.audioIo.status.inputUnavailable': 'L’appareil d’entrée audio précédemment sélectionné n’est pas disponible ; utilisation de l’appareil système par défaut.',
|
||||
'settings.audioIo.status.outputUnavailable': 'L’appareil de sortie audio précédemment sélectionné n’est pas disponible ; utilisation de l’appareil système par défaut.',
|
||||
'settings.audioIo.status.refreshed': 'La liste des appareils audio a été rafraîchie.',
|
||||
'settings.audioIo.status.readFailed': 'Impossible de lire les appareils audio depuis le navigateur.',
|
||||
'settings.audioIo.status.inputDefaultNextSession': 'L’entrée audio utilisera l’appareil système par défaut lors de la prochaine session d’enregistrement.',
|
||||
'settings.audioIo.status.inputChangedNextSession': 'L’entrée audio changera lors de la prochaine session d’enregistrement.',
|
||||
'settings.audioIo.status.outputDefaultAfterRefresh': 'La sortie audio utilisera l’appareil système par défaut après rechargement.',
|
||||
'settings.audioIo.status.outputSavedRefreshRequired': 'L’appareil de sortie audio a été enregistré. Rechargez la page pour l’appliquer.',
|
||||
'settings.audioIo.status.outputPromptUnsupported': 'Ce navigateur ne prend pas en charge l’invite de sélection d’un appareil de sortie audio.',
|
||||
'settings.audioIo.status.outputSelectedRefreshRequired': 'Appareil de sortie sélectionné. Rechargez la page pour l’appliquer.',
|
||||
'settings.audioIo.status.outputSelectionDenied': 'Le navigateur n’a pas autorisé la sélection d’une sortie non par défaut.',
|
||||
'settings.behavior.title': 'Comportement',
|
||||
'settings.behavior.editor': 'Éditeur',
|
||||
'settings.behavior.playheadUpdateFrequency': 'Fréquence de mise à jour de la tête de lecture (fps)',
|
||||
'settings.behavior.playheadUpdateFrequencyHelp': 'Fréquence de mise à jour de l’animation de la tête de lecture pendant la lecture. Des valeurs plus élevées (60 fps) donnent une animation plus fluide mais utilisent plus de CPU. Des valeurs plus faibles (10 fps) sont plus efficaces. Les changements s’appliquent immédiatement sans redémarrage.',
|
||||
'settings.behavior.spectrogramHeightResolution': 'Résolution verticale du spectrogramme',
|
||||
'settings.behavior.spectrogramHeightResolutionHelp': 'Contrôle le niveau de détail vertical du spectrogramme sur la plage de hauteurs visible. Des valeurs plus élevées accentuent les contours de hauteur mais consomment davantage de CPU et de mémoire lors du calcul du spectrogramme.',
|
||||
'settings.behavior.chatBox': 'Boîte de discussion',
|
||||
'settings.behavior.openAtStartup': 'Ouvrir au démarrage',
|
||||
'settings.behavior.audio': 'Audio',
|
||||
'settings.behavior.lookaheadTime': 'Temps d’anticipation (ms)',
|
||||
'settings.behavior.lookaheadTimeHelp': 'Temps d’anticipation de planification audio (0-500 ms). Des valeurs plus faibles (10-20 ms) réduisent la latence d’entrée MIDI mais peuvent provoquer des glitches audio sur les systèmes plus lents. Des valeurs plus élevées (100 ms et plus) améliorent la stabilité de lecture. Les changements s’appliquent immédiatement sans redémarrage.',
|
||||
'settings.behavior.playbackDelay': 'Délai de lecture (ms)',
|
||||
'settings.behavior.playbackDelayHelp': 'La lecture démarrera avec un léger délai après avoir appuyé sur le bouton de démarrage (0-500 ms). Augmenter cette valeur peut aider à stabiliser la lecture, surtout pendant les premiers ticks si la valeur d’anticipation est trop faible. Les changements s’appliquent immédiatement sans redémarrage.',
|
||||
'settings.behavior.midiAutomationInterpolation': 'Interpolation de l’automation MIDI',
|
||||
'settings.behavior.midiAutomationInterpolation.low': 'Économie (20 ms)',
|
||||
'settings.behavior.midiAutomationInterpolation.balanced': 'Équilibré (10 ms)',
|
||||
'settings.behavior.midiAutomationInterpolation.high': 'Haute qualité (5 ms)',
|
||||
'settings.behavior.midiAutomationInterpolationHelp': 'Contrôle la densité de cuisson de l’automation MIDI pour la lecture et le bounce. Des intervalles plus courts sonnent plus fluides mais planifient davantage d’événements.',
|
||||
'settings.behavior.midiInputLatency': 'Latence d’entrée MIDI (ms)',
|
||||
'settings.behavior.midiInputLatencyHelp': 'Correction temporelle pour l’enregistrement MIDI (0-500 ms). Si les notes enregistrées apparaissent légèrement en retard par rapport à ce que vous vouliez jouer, augmentez cette valeur pour l’aligner sur la latence d’entrée de votre appareil MIDI. La position de chaque note est reculée de cette valeur lors de la validation.',
|
||||
'settings.behavior.bounceStartsFromBeat1': 'Le bounce démarre au temps 1',
|
||||
'settings.behavior.bounceStartsFromBeat1Help': 'Oui inclut le silence initial entre le début du morceau et la première région rendue lors du bounce WAV/MP3. Non supprime ce silence initial et démarre le bounce à la première note rendue ou à la première région audio.',
|
||||
'settings.behavior.captureAudioForScreenSharing': 'Capturer l’audio pour le partage d’écran',
|
||||
'settings.behavior.captureAudioForScreenSharingHelp': 'Redémarrez KGStudio (rechargez la page) pour appliquer ce réglage. Activez cette option lorsque l’audio de KGStudio ne peut pas être capturé pendant le partage d’écran dans les appels vidéo (ex. Zoom, Teams). Cela crée un flux audio supplémentaire que les applications de capture d’écran peuvent détecter.',
|
||||
'settings.behavior.captureAudioForScreenSharingWarning': 'Lorsque vous partagez l’écran dans Zoom, vérifiez bien que l’option "Share Sound" est activée.',
|
||||
'settings.behavior.validation.lookahead.invalidNumber': 'Le temps d’anticipation doit être un nombre valide',
|
||||
'settings.behavior.validation.lookahead.range': 'Le temps d’anticipation doit être compris entre 0 et 0,5 seconde (0-500 ms)',
|
||||
'settings.behavior.validation.playbackDelay.invalidNumber': 'Le délai de lecture doit être un nombre valide',
|
||||
'settings.behavior.validation.playbackDelay.range': 'Le délai de lecture doit être compris entre 0 et 0,5 seconde (0-500 ms)',
|
||||
'settings.behavior.validation.recordingOffset.invalidNumber': 'La latence d’entrée MIDI doit être un nombre valide',
|
||||
'settings.behavior.validation.recordingOffset.range': 'La latence d’entrée MIDI doit être comprise entre 0 et 0,5 seconde (0-500 ms)',
|
||||
'settings.templates.title': 'Modèles',
|
||||
'settings.templates.customInstructions': 'Instructions personnalisées',
|
||||
'settings.templates.placeholder': 'Veuillez saisir vos instructions personnalisées pour l’assistant musical K.G.Studio',
|
||||
'settings.chordGuide.title': 'Guide d’accords',
|
||||
'settings.chordGuide.resetToDefault': 'Rétablir les valeurs par défaut',
|
||||
'settings.chordGuide.group.major': 'Accords candidats majeurs',
|
||||
'settings.chordGuide.group.minor': 'Accords candidats mineurs',
|
||||
'settings.chordGuide.help.major': 'Saisissez des accords relatifs à Do majeur. L’application les transposera automatiquement pour les autres armures majeures.',
|
||||
'settings.chordGuide.help.minor': 'Saisissez des accords relatifs à La mineur. L’application les transposera automatiquement pour les autres armures mineures.',
|
||||
'settings.chordGuide.functionsAria': 'Fonctions pour {group}',
|
||||
'settings.chordGuide.addTitle': 'Ajouter un accord à {group}',
|
||||
'settings.chordGuide.deleteSelectedRows': 'Supprimer les lignes sélectionnées',
|
||||
'settings.chordGuide.table.chord': 'Accord',
|
||||
'settings.chordGuide.table.notes': 'Notes',
|
||||
'settings.chordGuide.table.source': 'Source',
|
||||
'settings.chordGuide.table.note': 'Note',
|
||||
'settings.chordGuide.loadError': 'Impossible de charger les réglages du guide d’accords.',
|
||||
'settings.chordGuide.invalidChordSymbol': 'Veuillez saisir un symbole d’accord valide. Exemple : Bm7b5',
|
||||
'settings.chordGuide.unsupportedChordSymbol': 'Impossible de déduire les notes pour cet accord. Veuillez utiliser un symbole d’accord pris en charge.',
|
||||
'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',
|
||||
'track.controls.automationButton': 'Automation de piste',
|
||||
'track.controls.automationDropdown': 'Automation',
|
||||
'track.controls.automation.volume': 'Volume',
|
||||
'track.controls.automation.pan': 'Panoramique',
|
||||
'track.controls.settingsDropdown': 'Réglages',
|
||||
'track.controls.settings.deleteTrack': 'Supprimer la piste',
|
||||
'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 <strong>piste audio</strong> pour l’importer comme région WAV (recommandé), ou sur une <strong>piste MIDI</strong> 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 <strong>piste audio</strong> 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 <strong>piste audio</strong> 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 <strong>piste audio</strong> 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 <strong>piste audio</strong> 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.',
|
||||
};
|
||||
@@ -0,0 +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<ResolvedLocaleCode, TranslationMessages> = {
|
||||
en_us: enUsMessages,
|
||||
fr_fr: frFrMessages,
|
||||
zh_cn: zhCnMessages,
|
||||
zh_hk: zhHkMessages,
|
||||
};
|
||||
@@ -0,0 +1,779 @@
|
||||
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 音乐创作助手。',
|
||||
'mainContent.createTrack': '创建轨道',
|
||||
'mainContent.showGlobalTracks': '显示全局轨道',
|
||||
'status.chordGuideCandidate': '和弦指导候选: {name} - {notes} - {note}',
|
||||
'settings.sidebar.title': '设置',
|
||||
'settings.sidebar.close': '关闭设置',
|
||||
'settings.sidebar.general': '通用',
|
||||
'settings.sidebar.audioIo': '音频 I/O',
|
||||
'settings.sidebar.behavior': '行为',
|
||||
'settings.sidebar.templates': '模板',
|
||||
'settings.sidebar.chordGuide': '和弦指导',
|
||||
'settings.general.title': '通用',
|
||||
'settings.general.language.label': '语言',
|
||||
'settings.general.language.help': '自动会使用您的系统语言。',
|
||||
'settings.general.language.auto': '自动',
|
||||
'settings.yes': '是',
|
||||
'settings.no': '否',
|
||||
'settings.restoreDefault': '恢复默认',
|
||||
'settings.deleteCachedModel': '删除已缓存模型',
|
||||
'settings.deleting': '删除中...',
|
||||
'settings.general.llmProvider.section': 'LLM 提供方',
|
||||
'settings.general.llmProvider.label': 'LLM 提供方',
|
||||
'settings.general.llmProvider.local': '本地 LLM(浏览器)',
|
||||
'settings.general.llmProvider.openai': 'OpenAI',
|
||||
'settings.general.llmProvider.claudeOpenRouter': 'Claude(通过 OpenRouter)',
|
||||
'settings.general.llmProvider.openaiCompatible': 'OpenAI 兼容服务(例如 OpenRouter、Ollama)',
|
||||
'settings.general.persistKeys.label': '在非 localhost 环境持久化 API Key',
|
||||
'settings.general.persistKeys.help': '启用后,即使在非 localhost 环境下,API Key 也会保存到浏览器存储中。警告:这可能增加 XSS 攻击带来的安全风险。',
|
||||
'settings.general.localRuntime.cachedStatus': '缓存模型状态',
|
||||
'settings.general.localRuntime.cacheChecking': '正在检查本地模型缓存...',
|
||||
'settings.general.localRuntime.cacheDownloaded': '已下载到浏览器缓存。',
|
||||
'settings.general.localRuntime.cacheMissing': '尚未下载。',
|
||||
'settings.general.localRuntime.contextLength': '上下文长度',
|
||||
'settings.general.localRuntime.contextHelp': '更大的上下文长度需要更多显存,并且在对话变长后也可能降低性能。',
|
||||
'settings.general.localRuntime.downloadUrl': '下载地址',
|
||||
'settings.general.localRuntime.downloadHelp': '修改这个地址可能导致下载失败,或指向不兼容的模型文件。',
|
||||
'settings.general.localRuntime.autoDownload': '下次您使用 `Local LLM (Browser)` 聊天时,本地模型会自动下载。',
|
||||
'settings.general.uvr5.section': 'UVR5 Web 运行时',
|
||||
'settings.general.uvr5.downloadUrl': 'UVR-MDX-NET-Inst_HQ_3 下载地址',
|
||||
'settings.general.htdemucs.downloadUrl': 'htdemucs_4s 下载地址',
|
||||
'settings.general.modelUrl.help': '修改这个地址可能导致下载失败,或指向不兼容的模型文件。',
|
||||
'settings.general.openai.section': 'OpenAI',
|
||||
'settings.general.openai.key': '密钥',
|
||||
'settings.general.openai.keyPlaceholder': '输入您的 OpenAI API Key',
|
||||
'settings.general.openai.model': '模型',
|
||||
'settings.general.openai.flexMode': 'Flex 模式',
|
||||
'settings.general.openai.flexHelp': 'Flex 模式使用 OpenAI 的弹性服务层。优点:繁忙时段可能节省成本并提高吞吐。缺点:延迟可能波动,也可能排队或被降优先级。仅对 OpenAI 提供方生效,对 OpenAI 兼容服务无影响。',
|
||||
'settings.general.keys.persisted': '密钥会保存在本地(浏览器中的 IndexedDB)。',
|
||||
'settings.general.keys.sessionOnly': '出于安全考虑,在非本地主机环境下密钥不会持久化,只保留在当前会话内存中。',
|
||||
'settings.general.claudeOpenRouter.section': 'Anthropic Claude(通过 OpenRouter)',
|
||||
'settings.general.claudeOpenRouter.keyPlaceholder': '输入您的 Claude API Key',
|
||||
'settings.general.baseUrl': '基础 URL',
|
||||
'settings.general.claudeOpenRouter.baseUrlHelp': '这是 OpenRouter API 的基础 URL。除非您明确知道自己在做什么,否则不要修改。',
|
||||
'settings.general.openaiCompatible.section': 'OpenAI 兼容服务',
|
||||
'settings.general.openaiCompatible.keyPlaceholder': '输入您的 API Key',
|
||||
'settings.general.openaiCompatible.baseUrlPlaceholder': '例如 https://openrouter.ai/api/v1',
|
||||
'settings.general.openaiCompatible.baseUrlHelp': '快捷预设:',
|
||||
'settings.general.openaiCompatible.modelPlaceholder': '例如 qwen3:30b',
|
||||
'settings.general.soundfont.section': 'Soundfont 设置',
|
||||
'settings.general.soundfont.managed': 'Soundfont 配置由服务器(kgone-server.json)管理,当前设置为只读。',
|
||||
'settings.general.soundfont.baseUrl': '基础 URL',
|
||||
'settings.general.soundfont.baseUrlHelp': '如果改成不兼容的 soundfont 源,某些乐器可能发声错误或无法播放。',
|
||||
'settings.general.kgone.section': 'K.G.One 设置',
|
||||
'settings.general.kgone.managed': 'K.G.One 配置由服务器(kgone-server.json)管理,当前设置为只读。',
|
||||
'settings.general.kgone.enabled': '启用 K.G.One 集成',
|
||||
'settings.general.kgone.disabled': '禁用',
|
||||
'settings.general.kgone.enabledOption': '启用',
|
||||
'settings.general.kgone.serverBaseUrl': '服务器基础 URL',
|
||||
'settings.general.kgone.serverBaseUrlHelp': '正在运行的 K.G.One Music Studio 服务器基础 URL。用于整曲生成、片段生成和分轨。',
|
||||
'settings.audioIo.title': '音频 I/O',
|
||||
'settings.audioIo.deviceRouting': '设备路由',
|
||||
'settings.audioIo.refresh': '刷新设备列表',
|
||||
'settings.audioIo.refreshing': '刷新中…',
|
||||
'settings.audioIo.description': '选择 KGStudio 用于录音和播放的设备。输入设备的更改会在下一次录音时生效。输出设备的更改需要刷新页面后生效。',
|
||||
'settings.audioIo.inputDevice': '音频输入设备',
|
||||
'settings.audioIo.outputDevice': '音频输出设备',
|
||||
'settings.audioIo.inputHelp': '更改会在下一次录音时生效。如果所选设备被移除,KGStudio 会回退到系统默认设备。',
|
||||
'settings.audioIo.outputHelp': '输出设备更改需要刷新页面。浏览器对非默认输出路由的支持有限,因此在不支持时 KGStudio 会继续使用系统默认设备。',
|
||||
'settings.audioIo.outputSinkUnsupported': '此浏览器未提供可靠的实时 Web Audio 输出切换能力。非默认输出设备选择仅尽力而为,最终可能仍会使用系统默认设备。',
|
||||
'settings.audioIo.chooseOutputDevice': '选择输出设备…',
|
||||
'settings.audioIo.previousSelectedInput': '先前选择的输入设备(需要权限才能验证)',
|
||||
'settings.audioIo.previousSelectedOutput': '先前选择的输出设备(需要权限才能验证)',
|
||||
'settings.audioIo.status.inputUnavailable': '先前选择的音频输入设备不可用;将使用系统默认设备。',
|
||||
'settings.audioIo.status.outputUnavailable': '先前选择的音频输出设备不可用;将使用系统默认设备。',
|
||||
'settings.audioIo.status.refreshed': '音频设备列表已刷新。',
|
||||
'settings.audioIo.status.readFailed': '无法从浏览器读取音频设备。',
|
||||
'settings.audioIo.status.inputDefaultNextSession': '音频输入将在下一次录音时使用系统默认设备。',
|
||||
'settings.audioIo.status.inputChangedNextSession': '音频输入将在下一次录音时切换。',
|
||||
'settings.audioIo.status.outputDefaultAfterRefresh': '刷新后音频输出将使用系统默认设备。',
|
||||
'settings.audioIo.status.outputSavedRefreshRequired': '音频输出设备已保存。请刷新页面以应用。',
|
||||
'settings.audioIo.status.outputPromptUnsupported': '此浏览器不支持弹出音频输出设备选择。',
|
||||
'settings.audioIo.status.outputSelectedRefreshRequired': '已选择输出设备。请刷新页面以应用。',
|
||||
'settings.audioIo.status.outputSelectionDenied': '浏览器不允许选择非默认输出设备。',
|
||||
'settings.behavior.title': '行为',
|
||||
'settings.behavior.editor': '编辑器',
|
||||
'settings.behavior.playheadUpdateFrequency': '播放头更新频率(fps)',
|
||||
'settings.behavior.playheadUpdateFrequencyHelp': '播放时播放头动画的更新频率。更高的值(60 fps)会带来更平滑的动画,但会占用更多 CPU。更低的值(10 fps)更节省资源。更改会立即生效,无需重启。',
|
||||
'settings.behavior.spectrogramHeightResolution': '频谱图高度分辨率',
|
||||
'settings.behavior.spectrogramHeightResolutionHelp': '控制可见音高范围内的频谱图垂直细节。更高的值会让音高轮廓更清晰,但在计算频谱图时会消耗更多 CPU 和内存。',
|
||||
'settings.behavior.chatBox': '聊天框',
|
||||
'settings.behavior.openAtStartup': '启动时打开',
|
||||
'settings.behavior.audio': '音频',
|
||||
'settings.behavior.lookaheadTime': '前瞻时间(ms)',
|
||||
'settings.behavior.lookaheadTimeHelp': '音频调度前瞻时间(0-500ms)。较低的值(10-20ms)可以降低 MIDI 输入延迟,但在较慢的系统上可能造成音频卡顿。较高的值(100ms 以上)更有利于播放稳定性。更改会立即生效,无需重启。',
|
||||
'settings.behavior.playbackDelay': '播放延迟(ms)',
|
||||
'settings.behavior.playbackDelayHelp': '按下开始按钮后,播放会有一个很短的延迟(0-500ms)。增加此值可能有助于稳定播放,特别是在前瞻值较低时的前几个节拍。更改会立即生效,无需重启。',
|
||||
'settings.behavior.midiAutomationInterpolation': 'MIDI 自动化插值',
|
||||
'settings.behavior.midiAutomationInterpolation.low': '低配(20 ms)',
|
||||
'settings.behavior.midiAutomationInterpolation.balanced': '平衡(10 ms)',
|
||||
'settings.behavior.midiAutomationInterpolation.high': '高质量(5 ms)',
|
||||
'settings.behavior.midiAutomationInterpolationHelp': '控制在播放和导出时将 MIDI 自动化烘焙得多密集。间隔越小声音越平滑,但会调度更多事件。',
|
||||
'settings.behavior.midiInputLatency': 'MIDI 输入延迟(ms)',
|
||||
'settings.behavior.midiInputLatencyHelp': '用于 MIDI 录音的时间校正(0-500ms)。如果录下来的音符相比您实际想要演奏的位置略晚,可以增加此值以匹配您的 MIDI 设备输入延迟。每个音符在提交时都会向前回移这段时间。',
|
||||
'settings.behavior.bounceStartsFromBeat1': '导出从第 1 拍开始',
|
||||
'settings.behavior.bounceStartsFromBeat1Help': '选择“是”时,导出 WAV/MP3 会包含从歌曲开头到第一个被渲染区域之前的前导静音。选择“否”时,会裁掉这段前导静音,并从第一个被渲染的音符或音频区域开始导出。',
|
||||
'settings.behavior.captureAudioForScreenSharing': '为屏幕共享捕获音频',
|
||||
'settings.behavior.captureAudioForScreenSharingHelp': '需要重启 KGStudio(刷新页面)后生效。当在视频会议中共享屏幕时,如果 KGStudio 的音频无法被捕获,请启用此选项(例如 Zoom、Teams)。这会创建一个额外的音频流,供屏幕捕获应用检测。',
|
||||
'settings.behavior.captureAudioForScreenSharingWarning': '在 Zoom 中共享屏幕时,请务必确认已启用“共享声音”选项。',
|
||||
'settings.behavior.validation.lookahead.invalidNumber': '前瞻时间必须是有效数字',
|
||||
'settings.behavior.validation.lookahead.range': '前瞻时间必须在 0 到 0.5 秒之间(0-500ms)',
|
||||
'settings.behavior.validation.playbackDelay.invalidNumber': '播放延迟必须是有效数字',
|
||||
'settings.behavior.validation.playbackDelay.range': '播放延迟必须在 0 到 0.5 秒之间(0-500ms)',
|
||||
'settings.behavior.validation.recordingOffset.invalidNumber': 'MIDI 输入延迟必须是有效数字',
|
||||
'settings.behavior.validation.recordingOffset.range': 'MIDI 输入延迟必须在 0 到 0.5 秒之间(0-500ms)',
|
||||
'settings.templates.title': '模板',
|
||||
'settings.templates.customInstructions': '自定义指令',
|
||||
'settings.templates.placeholder': '请输入您为 K.G.Studio 音乐创作助手准备的自定义指令',
|
||||
'settings.chordGuide.title': '和弦指导',
|
||||
'settings.chordGuide.resetToDefault': '恢复默认',
|
||||
'settings.chordGuide.group.major': '大调候选和弦',
|
||||
'settings.chordGuide.group.minor': '小调候选和弦',
|
||||
'settings.chordGuide.help.major': '请输入相对于 C 大调的和弦。应用会自动将其转调到其他大调调号。',
|
||||
'settings.chordGuide.help.minor': '请输入相对于 A 小调的和弦。应用会自动将其转调到其他小调调号。',
|
||||
'settings.chordGuide.functionsAria': '{group} 功能',
|
||||
'settings.chordGuide.addTitle': '添加 {group}',
|
||||
'settings.chordGuide.deleteSelectedRows': '删除所选行',
|
||||
'settings.chordGuide.table.chord': '和弦',
|
||||
'settings.chordGuide.table.notes': '音符',
|
||||
'settings.chordGuide.table.source': '来源',
|
||||
'settings.chordGuide.table.note': '备注',
|
||||
'settings.chordGuide.loadError': '加载和弦指导设置失败。',
|
||||
'settings.chordGuide.invalidChordSymbol': '请输入有效的和弦符号。示例:Bm7b5',
|
||||
'settings.chordGuide.unsupportedChordSymbol': '无法为该和弦推导音符。请使用受支持的和弦符号。',
|
||||
'dialog.title.notice': '提示',
|
||||
'dialog.title.timeSignature': '拍号',
|
||||
'dialog.title.tempoDetection': '速度检测',
|
||||
'dialog.title.applyTempo': '应用速度',
|
||||
'dialog.title.chordDetection': '和弦检测',
|
||||
'dialog.title.input': '输入',
|
||||
'dialog.title.confirm': '确认',
|
||||
'dialog.close': '关闭对话框',
|
||||
'dialog.cancel': '取消',
|
||||
'dialog.ok': '确定',
|
||||
'dialog.experimentalFeature': '实验性功能',
|
||||
'dialog.recommendedSource': '推荐源素材',
|
||||
'dialog.chordHint.audio': '和弦分析仍属实验性功能。和声内容、编配密度以及瞬态较强的素材都可能影响准确率。建议先使用默认设置,再逐步微调灵敏度和稳定性,让检测结果更贴近实际乐句。',
|
||||
'dialog.chordHint.midi': '和弦分析仍属实验性功能。和弦堆叠密度、重叠以及装饰音都会影响结果。建议先使用默认设置,再调整短音抑制和和声关注方式,以匹配该段音乐的和声作用。',
|
||||
'dialog.chordHint.tempo': '速度分析仍属实验性功能。自由速度、瞬态稀疏以及多层打击乐都可能降低准确率。建议先使用默认 BPM 范围,如果第一次结果不理想,再把分析范围收窄到更可能的速度区间。',
|
||||
'dialog.sourceHint.kgone': '为了获得更可靠的和弦标签,建议分析已经削弱或移除了人声和打击乐的 stem。如果已启用 K.G.One Music Studio 服务器集成,请使用带有 “Vocal, Drums, Bass, Guitar, Piano, and Others” 的 Separator 模型,并优先分析 Piano 或 Others stem。',
|
||||
'dialog.sourceHint.local': '为了获得更可靠的和弦标签,建议分析已经削弱或移除了人声和打击乐的 stem。如果您使用本地分轨器,请选择 “Vocal, Drums, Bass, and Others” 模型,并使用 Others stem 进行分析。',
|
||||
'dialog.label.sensitivity': '灵敏度',
|
||||
'dialog.label.stability': '稳定性',
|
||||
'dialog.label.noChordThreshold': '无和弦阈值',
|
||||
'dialog.label.enableSevenths': '和弦细节:启用七和弦',
|
||||
'dialog.label.shortNotes': '短音',
|
||||
'dialog.option.suppressionLow': '低抑制',
|
||||
'dialog.option.suppressionMedium': '中抑制',
|
||||
'dialog.option.suppressionHigh': '高抑制',
|
||||
'dialog.label.harmonicFocus': '和声关注',
|
||||
'dialog.option.harmonicBalanced': '均衡',
|
||||
'dialog.option.harmonicSustained': '偏向持续音',
|
||||
'dialog.label.minimumBpm': '最小 BPM',
|
||||
'dialog.label.maximumBpm': '最大 BPM',
|
||||
'dialog.label.autoAlignRegionToBeat': '自动将片段对齐到拍点',
|
||||
'dialog.message.chordDetection': '在处理前调整音频和弦检测设置。',
|
||||
'dialog.message.tempoDetection': '在处理前调整音频速度检测设置。',
|
||||
'dialog.message.tempoApply': '检测到速度:{bpm} BPM。请选择应用方式。\n\n"更新当前速度"将修改此片段位置的有效速度。"插入速度变更"将在片段起始点前最近的小节处添加新的速度区域。',
|
||||
'dialog.action.updateCurrentTempo': '更新当前速度',
|
||||
'dialog.action.insertTempoChange': '插入速度变更',
|
||||
'eventList.title': '事件列表',
|
||||
'eventList.scopeTabs': '事件列表范围',
|
||||
'eventList.scope.region': '区域',
|
||||
'eventList.scope.track': '音轨',
|
||||
'eventList.scope.global': '全局',
|
||||
'eventList.deleteVisibleSelectedRows': '删除当前可见的已选行',
|
||||
'eventList.table.position': '位置',
|
||||
'eventList.table.status': '状态',
|
||||
'eventList.table.num': '编号',
|
||||
'eventList.table.val': '数值',
|
||||
'eventList.table.lengthInfo': '长度/信息',
|
||||
'eventList.region.filters': '区域事件筛选',
|
||||
'eventList.region.filter.notes': '音符',
|
||||
'eventList.region.filter.pitchBends': '弯音',
|
||||
'eventList.region.filter.controller': '控制器',
|
||||
'eventList.region.empty': '请选择一个 MIDI 区域,或先在钢琴卷帘中打开一个区域,再查看事件列表。',
|
||||
'eventList.region.add.label': '添加',
|
||||
'eventList.region.add.noteTitle': '在播放头位置添加音符',
|
||||
'eventList.region.add.pitchBendTitle': '在播放头位置添加弯音',
|
||||
'eventList.region.add.controllerTitle': '在播放头位置添加控制器事件',
|
||||
'eventList.region.addType.note': '音符',
|
||||
'eventList.region.addType.pitchBend': '弯音',
|
||||
'eventList.region.addType.controller': '控制器',
|
||||
'eventList.region.status.note': '音符',
|
||||
'eventList.region.status.pitchBend': '弯音',
|
||||
'eventList.region.status.controller': '控制器',
|
||||
'eventList.track.filters': '音轨列表模式',
|
||||
'eventList.track.filter.regions': '区域',
|
||||
'eventList.track.filter.volume': '音量',
|
||||
'eventList.track.filter.pan': '声像',
|
||||
'eventList.track.empty': '请选择一条音轨以查看区域和音轨自动化。',
|
||||
'eventList.track.add.label': '添加',
|
||||
'eventList.track.add.midiRegionTitle': '在播放头位置添加 MIDI 区域',
|
||||
'eventList.track.add.volumeTitle': '在播放头位置添加音量自动化点',
|
||||
'eventList.track.add.panTitle': '在播放头位置添加声像自动化点',
|
||||
'eventList.track.addType.midiRegion': 'MIDI 区域',
|
||||
'eventList.track.status.audio': '音频',
|
||||
'eventList.track.status.midi': 'MIDI',
|
||||
'eventList.track.status.volume': '音量',
|
||||
'eventList.track.status.pan': '声像',
|
||||
'eventList.global.filters': '全局事件筛选',
|
||||
'eventList.global.filter.marker': '标记',
|
||||
'eventList.global.filter.tempo': '速度',
|
||||
'eventList.global.filter.keySignature': '调号',
|
||||
'eventList.global.filter.chord': '和弦',
|
||||
'eventList.global.add.label': '添加',
|
||||
'eventList.global.add.markerTitle': '在播放头位置添加标记区段',
|
||||
'eventList.global.add.tempoTitle': '在播放头位置添加速度区段',
|
||||
'eventList.global.add.keySignatureTitle': '在播放头位置添加调号区段',
|
||||
'eventList.global.add.chordTitle': '在播放头位置添加和弦区段',
|
||||
'eventList.global.addType.marker': '标记',
|
||||
'eventList.global.addType.tempo': '速度',
|
||||
'eventList.global.addType.keySignature': '调号',
|
||||
'eventList.global.addType.chord': '和弦',
|
||||
'eventList.global.status.marker': '标记',
|
||||
'eventList.global.status.tempo': '速度',
|
||||
'eventList.global.status.keySignature': '调号',
|
||||
'eventList.global.status.chord': '和弦',
|
||||
'eventList.global.validation.marker': '请输入标记标签。需要非空文本标签。示例:前奏',
|
||||
'eventList.global.validation.tempo': '请输入仅包含数字的 BPM 值。需要介于 {min} 和 {max} 之间的整数。示例:128',
|
||||
'eventList.global.validation.keySignature': '请输入一个精确的调号名称。需要规范值,例如 “C major” 或 “F# minor”。示例:F# minor',
|
||||
'eventList.global.validation.chord': '请输入有效的和弦符号。需要应用可解析的和弦表示。示例:Bm7b5',
|
||||
'eventList.global.validation.position': '请输入项目起点或之后的位置。需要非负位置。示例:1 1 0',
|
||||
'eventList.global.validation.length': '请输入正长度。需要大于零的时值。示例:4 0',
|
||||
'globalTracks.label': '全局轨道',
|
||||
'globalTracks.marker': '标记',
|
||||
'globalTracks.tempo': '速度',
|
||||
'globalTracks.signature': '调号',
|
||||
'globalTracks.chord': '和弦',
|
||||
'globalTracks.addItem': '添加 {label} 全局轨道项目',
|
||||
'pianoRoll.power.linear': '线性',
|
||||
'pianoRoll.power.sqrtDefault': '√(默认)',
|
||||
'pianoRoll.power.mild': '轻度',
|
||||
'pianoRoll.power.strong': '强烈',
|
||||
'pianoRoll.chordGuide.off': '关闭和弦指导',
|
||||
'pianoRoll.chordGuide.tonic': '主功能和弦指导',
|
||||
'pianoRoll.chordGuide.subdominant': '下属功能和弦指导',
|
||||
'pianoRoll.chordGuide.dominant': '属功能和弦指导',
|
||||
'pianoRoll.spectrogramView': '频谱视图',
|
||||
'pianoRoll.sheetMusicView': '五线谱视图',
|
||||
'pianoRoll.pointerTool': '指针工具',
|
||||
'pianoRoll.pencilTool': '铅笔工具',
|
||||
'pianoRoll.toggleAutomationLane': '切换自动化轨',
|
||||
'pianoRoll.automation': '自动化',
|
||||
'pianoRoll.automationLane': '{label} 自动化轨',
|
||||
'pianoRoll.automationType.pitchBend': '弯音',
|
||||
'pianoRoll.automationType.cc1': 'CC1',
|
||||
'pianoRoll.automationType.cc2': 'CC2',
|
||||
'pianoRoll.automationType.cc7': 'CC7',
|
||||
'pianoRoll.automationType.cc11': 'CC11',
|
||||
'pianoRoll.automationType.cc64': 'CC64',
|
||||
'pianoRoll.mode': '调式',
|
||||
'pianoRoll.modeOption.ionian': '伊奥尼亚',
|
||||
'pianoRoll.modeOption.dorian': '多利亚',
|
||||
'pianoRoll.modeOption.phrygian': '弗里吉亚',
|
||||
'pianoRoll.modeOption.lydian': '利底亚',
|
||||
'pianoRoll.modeOption.mixolydian': '混合利底亚',
|
||||
'pianoRoll.modeOption.aeolian': '爱奥利亚',
|
||||
'pianoRoll.modeOption.locrian': '洛克里亚',
|
||||
'pianoRoll.modeOption.harmonic_minor': '和声小调',
|
||||
'pianoRoll.modeOption.melodic_minor': '旋律小调',
|
||||
'pianoRoll.modeOption.phrygian_dominant': '弗里吉亚属调式',
|
||||
'pianoRoll.modeOption.harmonic_major': '和声大调',
|
||||
'pianoRoll.chordGuide': '和弦指导',
|
||||
'pianoRoll.sheetQuantization': '谱面量化',
|
||||
'pianoRoll.snap': '吸附',
|
||||
'pianoRoll.snap.none': '无吸附',
|
||||
'pianoRoll.quantizePositionCompact': '位置量化',
|
||||
'pianoRoll.quantizeLengthCompact': '长度量化',
|
||||
'pianoRoll.floor': '底噪',
|
||||
'pianoRoll.curve': '曲线',
|
||||
'pianoRoll.zoom': '缩放',
|
||||
'pianoRoll.moreOptions': '更多选项',
|
||||
'pianoRoll.quantize.1/1': '1/1',
|
||||
'pianoRoll.quantize.1/2': '1/2',
|
||||
'pianoRoll.quantize.1/3': '1/3',
|
||||
'pianoRoll.quantize.1/4': '1/4',
|
||||
'pianoRoll.quantize.1/6': '1/6',
|
||||
'pianoRoll.quantize.1/8': '1/8',
|
||||
'pianoRoll.quantize.1/12': '1/12',
|
||||
'pianoRoll.quantize.1/16': '1/16',
|
||||
'pianoRoll.quantize.1/24': '1/24',
|
||||
'pianoRoll.quantize.1/32': '1/32',
|
||||
'pianoRoll.showActiveRegionOnly': '仅显示当前区域',
|
||||
'pianoRoll.showEntireTrack': '显示整条音轨',
|
||||
'pianoRoll.detectChords': '检测和弦...',
|
||||
'pianoRoll.detectingChords': '正在检测和弦...',
|
||||
'pianoRoll.detectTempo': '检测速度...',
|
||||
'pianoRoll.detectingTempo': '正在检测速度...',
|
||||
'percussion.short.35': '原底鼓',
|
||||
'percussion.short.36': '底鼓',
|
||||
'percussion.short.37': '边击',
|
||||
'percussion.short.38': '原军鼓',
|
||||
'percussion.short.39': '拍手',
|
||||
'percussion.short.40': '电军鼓',
|
||||
'percussion.short.41': '低落地鼓',
|
||||
'percussion.short.42': '闭镲',
|
||||
'percussion.short.43': '高落地鼓',
|
||||
'percussion.short.44': '踏镲',
|
||||
'percussion.short.45': '低嗵鼓',
|
||||
'percussion.short.46': '开镲',
|
||||
'percussion.short.47': '中低嗵鼓',
|
||||
'percussion.short.48': '中高嗵鼓',
|
||||
'percussion.short.49': '碎音镲1',
|
||||
'percussion.short.50': '高嗵鼓',
|
||||
'percussion.short.51': '叮叮镲1',
|
||||
'percussion.short.52': '中国镲',
|
||||
'percussion.short.53': '叮叮镲铃',
|
||||
'percussion.short.54': '铃鼓',
|
||||
'percussion.short.55': '飞溅镲',
|
||||
'percussion.short.56': '牛铃',
|
||||
'percussion.short.57': '碎音镲2',
|
||||
'percussion.short.58': '振响器',
|
||||
'percussion.short.59': '叮叮镲2',
|
||||
'percussion.short.60': '高邦戈',
|
||||
'percussion.short.61': '低邦戈',
|
||||
'percussion.short.62': '闷高康加',
|
||||
'percussion.short.63': '开高康加',
|
||||
'percussion.short.64': '低康加',
|
||||
'percussion.short.65': '高廷巴勒',
|
||||
'percussion.short.66': '低廷巴勒',
|
||||
'percussion.short.67': '高阿哥哥',
|
||||
'percussion.short.68': '低阿哥哥',
|
||||
'percussion.short.69': '卡巴萨',
|
||||
'percussion.short.70': '沙锤',
|
||||
'percussion.short.71': '短哨',
|
||||
'percussion.short.72': '长哨',
|
||||
'percussion.short.73': '短刮瓜',
|
||||
'percussion.short.74': '长刮瓜',
|
||||
'percussion.short.75': '响棒',
|
||||
'percussion.short.76': '高木块',
|
||||
'percussion.short.77': '低木块',
|
||||
'percussion.short.78': '闷库卡',
|
||||
'percussion.short.79': '开库卡',
|
||||
'percussion.short.80': '闷三角铁',
|
||||
'percussion.short.81': '开三角铁',
|
||||
'percussion.full.35': '原声底鼓',
|
||||
'percussion.full.36': '底鼓 1',
|
||||
'percussion.full.37': '边击',
|
||||
'percussion.full.38': '原声军鼓',
|
||||
'percussion.full.39': '拍手',
|
||||
'percussion.full.40': '电军鼓',
|
||||
'percussion.full.41': '低落地嗵鼓',
|
||||
'percussion.full.42': '闭合踩镲',
|
||||
'percussion.full.43': '高落地嗵鼓',
|
||||
'percussion.full.44': '踏踩镲',
|
||||
'percussion.full.45': '低嗵鼓',
|
||||
'percussion.full.46': '开放踩镲',
|
||||
'percussion.full.47': '中低嗵鼓',
|
||||
'percussion.full.48': '中高嗵鼓',
|
||||
'percussion.full.49': '碎音镲 1',
|
||||
'percussion.full.50': '高嗵鼓',
|
||||
'percussion.full.51': '叮叮镲 1',
|
||||
'percussion.full.52': '中国镲',
|
||||
'percussion.full.53': '叮叮镲铃',
|
||||
'percussion.full.54': '铃鼓',
|
||||
'percussion.full.55': '飞溅镲',
|
||||
'percussion.full.56': '牛铃',
|
||||
'percussion.full.57': '碎音镲 2',
|
||||
'percussion.full.58': '振响器',
|
||||
'percussion.full.59': '叮叮镲 2',
|
||||
'percussion.full.60': '高邦戈',
|
||||
'percussion.full.61': '低邦戈',
|
||||
'percussion.full.62': '闷高康加',
|
||||
'percussion.full.63': '开高康加',
|
||||
'percussion.full.64': '低康加',
|
||||
'percussion.full.65': '高廷巴勒',
|
||||
'percussion.full.66': '低廷巴勒',
|
||||
'percussion.full.67': '高阿哥哥',
|
||||
'percussion.full.68': '低阿哥哥',
|
||||
'percussion.full.69': '卡巴萨',
|
||||
'percussion.full.70': '沙锤',
|
||||
'percussion.full.71': '短哨',
|
||||
'percussion.full.72': '长哨',
|
||||
'percussion.full.73': '短刮瓜',
|
||||
'percussion.full.74': '长刮瓜',
|
||||
'percussion.full.75': '响棒',
|
||||
'percussion.full.76': '高木块',
|
||||
'percussion.full.77': '低木块',
|
||||
'percussion.full.78': '闷库卡',
|
||||
'percussion.full.79': '开库卡',
|
||||
'percussion.full.80': '闷三角铁',
|
||||
'percussion.full.81': '开三角铁',
|
||||
'toolbar.export.kgstudio': '导出为 KGStudio 文件',
|
||||
'toolbar.export.midi': '导出为 MIDI 文件',
|
||||
'toolbar.export.wav': '导出为 WAV',
|
||||
'toolbar.export.mp3': '导出为 MP3',
|
||||
'toolbar.projectName.prompt': '输入项目名称:',
|
||||
'toolbar.projectName.invalid': '无效的项目名称。只允许字母、数字、空格、连字符、下划线、句点和括号。',
|
||||
'toolbar.projectName.reserved': '"{name}" 是保留项目名称。请选择其他名称。',
|
||||
'toolbar.projectName.overwrite': '项目 “{name}” 已存在。要覆盖它吗?',
|
||||
'toolbar.projectName.renameOrCopy': '您想重命名这个项目,还是另存为一个副本?',
|
||||
'toolbar.projectName.saveAsCopy': '另存为副本',
|
||||
'toolbar.projectName.rename': '重命名',
|
||||
'toolbar.save.error': '保存时发生错误:{error}',
|
||||
'toolbar.load.error': '加载项目时发生错误:{error}',
|
||||
'toolbar.newProject.confirm': '确定要创建新项目吗?当前未保存的更改将会丢失。',
|
||||
'toolbar.project.notFound': '未找到项目 “{name}”。',
|
||||
'toolbar.openProject.confirm': '要打开这个项目吗?当前项目中未保存的更改将会丢失。',
|
||||
'toolbar.status.loadFailed': '加载项目失败:{error}',
|
||||
'toolbar.status.newProjectCreated': '已创建新项目 “{name}”',
|
||||
'toolbar.status.projectLoaded': '{description} 加载成功',
|
||||
'toolbar.status.projectExportedKgstudio': '项目 “{name}” 已导出为 KGStudio 文件',
|
||||
'toolbar.status.exportProjectError': '导出项目出错:{error}',
|
||||
'toolbar.export.failedProject': '导出项目失败:{error}',
|
||||
'toolbar.status.projectExportedMidi': '项目 “{name}” 已导出为 MIDI 文件',
|
||||
'toolbar.status.exportMidiError': '导出 MIDI 出错:{error}',
|
||||
'toolbar.export.failedMidi': '导出项目为 MIDI 失败:{error}',
|
||||
'toolbar.status.projectExportedWav': '项目 “{name}” 已导出为 WAV 文件',
|
||||
'toolbar.status.exportWavError': '导出 WAV 出错:{error}',
|
||||
'toolbar.export.failedWav': '导出项目为 WAV 失败:{error}',
|
||||
'toolbar.status.projectExportedMp3': '项目 “{name}” 已导出为 MP3 文件',
|
||||
'toolbar.status.exportMp3Error': '导出 MP3 出错:{error}',
|
||||
'toolbar.export.failedMp3': '导出项目为 MP3 失败:{error}',
|
||||
'toolbar.status.importFailed': '导入文件失败:{error}',
|
||||
'toolbar.import.failedProjectFile': '导入项目文件失败:{error}',
|
||||
'toolbar.import.corruptedKgstudio': '.kgstudio 文件已损坏或无效:{error}',
|
||||
'toolbar.status.importingMidi': '正在导入 MIDI 文件 “{name}”...',
|
||||
'toolbar.status.failedImportMidi': '导入 MIDI 文件失败:{error}',
|
||||
'toolbar.status.playbackFailedStart': '播放启动失败',
|
||||
'toolbar.status.recordingStoppedCommitted': '录音已停止 - 音符已提交',
|
||||
'toolbar.status.failedStopPlayback': '停止播放失败',
|
||||
'toolbar.maxBars.prompt': '输入新的最大小节数(>= {min}):',
|
||||
'toolbar.input.invalidNumber': '输入无效。请输入有效数字。',
|
||||
'toolbar.maxBars.invalid': '数值无效。请输入一个 >= {min} 的数字。',
|
||||
'toolbar.status.maxBarsChanged': '最大小节数已改为 {value}',
|
||||
'toolbar.bpm.prompt': '输入新的 BPM({min}-{max}):',
|
||||
'toolbar.bpm.invalid': 'BPM 无效。请输入 {min} 到 {max} 之间的值。',
|
||||
'toolbar.status.bpmChanged': 'BPM 已改为 {value}',
|
||||
'toolbar.timeSignature.prompt': '设置拍号:',
|
||||
'toolbar.status.timeSignatureChanged': '拍号已改为 {value}',
|
||||
'toolbar.status.keySignatureChanged': '调号已改为 {value}',
|
||||
'toolbar.status.copied': '项目已复制到剪贴板',
|
||||
'toolbar.status.copyNone': '没有可复制的已选项目',
|
||||
'toolbar.status.pasted': '已从剪贴板粘贴项目',
|
||||
'toolbar.status.pasteFailed': '无法粘贴 - 没有有效的剪贴板内容或上下文',
|
||||
'toolbar.status.deleted': '已删除所选区域',
|
||||
'toolbar.status.deleteNone': '没有选中可删除的区域',
|
||||
'toolbar.undo.none': '没有可撤销的操作',
|
||||
'toolbar.redo.none': '没有可重做的操作',
|
||||
'toolbar.status.undid': '已撤销:{description}',
|
||||
'toolbar.status.redid': '已重做:{description}',
|
||||
'toolbar.action': '操作',
|
||||
'toolbar.status.chatToggled': '聊天窗口已切换',
|
||||
'toolbar.status.settingsToggled': '设置面板已切换',
|
||||
'toolbar.pianoRoll.selectMidiRegion': '请先选择一个 MIDI 区域再打开钢琴卷帘。',
|
||||
'toolbar.status.recordingStopped': '录音已停止',
|
||||
'toolbar.status.audioRecordingStarted': '音频录音已开始...',
|
||||
'toolbar.recording.openMidiRegion': '开始录音前,请先在钢琴卷帘中打开一个 MIDI 区域。',
|
||||
'toolbar.recording.selectMidiRegion': '开始录音前,请先选择一个 MIDI 区域。',
|
||||
'toolbar.recording.noMidiDevice': '未检测到 MIDI 设备。请连接 MIDI 键盘后重试。',
|
||||
'toolbar.status.recordingStarted': '录音已开始...',
|
||||
'toolbar.button.new': '新建',
|
||||
'toolbar.button.load': '打开',
|
||||
'toolbar.button.save': '保存',
|
||||
'toolbar.button.export': '导出',
|
||||
'toolbar.button.import': '导入',
|
||||
'toolbar.button.settings': '设置',
|
||||
'toolbar.button.undo': '撤销',
|
||||
'toolbar.button.redo': '重做',
|
||||
'toolbar.button.select': '选择',
|
||||
'toolbar.button.pencil': '铅笔',
|
||||
'toolbar.button.splitRegion': '在播放头处分割区域',
|
||||
'toolbar.button.mergeRegions': '合并选中的 MIDI 区域',
|
||||
'toolbar.button.snapToGrid': '吸附到网格',
|
||||
'toolbar.button.copy': '复制',
|
||||
'toolbar.button.paste': '粘贴',
|
||||
'toolbar.button.delete': '删除',
|
||||
'toolbar.button.backToBeginning': '回到开头',
|
||||
'toolbar.button.play': '播放',
|
||||
'toolbar.button.pause': '暂停',
|
||||
'toolbar.button.stopRecording': '停止录音',
|
||||
'toolbar.button.record': '录音',
|
||||
'toolbar.button.loop': '循环',
|
||||
'toolbar.button.metronome': '节拍器',
|
||||
'toolbar.button.piano': '钢琴',
|
||||
'toolbar.button.kgone': 'K.G.One 音乐生成器',
|
||||
'toolbar.button.chat': '聊天',
|
||||
'toolbar.button.eventList': '事件列表编辑器',
|
||||
'track.controls.automationButton': '轨道自动化',
|
||||
'track.controls.automationDropdown': '自动化',
|
||||
'track.controls.automation.volume': '音量',
|
||||
'track.controls.automation.pan': '声像',
|
||||
'track.controls.settingsDropdown': '设置',
|
||||
'track.controls.settings.deleteTrack': '删除轨道',
|
||||
'toolbar.keySignatureChooser': '选择调号,当前为 {value}',
|
||||
'toolbar.export.label': '导出',
|
||||
'toolbar.importProject.title': '导入项目',
|
||||
'toolbar.importProject.description': '将项目文件拖放到这里',
|
||||
'toolbar.openingProject': '正在打开项目...',
|
||||
'fileImport.invalidType': '文件类型无效。请选择以下扩展名之一的文件:{extensions}',
|
||||
'fileImport.close': '关闭导入弹窗',
|
||||
'fileImport.supportedFormats': '支持的格式:{formats}',
|
||||
'fileImport.or': '或',
|
||||
'fileImport.browse': '浏览文件',
|
||||
'instrument.group.PIANO_AND_KEYBOARDS': '钢琴与键盘',
|
||||
'instrument.group.GUITAR': '吉他',
|
||||
'instrument.group.BASS': '贝斯',
|
||||
'instrument.group.STRINGS': '弦乐',
|
||||
'instrument.group.BRASS': '铜管',
|
||||
'instrument.group.WOODWIND': '木管',
|
||||
'instrument.group.PERCUSSION_KIT': '打击乐套件',
|
||||
'instrument.group.SYNTH': '合成器',
|
||||
'instrument.name.acoustic_grand_piano': '原声大钢琴',
|
||||
'instrument.name.bright_acoustic_piano': '明亮原声钢琴',
|
||||
'instrument.name.electric_grand_piano': '电大钢琴',
|
||||
'instrument.name.electric_piano_1': '电钢琴 1',
|
||||
'instrument.name.electric_piano_2': '电钢琴 2',
|
||||
'instrument.name.drawbar_organ': '击杆风琴',
|
||||
'instrument.name.clavinet': '击弦电钢琴',
|
||||
'instrument.name.harpsichord': '羽管键琴',
|
||||
'instrument.name.accordion': '手风琴',
|
||||
'instrument.name.acoustic_guitar_nylon': '原声吉他(尼龙弦)',
|
||||
'instrument.name.acoustic_guitar_steel': '原声吉他(钢弦)',
|
||||
'instrument.name.electric_guitar_clean': '电吉他(清音)',
|
||||
'instrument.name.overdriven_guitar': '电吉他(过载)',
|
||||
'instrument.name.distortion_guitar': '电吉他(失真)',
|
||||
'instrument.name.acoustic_bass': '原声贝斯',
|
||||
'instrument.name.electric_bass_finger': '电贝斯(指弹)',
|
||||
'instrument.name.electric_bass_pick': '电贝斯(拨片)',
|
||||
'instrument.name.slap_bass_1': '击勾贝斯',
|
||||
'instrument.name.synth_bass_1': '合成贝斯 1',
|
||||
'instrument.name.violin': '小提琴',
|
||||
'instrument.name.viola': '中提琴',
|
||||
'instrument.name.cello': '大提琴',
|
||||
'instrument.name.contrabass': '低音提琴',
|
||||
'instrument.name.string_ensemble_1': '弦乐合奏',
|
||||
'instrument.name.string_ensemble_2': '弦乐合奏 2',
|
||||
'instrument.name.pizzicato_strings': '拨奏弦乐',
|
||||
'instrument.name.tremolo_strings': '震音弦乐',
|
||||
'instrument.name.orchestral_harp': '管弦竖琴',
|
||||
'instrument.name.trumpet': '小号',
|
||||
'instrument.name.trombone': '长号',
|
||||
'instrument.name.tuba': '大号',
|
||||
'instrument.name.french_horn': '圆号',
|
||||
'instrument.name.brass_section': '铜管组',
|
||||
'instrument.name.soprano_sax': '高音萨克斯',
|
||||
'instrument.name.alto_sax': '中音萨克斯',
|
||||
'instrument.name.tenor_sax': '次中音萨克斯',
|
||||
'instrument.name.baritone_sax': '上低音萨克斯',
|
||||
'instrument.name.oboe': '双簧管',
|
||||
'instrument.name.english_horn': '英国管',
|
||||
'instrument.name.bassoon': '巴松',
|
||||
'instrument.name.clarinet': '单簧管',
|
||||
'instrument.name.piccolo': '短笛',
|
||||
'instrument.name.flute': '长笛',
|
||||
'instrument.name.shakuhachi': '尺八',
|
||||
'instrument.name.harmonica': '口琴',
|
||||
'instrument.name.standard': '标准鼓组',
|
||||
'instrument.name.orchestra_kit': '管弦打击乐组',
|
||||
'instrument.name.marimba': '马林巴',
|
||||
'instrument.name.timpani': '定音鼓',
|
||||
'instrument.name.taiko_drum': '太鼓',
|
||||
'instrument.name.woodblock': '木鱼',
|
||||
'instrument.name.lead_1_square': '主音 1(方波)',
|
||||
'instrument.name.lead_2_sawtooth': '主音 2(锯齿波)',
|
||||
'instrument.name.pad_1_new_age': '铺底 1(新世纪)',
|
||||
'instrument.name.pad_2_warm': '铺底 2(温暖)',
|
||||
'instrument.name.pad_3_polysynth': '铺底 3(复音合成)',
|
||||
'instrument.name.pad_4_choir': '铺底 4(合唱)',
|
||||
'instrument.name.pad_5_bowed': '铺底 5(弓弦玻璃)',
|
||||
'instrument.name.pad_6_metallic': '铺底 6(金属)',
|
||||
'instrument.name.pad_7_halo': '铺底 7(光晕)',
|
||||
'instrument.name.pad_8_sweep': '铺底 8(扫频)',
|
||||
'instrument.name.fx_1_rain': '特效 1(雨)',
|
||||
'instrument.name.fx_2_soundtrack': '特效 2(配乐)',
|
||||
'instrument.name.fx_3_crystal': '特效 3(水晶)',
|
||||
'instrument.name.fx_4_atmosphere': '特效 4(氛围)',
|
||||
'instrument.name.fx_5_brightness': '特效 5(明亮)',
|
||||
'instrument.name.fx_6_goblins': '特效 6(妖精)',
|
||||
'instrument.name.fx_7_echoes': '特效 7(回声)',
|
||||
'instrument.name.fx_8_scifi': '特效 8(科幻)',
|
||||
// ─── K.G.One / Music Generator Panel ─────────────────────────────────────────
|
||||
'kgone.panel.title.server': 'K.G.One 音乐生成器',
|
||||
'kgone.panel.title.local': '音乐生成器',
|
||||
'kgone.tab.fullSong': '完整歌曲',
|
||||
'kgone.tab.remix': '混音',
|
||||
'kgone.tab.repaint': '重绘',
|
||||
'kgone.tab.separator': '分离',
|
||||
'kgone.tab.requiresServer': '需要 K.G.One Music Studio 服务器集成',
|
||||
// Shared across tabs
|
||||
'kgone.shared.advancedSettings': '高级设置',
|
||||
'kgone.shared.caption': '描述',
|
||||
'kgone.shared.lyrics': '歌词',
|
||||
'kgone.shared.lyricsPlaceholder': '[Verse 1]\n在这里输入歌词...\n\n[Chorus]\n...',
|
||||
'kgone.shared.instrumental': '纯音乐(无人声)',
|
||||
'kgone.shared.inferenceSteps': '推理步数',
|
||||
'kgone.shared.guidanceScale': '引导强度',
|
||||
'kgone.shared.useRandomSeed': '使用随机种子',
|
||||
'kgone.shared.seed': '种子',
|
||||
'kgone.shared.thinking': '思考模式(CoT 元数据生成)',
|
||||
'kgone.shared.selectedRegion': '已选区域',
|
||||
'kgone.shared.track': '音轨',
|
||||
'kgone.shared.poweredBy': '技术支持:',
|
||||
'kgone.shared.btn.loadingModel': '加载模型中...',
|
||||
'kgone.shared.btn.generating': '生成中...',
|
||||
'kgone.shared.btn.processing': '处理中...',
|
||||
'kgone.shared.btn.downloading': '下载中...',
|
||||
'kgone.shared.btn.preparingUpload': '准备上传...',
|
||||
'kgone.shared.btn.importing': '导入中...',
|
||||
'kgone.shared.hint.loadingModel': '正在加载模型——此过程可能需要 60 秒以上,请耐心等待...',
|
||||
'kgone.shared.hint.submitting': '正在提交生成请求...',
|
||||
'kgone.shared.hint.downloadingAudio': '正在下载音频...',
|
||||
// Clip tab
|
||||
'kgone.clip.field.prompt': '提示词',
|
||||
'kgone.clip.field.promptPlaceholder': '例:粗糙, 酸性, 贝斯线, 303, 合成主音, FM, 次低音, 中高频, 高频相位, 高频混响, 弯音, 8小节, 140 BPM, E 小调',
|
||||
'kgone.clip.field.promptHint': '用逗号分隔的标签描述片段:乐器类别、子类型、音色、效果、小节数、BPM、调性。',
|
||||
'kgone.clip.field.negativePrompt': '负向提示词',
|
||||
'kgone.clip.field.negativePromptPlaceholder': '例:失真, 噪音',
|
||||
'kgone.clip.field.bars': '小节数',
|
||||
'kgone.clip.field.bars4': '4 小节',
|
||||
'kgone.clip.field.bars8': '8 小节',
|
||||
'kgone.clip.field.note': '音符',
|
||||
'kgone.clip.field.scale': '音阶',
|
||||
'kgone.clip.field.scaleMajor': '大调',
|
||||
'kgone.clip.field.scaleMinor': '小调',
|
||||
'kgone.clip.field.bpm': 'BPM',
|
||||
'kgone.clip.field.steps': '步数',
|
||||
'kgone.clip.field.cfgScale': 'CFG 强度',
|
||||
'kgone.clip.field.seedLabel': '种子(-1 = 随机)',
|
||||
'kgone.clip.field.samplerType': '采样器类型',
|
||||
'kgone.clip.field.sigmaMin': 'Sigma 最小值',
|
||||
'kgone.clip.field.sigmaMax': 'Sigma 最大值',
|
||||
'kgone.clip.field.cfgRescale': 'CFG 重缩放',
|
||||
'kgone.clip.hint.generating': '正在生成片段...',
|
||||
'kgone.clip.hint.drag': '将上方播放器拖至音轨以导入片段。拖到<strong>音频音轨</strong>可作为 WAV 区域导入(推荐),拖到<strong>MIDI 音轨</strong>可作为 MIDI 区域导入。注意:MIDI 是从音频转录的,可能不完全准确。',
|
||||
'kgone.clip.btn.generate': '生成片段',
|
||||
'kgone.clip.poweredBy': 'Foundation-1',
|
||||
// Full Song tab
|
||||
'kgone.fullSong.field.captionPlaceholder': '例:风格:欧洲舞曲,90年代流行电子。感觉:朗朗上口、充满活力... 速度:约 130 BPM。编排:强劲底鼓、欧洲舞曲贝斯线...',
|
||||
'kgone.fullSong.field.captionHint': '用自然语言描述歌曲风格、情绪、速度、编排和结构。',
|
||||
'kgone.fullSong.field.lyricsHint': '使用 [Intro]、[Verse]、[Chorus]、[Bridge] 标签标注段落。',
|
||||
'kgone.fullSong.hint.generating': '生成中...',
|
||||
'kgone.fullSong.hint.generatingProgress': '生成中... {pct}% — {stage}',
|
||||
'kgone.fullSong.hint.generatingProgressNoStage': '生成中... {pct}%',
|
||||
'kgone.fullSong.hint.drag': '将上方播放器拖至<strong>音频音轨</strong>以导入歌曲。完整歌曲生成不支持拖至 MIDI 音轨。',
|
||||
'kgone.fullSong.btn.generate': '生成歌曲',
|
||||
'kgone.fullSong.poweredBy': 'ACE-Step 1.5',
|
||||
// Remix tab
|
||||
'kgone.remix.field.captionPlaceholder': '例:风格:爵士,感觉:流畅悠扬,编排:钢琴、立式贝斯、刷式鼓...',
|
||||
'kgone.remix.field.captionHint': '描述混音的目标风格、情绪和编排。',
|
||||
'kgone.remix.field.lyricsHint': '留空则保留原歌词,或提供新歌词。使用 [Verse]、[Chorus]、[Bridge] 标签。',
|
||||
'kgone.remix.field.coverStrength': '翻唱强度',
|
||||
'kgone.remix.field.coverStrengthHint': '0 = 创意改编,1 = 忠实于原曲结构',
|
||||
'kgone.remix.field.noiseStrength': '噪声强度',
|
||||
'kgone.remix.field.noiseStrengthHint': '0 = 纯风格迁移,推荐 0.1–0.25',
|
||||
'kgone.remix.hint.submitting': '正在提交混音请求...',
|
||||
'kgone.remix.hint.generating': '正在生成混音...',
|
||||
'kgone.remix.hint.generatingProgress': '正在生成混音... {pct}% — {stage}',
|
||||
'kgone.remix.hint.generatingProgressNoStage': '正在生成混音... {pct}%',
|
||||
'kgone.remix.hint.drag': '将上方播放器拖至<strong>音频音轨</strong>以导入混音。',
|
||||
'kgone.remix.hint.noRegion': '请在时间轴上选择一个音频区域以进行混音。仅支持音频区域——MIDI 区域不可混音。',
|
||||
'kgone.remix.btn.generate': '生成混音',
|
||||
'kgone.remix.btn.processingRemix': '处理混音中...',
|
||||
'kgone.remix.btn.importAligned': '对齐源区域并导入',
|
||||
'kgone.remix.poweredBy': 'ACE-Step 1.5',
|
||||
// Repaint tab
|
||||
'kgone.repaint.field.repaintRange': '重绘范围',
|
||||
'kgone.repaint.field.loopModeOff': '循环模式已关闭。请启用工具栏上的循环按钮并设置循环范围以定义重绘窗口。',
|
||||
'kgone.repaint.field.start': '开始',
|
||||
'kgone.repaint.field.end': '结束',
|
||||
'kgone.repaint.field.untilEnd': '直至音频末尾',
|
||||
'kgone.repaint.field.captionPlaceholder': '例:风格:爵士,感觉:流畅悠扬,编排:钢琴、立式贝斯、刷式鼓...',
|
||||
'kgone.repaint.field.captionHint': '描述重绘段落的目标风格和编排。',
|
||||
'kgone.repaint.field.lyricsHint': '留空则保留原歌词,或为重绘段落提供新歌词。',
|
||||
'kgone.repaint.field.repaintStrength': '重绘强度',
|
||||
'kgone.repaint.field.repaintStrengthHint': '0 = 保留原音,1 = 完全重新生成',
|
||||
'kgone.repaint.hint.submitting': '正在提交重绘请求...',
|
||||
'kgone.repaint.hint.generating': '正在生成重绘...',
|
||||
'kgone.repaint.hint.generatingProgress': '正在生成重绘... {pct}% — {stage}',
|
||||
'kgone.repaint.hint.generatingProgressNoStage': '正在生成重绘... {pct}%',
|
||||
'kgone.repaint.hint.drag': '将上方播放器拖至<strong>音频音轨</strong>以导入重绘结果。',
|
||||
'kgone.repaint.hint.noRegion': '请在时间轴上选择一个音频区域以进行重绘。仅支持音频区域——MIDI 区域不可重绘。',
|
||||
'kgone.repaint.btn.generate': '生成重绘',
|
||||
'kgone.repaint.btn.processingRepaint': '处理重绘中...',
|
||||
'kgone.repaint.btn.importAligned': '对齐源区域并导入',
|
||||
'kgone.repaint.poweredBy': 'ACE-Step 1.5',
|
||||
// Separator tab
|
||||
'kgone.separator.field.separationModel': '分离模型',
|
||||
'kgone.separator.field.chunkDuration': '可选音频分块时长(秒)',
|
||||
'kgone.separator.field.chunkDurationPlaceholder': '留空则处理整个区域',
|
||||
'kgone.separator.field.modelOverlap': '模型重叠',
|
||||
'kgone.separator.hint.drag': '将上方各音轨播放器拖至<strong>音频音轨</strong>以导入。不支持拖至 MIDI 音轨进行音轨分离。',
|
||||
'kgone.separator.hint.noRegion.download': '请下载 {model},然后在时间轴上选择一个音频区域以提取音轨。',
|
||||
'kgone.separator.hint.noRegion.select': '请在时间轴上选择一个音频区域以提取音轨。仅支持音频区域——MIDI 区域不可分离。',
|
||||
'kgone.separator.btn.separateStems': '分离音轨',
|
||||
'kgone.separator.btn.importAllStems': '将所有音轨导入时间轴',
|
||||
'kgone.separator.poweredBy': 'UVR5 CLI',
|
||||
'kgone.separator.model.vocalInstMedium': '人声与乐器(中等精度)',
|
||||
'kgone.separator.model.vocalInstHigh': '人声与乐器(高精度)',
|
||||
'kgone.separator.model.6stem': '人声、鼓、贝斯、吉他、钢琴及其他',
|
||||
'kgone.separator.model.htdemucs4s': '人声、鼓、贝斯及其他',
|
||||
'kgone.separator.btn.server.loadingModel': '加载模型中...',
|
||||
'kgone.separator.btn.server.generating': '准备上传...',
|
||||
'kgone.separator.btn.server.polling': '正在分离音轨...',
|
||||
'kgone.separator.btn.server.downloading': '下载中...',
|
||||
'kgone.separator.btn.local.loadingModel': '准备本地模型...',
|
||||
'kgone.separator.btn.local.generating': '准备音频...',
|
||||
'kgone.separator.btn.local.polling': '本地分离中...',
|
||||
'kgone.separator.btn.local.downloading': '收尾处理...',
|
||||
'kgone.separator.local.title': '本地分离模式',
|
||||
'kgone.separator.local.description': '如果 K.G.One Music Studio 不可用,本地分离模式提供了一种直接在浏览器中提取音轨的内置替代方案。提供两种本地模型:人声与乐器(中等精度),以及人声、鼓、贝斯及其他。下方的下载状态反映当前选定的模型。人声与乐器(中等精度)的处理时间通常比人声、鼓、贝斯及其他更长,总处理时间仍取决于您的硬件配置。如果处理回退至 CPU,分离运行期间页面响应可能暂时变慢。',
|
||||
'kgone.separator.local.learnMore': '了解更多关于 K.G.One Music Studio 服务器集成的信息。',
|
||||
'kgone.separator.local.provider': '当前设备:',
|
||||
'kgone.separator.local.model': '模型:',
|
||||
'kgone.separator.local.downloaded': '已下载',
|
||||
'kgone.separator.local.notDownloaded': '未下载',
|
||||
'kgone.separator.local.checkingCache': '正在检查本地模型缓存...',
|
||||
'kgone.separator.local.btn.download': '下载所选模型',
|
||||
'kgone.separator.local.btn.downloading': '模型下载中...',
|
||||
'kgone.separator.local.btn.redownload': '重新下载模型',
|
||||
'kgone.separator.local.btn.redownloading': '重新下载中...',
|
||||
'kgone.separator.local.btn.deleteCache': '删除已缓存模型',
|
||||
'kgone.separator.local.btn.deleting': '删除中...',
|
||||
'kgone.separator.local.progress.downloading': '正在下载 {name}...',
|
||||
'kgone.separator.local.progress.downloadingWithSize': '正在下载 {name}... {received} / {total} MB',
|
||||
'kgone.separator.local.progress.downloadingMbOnly': '正在下载 {name}... {received} MB',
|
||||
'kgone.separator.local.progress.ready': '{name} 已就绪。',
|
||||
'kgone.separator.local.progress.preparingRuntime': '正在准备 ONNX 运行时环境...',
|
||||
'kgone.separator.local.progress.readingAudio': '正在读取音频文件...',
|
||||
'kgone.separator.local.progress.running': '正在浏览器中执行分离...',
|
||||
'kgone.separator.local.progress.complete': '分离完成。',
|
||||
|
||||
'openProjectModal.title': '打开项目',
|
||||
'openProjectModal.close': '关闭',
|
||||
'openProjectModal.loading': '正在加载项目...',
|
||||
'openProjectModal.date.unknown': '未知',
|
||||
'openProjectModal.filter.placeholder': '筛选项目...',
|
||||
'openProjectModal.viewToggle.projects': '项目',
|
||||
'openProjectModal.viewToggle.trash': '回收站',
|
||||
'openProjectModal.trash.hint': '已删除的项目将在 30 天后自动移除。',
|
||||
'openProjectModal.sort.name': '名称',
|
||||
'openProjectModal.sort.created': '创建时间',
|
||||
'openProjectModal.sort.updated': '更新时间',
|
||||
'openProjectModal.empty.trash': '回收站为空',
|
||||
'openProjectModal.empty.noProjects': '暂无已保存的项目',
|
||||
'openProjectModal.empty.noMatch': '没有符合筛选条件的项目',
|
||||
'openProjectModal.meta.created': '创建于 {date}',
|
||||
'openProjectModal.meta.updated': '更新于 {date}',
|
||||
'openProjectModal.meta.deleted': '删除于 {date}',
|
||||
'openProjectModal.action.open': '打开',
|
||||
'openProjectModal.action.duplicate': '复制',
|
||||
'openProjectModal.action.delete': '删除',
|
||||
'openProjectModal.action.restore': '恢复',
|
||||
'openProjectModal.action.deletePermanently': '永久删除',
|
||||
'openProjectModal.dialog.duplicateName': '请输入复制项目的名称:',
|
||||
'openProjectModal.dialog.invalidName': '项目名称无效。只允许使用字母、数字、空格、连字符、下划线、句号和括号。',
|
||||
'openProjectModal.dialog.duplicateError': '复制项目失败:{error}',
|
||||
'openProjectModal.dialog.deleteError': '删除项目失败:{error}',
|
||||
'openProjectModal.dialog.restoreError': '恢复项目失败:{error}',
|
||||
'openProjectModal.dialog.confirmPermanentDelete': '确定要永久删除"{name}"吗?\n\n此操作无法撤销。',
|
||||
'openProjectModal.dialog.permanentDeleteError': '永久删除项目失败:{error}',
|
||||
};
|
||||
@@ -0,0 +1,779 @@
|
||||
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 音樂創作助手。',
|
||||
'mainContent.createTrack': '建立音軌',
|
||||
'mainContent.showGlobalTracks': '顯示全域音軌',
|
||||
'status.chordGuideCandidate': '和弦指導候選: {name} - {notes} - {note}',
|
||||
'settings.sidebar.title': '設定',
|
||||
'settings.sidebar.close': '關閉設定',
|
||||
'settings.sidebar.general': '通用',
|
||||
'settings.sidebar.audioIo': '音訊 I/O',
|
||||
'settings.sidebar.behavior': '行為',
|
||||
'settings.sidebar.templates': '模板',
|
||||
'settings.sidebar.chordGuide': '和弦指導',
|
||||
'settings.general.title': '通用',
|
||||
'settings.general.language.label': '語言',
|
||||
'settings.general.language.help': '自動會使用您的系統語言。',
|
||||
'settings.general.language.auto': '自動',
|
||||
'settings.yes': '是',
|
||||
'settings.no': '否',
|
||||
'settings.restoreDefault': '恢復預設',
|
||||
'settings.deleteCachedModel': '刪除已緩存模型',
|
||||
'settings.deleting': '刪除中...',
|
||||
'settings.general.llmProvider.section': 'LLM 提供方',
|
||||
'settings.general.llmProvider.label': 'LLM 提供方',
|
||||
'settings.general.llmProvider.local': '本地 LLM(瀏覽器)',
|
||||
'settings.general.llmProvider.openai': 'OpenAI',
|
||||
'settings.general.llmProvider.claudeOpenRouter': 'Claude(通過 OpenRouter)',
|
||||
'settings.general.llmProvider.openaiCompatible': 'OpenAI 兼容服務(例如 OpenRouter、Ollama)',
|
||||
'settings.general.persistKeys.label': '在非 localhost 環境持久化 API Key',
|
||||
'settings.general.persistKeys.help': '啟用後,即使在非 localhost 環境下,API Key 也會儲存到瀏覽器儲存空間中。警告:這可能增加 XSS 攻擊帶來的安全風險。',
|
||||
'settings.general.localRuntime.cachedStatus': '緩存模型狀態',
|
||||
'settings.general.localRuntime.cacheChecking': '正在檢查本地模型緩存...',
|
||||
'settings.general.localRuntime.cacheDownloaded': '已下載到瀏覽器緩存。',
|
||||
'settings.general.localRuntime.cacheMissing': '尚未下載。',
|
||||
'settings.general.localRuntime.contextLength': '上下文長度',
|
||||
'settings.general.localRuntime.contextHelp': '更大的上下文長度需要更多顯存,並且在對話變長後也可能降低性能。',
|
||||
'settings.general.localRuntime.downloadUrl': '下載地址',
|
||||
'settings.general.localRuntime.downloadHelp': '修改這個地址可能導致下載失敗,或指向不兼容的模型文件。',
|
||||
'settings.general.localRuntime.autoDownload': '下次您使用 `Local LLM (Browser)` 聊天時,本地模型會自動下載。',
|
||||
'settings.general.uvr5.section': 'UVR5 Web 運行時',
|
||||
'settings.general.uvr5.downloadUrl': 'UVR-MDX-NET-Inst_HQ_3 下載地址',
|
||||
'settings.general.htdemucs.downloadUrl': 'htdemucs_4s 下載地址',
|
||||
'settings.general.modelUrl.help': '修改這個地址可能導致下載失敗,或指向不兼容的模型文件。',
|
||||
'settings.general.openai.section': 'OpenAI',
|
||||
'settings.general.openai.key': '密鑰',
|
||||
'settings.general.openai.keyPlaceholder': '輸入您的 OpenAI API Key',
|
||||
'settings.general.openai.model': '模型',
|
||||
'settings.general.openai.flexMode': 'Flex 模式',
|
||||
'settings.general.openai.flexHelp': 'Flex 模式使用 OpenAI 的彈性服務層。優點:繁忙時段可能節省成本並提高吞吐。缺點:延遲可能波動,也可能排隊或被降優先級。僅對 OpenAI 提供方生效,對 OpenAI 兼容服務無影響。',
|
||||
'settings.general.keys.persisted': '密鑰會儲存在本地(瀏覽器中的 IndexedDB)。',
|
||||
'settings.general.keys.sessionOnly': '出於安全考慮,在非本地主機環境下密鑰不會持久化,只保留在當前會話內存中。',
|
||||
'settings.general.claudeOpenRouter.section': 'Anthropic Claude(通過 OpenRouter)',
|
||||
'settings.general.claudeOpenRouter.keyPlaceholder': '輸入您的 Claude API Key',
|
||||
'settings.general.baseUrl': '基礎 URL',
|
||||
'settings.general.claudeOpenRouter.baseUrlHelp': '這是 OpenRouter API 的基礎 URL。除非您明確知道自己在做什麼,否則不要修改。',
|
||||
'settings.general.openaiCompatible.section': 'OpenAI 兼容服務',
|
||||
'settings.general.openaiCompatible.keyPlaceholder': '輸入您的 API Key',
|
||||
'settings.general.openaiCompatible.baseUrlPlaceholder': '例如 https://openrouter.ai/api/v1',
|
||||
'settings.general.openaiCompatible.baseUrlHelp': '快捷預設:',
|
||||
'settings.general.openaiCompatible.modelPlaceholder': '例如 qwen3:30b',
|
||||
'settings.general.soundfont.section': 'Soundfont 設定',
|
||||
'settings.general.soundfont.managed': 'Soundfont 設定由伺服器(kgone-server.json)管理,目前設定為唯讀。',
|
||||
'settings.general.soundfont.baseUrl': '基礎 URL',
|
||||
'settings.general.soundfont.baseUrlHelp': '如果改成不兼容的 soundfont 源,某些樂器可能發聲錯誤或無法播放。',
|
||||
'settings.general.kgone.section': 'K.G.One 設定',
|
||||
'settings.general.kgone.managed': 'K.G.One 設定由伺服器(kgone-server.json)管理,目前設定為唯讀。',
|
||||
'settings.general.kgone.enabled': '啟用 K.G.One 集成',
|
||||
'settings.general.kgone.disabled': '禁用',
|
||||
'settings.general.kgone.enabledOption': '啟用',
|
||||
'settings.general.kgone.serverBaseUrl': '伺服器基礎 URL',
|
||||
'settings.general.kgone.serverBaseUrlHelp': '正在運行的 K.G.One Music Studio 伺服器基礎 URL,用於整曲生成、片段生成和分軌。',
|
||||
'settings.audioIo.title': '音訊 I/O',
|
||||
'settings.audioIo.deviceRouting': '裝置路由',
|
||||
'settings.audioIo.refresh': '重新整理裝置清單',
|
||||
'settings.audioIo.refreshing': '重新整理中…',
|
||||
'settings.audioIo.description': '選擇 KGStudio 用於錄音與播放的裝置。輸入裝置的變更會在下一次錄音時生效。輸出裝置的變更需要重新整理頁面後生效。',
|
||||
'settings.audioIo.inputDevice': '音訊輸入裝置',
|
||||
'settings.audioIo.outputDevice': '音訊輸出裝置',
|
||||
'settings.audioIo.inputHelp': '變更會在下一次錄音時生效。如果所選裝置被移除,KGStudio 會回退到系統預設裝置。',
|
||||
'settings.audioIo.outputHelp': '輸出裝置變更需要重新整理頁面。瀏覽器對非預設輸出路由的支援有限,因此在不支援時 KGStudio 會繼續使用系統預設裝置。',
|
||||
'settings.audioIo.outputSinkUnsupported': '此瀏覽器未提供可靠的即時 Web Audio 輸出切換能力。非預設輸出裝置選擇僅屬盡力而為,最終可能仍會使用系統預設裝置。',
|
||||
'settings.audioIo.chooseOutputDevice': '選擇輸出裝置…',
|
||||
'settings.audioIo.previousSelectedInput': '先前選擇的輸入裝置(需要權限才能驗證)',
|
||||
'settings.audioIo.previousSelectedOutput': '先前選擇的輸出裝置(需要權限才能驗證)',
|
||||
'settings.audioIo.status.inputUnavailable': '先前選擇的音訊輸入裝置不可用;將使用系統預設裝置。',
|
||||
'settings.audioIo.status.outputUnavailable': '先前選擇的音訊輸出裝置不可用;將使用系統預設裝置。',
|
||||
'settings.audioIo.status.refreshed': '音訊裝置清單已重新整理。',
|
||||
'settings.audioIo.status.readFailed': '無法從瀏覽器讀取音訊裝置。',
|
||||
'settings.audioIo.status.inputDefaultNextSession': '音訊輸入將在下一次錄音時使用系統預設裝置。',
|
||||
'settings.audioIo.status.inputChangedNextSession': '音訊輸入將在下一次錄音時切換。',
|
||||
'settings.audioIo.status.outputDefaultAfterRefresh': '重新整理後音訊輸出將使用系統預設裝置。',
|
||||
'settings.audioIo.status.outputSavedRefreshRequired': '音訊輸出裝置已儲存。請重新整理頁面以套用。',
|
||||
'settings.audioIo.status.outputPromptUnsupported': '此瀏覽器不支援彈出音訊輸出裝置選擇。',
|
||||
'settings.audioIo.status.outputSelectedRefreshRequired': '已選擇輸出裝置。請重新整理頁面以套用。',
|
||||
'settings.audioIo.status.outputSelectionDenied': '瀏覽器不允許選擇非預設輸出裝置。',
|
||||
'settings.behavior.title': '行為',
|
||||
'settings.behavior.editor': '編輯器',
|
||||
'settings.behavior.playheadUpdateFrequency': '播放頭更新頻率(fps)',
|
||||
'settings.behavior.playheadUpdateFrequencyHelp': '播放時播放頭動畫的更新頻率。較高的值(60 fps)會帶來更平滑的動畫,但會佔用更多 CPU。較低的值(10 fps)更節省資源。變更會立即生效,無需重新啟動。',
|
||||
'settings.behavior.spectrogramHeightResolution': '頻譜圖高度解析度',
|
||||
'settings.behavior.spectrogramHeightResolutionHelp': '控制可見音高範圍內的頻譜圖垂直細節。較高的值會讓音高輪廓更清晰,但在計算頻譜圖時會消耗更多 CPU 與記憶體。',
|
||||
'settings.behavior.chatBox': '聊天框',
|
||||
'settings.behavior.openAtStartup': '啟動時開啟',
|
||||
'settings.behavior.audio': '音訊',
|
||||
'settings.behavior.lookaheadTime': '前瞻時間(ms)',
|
||||
'settings.behavior.lookaheadTimeHelp': '音訊排程前瞻時間(0-500ms)。較低的值(10-20ms)可降低 MIDI 輸入延遲,但在較慢的系統上可能造成音訊卡頓。較高的值(100ms 以上)更有利於播放穩定性。變更會立即生效,無需重新啟動。',
|
||||
'settings.behavior.playbackDelay': '播放延遲(ms)',
|
||||
'settings.behavior.playbackDelayHelp': '按下開始按鈕後,播放會有一個很短的延遲(0-500ms)。增加此值可能有助於穩定播放,特別是在前瞻值較低時的前幾個節拍。變更會立即生效,無需重新啟動。',
|
||||
'settings.behavior.midiAutomationInterpolation': 'MIDI 自動化插值',
|
||||
'settings.behavior.midiAutomationInterpolation.low': '低配(20 ms)',
|
||||
'settings.behavior.midiAutomationInterpolation.balanced': '平衡(10 ms)',
|
||||
'settings.behavior.midiAutomationInterpolation.high': '高品質(5 ms)',
|
||||
'settings.behavior.midiAutomationInterpolationHelp': '控制在播放與匯出時將 MIDI 自動化烘焙得多密集。間隔越小聲音越平滑,但會排程更多事件。',
|
||||
'settings.behavior.midiInputLatency': 'MIDI 輸入延遲(ms)',
|
||||
'settings.behavior.midiInputLatencyHelp': '用於 MIDI 錄音的時間校正(0-500ms)。如果錄下來的音符比您實際想演奏的位置略晚,可以增加此值以匹配您的 MIDI 裝置輸入延遲。每個音符在提交時都會向前回移這段時間。',
|
||||
'settings.behavior.bounceStartsFromBeat1': '匯出從第 1 拍開始',
|
||||
'settings.behavior.bounceStartsFromBeat1Help': '選擇「是」時,匯出 WAV/MP3 會包含從歌曲開頭到第一個被渲染區域之前的前導靜音。選擇「否」時,會裁掉這段前導靜音,並從第一個被渲染的音符或音訊區域開始匯出。',
|
||||
'settings.behavior.captureAudioForScreenSharing': '為螢幕分享擷取音訊',
|
||||
'settings.behavior.captureAudioForScreenSharingHelp': '需要重新啟動 KGStudio(重新整理頁面)後生效。當在視像會議中分享螢幕時,如果 KGStudio 的音訊無法被擷取,請啟用此選項(例如 Zoom、Teams)。這會建立一個額外的音訊串流,供螢幕擷取應用程式偵測。',
|
||||
'settings.behavior.captureAudioForScreenSharingWarning': '在 Zoom 中分享螢幕時,請務必確認已啟用「Share Sound」選項。',
|
||||
'settings.behavior.validation.lookahead.invalidNumber': '前瞻時間必須是有效數字',
|
||||
'settings.behavior.validation.lookahead.range': '前瞻時間必須在 0 到 0.5 秒之間(0-500ms)',
|
||||
'settings.behavior.validation.playbackDelay.invalidNumber': '播放延遲必須是有效數字',
|
||||
'settings.behavior.validation.playbackDelay.range': '播放延遲必須在 0 到 0.5 秒之間(0-500ms)',
|
||||
'settings.behavior.validation.recordingOffset.invalidNumber': 'MIDI 輸入延遲必須是有效數字',
|
||||
'settings.behavior.validation.recordingOffset.range': 'MIDI 輸入延遲必須在 0 到 0.5 秒之間(0-500ms)',
|
||||
'settings.templates.title': '模板',
|
||||
'settings.templates.customInstructions': '自訂指示',
|
||||
'settings.templates.placeholder': '請輸入您為 K.G.Studio 音樂創作助手準備的自訂指示',
|
||||
'settings.chordGuide.title': '和弦指導',
|
||||
'settings.chordGuide.resetToDefault': '恢復預設',
|
||||
'settings.chordGuide.group.major': '大調候選和弦',
|
||||
'settings.chordGuide.group.minor': '小調候選和弦',
|
||||
'settings.chordGuide.help.major': '請輸入相對於 C 大調的和弦。應用程式會自動將其移調到其他大調調號。',
|
||||
'settings.chordGuide.help.minor': '請輸入相對於 A 小調的和弦。應用程式會自動將其移調到其他小調調號。',
|
||||
'settings.chordGuide.functionsAria': '{group} 功能',
|
||||
'settings.chordGuide.addTitle': '新增 {group}',
|
||||
'settings.chordGuide.deleteSelectedRows': '刪除所選列',
|
||||
'settings.chordGuide.table.chord': '和弦',
|
||||
'settings.chordGuide.table.notes': '音符',
|
||||
'settings.chordGuide.table.source': '來源',
|
||||
'settings.chordGuide.table.note': '備註',
|
||||
'settings.chordGuide.loadError': '載入和弦指導設定失敗。',
|
||||
'settings.chordGuide.invalidChordSymbol': '請輸入有效的和弦符號。範例:Bm7b5',
|
||||
'settings.chordGuide.unsupportedChordSymbol': '無法為這個和弦推導音符。請使用受支援的和弦符號。',
|
||||
'dialog.title.notice': '提示',
|
||||
'dialog.title.timeSignature': '拍號',
|
||||
'dialog.title.tempoDetection': '速度檢測',
|
||||
'dialog.title.applyTempo': '應用速度',
|
||||
'dialog.title.chordDetection': '和弦檢測',
|
||||
'dialog.title.input': '輸入',
|
||||
'dialog.title.confirm': '確認',
|
||||
'dialog.close': '關閉對話框',
|
||||
'dialog.cancel': '取消',
|
||||
'dialog.ok': '確定',
|
||||
'dialog.experimentalFeature': '實驗性功能',
|
||||
'dialog.recommendedSource': '推薦源素材',
|
||||
'dialog.chordHint.audio': '和弦分析仍屬實驗性功能。和聲內容、編配密度以及瞬態較強的素材都可能影響準確率。建議先使用預設設定,再逐步微調靈敏度和穩定性,讓檢測結果更貼近實際樂句。',
|
||||
'dialog.chordHint.midi': '和弦分析仍屬實驗性功能。和弦堆疊密度、重疊以及裝飾音都會影響結果。建議先使用預設設定,再調整短音抑制和和聲關注方式,以配合該段音樂的和聲作用。',
|
||||
'dialog.chordHint.tempo': '速度分析仍屬實驗性功能。自由速度、瞬態稀疏以及多層打擊樂都可能降低準確率。建議先使用預設 BPM 範圍,如果第一次結果不理想,再把分析範圍收窄到更可能的速度區間。',
|
||||
'dialog.sourceHint.kgone': '為了獲得更可靠的和弦標籤,建議分析已經削弱或移除了人聲和打擊樂的 stem。如果已啟用 K.G.One Music Studio 伺服器整合,請使用帶有 "Vocal, Drums, Bass, Guitar, Piano, and Others" 的 Separator 模型,並優先分析 Piano 或 Others stem。',
|
||||
'dialog.sourceHint.local': '為了獲得更可靠的和弦標籤,建議分析已經削弱或移除了人聲和打擊樂的 stem。如果您使用本地分軌器,請選擇 "Vocal, Drums, Bass, and Others" 模型,並使用 Others stem 進行分析。',
|
||||
'dialog.label.sensitivity': '靈敏度',
|
||||
'dialog.label.stability': '穩定性',
|
||||
'dialog.label.noChordThreshold': '無和弦閾值',
|
||||
'dialog.label.enableSevenths': '和弦細節:啟用七和弦',
|
||||
'dialog.label.shortNotes': '短音',
|
||||
'dialog.option.suppressionLow': '低抑制',
|
||||
'dialog.option.suppressionMedium': '中抑制',
|
||||
'dialog.option.suppressionHigh': '高抑制',
|
||||
'dialog.label.harmonicFocus': '和聲關注',
|
||||
'dialog.option.harmonicBalanced': '均衡',
|
||||
'dialog.option.harmonicSustained': '偏向持續音',
|
||||
'dialog.label.minimumBpm': '最小 BPM',
|
||||
'dialog.label.maximumBpm': '最大 BPM',
|
||||
'dialog.label.autoAlignRegionToBeat': '自動將片段對齊到拍點',
|
||||
'dialog.message.chordDetection': '在處理前調整音訊和弦檢測設定。',
|
||||
'dialog.message.tempoDetection': '在處理前調整音訊速度檢測設定。',
|
||||
'dialog.message.tempoApply': '檢測到速度:{bpm} BPM。請選擇應用方式。\n\n"更新當前速度"會修改此片段位置的有效速度。"插入速度變更"會在片段起始點前最近的小節加入新的速度區域。',
|
||||
'dialog.action.updateCurrentTempo': '更新當前速度',
|
||||
'dialog.action.insertTempoChange': '插入速度變更',
|
||||
'eventList.title': '事件列表',
|
||||
'eventList.scopeTabs': '事件列表範圍',
|
||||
'eventList.scope.region': '區域',
|
||||
'eventList.scope.track': '音軌',
|
||||
'eventList.scope.global': '全域',
|
||||
'eventList.deleteVisibleSelectedRows': '刪除當前可見的已選行',
|
||||
'eventList.table.position': '位置',
|
||||
'eventList.table.status': '狀態',
|
||||
'eventList.table.num': '編號',
|
||||
'eventList.table.val': '數值',
|
||||
'eventList.table.lengthInfo': '長度/信息',
|
||||
'eventList.region.filters': '區域事件篩選',
|
||||
'eventList.region.filter.notes': '音符',
|
||||
'eventList.region.filter.pitchBends': '彎音',
|
||||
'eventList.region.filter.controller': '控制器',
|
||||
'eventList.region.empty': '請選擇一個 MIDI 區域,或先在 Piano Roll 中開啟一個區域,再查看事件列表。',
|
||||
'eventList.region.add.label': '新增',
|
||||
'eventList.region.add.noteTitle': '在播放頭位置新增音符',
|
||||
'eventList.region.add.pitchBendTitle': '在播放頭位置新增彎音',
|
||||
'eventList.region.add.controllerTitle': '在播放頭位置新增控制器事件',
|
||||
'eventList.region.addType.note': '音符',
|
||||
'eventList.region.addType.pitchBend': '彎音',
|
||||
'eventList.region.addType.controller': '控制器',
|
||||
'eventList.region.status.note': '音符',
|
||||
'eventList.region.status.pitchBend': '彎音',
|
||||
'eventList.region.status.controller': '控制器',
|
||||
'eventList.track.filters': '音軌列表模式',
|
||||
'eventList.track.filter.regions': '區域',
|
||||
'eventList.track.filter.volume': '音量',
|
||||
'eventList.track.filter.pan': '聲像',
|
||||
'eventList.track.empty': '請選擇一條音軌以查看區域和音軌自動化。',
|
||||
'eventList.track.add.label': '新增',
|
||||
'eventList.track.add.midiRegionTitle': '在播放頭位置新增 MIDI 區域',
|
||||
'eventList.track.add.volumeTitle': '在播放頭位置新增音量自動化點',
|
||||
'eventList.track.add.panTitle': '在播放頭位置新增聲像自動化點',
|
||||
'eventList.track.addType.midiRegion': 'MIDI 區域',
|
||||
'eventList.track.status.audio': '音訊',
|
||||
'eventList.track.status.midi': 'MIDI',
|
||||
'eventList.track.status.volume': '音量',
|
||||
'eventList.track.status.pan': '聲像',
|
||||
'eventList.global.filters': '全域事件篩選',
|
||||
'eventList.global.filter.marker': '標記',
|
||||
'eventList.global.filter.tempo': '速度',
|
||||
'eventList.global.filter.keySignature': '調號',
|
||||
'eventList.global.filter.chord': '和弦',
|
||||
'eventList.global.add.label': '新增',
|
||||
'eventList.global.add.markerTitle': '在播放頭位置新增標記區段',
|
||||
'eventList.global.add.tempoTitle': '在播放頭位置新增速度區段',
|
||||
'eventList.global.add.keySignatureTitle': '在播放頭位置新增調號區段',
|
||||
'eventList.global.add.chordTitle': '在播放頭位置新增和弦區段',
|
||||
'eventList.global.addType.marker': '標記',
|
||||
'eventList.global.addType.tempo': '速度',
|
||||
'eventList.global.addType.keySignature': '調號',
|
||||
'eventList.global.addType.chord': '和弦',
|
||||
'eventList.global.status.marker': '標記',
|
||||
'eventList.global.status.tempo': '速度',
|
||||
'eventList.global.status.keySignature': '調號',
|
||||
'eventList.global.status.chord': '和弦',
|
||||
'eventList.global.validation.marker': '請輸入標記標籤。需要非空文本標籤。示例:前奏',
|
||||
'eventList.global.validation.tempo': '請輸入僅包含數字的 BPM 值。需要介於 {min} 和 {max} 之間的整數。示例:128',
|
||||
'eventList.global.validation.keySignature': '請輸入一個精確的調號名稱。需要規範值,例如 "C major" 或 "F# minor"。示例:F# minor',
|
||||
'eventList.global.validation.chord': '請輸入有效的和弦符號。需要應用可解析的和弦表示。示例:Bm7b5',
|
||||
'eventList.global.validation.position': '請輸入專案起點或之後的位置。需要非負位置。示例:1 1 0',
|
||||
'eventList.global.validation.length': '請輸入正長度。需要大於零的時值。示例:4 0',
|
||||
'globalTracks.label': '全域軌道',
|
||||
'globalTracks.marker': '標記',
|
||||
'globalTracks.tempo': '速度',
|
||||
'globalTracks.signature': '調號',
|
||||
'globalTracks.chord': '和弦',
|
||||
'globalTracks.addItem': '新增 {label} 全域軌道項目',
|
||||
'pianoRoll.power.linear': '線性',
|
||||
'pianoRoll.power.sqrtDefault': '√(預設)',
|
||||
'pianoRoll.power.mild': '輕度',
|
||||
'pianoRoll.power.strong': '強烈',
|
||||
'pianoRoll.chordGuide.off': '關閉和弦指導',
|
||||
'pianoRoll.chordGuide.tonic': '主功能和弦指導',
|
||||
'pianoRoll.chordGuide.subdominant': '下屬功能和弦指導',
|
||||
'pianoRoll.chordGuide.dominant': '屬功能和弦指導',
|
||||
'pianoRoll.spectrogramView': '頻譜視圖',
|
||||
'pianoRoll.sheetMusicView': '五線譜視圖',
|
||||
'pianoRoll.pointerTool': '指針工具',
|
||||
'pianoRoll.pencilTool': '鉛筆工具',
|
||||
'pianoRoll.toggleAutomationLane': '切換自動化軌',
|
||||
'pianoRoll.automation': '自動化',
|
||||
'pianoRoll.automationLane': '{label} 自動化軌',
|
||||
'pianoRoll.automationType.pitchBend': '彎音',
|
||||
'pianoRoll.automationType.cc1': 'CC1',
|
||||
'pianoRoll.automationType.cc2': 'CC2',
|
||||
'pianoRoll.automationType.cc7': 'CC7',
|
||||
'pianoRoll.automationType.cc11': 'CC11',
|
||||
'pianoRoll.automationType.cc64': 'CC64',
|
||||
'pianoRoll.mode': '調式',
|
||||
'pianoRoll.modeOption.ionian': '伊奧尼亞',
|
||||
'pianoRoll.modeOption.dorian': '多利亞',
|
||||
'pianoRoll.modeOption.phrygian': '弗里吉亞',
|
||||
'pianoRoll.modeOption.lydian': '利底亞',
|
||||
'pianoRoll.modeOption.mixolydian': '混合利底亞',
|
||||
'pianoRoll.modeOption.aeolian': '愛奧利亞',
|
||||
'pianoRoll.modeOption.locrian': '洛克里亞',
|
||||
'pianoRoll.modeOption.harmonic_minor': '和聲小調',
|
||||
'pianoRoll.modeOption.melodic_minor': '旋律小調',
|
||||
'pianoRoll.modeOption.phrygian_dominant': '弗里吉亞屬調式',
|
||||
'pianoRoll.modeOption.harmonic_major': '和聲大調',
|
||||
'pianoRoll.chordGuide': '和弦指導',
|
||||
'pianoRoll.sheetQuantization': '譜面量化',
|
||||
'pianoRoll.snap': '吸附',
|
||||
'pianoRoll.snap.none': '無吸附',
|
||||
'pianoRoll.quantizePositionCompact': '位置量化',
|
||||
'pianoRoll.quantizeLengthCompact': '長度量化',
|
||||
'pianoRoll.floor': '底噪',
|
||||
'pianoRoll.curve': '曲線',
|
||||
'pianoRoll.zoom': '縮放',
|
||||
'pianoRoll.moreOptions': '更多選項',
|
||||
'pianoRoll.quantize.1/1': '1/1',
|
||||
'pianoRoll.quantize.1/2': '1/2',
|
||||
'pianoRoll.quantize.1/3': '1/3',
|
||||
'pianoRoll.quantize.1/4': '1/4',
|
||||
'pianoRoll.quantize.1/6': '1/6',
|
||||
'pianoRoll.quantize.1/8': '1/8',
|
||||
'pianoRoll.quantize.1/12': '1/12',
|
||||
'pianoRoll.quantize.1/16': '1/16',
|
||||
'pianoRoll.quantize.1/24': '1/24',
|
||||
'pianoRoll.quantize.1/32': '1/32',
|
||||
'pianoRoll.showActiveRegionOnly': '僅顯示當前區域',
|
||||
'pianoRoll.showEntireTrack': '顯示整條音軌',
|
||||
'pianoRoll.detectChords': '檢測和弦...',
|
||||
'pianoRoll.detectingChords': '正在檢測和弦...',
|
||||
'pianoRoll.detectTempo': '檢測速度...',
|
||||
'pianoRoll.detectingTempo': '正在檢測速度...',
|
||||
'percussion.short.35': '原底鼓',
|
||||
'percussion.short.36': '底鼓',
|
||||
'percussion.short.37': '邊擊',
|
||||
'percussion.short.38': '原軍鼓',
|
||||
'percussion.short.39': '拍手',
|
||||
'percussion.short.40': '電軍鼓',
|
||||
'percussion.short.41': '低落地鼓',
|
||||
'percussion.short.42': '閉鑔',
|
||||
'percussion.short.43': '高落地鼓',
|
||||
'percussion.short.44': '踏鑔',
|
||||
'percussion.short.45': '低嗵鼓',
|
||||
'percussion.short.46': '開鑔',
|
||||
'percussion.short.47': '中低嗵鼓',
|
||||
'percussion.short.48': '中高嗵鼓',
|
||||
'percussion.short.49': '碎音鑔1',
|
||||
'percussion.short.50': '高嗵鼓',
|
||||
'percussion.short.51': '叮叮鑔1',
|
||||
'percussion.short.52': '中國鑔',
|
||||
'percussion.short.53': '叮叮鑔鈴',
|
||||
'percussion.short.54': '鈴鼓',
|
||||
'percussion.short.55': '飛濺鑔',
|
||||
'percussion.short.56': '牛鈴',
|
||||
'percussion.short.57': '碎音鑔2',
|
||||
'percussion.short.58': '振響器',
|
||||
'percussion.short.59': '叮叮鑔2',
|
||||
'percussion.short.60': '高邦戈',
|
||||
'percussion.short.61': '低邦戈',
|
||||
'percussion.short.62': '悶高康加',
|
||||
'percussion.short.63': '開高康加',
|
||||
'percussion.short.64': '低康加',
|
||||
'percussion.short.65': '高廷巴勒',
|
||||
'percussion.short.66': '低廷巴勒',
|
||||
'percussion.short.67': '高阿哥哥',
|
||||
'percussion.short.68': '低阿哥哥',
|
||||
'percussion.short.69': '卡巴薩',
|
||||
'percussion.short.70': '沙鎚',
|
||||
'percussion.short.71': '短哨',
|
||||
'percussion.short.72': '長哨',
|
||||
'percussion.short.73': '短刮瓜',
|
||||
'percussion.short.74': '長刮瓜',
|
||||
'percussion.short.75': '響棒',
|
||||
'percussion.short.76': '高木塊',
|
||||
'percussion.short.77': '低木塊',
|
||||
'percussion.short.78': '悶庫卡',
|
||||
'percussion.short.79': '開庫卡',
|
||||
'percussion.short.80': '悶三角鐵',
|
||||
'percussion.short.81': '開三角鐵',
|
||||
'percussion.full.35': '原聲底鼓',
|
||||
'percussion.full.36': '底鼓 1',
|
||||
'percussion.full.37': '邊擊',
|
||||
'percussion.full.38': '原聲軍鼓',
|
||||
'percussion.full.39': '拍手',
|
||||
'percussion.full.40': '電軍鼓',
|
||||
'percussion.full.41': '低落地嗵鼓',
|
||||
'percussion.full.42': '閉合踩鑔',
|
||||
'percussion.full.43': '高落地嗵鼓',
|
||||
'percussion.full.44': '踏踩鑔',
|
||||
'percussion.full.45': '低嗵鼓',
|
||||
'percussion.full.46': '開放踩鑔',
|
||||
'percussion.full.47': '中低嗵鼓',
|
||||
'percussion.full.48': '中高嗵鼓',
|
||||
'percussion.full.49': '碎音鑔 1',
|
||||
'percussion.full.50': '高嗵鼓',
|
||||
'percussion.full.51': '叮叮鑔 1',
|
||||
'percussion.full.52': '中國鑔',
|
||||
'percussion.full.53': '叮叮鑔鈴',
|
||||
'percussion.full.54': '鈴鼓',
|
||||
'percussion.full.55': '飛濺鑔',
|
||||
'percussion.full.56': '牛鈴',
|
||||
'percussion.full.57': '碎音鑔 2',
|
||||
'percussion.full.58': '振響器',
|
||||
'percussion.full.59': '叮叮鑔 2',
|
||||
'percussion.full.60': '高邦戈',
|
||||
'percussion.full.61': '低邦戈',
|
||||
'percussion.full.62': '悶高康加',
|
||||
'percussion.full.63': '開高康加',
|
||||
'percussion.full.64': '低康加',
|
||||
'percussion.full.65': '高廷巴勒',
|
||||
'percussion.full.66': '低廷巴勒',
|
||||
'percussion.full.67': '高阿哥哥',
|
||||
'percussion.full.68': '低阿哥哥',
|
||||
'percussion.full.69': '卡巴薩',
|
||||
'percussion.full.70': '沙鎚',
|
||||
'percussion.full.71': '短哨',
|
||||
'percussion.full.72': '長哨',
|
||||
'percussion.full.73': '短刮瓜',
|
||||
'percussion.full.74': '長刮瓜',
|
||||
'percussion.full.75': '響棒',
|
||||
'percussion.full.76': '高木塊',
|
||||
'percussion.full.77': '低木塊',
|
||||
'percussion.full.78': '悶庫卡',
|
||||
'percussion.full.79': '開庫卡',
|
||||
'percussion.full.80': '悶三角鐵',
|
||||
'percussion.full.81': '開三角鐵',
|
||||
'toolbar.export.kgstudio': '匯出為 KGStudio 檔案',
|
||||
'toolbar.export.midi': '匯出為 MIDI 檔案',
|
||||
'toolbar.export.wav': '匯出為 WAV',
|
||||
'toolbar.export.mp3': '匯出為 MP3',
|
||||
'toolbar.projectName.prompt': '輸入專案名稱:',
|
||||
'toolbar.projectName.invalid': '無效的專案名稱。只允許字母、數字、空格、連字符、底線、句點和括號。',
|
||||
'toolbar.projectName.reserved': '"{name}" 是保留專案名稱。請選擇其他名稱。',
|
||||
'toolbar.projectName.overwrite': '專案 "{name}" 已存在。要覆寫它嗎?',
|
||||
'toolbar.projectName.renameOrCopy': '您想重新命名這個專案,還是另存為副本?',
|
||||
'toolbar.projectName.saveAsCopy': '另存為副本',
|
||||
'toolbar.projectName.rename': '重命名',
|
||||
'toolbar.save.error': '儲存時發生錯誤:{error}',
|
||||
'toolbar.load.error': '載入專案時發生錯誤:{error}',
|
||||
'toolbar.newProject.confirm': '確定要建立新專案嗎?目前未儲存的更改將會遺失。',
|
||||
'toolbar.project.notFound': '找不到專案 "{name}"。',
|
||||
'toolbar.openProject.confirm': '要開啟這個專案嗎?目前專案中未儲存的更改將會遺失。',
|
||||
'toolbar.status.loadFailed': '載入專案失敗:{error}',
|
||||
'toolbar.status.newProjectCreated': '已建立新專案 "{name}"',
|
||||
'toolbar.status.projectLoaded': '{description} 載入成功',
|
||||
'toolbar.status.projectExportedKgstudio': '專案 "{name}" 已匯出為 KGStudio 檔案',
|
||||
'toolbar.status.exportProjectError': '匯出專案時發生錯誤:{error}',
|
||||
'toolbar.export.failedProject': '匯出專案失敗:{error}',
|
||||
'toolbar.status.projectExportedMidi': '專案 "{name}" 已匯出為 MIDI 檔案',
|
||||
'toolbar.status.exportMidiError': '匯出 MIDI 時發生錯誤:{error}',
|
||||
'toolbar.export.failedMidi': '匯出專案為 MIDI 失敗:{error}',
|
||||
'toolbar.status.projectExportedWav': '專案 "{name}" 已匯出為 WAV 檔案',
|
||||
'toolbar.status.exportWavError': '匯出 WAV 時發生錯誤:{error}',
|
||||
'toolbar.export.failedWav': '匯出專案為 WAV 失敗:{error}',
|
||||
'toolbar.status.projectExportedMp3': '專案 "{name}" 已匯出為 MP3 檔案',
|
||||
'toolbar.status.exportMp3Error': '匯出 MP3 時發生錯誤:{error}',
|
||||
'toolbar.export.failedMp3': '匯出專案為 MP3 失敗:{error}',
|
||||
'toolbar.status.importFailed': '匯入檔案失敗:{error}',
|
||||
'toolbar.import.failedProjectFile': '匯入專案檔案失敗:{error}',
|
||||
'toolbar.import.corruptedKgstudio': '.kgstudio 檔案已損壞或無效:{error}',
|
||||
'toolbar.status.importingMidi': '正在匯入 MIDI 檔案 "{name}"...',
|
||||
'toolbar.status.failedImportMidi': '匯入 MIDI 檔案失敗:{error}',
|
||||
'toolbar.status.playbackFailedStart': '播放啟動失敗',
|
||||
'toolbar.status.recordingStoppedCommitted': '錄音已停止 - 音符已提交',
|
||||
'toolbar.status.failedStopPlayback': '停止播放失敗',
|
||||
'toolbar.maxBars.prompt': '輸入新的最大小節數(>= {min}):',
|
||||
'toolbar.input.invalidNumber': '輸入無效。請輸入有效數字。',
|
||||
'toolbar.maxBars.invalid': '數值無效。請輸入一個 >= {min} 的數字。',
|
||||
'toolbar.status.maxBarsChanged': '最大小節數已改為 {value}',
|
||||
'toolbar.bpm.prompt': '輸入新的 BPM({min}-{max}):',
|
||||
'toolbar.bpm.invalid': 'BPM 無效。請輸入 {min} 到 {max} 之間的值。',
|
||||
'toolbar.status.bpmChanged': 'BPM 已改為 {value}',
|
||||
'toolbar.timeSignature.prompt': '設定拍號:',
|
||||
'toolbar.status.timeSignatureChanged': '拍號已改為 {value}',
|
||||
'toolbar.status.keySignatureChanged': '調號已改為 {value}',
|
||||
'toolbar.status.copied': '專案已複製到剪貼簿',
|
||||
'toolbar.status.copyNone': '沒有可複製的已選專案',
|
||||
'toolbar.status.pasted': '已從剪貼簿貼上專案',
|
||||
'toolbar.status.pasteFailed': '無法貼上,沒有有效的剪貼簿內容或上下文',
|
||||
'toolbar.status.deleted': '已刪除所選區域',
|
||||
'toolbar.status.deleteNone': '沒有已選取且可刪除的區域',
|
||||
'toolbar.undo.none': '沒有可撤銷的操作',
|
||||
'toolbar.redo.none': '沒有可重做的操作',
|
||||
'toolbar.status.undid': '已撤銷:{description}',
|
||||
'toolbar.status.redid': '已重做:{description}',
|
||||
'toolbar.action': '操作',
|
||||
'toolbar.status.chatToggled': '聊天視窗已切換',
|
||||
'toolbar.status.settingsToggled': '設定面板已切換',
|
||||
'toolbar.pianoRoll.selectMidiRegion': '請先選擇一個 MIDI 區域,再開啟 Piano Roll。',
|
||||
'toolbar.status.recordingStopped': '錄音已停止',
|
||||
'toolbar.status.audioRecordingStarted': '音訊錄音已開始...',
|
||||
'toolbar.recording.openMidiRegion': '開始錄音前,請先在 Piano Roll 中開啟一個 MIDI 區域。',
|
||||
'toolbar.recording.selectMidiRegion': '開始錄音前,請先選擇一個 MIDI 區域。',
|
||||
'toolbar.recording.noMidiDevice': '未檢測到 MIDI 裝置。請連接 MIDI 鍵盤後重試。',
|
||||
'toolbar.status.recordingStarted': '錄音已開始...',
|
||||
'toolbar.button.new': '新建',
|
||||
'toolbar.button.load': '開啟',
|
||||
'toolbar.button.save': '儲存',
|
||||
'toolbar.button.export': '匯出',
|
||||
'toolbar.button.import': '匯入',
|
||||
'toolbar.button.settings': '設定',
|
||||
'toolbar.button.undo': '撤銷',
|
||||
'toolbar.button.redo': '重做',
|
||||
'toolbar.button.select': '選擇',
|
||||
'toolbar.button.pencil': '鉛筆',
|
||||
'toolbar.button.splitRegion': '在播放頭處分割區域',
|
||||
'toolbar.button.mergeRegions': '合併已選取的 MIDI 區域',
|
||||
'toolbar.button.snapToGrid': '吸附到網格',
|
||||
'toolbar.button.copy': '複製',
|
||||
'toolbar.button.paste': '貼上',
|
||||
'toolbar.button.delete': '刪除',
|
||||
'toolbar.button.backToBeginning': '回到開頭',
|
||||
'toolbar.button.play': '播放',
|
||||
'toolbar.button.pause': '暫停',
|
||||
'toolbar.button.stopRecording': '停止錄音',
|
||||
'toolbar.button.record': '錄音',
|
||||
'toolbar.button.loop': '循環',
|
||||
'toolbar.button.metronome': '節拍器',
|
||||
'toolbar.button.piano': '鋼琴',
|
||||
'toolbar.button.kgone': 'K.G.One 音樂生成器',
|
||||
'toolbar.button.chat': '聊天',
|
||||
'toolbar.button.eventList': '事件列表編輯器',
|
||||
'track.controls.automationButton': '音軌自動化',
|
||||
'track.controls.automationDropdown': '自動化',
|
||||
'track.controls.automation.volume': '音量',
|
||||
'track.controls.automation.pan': '聲像',
|
||||
'track.controls.settingsDropdown': '設定',
|
||||
'track.controls.settings.deleteTrack': '刪除音軌',
|
||||
'toolbar.keySignatureChooser': '選擇調號,當前為 {value}',
|
||||
'toolbar.export.label': '匯出',
|
||||
'toolbar.importProject.title': '匯入專案',
|
||||
'toolbar.importProject.description': '將專案檔案拖放到這裡',
|
||||
'toolbar.openingProject': '正在開啟專案...',
|
||||
'fileImport.invalidType': '檔案類型無效。請選擇以下副檔名之一的檔案:{extensions}',
|
||||
'fileImport.close': '關閉匯入彈窗',
|
||||
'fileImport.supportedFormats': '支援的格式:{formats}',
|
||||
'fileImport.or': '或',
|
||||
'fileImport.browse': '瀏覽檔案',
|
||||
'instrument.group.PIANO_AND_KEYBOARDS': '鋼琴與鍵盤',
|
||||
'instrument.group.GUITAR': '吉他',
|
||||
'instrument.group.BASS': '貝斯',
|
||||
'instrument.group.STRINGS': '弦樂',
|
||||
'instrument.group.BRASS': '銅管',
|
||||
'instrument.group.WOODWIND': '木管',
|
||||
'instrument.group.PERCUSSION_KIT': '打擊樂套件',
|
||||
'instrument.group.SYNTH': '合成器',
|
||||
'instrument.name.acoustic_grand_piano': '原聲大鋼琴',
|
||||
'instrument.name.bright_acoustic_piano': '明亮原聲鋼琴',
|
||||
'instrument.name.electric_grand_piano': '電大鋼琴',
|
||||
'instrument.name.electric_piano_1': '電鋼琴 1',
|
||||
'instrument.name.electric_piano_2': '電鋼琴 2',
|
||||
'instrument.name.drawbar_organ': '擊桿風琴',
|
||||
'instrument.name.clavinet': '擊弦電鋼琴',
|
||||
'instrument.name.harpsichord': '羽管鍵琴',
|
||||
'instrument.name.accordion': '手風琴',
|
||||
'instrument.name.acoustic_guitar_nylon': '原聲吉他(尼龍弦)',
|
||||
'instrument.name.acoustic_guitar_steel': '原聲吉他(鋼弦)',
|
||||
'instrument.name.electric_guitar_clean': '電吉他(清音)',
|
||||
'instrument.name.overdriven_guitar': '電吉他(過載)',
|
||||
'instrument.name.distortion_guitar': '電吉他(失真)',
|
||||
'instrument.name.acoustic_bass': '原聲貝斯',
|
||||
'instrument.name.electric_bass_finger': '電貝斯(指彈)',
|
||||
'instrument.name.electric_bass_pick': '電貝斯(撥片)',
|
||||
'instrument.name.slap_bass_1': '擊勾貝斯',
|
||||
'instrument.name.synth_bass_1': '合成貝斯 1',
|
||||
'instrument.name.violin': '小提琴',
|
||||
'instrument.name.viola': '中提琴',
|
||||
'instrument.name.cello': '大提琴',
|
||||
'instrument.name.contrabass': '低音提琴',
|
||||
'instrument.name.string_ensemble_1': '弦樂合奏',
|
||||
'instrument.name.string_ensemble_2': '弦樂合奏 2',
|
||||
'instrument.name.pizzicato_strings': '撥奏弦樂',
|
||||
'instrument.name.tremolo_strings': '震音弦樂',
|
||||
'instrument.name.orchestral_harp': '管弦豎琴',
|
||||
'instrument.name.trumpet': '小號',
|
||||
'instrument.name.trombone': '長號',
|
||||
'instrument.name.tuba': '大號',
|
||||
'instrument.name.french_horn': '圓號',
|
||||
'instrument.name.brass_section': '銅管組',
|
||||
'instrument.name.soprano_sax': '高音薩克斯',
|
||||
'instrument.name.alto_sax': '中音薩克斯',
|
||||
'instrument.name.tenor_sax': '次中音薩克斯',
|
||||
'instrument.name.baritone_sax': '上低音薩克斯',
|
||||
'instrument.name.oboe': '雙簧管',
|
||||
'instrument.name.english_horn': '英國管',
|
||||
'instrument.name.bassoon': '巴松',
|
||||
'instrument.name.clarinet': '單簧管',
|
||||
'instrument.name.piccolo': '短笛',
|
||||
'instrument.name.flute': '長笛',
|
||||
'instrument.name.shakuhachi': '尺八',
|
||||
'instrument.name.harmonica': '口琴',
|
||||
'instrument.name.standard': '標準鼓組',
|
||||
'instrument.name.orchestra_kit': '管弦打擊樂組',
|
||||
'instrument.name.marimba': '馬林巴',
|
||||
'instrument.name.timpani': '定音鼓',
|
||||
'instrument.name.taiko_drum': '太鼓',
|
||||
'instrument.name.woodblock': '木魚',
|
||||
'instrument.name.lead_1_square': '主音 1(方波)',
|
||||
'instrument.name.lead_2_sawtooth': '主音 2(鋸齒波)',
|
||||
'instrument.name.pad_1_new_age': '鋪底 1(新世紀)',
|
||||
'instrument.name.pad_2_warm': '鋪底 2(溫暖)',
|
||||
'instrument.name.pad_3_polysynth': '鋪底 3(複音合成)',
|
||||
'instrument.name.pad_4_choir': '鋪底 4(合唱)',
|
||||
'instrument.name.pad_5_bowed': '鋪底 5(弓弦玻璃)',
|
||||
'instrument.name.pad_6_metallic': '鋪底 6(金屬)',
|
||||
'instrument.name.pad_7_halo': '鋪底 7(光暈)',
|
||||
'instrument.name.pad_8_sweep': '鋪底 8(掃頻)',
|
||||
'instrument.name.fx_1_rain': '特效 1(雨)',
|
||||
'instrument.name.fx_2_soundtrack': '特效 2(配樂)',
|
||||
'instrument.name.fx_3_crystal': '特效 3(水晶)',
|
||||
'instrument.name.fx_4_atmosphere': '特效 4(氛圍)',
|
||||
'instrument.name.fx_5_brightness': '特效 5(明亮)',
|
||||
'instrument.name.fx_6_goblins': '特效 6(妖精)',
|
||||
'instrument.name.fx_7_echoes': '特效 7(回聲)',
|
||||
'instrument.name.fx_8_scifi': '特效 8(科幻)',
|
||||
// ─── K.G.One / Music Generator Panel ─────────────────────────────────────────
|
||||
'kgone.panel.title.server': 'K.G.One 音樂生成器',
|
||||
'kgone.panel.title.local': '音樂生成器',
|
||||
'kgone.tab.fullSong': '完整歌曲',
|
||||
'kgone.tab.remix': '混音',
|
||||
'kgone.tab.repaint': '重繪',
|
||||
'kgone.tab.separator': '分離',
|
||||
'kgone.tab.requiresServer': '需要 K.G.One Music Studio 伺服器整合',
|
||||
// Shared across tabs
|
||||
'kgone.shared.advancedSettings': '進階設定',
|
||||
'kgone.shared.caption': '描述',
|
||||
'kgone.shared.lyrics': '歌詞',
|
||||
'kgone.shared.lyricsPlaceholder': '[Verse 1]\n在這裡輸入歌詞...\n\n[Chorus]\n...',
|
||||
'kgone.shared.instrumental': '純音樂(無人聲)',
|
||||
'kgone.shared.inferenceSteps': '推理步數',
|
||||
'kgone.shared.guidanceScale': '引導強度',
|
||||
'kgone.shared.useRandomSeed': '使用隨機種子',
|
||||
'kgone.shared.seed': '種子',
|
||||
'kgone.shared.thinking': '思考模式(CoT 元數據生成)',
|
||||
'kgone.shared.selectedRegion': '已選區域',
|
||||
'kgone.shared.track': '音軌',
|
||||
'kgone.shared.poweredBy': '技術支援:',
|
||||
'kgone.shared.btn.loadingModel': '載入模型中...',
|
||||
'kgone.shared.btn.generating': '生成中...',
|
||||
'kgone.shared.btn.processing': '處理中...',
|
||||
'kgone.shared.btn.downloading': '下載中...',
|
||||
'kgone.shared.btn.preparingUpload': '準備上傳...',
|
||||
'kgone.shared.btn.importing': '匯入中...',
|
||||
'kgone.shared.hint.loadingModel': '正在載入模型,此過程可能需要 60 秒以上,請耐心等候...',
|
||||
'kgone.shared.hint.submitting': '正在提交生成請求...',
|
||||
'kgone.shared.hint.downloadingAudio': '正在下載音訊...',
|
||||
// Clip tab
|
||||
'kgone.clip.field.prompt': '提示詞',
|
||||
'kgone.clip.field.promptPlaceholder': '例:粗糙, 酸性, 貝斯線, 303, 合成主音, FM, 次低音, 中高頻, 高頻相位, 高頻混響, 彎音, 8小節, 140 BPM, E 小調',
|
||||
'kgone.clip.field.promptHint': '用逗號分隔的標籤描述片段:樂器類別、子類型、音色、效果、小節數、BPM、調性。',
|
||||
'kgone.clip.field.negativePrompt': '負向提示詞',
|
||||
'kgone.clip.field.negativePromptPlaceholder': '例:失真, 噪音',
|
||||
'kgone.clip.field.bars': '小節數',
|
||||
'kgone.clip.field.bars4': '4 小節',
|
||||
'kgone.clip.field.bars8': '8 小節',
|
||||
'kgone.clip.field.note': '音符',
|
||||
'kgone.clip.field.scale': '音階',
|
||||
'kgone.clip.field.scaleMajor': '大調',
|
||||
'kgone.clip.field.scaleMinor': '小調',
|
||||
'kgone.clip.field.bpm': 'BPM',
|
||||
'kgone.clip.field.steps': '步數',
|
||||
'kgone.clip.field.cfgScale': 'CFG 強度',
|
||||
'kgone.clip.field.seedLabel': '種子(-1 = 隨機)',
|
||||
'kgone.clip.field.samplerType': '採樣器類型',
|
||||
'kgone.clip.field.sigmaMin': 'Sigma 最小值',
|
||||
'kgone.clip.field.sigmaMax': 'Sigma 最大值',
|
||||
'kgone.clip.field.cfgRescale': 'CFG 重縮放',
|
||||
'kgone.clip.hint.generating': '正在生成片段...',
|
||||
'kgone.clip.hint.drag': '將上方播放器拖至音軌以匯入片段。拖到<strong>音訊音軌</strong>可作為 WAV 區域匯入(推薦),拖到<strong>MIDI 音軌</strong>可作為 MIDI 區域匯入。注意:MIDI 是從音訊轉錄而來,未必完全準確。',
|
||||
'kgone.clip.btn.generate': '生成片段',
|
||||
'kgone.clip.poweredBy': 'Foundation-1',
|
||||
// Full Song tab
|
||||
'kgone.fullSong.field.captionPlaceholder': '例:風格:歐洲舞曲,90年代流行電子。感覺:朗朗上口、充滿活力... 速度:約 130 BPM。編排:強勁底鼓、歐洲舞曲貝斯線...',
|
||||
'kgone.fullSong.field.captionHint': '用自然語言描述歌曲風格、情緒、速度、編排和結構。',
|
||||
'kgone.fullSong.field.lyricsHint': '使用 [Intro]、[Verse]、[Chorus]、[Bridge] 標籤標注段落。',
|
||||
'kgone.fullSong.hint.generating': '生成中...',
|
||||
'kgone.fullSong.hint.generatingProgress': '生成中... {pct}% — {stage}',
|
||||
'kgone.fullSong.hint.generatingProgressNoStage': '生成中... {pct}%',
|
||||
'kgone.fullSong.hint.drag': '將上方播放器拖至<strong>音訊音軌</strong>以匯入歌曲。完整歌曲生成不支援拖至 MIDI 音軌。',
|
||||
'kgone.fullSong.btn.generate': '生成歌曲',
|
||||
'kgone.fullSong.poweredBy': 'ACE-Step 1.5',
|
||||
// Remix tab
|
||||
'kgone.remix.field.captionPlaceholder': '例:風格:爵士,感覺:流暢悠揚,編排:鋼琴、立式貝斯、刷式鼓...',
|
||||
'kgone.remix.field.captionHint': '描述混音的目標風格、情緒和編排。',
|
||||
'kgone.remix.field.lyricsHint': '留空則保留原歌詞,或提供新歌詞。使用 [Verse]、[Chorus]、[Bridge] 標籤。',
|
||||
'kgone.remix.field.coverStrength': '翻唱強度',
|
||||
'kgone.remix.field.coverStrengthHint': '0 = 創意改編,1 = 忠實於原曲結構',
|
||||
'kgone.remix.field.noiseStrength': '噪聲強度',
|
||||
'kgone.remix.field.noiseStrengthHint': '0 = 純風格遷移,推薦 0.1–0.25',
|
||||
'kgone.remix.hint.submitting': '正在提交混音請求...',
|
||||
'kgone.remix.hint.generating': '正在生成混音...',
|
||||
'kgone.remix.hint.generatingProgress': '正在生成混音... {pct}% — {stage}',
|
||||
'kgone.remix.hint.generatingProgressNoStage': '正在生成混音... {pct}%',
|
||||
'kgone.remix.hint.drag': '將上方播放器拖至<strong>音訊音軌</strong>以匯入混音。',
|
||||
'kgone.remix.hint.noRegion': '請在時間軸上選擇一個音訊區域以進行混音。僅支援音訊區域,MIDI 區域不可混音。',
|
||||
'kgone.remix.btn.generate': '生成混音',
|
||||
'kgone.remix.btn.processingRemix': '處理混音中...',
|
||||
'kgone.remix.btn.importAligned': '對齊來源區域並匯入',
|
||||
'kgone.remix.poweredBy': 'ACE-Step 1.5',
|
||||
// Repaint tab
|
||||
'kgone.repaint.field.repaintRange': '重繪範圍',
|
||||
'kgone.repaint.field.loopModeOff': '循環模式已關閉。請啟用工具列上的循環按鈕並設定循環範圍,以定義重繪視窗。',
|
||||
'kgone.repaint.field.start': '開始',
|
||||
'kgone.repaint.field.end': '結束',
|
||||
'kgone.repaint.field.untilEnd': '直到音訊結尾',
|
||||
'kgone.repaint.field.captionPlaceholder': '例:風格:爵士,感覺:流暢悠揚,編排:鋼琴、立式貝斯、刷式鼓...',
|
||||
'kgone.repaint.field.captionHint': '描述重繪段落的目標風格和編排。',
|
||||
'kgone.repaint.field.lyricsHint': '留空則保留原歌詞,或為重繪段落提供新歌詞。',
|
||||
'kgone.repaint.field.repaintStrength': '重繪強度',
|
||||
'kgone.repaint.field.repaintStrengthHint': '0 = 保留原音,1 = 完全重新生成',
|
||||
'kgone.repaint.hint.submitting': '正在提交重繪請求...',
|
||||
'kgone.repaint.hint.generating': '正在生成重繪...',
|
||||
'kgone.repaint.hint.generatingProgress': '正在生成重繪... {pct}% — {stage}',
|
||||
'kgone.repaint.hint.generatingProgressNoStage': '正在生成重繪... {pct}%',
|
||||
'kgone.repaint.hint.drag': '將上方播放器拖至<strong>音訊音軌</strong>以匯入重繪結果。',
|
||||
'kgone.repaint.hint.noRegion': '請在時間軸上選擇一個音訊區域以進行重繪。僅支援音訊區域,MIDI 區域不可重繪。',
|
||||
'kgone.repaint.btn.generate': '生成重繪',
|
||||
'kgone.repaint.btn.processingRepaint': '處理重繪中...',
|
||||
'kgone.repaint.btn.importAligned': '對齊來源區域並匯入',
|
||||
'kgone.repaint.poweredBy': 'ACE-Step 1.5',
|
||||
// Separator tab
|
||||
'kgone.separator.field.separationModel': '分離模型',
|
||||
'kgone.separator.field.chunkDuration': '可選音訊分塊時長(秒)',
|
||||
'kgone.separator.field.chunkDurationPlaceholder': '留空則處理整個區域',
|
||||
'kgone.separator.field.modelOverlap': '模型重疊',
|
||||
'kgone.separator.hint.drag': '將上方各音軌播放器拖至<strong>音訊音軌</strong>以匯入。不支援拖至 MIDI 音軌進行音軌分離。',
|
||||
'kgone.separator.hint.noRegion.download': '請下載 {model},然後在時間軸上選擇一個音訊區域以提取音軌。',
|
||||
'kgone.separator.hint.noRegion.select': '請在時間軸上選擇一個音訊區域以提取音軌。僅支援音訊區域,MIDI 區域不可分離。',
|
||||
'kgone.separator.btn.separateStems': '分離音軌',
|
||||
'kgone.separator.btn.importAllStems': '將所有音軌匯入時間軸',
|
||||
'kgone.separator.poweredBy': 'UVR5 CLI',
|
||||
'kgone.separator.model.vocalInstMedium': '人聲與樂器(中等精度)',
|
||||
'kgone.separator.model.vocalInstHigh': '人聲與樂器(高精度)',
|
||||
'kgone.separator.model.6stem': '人聲、鼓、貝斯、吉他、鋼琴及其他',
|
||||
'kgone.separator.model.htdemucs4s': '人聲、鼓、貝斯及其他',
|
||||
'kgone.separator.btn.server.loadingModel': '載入模型中...',
|
||||
'kgone.separator.btn.server.generating': '準備上傳...',
|
||||
'kgone.separator.btn.server.polling': '正在分離音軌...',
|
||||
'kgone.separator.btn.server.downloading': '下載中...',
|
||||
'kgone.separator.btn.local.loadingModel': '準備本地模型...',
|
||||
'kgone.separator.btn.local.generating': '準備音訊...',
|
||||
'kgone.separator.btn.local.polling': '本地分離中...',
|
||||
'kgone.separator.btn.local.downloading': '收尾處理...',
|
||||
'kgone.separator.local.title': '本地分離模式',
|
||||
'kgone.separator.local.description': '如果 K.G.One Music Studio 不可用,本地分離模式提供一個可直接在瀏覽器中提取音軌的內建替代方案。提供兩種本地模型:人聲與樂器(中等精度),以及人聲、鼓、貝斯及其他。下方的下載狀態反映目前選定的模型。人聲與樂器(中等精度)的處理時間通常比人聲、鼓、貝斯及其他更長,總處理時間仍取決於您的硬體配置。如果處理回退至 CPU,分離運行期間頁面回應可能會暫時變慢。',
|
||||
'kgone.separator.local.learnMore': '了解更多關於 K.G.One Music Studio 伺服器整合的資訊。',
|
||||
'kgone.separator.local.provider': '目前裝置:',
|
||||
'kgone.separator.local.model': '模型:',
|
||||
'kgone.separator.local.downloaded': '已下載',
|
||||
'kgone.separator.local.notDownloaded': '未下載',
|
||||
'kgone.separator.local.checkingCache': '正在檢查本地模型緩存...',
|
||||
'kgone.separator.local.btn.download': '下載所選模型',
|
||||
'kgone.separator.local.btn.downloading': '模型下載中...',
|
||||
'kgone.separator.local.btn.redownload': '重新下載模型',
|
||||
'kgone.separator.local.btn.redownloading': '重新下載中...',
|
||||
'kgone.separator.local.btn.deleteCache': '刪除已緩存模型',
|
||||
'kgone.separator.local.btn.deleting': '刪除中...',
|
||||
'kgone.separator.local.progress.downloading': '正在下載 {name}...',
|
||||
'kgone.separator.local.progress.downloadingWithSize': '正在下載 {name}... {received} / {total} MB',
|
||||
'kgone.separator.local.progress.downloadingMbOnly': '正在下載 {name}... {received} MB',
|
||||
'kgone.separator.local.progress.ready': '{name} 已就緒。',
|
||||
'kgone.separator.local.progress.preparingRuntime': '正在準備 ONNX 運行時環境...',
|
||||
'kgone.separator.local.progress.readingAudio': '正在讀取音訊檔案...',
|
||||
'kgone.separator.local.progress.running': '正在瀏覽器中執行分離...',
|
||||
'kgone.separator.local.progress.complete': '分離完成。',
|
||||
|
||||
'openProjectModal.title': '開啟專案',
|
||||
'openProjectModal.close': '關閉',
|
||||
'openProjectModal.loading': '正在載入專案...',
|
||||
'openProjectModal.date.unknown': '未知',
|
||||
'openProjectModal.filter.placeholder': '篩選專案...',
|
||||
'openProjectModal.viewToggle.projects': '專案',
|
||||
'openProjectModal.viewToggle.trash': '回收站',
|
||||
'openProjectModal.trash.hint': '已刪除的專案將在 30 天後自動移除。',
|
||||
'openProjectModal.sort.name': '名稱',
|
||||
'openProjectModal.sort.created': '建立時間',
|
||||
'openProjectModal.sort.updated': '更新時間',
|
||||
'openProjectModal.empty.trash': '回收站為空',
|
||||
'openProjectModal.empty.noProjects': '暫無已儲存的專案',
|
||||
'openProjectModal.empty.noMatch': '沒有符合篩選條件的專案',
|
||||
'openProjectModal.meta.created': '建立於 {date}',
|
||||
'openProjectModal.meta.updated': '更新於 {date}',
|
||||
'openProjectModal.meta.deleted': '刪除於 {date}',
|
||||
'openProjectModal.action.open': '開啟',
|
||||
'openProjectModal.action.duplicate': '複製',
|
||||
'openProjectModal.action.delete': '刪除',
|
||||
'openProjectModal.action.restore': '還原',
|
||||
'openProjectModal.action.deletePermanently': '永久刪除',
|
||||
'openProjectModal.dialog.duplicateName': '請輸入複製專案的名稱:',
|
||||
'openProjectModal.dialog.invalidName': '專案名稱無效。只允許使用字母、數字、空格、連字符、底線、句號和括號。',
|
||||
'openProjectModal.dialog.duplicateError': '複製專案失敗:{error}',
|
||||
'openProjectModal.dialog.deleteError': '刪除專案失敗:{error}',
|
||||
'openProjectModal.dialog.restoreError': '還原專案失敗:{error}',
|
||||
'openProjectModal.dialog.confirmPermanentDelete': '確定要永久刪除"{name}"嗎?\n\n此操作無法撤銷。',
|
||||
'openProjectModal.dialog.permanentDeleteError': '永久刪除專案失敗:{error}',
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getPercussionKeyFullLabel, getPercussionKeyShortLabel, isGmDrumKitInstrument } from './percussion';
|
||||
import { translate } from './translate';
|
||||
|
||||
describe('percussion i18n helpers', () => {
|
||||
it('returns translated short labels for supported locales', () => {
|
||||
expect(getPercussionKeyShortLabel(35, (key, params) => translate(key, params, 'en_us'))).toBe('Ac.Bass');
|
||||
expect(getPercussionKeyShortLabel(35, (key, params) => translate(key, params, 'zh_cn'))).toBe('原底鼓');
|
||||
});
|
||||
|
||||
it('returns translated full labels when available', () => {
|
||||
expect(getPercussionKeyFullLabel(42, (key, params) => translate(key, params, 'en_us'))).toBe('Closed Hi Hat');
|
||||
expect(getPercussionKeyFullLabel(42, (key, params) => translate(key, params, 'zh_cn'))).toBe('闭合踩镲');
|
||||
});
|
||||
|
||||
it('falls back to English labels when translation is missing', () => {
|
||||
expect(getPercussionKeyShortLabel(42, () => '')).toBe('ClosedHH');
|
||||
expect(getPercussionKeyFullLabel(42, () => '')).toBe('Closed Hi Hat');
|
||||
});
|
||||
|
||||
it('returns null for unknown pitches', () => {
|
||||
expect(getPercussionKeyShortLabel(10, (key, params) => translate(key, params, 'en_us'))).toBeNull();
|
||||
expect(getPercussionKeyFullLabel(10, (key, params) => translate(key, params, 'en_us'))).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps drum-kit eligibility explicit', () => {
|
||||
expect(isGmDrumKitInstrument('standard')).toBe(true);
|
||||
expect(isGmDrumKitInstrument('orchestra_kit')).toBe(true);
|
||||
expect(isGmDrumKitInstrument('taiko_drum')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { InstrumentType } from '../core/track/KGMidiTrack';
|
||||
import { midiPercussionKeyMap } from '../util/midiUtil';
|
||||
import type { TranslationParams } from './types';
|
||||
|
||||
type TranslateFn = (key: string, params?: TranslationParams) => string;
|
||||
|
||||
const GM_DRUM_KIT_INSTRUMENTS: ReadonlySet<InstrumentType> = new Set([
|
||||
'standard',
|
||||
'orchestra_kit',
|
||||
]);
|
||||
|
||||
export function isGmDrumKitInstrument(instrument: InstrumentType): boolean {
|
||||
return GM_DRUM_KIT_INSTRUMENTS.has(instrument);
|
||||
}
|
||||
|
||||
export function getPercussionKeyShortLabel(pitch: number, t: TranslateFn): string | null {
|
||||
const drumInfo = midiPercussionKeyMap[pitch];
|
||||
if (!drumInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return t(`percussion.short.${pitch}`) || drumInfo.shortName;
|
||||
}
|
||||
|
||||
export function getPercussionKeyFullLabel(pitch: number, t: TranslateFn): string | null {
|
||||
const drumInfo = midiPercussionKeyMap[pitch];
|
||||
if (!drumInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return t(`percussion.full.${pitch}`) || drumInfo.fullName;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { translate } from './translate';
|
||||
|
||||
describe('translate', () => {
|
||||
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 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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { messagesByLocale } from './messages';
|
||||
import type { ResolvedLocaleCode, TranslationMessages, TranslationParams } from './types';
|
||||
|
||||
let currentLocale: ResolvedLocaleCode = 'en_us';
|
||||
|
||||
function interpolate(template: string, params?: TranslationParams): string {
|
||||
if (!params) {
|
||||
return template;
|
||||
}
|
||||
|
||||
return template.replace(/\{(\w+)\}/g, (_match, key: string) => {
|
||||
const value = params[key];
|
||||
return value === undefined ? `{${key}}` : String(value);
|
||||
});
|
||||
}
|
||||
|
||||
export function setCurrentLocale(locale: ResolvedLocaleCode): void {
|
||||
currentLocale = locale;
|
||||
}
|
||||
|
||||
export function getCurrentLocale(): ResolvedLocaleCode {
|
||||
return currentLocale;
|
||||
}
|
||||
|
||||
export function getMessagesForLocale(locale: ResolvedLocaleCode): TranslationMessages {
|
||||
return messagesByLocale[locale];
|
||||
}
|
||||
|
||||
export function translate(
|
||||
key: string,
|
||||
params?: TranslationParams,
|
||||
locale: ResolvedLocaleCode = currentLocale,
|
||||
): string {
|
||||
const localeMessages = messagesByLocale[locale];
|
||||
const englishMessages = messagesByLocale.en_us;
|
||||
const template = localeMessages[key] ?? englishMessages[key] ?? key;
|
||||
return interpolate(template, params);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export type LanguageSetting = 'auto' | 'en_us' | 'zh_cn' | 'zh_hk' | 'fr_fr';
|
||||
|
||||
export type ResolvedLocaleCode = Exclude<LanguageSetting, 'auto'>;
|
||||
|
||||
export type TranslationParams = Record<string, string | number>;
|
||||
|
||||
export type TranslationMessages = Record<string, string>;
|
||||
|
||||
export interface I18nContextValue {
|
||||
languageSetting: LanguageSetting;
|
||||
resolvedLocale: ResolvedLocaleCode;
|
||||
setLanguageSetting: (value: LanguageSetting) => Promise<void>;
|
||||
t: (key: string, params?: TranslationParams) => string;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { useContext } from 'react';
|
||||
import { I18nContext } from './I18nProvider';
|
||||
|
||||
export function useI18n() {
|
||||
return useContext(I18nContext);
|
||||
}
|
||||
+6
-3
@@ -12,6 +12,7 @@ import { ConfigManager } from './core/config/ConfigManager';
|
||||
import { KGMidiInput } from './core/midi-input/KGMidiInput';
|
||||
import { KGDebugger } from './core/KGDebugger';
|
||||
import { enumerateAudioDevices, validateConfiguredAudioDevices } from './util/audioDeviceUtil';
|
||||
import { I18nProvider } from './i18n/I18nProvider';
|
||||
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
|
||||
@@ -169,9 +170,11 @@ window.addEventListener('beforeunload', (event) => {
|
||||
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<DialogProvider>
|
||||
<App />
|
||||
</DialogProvider>
|
||||
<I18nProvider>
|
||||
<DialogProvider>
|
||||
<App />
|
||||
</DialogProvider>
|
||||
</I18nProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
} // end isSecureContext else
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
findTempoRegionAtBar,
|
||||
findTempoRegionAtBeat,
|
||||
} from './globalTrackUtil';
|
||||
import { translate } from '../i18n/translate';
|
||||
|
||||
export const DETECTED_TEMPO_ACTION_UPDATE_CURRENT = 'update-current-tempo';
|
||||
export const DETECTED_TEMPO_ACTION_INSERT_REGION = 'insert-tempo-change';
|
||||
@@ -16,7 +17,7 @@ export type DetectedTempoAction =
|
||||
| typeof DETECTED_TEMPO_ACTION_INSERT_REGION;
|
||||
|
||||
export function buildDetectedTempoChoiceMessage(bpm: number): string {
|
||||
return `Detected tempo: ${bpm} BPM. Choose how to apply it.\n\nUpdate Current Tempo changes the active tempo at this clip location. Insert Tempo Change adds a new tempo region at the nearest bar before the clip starts.`;
|
||||
return translate('dialog.message.tempoApply', { bpm });
|
||||
}
|
||||
|
||||
const ALIGNMENT_EPSILON = 1e-6;
|
||||
|
||||
@@ -59,6 +59,7 @@ describe('processUserMessage slash commands', () => {
|
||||
configState.set('general.claude_openrouter.api_key', '');
|
||||
configState.set('general.openai_compatible.base_url', '');
|
||||
configState.set('general.openai_compatible.model', '');
|
||||
configState.set('general.language', 'en_us');
|
||||
|
||||
configManagerMock.getIsInitialized.mockReturnValue(true);
|
||||
configManagerMock.initialize.mockClear();
|
||||
@@ -99,6 +100,41 @@ describe('processUserMessage slash commands', () => {
|
||||
expect(result.pseudoAssistantResponse).toContain('welcome_local_llm.md');
|
||||
});
|
||||
|
||||
it('uses the localized welcome asset when zh-CN is selected', async () => {
|
||||
configState.set('general.language', 'zh_cn');
|
||||
configState.set('general.llm_provider', 'local_browser');
|
||||
|
||||
const result = await processUserMessage('/welcome');
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('chat/welcome_local_llm-zh_cn.md'));
|
||||
expect(result.metadata).toMatchObject({ command: 'welcome', variant: 'local' });
|
||||
expect(result.pseudoAssistantResponse).toContain('welcome_local_llm-zh_cn.md');
|
||||
});
|
||||
|
||||
it('uses the 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');
|
||||
vi.stubGlobal('fetch', vi.fn(async () => {
|
||||
throw new Error('network down');
|
||||
}));
|
||||
|
||||
const result = await processUserMessage('/welcome');
|
||||
|
||||
expect(result.metadata).toMatchObject({ command: 'welcome' });
|
||||
expect(result.pseudoAssistantResponse).toBe('欢迎使用 K.G.Studio 音乐创作助手。');
|
||||
});
|
||||
|
||||
it('uses the new-user welcome for non-local providers without required config', async () => {
|
||||
configState.set('general.llm_provider', 'openai');
|
||||
configState.set('general.openai.api_key', '');
|
||||
@@ -132,6 +168,81 @@ describe('processUserMessage slash commands', () => {
|
||||
expect(message?.content).toContain('welcome_local_llm.md');
|
||||
});
|
||||
|
||||
it('uses localized welcome assets for addWelcomeMessage under auto + Chinese locale', async () => {
|
||||
configState.set('general.language', 'auto');
|
||||
vi.stubGlobal('navigator', {
|
||||
languages: ['zh-CN', 'en-US'],
|
||||
language: 'zh-CN',
|
||||
});
|
||||
|
||||
const message = await addWelcomeMessage();
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('chat/welcome_local_llm-zh_cn.md'));
|
||||
expect(message?.content).toContain('welcome_local_llm-zh_cn.md');
|
||||
});
|
||||
|
||||
it('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');
|
||||
|
||||
const result = await processUserMessage('/help');
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('chat/help-zh_cn.md'));
|
||||
expect(result).toMatchObject({
|
||||
displayUserMessage: false,
|
||||
sendToLLM: false,
|
||||
finalMessageForLLM: null,
|
||||
metadata: { command: 'help' },
|
||||
});
|
||||
expect(result.pseudoAssistantResponse).toContain('chat/help-zh_cn.md');
|
||||
});
|
||||
|
||||
it('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) => {
|
||||
const url = String(input);
|
||||
if (url.includes('chat/help-zh_cn.md')) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
text: async () => '',
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => `content:${url}`,
|
||||
};
|
||||
}));
|
||||
|
||||
const result = await processUserMessage('/help');
|
||||
|
||||
expect(fetch).toHaveBeenNthCalledWith(1, expect.stringContaining('chat/help-zh_cn.md'));
|
||||
expect(fetch).toHaveBeenNthCalledWith(2, expect.stringContaining('chat/help.md'));
|
||||
expect(result.pseudoAssistantResponse).toContain('chat/help.md');
|
||||
});
|
||||
|
||||
it('fetches the hotkeys guide for /hotkeys', async () => {
|
||||
const result = await processUserMessage('/hotkeys');
|
||||
|
||||
@@ -145,6 +256,24 @@ 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');
|
||||
|
||||
const result = await processUserMessage('/hotkeys');
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('chat/hotkeys-zh_cn.md'));
|
||||
expect(result.pseudoAssistantResponse).toContain('chat/hotkeys-zh_cn.md');
|
||||
});
|
||||
|
||||
it('supports /hotkey as an alias of /hotkeys', async () => {
|
||||
const result = await processUserMessage('/hotkey');
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@ import { useProjectStore } from '../../stores/projectStore';
|
||||
import { ConfigManager } from '../../core/config/ConfigManager';
|
||||
import { SystemPrompts } from '../../agent/core/SystemPrompts';
|
||||
import { detectLocalLLMRuntimeSupport, LOCAL_LLM_PROVIDER_KEY } from '../localLLMConfig';
|
||||
import { normalizeLanguageSetting, resolveLanguageSetting } from '../../i18n/locale';
|
||||
import type { ResolvedLocaleCode } from '../../i18n/types';
|
||||
import { translate } from '../../i18n/translate';
|
||||
|
||||
export interface UserMessageFilterResult {
|
||||
// Whether to render the user message bubble (div.message-user)
|
||||
@@ -59,6 +62,48 @@ function getWelcomeUrl(variant: 'local' | 'new' | 'again'): string {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCurrentCommandLocale(configManager: ConfigManager): ResolvedLocaleCode {
|
||||
const languageSetting = normalizeLanguageSetting(configManager.get('general.language'));
|
||||
return resolveLanguageSetting(languageSetting);
|
||||
}
|
||||
|
||||
function resolveLocalizedChatMarkdownUrl(baseFileName: string, locale: ResolvedLocaleCode): string {
|
||||
if (locale === 'en_us') {
|
||||
return `${import.meta.env.BASE_URL}chat/${baseFileName}`;
|
||||
}
|
||||
|
||||
const extensionIndex = baseFileName.lastIndexOf('.');
|
||||
const localizedFileName = extensionIndex >= 0
|
||||
? `${baseFileName.slice(0, extensionIndex)}-${locale}${baseFileName.slice(extensionIndex)}`
|
||||
: `${baseFileName}-${locale}`;
|
||||
return `${import.meta.env.BASE_URL}chat/${localizedFileName}`;
|
||||
}
|
||||
|
||||
async function fetchLocalizedChatMarkdown(
|
||||
configManager: ConfigManager,
|
||||
baseFileName: string,
|
||||
): Promise<string> {
|
||||
const locale = resolveCurrentCommandLocale(configManager);
|
||||
const localizedUrl = resolveLocalizedChatMarkdownUrl(baseFileName, locale);
|
||||
const fallbackUrl = `${import.meta.env.BASE_URL}chat/${baseFileName}`;
|
||||
|
||||
const urlsToTry = locale === 'en_us' || localizedUrl === fallbackUrl
|
||||
? [fallbackUrl]
|
||||
: [localizedUrl, fallbackUrl];
|
||||
|
||||
let lastStatus = 'unknown';
|
||||
|
||||
for (const url of urlsToTry) {
|
||||
const resp = await fetch(url);
|
||||
if (resp.ok) {
|
||||
return await resp.text();
|
||||
}
|
||||
lastStatus = String(resp.status);
|
||||
}
|
||||
|
||||
throw new Error(`Failed to fetch chat markdown ${baseFileName}: ${lastStatus}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a user message before it is displayed or sent to the LLM.
|
||||
* Handles slash-commands and returns a structured decision.
|
||||
@@ -89,19 +134,15 @@ export async function processUserMessage(originalMessage: string): Promise<UserM
|
||||
}
|
||||
|
||||
case '/welcome': {
|
||||
const configManager = ConfigManager.instance();
|
||||
try {
|
||||
const configManager = ConfigManager.instance();
|
||||
if (!configManager.getIsInitialized()) {
|
||||
await configManager.initialize();
|
||||
}
|
||||
|
||||
const variant = getWelcomeVariant(configManager);
|
||||
const url = getWelcomeUrl(variant);
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Failed to fetch ${url}: ${resp.status}`);
|
||||
}
|
||||
const md = await resp.text();
|
||||
const baseFileName = getWelcomeUrl(variant).split('/').pop() ?? 'welcome_new.md';
|
||||
const md = await fetchLocalizedChatMarkdown(configManager, baseFileName);
|
||||
return {
|
||||
displayUserMessage: false,
|
||||
sendToLLM: false,
|
||||
@@ -110,7 +151,8 @@ export async function processUserMessage(originalMessage: string): Promise<UserM
|
||||
metadata: { command: 'welcome', variant }
|
||||
};
|
||||
} catch (err) {
|
||||
const fallback = 'Welcome to K.G.Studio Musician Assistant.';
|
||||
const locale = resolveCurrentCommandLocale(configManager);
|
||||
const fallback = translate('assistant.welcomeFallback', undefined, locale);
|
||||
return {
|
||||
displayUserMessage: false,
|
||||
sendToLLM: false,
|
||||
@@ -123,12 +165,12 @@ export async function processUserMessage(originalMessage: string): Promise<UserM
|
||||
|
||||
case '/help': {
|
||||
try {
|
||||
const url = `${import.meta.env.BASE_URL}chat/help.md`;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Failed to fetch ${url}: ${resp.status}`);
|
||||
const configManager = ConfigManager.instance();
|
||||
if (!configManager.getIsInitialized()) {
|
||||
await configManager.initialize();
|
||||
}
|
||||
const md = await resp.text();
|
||||
|
||||
const md = await fetchLocalizedChatMarkdown(configManager, 'help.md');
|
||||
return {
|
||||
displayUserMessage: false,
|
||||
sendToLLM: false,
|
||||
@@ -151,12 +193,12 @@ export async function processUserMessage(originalMessage: string): Promise<UserM
|
||||
case '/hotkeys':
|
||||
case '/hotkey': {
|
||||
try {
|
||||
const url = `${import.meta.env.BASE_URL}chat/hotkeys.md`;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Failed to fetch ${url}: ${resp.status}`);
|
||||
const configManager = ConfigManager.instance();
|
||||
if (!configManager.getIsInitialized()) {
|
||||
await configManager.initialize();
|
||||
}
|
||||
const md = await resp.text();
|
||||
|
||||
const md = await fetchLocalizedChatMarkdown(configManager, 'hotkeys.md');
|
||||
return {
|
||||
displayUserMessage: false,
|
||||
sendToLLM: false,
|
||||
|
||||
+2
-1
@@ -23,6 +23,7 @@ export default defineConfig({
|
||||
'dist',
|
||||
'.git',
|
||||
'.cache',
|
||||
'src/test/browser/**',
|
||||
// Temporarily skip failing integration tests
|
||||
'src/test/integration/store/command-execution.integration.test.ts',
|
||||
'src/test/integration/store/project-store-sync.integration.test.ts'
|
||||
@@ -59,4 +60,4 @@ export default defineConfig({
|
||||
// Retry failed tests once
|
||||
retry: 1
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user