From 957f6db95016be2410529897612bd837deddadf2 Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Sat, 30 May 2026 20:02:55 -0700 Subject: [PATCH] feat: added i18n support; added Simplified Chinese support --- README-zh_cn.md | 453 ++++++++++++++++ README.md | 2 + public/chat/help-zh_cn.md | 126 +++++ public/chat/hotkeys-zh_cn.md | 59 ++ public/chat/welcome_again-zh_cn.md | 11 + public/chat/welcome_local_llm-zh_cn.md | 29 + public/chat/welcome_new-zh_cn.md | 25 + public/config.json | 2 +- src/components/EventListPanel.test.tsx | 40 +- src/components/EventListPanel.tsx | 12 +- src/components/InstrumentSelection.test.tsx | 78 +++ src/components/InstrumentSelection.tsx | 24 +- src/components/StatusBar.tsx | 7 +- src/components/Toolbar.tsx | 223 ++++---- src/components/common/DialogProvider.tsx | 81 +-- src/components/common/FileImportModal.tsx | 22 +- src/components/common/LoadingOverlay.tsx | 4 +- .../event-list-panel/GlobalEventListTab.tsx | 96 ++-- .../event-list-panel/RegionEventListTab.tsx | 61 ++- .../event-list-panel/TrackEventListTab.tsx | 46 +- .../MainContentGlobalTracksSection.tsx | 25 +- src/components/piano-roll/PianoKeys.test.tsx | 92 +++- src/components/piano-roll/PianoKeys.tsx | 13 +- src/components/piano-roll/PianoRoll.css | 17 + src/components/piano-roll/PianoRoll.tsx | 39 +- .../PianoRollAutomationLane.test.tsx | 56 +- .../piano-roll/PianoRollAutomationLane.tsx | 15 +- .../piano-roll/PianoRollToolbar.test.tsx | 122 ++++- .../piano-roll/PianoRollToolbar.tsx | 112 ++-- .../PianoRollZoomPersistence.test.tsx | 10 +- .../piano-roll/pianoRollAutomation.ts | 33 +- .../piano-roll/pianoRollSnap.test.ts | 14 + src/components/piano-roll/pianoRollSnap.ts | 9 +- src/components/settings/SettingsSidebar.tsx | 16 +- .../sections/GeneralSettings.test.tsx | 45 +- .../settings/sections/GeneralSettings.tsx | 183 ++++--- src/components/track/TrackInfoItem.tsx | 5 +- src/core/config/ConfigManager.ts | 5 +- src/core/state/KGPianoRollState.ts | 53 +- src/hooks/useNoteOperations.ts | 4 +- src/i18n/I18nProvider.test.tsx | 88 +++ src/i18n/I18nProvider.tsx | 76 +++ src/i18n/instruments.test.ts | 20 + src/i18n/instruments.ts | 25 + src/i18n/locale.test.ts | 33 ++ src/i18n/locale.ts | 40 ++ src/i18n/messages/en_us.ts | 510 ++++++++++++++++++ src/i18n/messages/index.ts | 8 + src/i18n/messages/zh_cn.ts | 510 ++++++++++++++++++ src/i18n/percussion.test.ts | 31 ++ src/i18n/percussion.ts | 32 ++ src/i18n/translate.test.ts | 19 + src/i18n/translate.ts | 38 ++ src/i18n/types.ts | 14 + src/i18n/useI18n.ts | 6 + src/main.tsx | 9 +- .../messageFilter/UserMessageFilter.test.ts | 74 +++ src/util/messageFilter/UserMessageFilter.ts | 72 ++- 58 files changed, 3354 insertions(+), 520 deletions(-) create mode 100644 README-zh_cn.md create mode 100644 public/chat/help-zh_cn.md create mode 100644 public/chat/hotkeys-zh_cn.md create mode 100644 public/chat/welcome_again-zh_cn.md create mode 100644 public/chat/welcome_local_llm-zh_cn.md create mode 100644 public/chat/welcome_new-zh_cn.md create mode 100644 src/components/InstrumentSelection.test.tsx create mode 100644 src/components/piano-roll/pianoRollSnap.test.ts create mode 100644 src/i18n/I18nProvider.test.tsx create mode 100644 src/i18n/I18nProvider.tsx create mode 100644 src/i18n/instruments.test.ts create mode 100644 src/i18n/instruments.ts create mode 100644 src/i18n/locale.test.ts create mode 100644 src/i18n/locale.ts create mode 100644 src/i18n/messages/en_us.ts create mode 100644 src/i18n/messages/index.ts create mode 100644 src/i18n/messages/zh_cn.ts create mode 100644 src/i18n/percussion.test.ts create mode 100644 src/i18n/percussion.ts create mode 100644 src/i18n/translate.test.ts create mode 100644 src/i18n/translate.ts create mode 100644 src/i18n/types.ts create mode 100644 src/i18n/useI18n.ts diff --git a/README-zh_cn.md b/README-zh_cn.md new file mode 100644 index 0000000..929c611 --- /dev/null +++ b/README-zh_cn.md @@ -0,0 +1,453 @@ +[English](./README.md) | 简体中文 + +
+ K.G.Studio Logo +
+ +# K.G.Studio — 一款基于浏览器的 DAW,并内置 AI 助手 + +
+

◀ 立即在浏览器中在线使用 K.G.Studio ▶

+
+ +## 新动态 + +> ### ✦ [K.G.One Music Studio 现已发布。](https://github.com/KGAudioLab/K.G.One) ✦
+> [**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 Musician Assistant** 为核心。它提供基于 Tone.js sampler 的真实乐器回放、钢琴卷帘编辑器、支持完整撤销/重做的音轨与区域管理、基于 OPFS(Origin Private File System)的项目持久化、可配置的设置面板,以及可执行工具的内置 AI 助手。 + +**K.G.Studio Musician Assistant** 是一个面向和声、编曲与音符编辑的 AI 助手,但并不负责整首作品的全自动作曲。 + +
+ K.G.One Logo +
+ +> 注意:整曲生成功能和音频片段生成功能需要集成 [**K.G.One Music Studio**](https://github.com/KGAudioLab/K.G.One)。 + +## 最新更新 + +- **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 Musician Assistant** 新增 **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 区域**。 +
+ K.G.Studio Logo +
+ +- **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 辅助音乐制作边界的过程中,偶尔出现不稳定情况也是可以预期的。 + +## 演示视频 + +
+ + + + + + +
+ + K.G.One Music Studio + +
K.G.One Music Studio +
+ + Short Demo + +
短演示(仅 DAW) +
+ + Full Demo + +
完整演示(仅 DAW) +
+
+ +## 快速开始 + +### 配置 K.G.Studio Musician Assistant + +使用 K.G.Studio Musician Assistant 有两种方式:一种是 **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 Musician Assistant + + - 选中您希望助手处理的音乐区域,在聊天框中输入提示词;按 Enter 发送,按 Shift+Enter 插入换行。 + - agent 会自动处理您的请求,并调用工具,在所选区域范围内执行修改。某些任务可能需要一轮或多轮对话才能完成。 + - 请注意,AI 也可能出错,因此您应始终检查结果,并在必要时自行调整。您也可以通过撤销/重做回退修改。 + - 点击 “+” 按钮或输入 `/clear` 命令可清空聊天历史。 + +### 更多说明 + +您可以在[这里](./docs/USER_GUIDE.md)查看详细用户指南。 + +### 亮点功能 +- **K.G.Studio Musician Assistant**:与由 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 Musician Assistant + +- 请先按照前文说明完成 LLM 提供方设置。 +- 您可以在右侧找到 K.G.Studio Musician Assistant 聊天框。如果当前未显示,可点击工具栏中的 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` 中。 diff --git a/README.md b/README.md index 3e9b8a7..8ecf73c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +English | [简体中文](./README-zh_cn.md) +
K.G.Studio Logo
diff --git a/public/chat/help-zh_cn.md b/public/chat/help-zh_cn.md new file mode 100644 index 0000000..993ebac --- /dev/null +++ b/public/chat/help-zh_cn.md @@ -0,0 +1,126 @@ +## 帮助 + +欢迎使用 **K.G.Studio Musician Assistant**。这是您的 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 Musician Assistant 创作愉快! diff --git a/public/chat/hotkeys-zh_cn.md b/public/chat/hotkeys-zh_cn.md new file mode 100644 index 0000000..8ca4a29 --- /dev/null +++ b/public/chat/hotkeys-zh_cn.md @@ -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` diff --git a/public/chat/welcome_again-zh_cn.md b/public/chat/welcome_again-zh_cn.md new file mode 100644 index 0000000..2989906 --- /dev/null +++ b/public/chat/welcome_again-zh_cn.md @@ -0,0 +1,11 @@ +## 再次欢迎 + +很高兴再次见到您。检测到您的 LLM 提供方已经配置完成,现在可以直接开始聊天。 + +提示: +- 随时输入 `/clear` 重置当前聊天。 +- 输入 `/welcome` 再次查看这条欢迎消息。 +- 输入 `/help` 查看帮助说明。 +- 输入 `/hotkeys` 查看快捷键指南。 + +祝你在 K.G.Studio Musician Assistant 中创作顺利。 diff --git a/public/chat/welcome_local_llm-zh_cn.md b/public/chat/welcome_local_llm-zh_cn.md new file mode 100644 index 0000000..4d14bd3 --- /dev/null +++ b/public/chat/welcome_local_llm-zh_cn.md @@ -0,0 +1,29 @@ +## 本地 LLM 模式 + +欢迎使用 **K.G.Studio Musician Assistant** 的本地 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**。 +- 切换提供方后,建议开启一个新的对话,以便聊天会话干净地使用新模型。 diff --git a/public/chat/welcome_new-zh_cn.md b/public/chat/welcome_new-zh_cn.md new file mode 100644 index 0000000..0c12336 --- /dev/null +++ b/public/chat/welcome_new-zh_cn.md @@ -0,0 +1,25 @@ +## 欢迎 + +欢迎使用 **K.G.Studio Musician Assistant**。这是您在浏览器中的 AI 音乐创作与编曲助手。 + +### 快速开始 +**配置 K.G.Studio Musician Assistant** +- [点击这里获取免费的 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 提供方均无隶属关系。所有数据默认都保存在您的本地设备中;您发送给第三方服务的数据由您自行负责。 diff --git a/public/config.json b/public/config.json index 17e41cd..e9c1bd7 100644 --- a/public/config.json +++ b/public/config.json @@ -1,6 +1,6 @@ { "general": { - "language": "en_us", + "language": "auto", "llm_provider": "local_browser", "persist_api_keys_non_localhost": false, "openai": { diff --git a/src/components/EventListPanel.test.tsx b/src/components/EventListPanel.test.tsx index 28215b7..89aefac 100644 --- a/src/components/EventListPanel.test.tsx +++ b/src/components/EventListPanel.test.tsx @@ -26,6 +26,8 @@ import { createMockMidiTrack, } from '../test/utils/mock-data'; import { showAlert } from '../util/dialogUtil'; +import { I18nContext } from '../i18n/I18nProvider'; +import { translate } from '../i18n/translate'; const clickDropdownOption = (label: string) => { const option = Array.from(document.querySelectorAll('.quant-option')) @@ -185,6 +187,19 @@ vi.mock('../core/KGCore', () => ({ })); describe('EventListPanel', () => { + const renderWithLocale = (locale: 'en_us' | 'zh_cn' = 'en_us') => render( + undefined, + t: (key, params) => translate(key, params, locale), + }} + > + + + ); + beforeEach(() => { project = new KGProject( 'Test Project', @@ -460,7 +475,7 @@ describe('EventListPanel', () => { expect(screen.getAllByRole('button', { name: 'Marker' })[0]).toBeInTheDocument(); expect(screen.getAllByRole('button', { name: 'Tempo' })[0]).toBeInTheDocument(); - expect(screen.getAllByRole('button', { name: 'Key Signature' })[0]).toBeInTheDocument(); + expect(screen.getAllByRole('button', { name: 'Key Sig.' })[0]).toBeInTheDocument(); expect(screen.getAllByRole('button', { name: 'Chord' })[0]).toBeInTheDocument(); expect(screen.getByText('Intro')).toBeInTheDocument(); expect(screen.getByText('120')).toBeInTheDocument(); @@ -554,4 +569,27 @@ describe('EventListPanel', () => { expect((tempoTrack.getRegions()[0] as KGTempoRegion).getStartBar()).toBe(0); }); }); + + it('renders translated event-list controls in zh-CN', () => { + renderWithLocale('zh_cn'); + + expect(screen.getAllByRole('button', { name: '音符' }).length).toBeGreaterThan(0); + expect(screen.getAllByRole('button', { name: '弯音' }).length).toBeGreaterThan(0); + expect(screen.getAllByRole('button', { name: '控制器' }).length).toBeGreaterThan(0); + + fireEvent.click(screen.getByRole('button', { name: '音轨' })); + expect(screen.getAllByRole('button', { name: '区域' }).length).toBeGreaterThan(0); + expect(screen.getByRole('button', { name: '音量' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '声像' })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: '全局' })); + expect(screen.getAllByRole('button', { name: '标记' }).length).toBeGreaterThan(0); + expect(screen.getByRole('button', { name: '速度' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '调号' })).toBeInTheDocument(); + expect(screen.getAllByRole('button', { name: '和弦' }).length).toBeGreaterThan(0); + expect(screen.getByRole('columnheader', { name: '位置' })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: '状态' })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: '数值' })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: '长度/信息' })).toBeInTheDocument(); + }); }); diff --git a/src/components/EventListPanel.tsx b/src/components/EventListPanel.tsx index 747bc4e..c03470f 100644 --- a/src/components/EventListPanel.tsx +++ b/src/components/EventListPanel.tsx @@ -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 = ({ isVisible }) => { + const { t } = useI18n(); const { tracks, globalTracks, @@ -55,30 +57,30 @@ const EventListPanel: React.FC = ({ isVisible }) => { return (
-

Event List

+

{t('eventList.title')}

-
+
diff --git a/src/components/InstrumentSelection.test.tsx b/src/components/InstrumentSelection.test.tsx new file mode 100644 index 0000000..ba4279e --- /dev/null +++ b/src/components/InstrumentSelection.test.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import InstrumentSelection from './InstrumentSelection'; +import { KGMidiTrack } from '../core/track/KGMidiTrack'; +import { I18nContext } from '../i18n/I18nProvider'; +import { translate } from '../i18n/translate'; + +const midiTrack = new KGMidiTrack('Lead Track', 1, 'acoustic_grand_piano'); + +const storeState = { + tracks: [midiTrack], + selectedTrackId: '1', + closeInstrumentSelection: vi.fn(), + setTrackInstrument: vi.fn().mockResolvedValue(undefined), +}; + +vi.mock('../stores/projectStore', () => ({ + useProjectStore: () => storeState, +})); + +function renderWithLocale(resolvedLocale: 'en_us' | 'zh_cn') { + return render( + undefined, + t: (key, params) => translate(key, params, resolvedLocale), + }} + > + + , + ); +} + +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( + undefined, + t: (key, params) => translate(key, params, 'zh_cn'), + }} + > + + , + ); + + 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'); + }); +}); diff --git a/src/components/InstrumentSelection.tsx b/src/components/InstrumentSelection.tsx index 8602c53..303281c 100644 --- a/src/components/InstrumentSelection.tsx +++ b/src/components/InstrumentSelection.tsx @@ -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>(() => { 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 = () => {
- {groups.map(([key, label]) => ( + {groups.map((key) => (
handleSelectGroup(key)} > - {label} + {getInstrumentGroupLabel(key, t)}
))}
@@ -113,5 +115,3 @@ const InstrumentSelection: React.FC = () => { }; export default InstrumentSelection; - - diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx index 9018eb4..3826775 100644 --- a/src/components/StatusBar.tsx +++ b/src/components/StatusBar.tsx @@ -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 = () => { diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 8f82e82..d4d3710 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -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 = () => {
- - - + + +
- +
- - + +
- - - + + +
- + {!isPlaying ? ( - + ) : ( - + )}
- + {/* */}
@@ -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} @@ -1216,21 +1223,21 @@ const Toolbar: React.FC = () => {
@@ -307,7 +301,7 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =

{dialog.message}

{detectionHintText && (
-
{DETECTION_HINT_TITLE}
+
{t('dialog.experimentalFeature')}
{detectionHintText}
)} @@ -356,14 +350,14 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) = {isChordDetection && (
-
{AUDIO_CHORD_SOURCE_HINT_TITLE}
+
{t('dialog.recommendedSource')}
- {isKGOneEnabled ? AUDIO_CHORD_SOURCE_HINT_KGONE_TEXT : AUDIO_CHORD_SOURCE_HINT_LOCAL_TEXT} + {isKGOneEnabled ? t('dialog.sourceHint.kgone') : t('dialog.sourceHint.local')}
- + {chordDetectionOptions.sensitivity}
= ({ children }) =
- + {chordDetectionOptions.stability}
= ({ children }) =
- + {chordDetectionOptions.noChordThreshold}
= ({ children }) = checked={chordDetectionOptions.enableSevenths} onChange={(e) => updateChordDetectionOption('enableSevenths', e.target.checked)} /> - Chord Detail: Enable sevenths + {t('dialog.label.enableSevenths')}
)} @@ -425,7 +419,7 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
- +
- +
)} @@ -468,7 +462,7 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
- + {tempoDetectionOptions.minTempo}
= ({ children }) =
- + {tempoDetectionOptions.maxTempo}
= ({ children }) = onChange={(e) => setAutoAlignRegionToBeat(e.target.checked)} autoFocus /> - Auto-align region to beat + {t('dialog.label.autoAlignRegionToBeat')}
)} @@ -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')} )} {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')))} )}
diff --git a/src/components/common/FileImportModal.tsx b/src/components/common/FileImportModal.tsx index bffecf2..e87ad93 100644 --- a/src/components/common/FileImportModal.tsx +++ b/src/components/common/FileImportModal.tsx @@ -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 = ({ 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 = ({ 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 = ({ >
-

{title}

+

{resolvedTitle}

@@ -120,17 +124,17 @@ const FileImportModal: React.FC = ({ >
📁
-

{description}

+

{resolvedDescription}

- Supported formats: {acceptedTypes.join(', ')} + {t('fileImport.supportedFormats', { formats: acceptedTypes.join(', ') })}

- or + {t('fileImport.or')}