feat: added i18n support; added Simplified Chinese support

This commit is contained in:
Xiaohan-Tian
2026-05-30 20:02:55 -07:00
parent c17d169dc6
commit 957f6db950
58 changed files with 3354 additions and 520 deletions
+453
View File
@@ -0,0 +1,453 @@
[English](./README.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 Musician Assistant** 为核心。它提供基于 Tone.js sampler 的真实乐器回放、钢琴卷帘编辑器、支持完整撤销/重做的音轨与区域管理、基于 OPFSOrigin Private File System)的项目持久化、可配置的设置面板,以及可执行工具的内置 AI 助手。
**K.G.Studio Musician Assistant** 是一个面向和声、编曲与音符编辑的 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.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 MBvocals / 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** 面板(✦ 按钮),下载模型一次后,即可完全在本机将人声与伴奏分离。以上两项功能都要求浏览器支持 WebGPUChrome 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 迁移到 OPFSOrigin 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 Musician Assistant
使用 K.G.Studio Musician Assistant 有两种方式:一种是 **Local LLM (Browser)**,完全在浏览器中运行,无需 API Key、无需费用,且数据不会离开您的设备;另一种是使用 **外部 LLM 提供方**,以获得更高质量的回复。
#### 方案 ALocal 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)会自动下载,并缓存在浏览器的 OPFSOrigin 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**:通过 WebGPULiteRT-LM)在浏览器中直接运行 **Gemma 4 E4B**。无 API 调用、无费用、数据不离开您的设备。模型只需下载一次,随后会缓存在本地。
- **浏览器内嵌分轨,无需服务器**:使用 **UVR-MDX-NET-Inst_HQ_3**2-stemVocals / Instrumental)或 **Demucs htdemucs_4s**4-stemVocals / 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 完全在客户端运行。运行应用不需要任何第一方服务器。
- 项目和音频文件保存在浏览器的 OPFSOrigin 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)、MuteM)和 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)、SSubdominant)或 DDominant)功能。
- 将鼠标悬停在任意琴键上时,会以红色高亮显示上下文相关的和弦建议,并匹配您当前选择的调号与调式。
- 按 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
- 钢琴卷帘
- 工具:SelectQ)、PencilW
- 按住以创建音符: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,以及 ThinkingCoT 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` 中。
+2
View File
@@ -1,3 +1,5 @@
English | [简体中文](./README-zh_cn.md)
<div align="center"> <div align="center">
<img src="./public/logo.png" alt="K.G.Studio Logo" width="160" /> <img src="./public/logo.png" alt="K.G.Studio Logo" width="160" />
</div> </div>
+126
View File
@@ -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)、MuteM)和 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 stemVocals / Instrumental,约 64 MB)和 **Demucs htdemucs_4s**4 stemVocals / Drums / Bass / Others,约 172 MB)。在生成器面板中下载模型后,点击 **Separate Stems** 即可。需要 WebGPUChrome 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 创作愉快!
+59
View File
@@ -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`
+11
View File
@@ -0,0 +1,11 @@
## 再次欢迎
很高兴再次见到您。检测到您的 LLM 提供方已经配置完成,现在可以直接开始聊天。
提示:
- 随时输入 `/clear` 重置当前聊天。
- 输入 `/welcome` 再次查看这条欢迎消息。
- 输入 `/help` 查看帮助说明。
- 输入 `/hotkeys` 查看快捷键指南。
祝你在 K.G.Studio Musician Assistant 中创作顺利。
+29
View File
@@ -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**
- 切换提供方后,建议开启一个新的对话,以便聊天会话干净地使用新模型。
+25
View File
@@ -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 提供方均无隶属关系。所有数据默认都保存在您的本地设备中;您发送给第三方服务的数据由您自行负责。
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"general": { "general": {
"language": "en_us", "language": "auto",
"llm_provider": "local_browser", "llm_provider": "local_browser",
"persist_api_keys_non_localhost": false, "persist_api_keys_non_localhost": false,
"openai": { "openai": {
+39 -1
View File
@@ -26,6 +26,8 @@ import {
createMockMidiTrack, createMockMidiTrack,
} from '../test/utils/mock-data'; } from '../test/utils/mock-data';
import { showAlert } from '../util/dialogUtil'; import { showAlert } from '../util/dialogUtil';
import { I18nContext } from '../i18n/I18nProvider';
import { translate } from '../i18n/translate';
const clickDropdownOption = (label: string) => { const clickDropdownOption = (label: string) => {
const option = Array.from(document.querySelectorAll('.quant-option')) const option = Array.from(document.querySelectorAll('.quant-option'))
@@ -185,6 +187,19 @@ vi.mock('../core/KGCore', () => ({
})); }));
describe('EventListPanel', () => { describe('EventListPanel', () => {
const renderWithLocale = (locale: 'en_us' | 'zh_cn' = 'en_us') => render(
<I18nContext.Provider
value={{
languageSetting: locale,
resolvedLocale: locale,
setLanguageSetting: async () => undefined,
t: (key, params) => translate(key, params, locale),
}}
>
<EventListPanel isVisible={true} />
</I18nContext.Provider>
);
beforeEach(() => { beforeEach(() => {
project = new KGProject( project = new KGProject(
'Test Project', 'Test Project',
@@ -460,7 +475,7 @@ describe('EventListPanel', () => {
expect(screen.getAllByRole('button', { name: 'Marker' })[0]).toBeInTheDocument(); expect(screen.getAllByRole('button', { name: 'Marker' })[0]).toBeInTheDocument();
expect(screen.getAllByRole('button', { name: 'Tempo' })[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.getAllByRole('button', { name: 'Chord' })[0]).toBeInTheDocument();
expect(screen.getByText('Intro')).toBeInTheDocument(); expect(screen.getByText('Intro')).toBeInTheDocument();
expect(screen.getByText('120')).toBeInTheDocument(); expect(screen.getByText('120')).toBeInTheDocument();
@@ -554,4 +569,27 @@ describe('EventListPanel', () => {
expect((tempoTrack.getRegions()[0] as KGTempoRegion).getStartBar()).toBe(0); 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 -5
View File
@@ -7,6 +7,7 @@ import { KGAudioTrack } from '../core/track/KGAudioTrack';
import RegionEventListTab from './event-list-panel/RegionEventListTab'; import RegionEventListTab from './event-list-panel/RegionEventListTab';
import TrackEventListTab from './event-list-panel/TrackEventListTab'; import TrackEventListTab from './event-list-panel/TrackEventListTab';
import GlobalEventListTab from './event-list-panel/GlobalEventListTab'; import GlobalEventListTab from './event-list-panel/GlobalEventListTab';
import { useI18n } from '../i18n/useI18n';
interface EventListPanelProps { interface EventListPanelProps {
isVisible: boolean; isVisible: boolean;
@@ -15,6 +16,7 @@ interface EventListPanelProps {
type ScopeTab = 'region' | 'track' | 'global'; type ScopeTab = 'region' | 'track' | 'global';
const EventListPanel: React.FC<EventListPanelProps> = ({ isVisible }) => { const EventListPanel: React.FC<EventListPanelProps> = ({ isVisible }) => {
const { t } = useI18n();
const { const {
tracks, tracks,
globalTracks, globalTracks,
@@ -55,30 +57,30 @@ const EventListPanel: React.FC<EventListPanelProps> = ({ isVisible }) => {
return ( return (
<div className={`event-list-panel${isVisible ? '' : ' is-hidden'}`}> <div className={`event-list-panel${isVisible ? '' : ' is-hidden'}`}>
<div className="event-list-panel-header"> <div className="event-list-panel-header">
<h3>Event List</h3> <h3>{t('eventList.title')}</h3>
</div> </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 <button
className={`event-list-scope-tab${scopeTab === 'region' ? ' active' : ''}`} className={`event-list-scope-tab${scopeTab === 'region' ? ' active' : ''}`}
type="button" type="button"
onClick={() => setScopeTab('region')} onClick={() => setScopeTab('region')}
> >
Region {t('eventList.scope.region')}
</button> </button>
<button <button
className={`event-list-scope-tab${scopeTab === 'track' ? ' active' : ''}`} className={`event-list-scope-tab${scopeTab === 'track' ? ' active' : ''}`}
type="button" type="button"
onClick={() => setScopeTab('track')} onClick={() => setScopeTab('track')}
> >
Track {t('eventList.scope.track')}
</button> </button>
<button <button
className={`event-list-scope-tab${scopeTab === 'global' ? ' active' : ''}`} className={`event-list-scope-tab${scopeTab === 'global' ? ' active' : ''}`}
type="button" type="button"
onClick={() => setScopeTab('global')} onClick={() => setScopeTab('global')}
> >
Global {t('eventList.scope.global')}
</button> </button>
</div> </div>
@@ -0,0 +1,78 @@
import React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import InstrumentSelection from './InstrumentSelection';
import { KGMidiTrack } from '../core/track/KGMidiTrack';
import { I18nContext } from '../i18n/I18nProvider';
import { translate } from '../i18n/translate';
const midiTrack = new KGMidiTrack('Lead Track', 1, 'acoustic_grand_piano');
const storeState = {
tracks: [midiTrack],
selectedTrackId: '1',
closeInstrumentSelection: vi.fn(),
setTrackInstrument: vi.fn().mockResolvedValue(undefined),
};
vi.mock('../stores/projectStore', () => ({
useProjectStore: () => storeState,
}));
function renderWithLocale(resolvedLocale: 'en_us' | 'zh_cn') {
return render(
<I18nContext.Provider
value={{
languageSetting: resolvedLocale,
resolvedLocale,
setLanguageSetting: async () => undefined,
t: (key, params) => translate(key, params, resolvedLocale),
}}
>
<InstrumentSelection />
</I18nContext.Provider>,
);
}
describe('InstrumentSelection', () => {
beforeEach(() => {
storeState.setTrackInstrument.mockClear();
midiTrack.setInstrument('acoustic_grand_piano');
});
it('renders translated group and instrument names under zh-CN', () => {
renderWithLocale('zh_cn');
expect(screen.getByText('钢琴与键盘')).toBeTruthy();
expect(screen.getAllByText('原声大钢琴').length).toBeGreaterThan(0);
});
it('updates visible labels when locale changes', () => {
const view = renderWithLocale('en_us');
expect(screen.getByText('Piano and Keyboards')).toBeTruthy();
expect(screen.getAllByText('Acoustic Grand Piano').length).toBeGreaterThan(0);
view.rerender(
<I18nContext.Provider
value={{
languageSetting: 'zh_cn',
resolvedLocale: 'zh_cn',
setLanguageSetting: async () => undefined,
t: (key, params) => translate(key, params, 'zh_cn'),
}}
>
<InstrumentSelection />
</I18nContext.Provider>,
);
expect(screen.getByText('钢琴与键盘')).toBeTruthy();
expect(screen.getAllByText('原声大钢琴').length).toBeGreaterThan(0);
});
it('keeps instrument selection behavior on the same instrument key', async () => {
renderWithLocale('zh_cn');
fireEvent.click(screen.getByText('电钢琴 1'));
expect(storeState.setTrackInstrument).toHaveBeenCalledWith(1, 'electric_piano_1');
});
});
+12 -12
View File
@@ -4,8 +4,11 @@ import { useProjectStore } from '../stores/projectStore';
import { INSTRUMENT_GROUPS, FLUIDR3_INSTRUMENT_MAP } from '../constants/generalMidiConstants'; import { INSTRUMENT_GROUPS, FLUIDR3_INSTRUMENT_MAP } from '../constants/generalMidiConstants';
import { KGMidiTrack, type InstrumentType } from '../core/track/KGMidiTrack'; import { KGMidiTrack, type InstrumentType } from '../core/track/KGMidiTrack';
import { KGAudioTrack } from '../core/track/KGAudioTrack'; import { KGAudioTrack } from '../core/track/KGAudioTrack';
import { useI18n } from '../i18n/useI18n';
import { getInstrumentDisplayName, getInstrumentGroupLabel, type InstrumentGroupKey } from '../i18n/instruments';
const InstrumentSelection: React.FC = () => { const InstrumentSelection: React.FC = () => {
const { t } = useI18n();
const { const {
tracks, tracks,
selectedTrackId, selectedTrackId,
@@ -31,24 +34,23 @@ const InstrumentSelection: React.FC = () => {
setSelectedGroupKey(currentInstrumentDef?.group || 'PIANO_AND_KEYBOARDS'); setSelectedGroupKey(currentInstrumentDef?.group || 'PIANO_AND_KEYBOARDS');
}, [selectedTrackId, currentInstrumentKey, currentInstrumentDef]); }, [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) return Object.entries(FLUIDR3_INSTRUMENT_MAP)
.filter((entry) => entry[1].group === selectedGroupKey) .filter((entry) => entry[1].group === selectedGroupKey)
.map((entry) => ({ key: entry[0], label: entry[1].displayName })); .map((entry) => ({ key: entry[0] as InstrumentType, label: getInstrumentDisplayName(entry[0] as InstrumentType, t) }));
}, [selectedGroupKey]); }, [selectedGroupKey, t]);
const handleSelectGroup = (groupKey: string) => { const handleSelectGroup = (groupKey: string) => {
setSelectedGroupKey(groupKey); setSelectedGroupKey(groupKey);
}; };
const handleSelectInstrument = async (instrumentKey: string) => { const handleSelectInstrument = async (instrumentKey: InstrumentType) => {
// If no valid target track, ignore user interaction // If no valid target track, ignore user interaction
if (!targetTrack || !(targetTrack instanceof KGMidiTrack)) return; if (!targetTrack || !(targetTrack instanceof KGMidiTrack)) return;
const instrument = instrumentKey as InstrumentType;
try { try {
await setTrackInstrument(targetTrack.getId(), instrument); await setTrackInstrument(targetTrack.getId(), instrumentKey);
} catch (err) { } catch (err) {
console.error('Failed to change instrument from panel:', err); console.error('Failed to change instrument from panel:', err);
} }
@@ -56,7 +58,7 @@ const InstrumentSelection: React.FC = () => {
const isAudioTrack = targetTrack instanceof KGAudioTrack; const isAudioTrack = targetTrack instanceof KGAudioTrack;
const previewImage = isAudioTrack ? 'speaker.png' : (FLUIDR3_INSTRUMENT_MAP[currentInstrumentKey]?.image || 'piano.png'); 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; const hasTargetTrack = !!targetTrack;
return ( return (
@@ -82,13 +84,13 @@ const InstrumentSelection: React.FC = () => {
<div className="instrument-selection-bottom"> <div className="instrument-selection-bottom">
<div className="instrument-groups"> <div className="instrument-groups">
<div className="instrument-groups-list"> <div className="instrument-groups-list">
{groups.map(([key, label]) => ( {groups.map((key) => (
<div <div
key={key} key={key}
className={`instrument-group-item${selectedGroupKey === key ? ' active' : ''}`} className={`instrument-group-item${selectedGroupKey === key ? ' active' : ''}`}
onClick={() => handleSelectGroup(key)} onClick={() => handleSelectGroup(key)}
> >
{label} {getInstrumentGroupLabel(key, t)}
</div> </div>
))} ))}
</div> </div>
@@ -113,5 +115,3 @@ const InstrumentSelection: React.FC = () => {
}; };
export default InstrumentSelection; export default InstrumentSelection;
+6 -1
View File
@@ -3,9 +3,14 @@ import './StatusBar.css';
import { useProjectStore } from '../stores/projectStore'; import { useProjectStore } from '../stores/projectStore';
import { KGPianoRollState } from '../core/state/KGPianoRollState'; import { KGPianoRollState } from '../core/state/KGPianoRollState';
import type { ResolvedChordGuideItem } from '../core/ChordGuideTypes'; import type { ResolvedChordGuideItem } from '../core/ChordGuideTypes';
import { translate } from '../i18n/translate';
function formatChordGuideCandidateStatus(candidate: ResolvedChordGuideItem): string { 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 = () => { const StatusBar: React.FC = () => {
+115 -108
View File
@@ -40,8 +40,10 @@ import MetronomeIcon from './common/icons/MetronomeIcon';
import { mergeSelectedMidiRegions, splitSelectedRegionAtPlayhead } from '../util/regionEditUtil'; import { mergeSelectedMidiRegions, splitSelectedRegionAtPlayhead } from '../util/regionEditUtil';
import { showAlert, showChoice, showConfirm, showPrompt, showTimeSigPrompt } from '../util/dialogUtil'; import { showAlert, showChoice, showConfirm, showPrompt, showTimeSigPrompt } from '../util/dialogUtil';
import { UpdateKeySignatureRegionCommand, UpdateTempoRegionCommand } from '../core/commands'; import { UpdateKeySignatureRegionCommand, UpdateTempoRegionCommand } from '../core/commands';
import { useI18n } from '../i18n/useI18n';
const Toolbar: React.FC = () => { const Toolbar: React.FC = () => {
const { t } = useI18n();
const { const {
projectName, setProjectName, projectName, setProjectName,
savedProjectName, setSavedProjectName, savedProjectName, setSavedProjectName,
@@ -116,18 +118,23 @@ const Toolbar: React.FC = () => {
}, [showZoomSlider]); }, [showZoomSlider]);
// Export options // 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 lastSelectedRegionId = selectedRegionIds[selectedRegionIds.length - 1] ?? null;
const handleProjectNameClick = async () => { const handleProjectNameClick = async () => {
const newName = await showPrompt("Enter project name:", projectName); const newName = await showPrompt(t('toolbar.projectName.prompt'), projectName);
if (!newName) return; if (!newName) return;
if (!isValidProjectName(newName)) { 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; return;
} }
if (isReservedProjectName(newName)) { 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; return;
} }
@@ -147,7 +154,7 @@ const Toolbar: React.FC = () => {
const exists = await storage.exists(newName); const exists = await storage.exists(newName);
if (exists) { if (exists) {
const confirmed = await showConfirm( const confirmed = await showConfirm(
`Project "${newName}" already exists. Do you want to overwrite it?` t('toolbar.projectName.overwrite', { name: newName })
); );
if (!confirmed) return; if (!confirmed) return;
setProjectName(newName); setProjectName(newName);
@@ -166,11 +173,11 @@ const Toolbar: React.FC = () => {
} }
// Ask whether the user wants to rename or save as a copy // Ask whether the user wants to rename or save as a copy
const choice = await showChoice( const choice = await showChoice(
"Would you like to rename this project, or save it as a new copy?", t('toolbar.projectName.renameOrCopy'),
[ [
{ label: 'Save as Copy', value: 'saveas' }, { label: t('toolbar.projectName.saveAsCopy'), value: 'saveas' },
{ label: 'Rename', value: 'rename' }, { label: t('toolbar.projectName.rename'), value: 'rename' },
] ]
); );
if (!choice) return; if (!choice) return;
@@ -181,7 +188,7 @@ const Toolbar: React.FC = () => {
const exists = await storage.exists(newName); const exists = await storage.exists(newName);
if (exists) { if (exists) {
const confirmed = await showConfirm( const confirmed = await showConfirm(
`Project "${newName}" already exists. Do you want to overwrite it?` t('toolbar.projectName.overwrite', { name: newName })
); );
if (!confirmed) return; if (!confirmed) return;
setProjectName(newName); setProjectName(newName);
@@ -204,10 +211,10 @@ const Toolbar: React.FC = () => {
await storage.saveAs(savedProjectName, finalName, KGCore.instance().getCurrentProject()); await storage.saveAs(savedProjectName, finalName, KGCore.instance().getCurrentProject());
setProjectName(finalName); setProjectName(finalName);
setSavedProjectName(finalName); setSavedProjectName(finalName);
setStatus(`Saved as "${finalName}"`); setStatus(t('toolbar.projectName.saveAsCopy') + ` "${finalName}"`);
} catch (error) { } catch (error) {
console.error('Error saving project as copy:', 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 // Update status to indicate project loaded
setStatus(`${sourceDescription} loaded successfully`); setStatus(t('toolbar.status.projectLoaded', { description: sourceDescription }));
if (DEBUG_MODE.TOOLBAR) { if (DEBUG_MODE.TOOLBAR) {
console.log(`project loaded successfully from ${sourceDescription}`); console.log(`project loaded successfully from ${sourceDescription}`);
@@ -240,8 +247,8 @@ const Toolbar: React.FC = () => {
} catch (error) { } catch (error) {
console.error(`Error loading project from ${sourceDescription}:`, error); console.error(`Error loading project from ${sourceDescription}:`, error);
setStatus(`Failed to load project: ${error}`); setStatus(t('toolbar.status.loadFailed', { error: String(error) }));
await showAlert(`An error occurred while loading the project: ${error}`); await showAlert(t('toolbar.load.error', { error: String(error) }));
} }
}; };
@@ -258,7 +265,7 @@ const Toolbar: React.FC = () => {
const { loadProject: storeLoadProject } = useProjectStore.getState(); const { loadProject: storeLoadProject } = useProjectStore.getState();
storeLoadProject(newProject); storeLoadProject(newProject);
setStatus(`New project "${newProject.getName()}" created`); setStatus(t('toolbar.status.newProjectCreated', { name: newProject.getName() }));
if (DEBUG_MODE.TOOLBAR) { if (DEBUG_MODE.TOOLBAR) {
console.log("new project created successfully"); console.log("new project created successfully");
@@ -267,7 +274,7 @@ const Toolbar: React.FC = () => {
// Handler functions for file operations // Handler functions for file operations
const handleNewProject = async () => { 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) { if (confirmed) {
createNewProject(); createNewProject();
} }
@@ -288,14 +295,14 @@ const Toolbar: React.FC = () => {
const loadedProject = await storage.load(projectNameToLoad); const loadedProject = await storage.load(projectNameToLoad);
if (!loadedProject) { if (!loadedProject) {
await showAlert(`Project "${projectNameToLoad}" not found.`); await showAlert(t('toolbar.project.notFound', { name: projectNameToLoad }));
return; return;
} }
await loadProjectFromData(loadedProject, `Project "${projectNameToLoad}"`, projectNameToLoad); await loadProjectFromData(loadedProject, `Project "${projectNameToLoad}"`, projectNameToLoad);
} catch (error) { } catch (error) {
console.error("Error loading project:", 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 { } finally {
setIsOpeningProject(false); setIsOpeningProject(false);
} }
@@ -303,7 +310,7 @@ const Toolbar: React.FC = () => {
const handleConfirmOpenProject = async () => { const handleConfirmOpenProject = async () => {
const confirmed = await showConfirm( const confirmed = await showConfirm(
'Open this project? Any unsaved changes in the current project will be lost.' t('toolbar.openProject.confirm')
); );
return confirmed; return confirmed;
}; };
@@ -326,13 +333,13 @@ const Toolbar: React.FC = () => {
console.log("user selected export option:", exportType); console.log("user selected export option:", exportType);
} }
if (exportType === "Export to KGStudio file") { if (exportType === 'kgstudio') {
handleExportKGStudio(); handleExportKGStudio();
} else if (exportType === "Export to MIDI file") { } else if (exportType === 'midi') {
handleExportMIDI(); handleExportMIDI();
} else if (exportType === "Export to WAV") { } else if (exportType === 'wav') {
handleBounceToWav(); handleBounceToWav();
} else if (exportType === "Export to MP3") { } else if (exportType === 'mp3') {
handleBounceToMp3(); handleBounceToMp3();
} }
@@ -362,7 +369,7 @@ const Toolbar: React.FC = () => {
document.body.removeChild(link); document.body.removeChild(link);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
setStatus(`Project "${projectName}" exported as KGStudio file`); setStatus(t('toolbar.status.projectExportedKgstudio', { name: projectName }));
if (DEBUG_MODE.TOOLBAR) { if (DEBUG_MODE.TOOLBAR) {
console.log("KGStudio export completed successfully"); console.log("KGStudio export completed successfully");
@@ -370,8 +377,8 @@ const Toolbar: React.FC = () => {
} catch (error) { } catch (error) {
console.error("Error exporting KGStudio file:", error); console.error("Error exporting KGStudio file:", error);
setStatus(`Error exporting project: ${error}`); setStatus(t('toolbar.status.exportProjectError', { error: String(error) }));
await showAlert(`Failed to export project: ${error}`); await showAlert(t('toolbar.export.failedProject', { error: String(error) }));
} }
}; };
@@ -404,7 +411,7 @@ const Toolbar: React.FC = () => {
document.body.removeChild(link); document.body.removeChild(link);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
setStatus(`Project "${projectName}" exported as MIDI file`); setStatus(t('toolbar.status.projectExportedMidi', { name: projectName }));
if (DEBUG_MODE.TOOLBAR) { if (DEBUG_MODE.TOOLBAR) {
console.log("MIDI export completed successfully"); console.log("MIDI export completed successfully");
@@ -412,8 +419,8 @@ const Toolbar: React.FC = () => {
} catch (error) { } catch (error) {
console.error("Error exporting MIDI:", error); console.error("Error exporting MIDI:", error);
setStatus(`Error exporting MIDI: ${error}`); setStatus(t('toolbar.status.exportMidiError', { error: String(error) }));
await showAlert(`Failed to export project as MIDI: ${error}`); await showAlert(t('toolbar.export.failedMidi', { error: String(error) }));
} }
}; };
@@ -425,11 +432,11 @@ const Toolbar: React.FC = () => {
try { try {
const currentProject = KGCore.instance().getCurrentProject(); const currentProject = KGCore.instance().getCurrentProject();
await KGOfflineRenderer.instance().bounceToWav(currentProject, projectName); await KGOfflineRenderer.instance().bounceToWav(currentProject, projectName);
setStatus(`Project "${projectName}" exported as WAV file`); setStatus(t('toolbar.status.projectExportedWav', { name: projectName }));
} catch (error) { } catch (error) {
console.error("Error bouncing to WAV:", error); console.error("Error bouncing to WAV:", error);
setStatus(`Error exporting WAV: ${error}`); setStatus(t('toolbar.status.exportWavError', { error: String(error) }));
await showAlert(`Failed to export project as WAV: ${error}`); await showAlert(t('toolbar.export.failedWav', { error: String(error) }));
} }
}; };
@@ -441,11 +448,11 @@ const Toolbar: React.FC = () => {
try { try {
const currentProject = KGCore.instance().getCurrentProject(); const currentProject = KGCore.instance().getCurrentProject();
await KGOfflineRenderer.instance().bounceToMp3(currentProject, projectName); await KGOfflineRenderer.instance().bounceToMp3(currentProject, projectName);
setStatus(`Project "${projectName}" exported as MP3 file`); setStatus(t('toolbar.status.projectExportedMp3', { name: projectName }));
} catch (error) { } catch (error) {
console.error("Error bouncing to MP3:", error); console.error("Error bouncing to MP3:", error);
setStatus(`Error exporting MP3: ${error}`); setStatus(t('toolbar.status.exportMp3Error', { error: String(error) }));
await showAlert(`Failed to export project as MP3: ${error}`); await showAlert(t('toolbar.export.failedMp3', { error: String(error) }));
} }
}; };
@@ -480,8 +487,8 @@ const Toolbar: React.FC = () => {
} catch (error) { } catch (error) {
console.error("Error importing file:", error); console.error("Error importing file:", error);
setStatus(`Failed to import file: ${error}`); setStatus(t('toolbar.status.importFailed', { error: String(error) }));
await showAlert(`Failed to import project file: ${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); console.log("KGStudio file imported successfully:", projectName);
} }
} catch (error) { } catch (error) {
await showAlert(`The .kgstudio file is corrupted or invalid: ${error}`); await showAlert(t('toolbar.import.corruptedKgstudio', { error: String(error) }));
throw error; throw error;
} }
}; };
@@ -555,7 +562,7 @@ const Toolbar: React.FC = () => {
} }
// Show loading status // Show loading status
setStatus(`Importing MIDI file "${file.name}"...`); setStatus(t('toolbar.status.importingMidi', { name: file.name }));
// Read the MIDI file as binary data // Read the MIDI file as binary data
const arrayBuffer = await file.arrayBuffer(); const arrayBuffer = await file.arrayBuffer();
@@ -585,7 +592,7 @@ const Toolbar: React.FC = () => {
} catch (error) { } catch (error) {
console.error("Error importing MIDI file:", error); console.error("Error importing MIDI file:", error);
const errorMessage = error instanceof Error ? error.message : String(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}`); throw new Error(`Invalid MIDI file: ${errorMessage}`);
} }
}; };
@@ -599,7 +606,7 @@ const Toolbar: React.FC = () => {
await startPlaying(); await startPlaying();
} catch (error) { } catch (error) {
console.error("Failed to start playback:", 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 { try {
await stopTransport(); await stopTransport();
if (isRecording) { if (isRecording) {
setStatus("Recording stopped — notes committed"); setStatus(t('toolbar.status.recordingStoppedCommitted'));
} }
} catch (error) { } catch (error) {
console.error("Failed to stop playback:", 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 // Prompt to change max bars when clicking on current-time display
const handleCurrentTimeClick = async () => { const handleCurrentTimeClick = async () => {
const MIN_BARS = 16; 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) { if (newMaxBarsStr === null) {
return; // cancelled return; // cancelled
} }
const parsed = parseInt(newMaxBarsStr.trim(), 10); const parsed = parseInt(newMaxBarsStr.trim(), 10);
if (isNaN(parsed)) { if (isNaN(parsed)) {
await showAlert('Invalid input. Please enter a valid number.'); await showAlert(t('toolbar.input.invalidNumber'));
return; return;
} }
if (parsed < MIN_BARS) { if (parsed < MIN_BARS) {
await showAlert(`Invalid value. Please enter a number >= ${MIN_BARS}.`); await showAlert(t('toolbar.maxBars.invalid', { min: MIN_BARS }));
return; return;
} }
setMaxBars(parsed); setMaxBars(parsed);
setStatus(`Max bars changed to ${parsed}`); setStatus(t('toolbar.status.maxBarsChanged', { value: parsed }));
}; };
const handleBpmClick = async () => { const handleBpmClick = async () => {
@@ -659,7 +666,7 @@ const Toolbar: React.FC = () => {
console.log("BPM clicked, current BPM:", bpm); 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 // Check if user cancelled
if (newBpmStr === null) { if (newBpmStr === null) {
@@ -671,13 +678,13 @@ const Toolbar: React.FC = () => {
// Check if it's a valid number // Check if it's a valid number
if (isNaN(newBpm)) { if (isNaN(newBpm)) {
await showAlert("Invalid input. Please enter a valid number."); await showAlert(t('toolbar.input.invalidNumber'));
return; return;
} }
// Check if it's within valid range // Check if it's within valid range
if (newBpm <= TIME_CONSTANTS.MIN_BPM || newBpm >= TIME_CONSTANTS.MAX_BPM) { 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; return;
} }
@@ -689,7 +696,7 @@ const Toolbar: React.FC = () => {
} else { } else {
setBpm(newBpm); setBpm(newBpm);
} }
setStatus(`BPM changed to ${newBpm}`); setStatus(t('toolbar.status.bpmChanged', { value: newBpm }));
if (DEBUG_MODE.TOOLBAR) { if (DEBUG_MODE.TOOLBAR) {
console.log(`BPM updated from ${displayedBpm} to ${newBpm}`); 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}`); 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; if (result === null) return;
const newTimeSignature = parseTimeSignature(`${result.numerator}/${result.denominator}`); const newTimeSignature = parseTimeSignature(`${result.numerator}/${result.denominator}`);
@@ -711,7 +718,7 @@ const Toolbar: React.FC = () => {
} }
setTimeSignature(newTimeSignature); 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) { if (DEBUG_MODE.TOOLBAR) {
console.log(`Time signature updated to ${newTimeSignature.numerator}/${newTimeSignature.denominator}`); console.log(`Time signature updated to ${newTimeSignature.numerator}/${newTimeSignature.denominator}`);
@@ -732,7 +739,7 @@ const Toolbar: React.FC = () => {
} else { } else {
setKeySignature(newKeySignature as KeySignature); setKeySignature(newKeySignature as KeySignature);
} }
setStatus(`Key signature changed to ${newKeySignature}`); setStatus(t('toolbar.status.keySignatureChanged', { value: newKeySignature }));
setShowKeySignatureDropdown(false); setShowKeySignatureDropdown(false);
}; };
@@ -764,12 +771,12 @@ const Toolbar: React.FC = () => {
const copied = handleCopyOperation(); const copied = handleCopyOperation();
if (copied) { if (copied) {
setStatus("Items copied to clipboard"); setStatus(t('toolbar.status.copied'));
if (DEBUG_MODE.TOOLBAR) { if (DEBUG_MODE.TOOLBAR) {
console.log("Items copied successfully"); console.log("Items copied successfully");
} }
} else { } else {
setStatus("No items selected to copy"); setStatus(t('toolbar.status.copyNone'));
if (DEBUG_MODE.TOOLBAR) { if (DEBUG_MODE.TOOLBAR) {
console.log("No items were selected for copying"); console.log("No items were selected for copying");
} }
@@ -785,12 +792,12 @@ const Toolbar: React.FC = () => {
const pasted = handlePasteOperation(); const pasted = handlePasteOperation();
if (pasted) { if (pasted) {
setStatus("Items pasted from clipboard"); setStatus(t('toolbar.status.pasted'));
if (DEBUG_MODE.TOOLBAR) { if (DEBUG_MODE.TOOLBAR) {
console.log("Items pasted successfully"); console.log("Items pasted successfully");
} }
} else { } else {
setStatus("Cannot paste - no valid clipboard content or context"); setStatus(t('toolbar.status.pasteFailed'));
if (DEBUG_MODE.TOOLBAR) { if (DEBUG_MODE.TOOLBAR) {
console.log("Paste operation failed or no valid context"); console.log("Paste operation failed or no valid context");
} }
@@ -806,12 +813,12 @@ const Toolbar: React.FC = () => {
const deleted = regionDeleteManager.deleteSelectedRegions(); const deleted = regionDeleteManager.deleteSelectedRegions();
if (deleted) { if (deleted) {
setStatus("Selected regions deleted"); setStatus(t('toolbar.status.deleted'));
if (DEBUG_MODE.TOOLBAR) { if (DEBUG_MODE.TOOLBAR) {
console.log("Regions deleted successfully"); console.log("Regions deleted successfully");
} }
} else { } else {
setStatus("No regions selected for deletion"); setStatus(t('toolbar.status.deleteNone'));
if (DEBUG_MODE.TOOLBAR) { if (DEBUG_MODE.TOOLBAR) {
console.log("No regions were selected for deletion"); console.log("No regions were selected for deletion");
} }
@@ -863,13 +870,13 @@ const Toolbar: React.FC = () => {
} }
if (!canUndo) { if (!canUndo) {
await showAlert("Nothing to undo"); await showAlert(t('toolbar.undo.none'));
return; return;
} }
undo(); undo();
const description = undoDescription || "action"; const description = undoDescription || t('toolbar.action');
setStatus(`Undid: ${description}`); setStatus(t('toolbar.status.undid', { description }));
if (DEBUG_MODE.TOOLBAR) { if (DEBUG_MODE.TOOLBAR) {
console.log(`Undo successful: ${description}`); console.log(`Undo successful: ${description}`);
@@ -883,13 +890,13 @@ const Toolbar: React.FC = () => {
} }
if (!canRedo) { if (!canRedo) {
await showAlert("Nothing to redo"); await showAlert(t('toolbar.redo.none'));
return; return;
} }
redo(); redo();
const description = redoDescription || "action"; const description = redoDescription || t('toolbar.action');
setStatus(`Redid: ${description}`); setStatus(t('toolbar.status.redid', { description }));
if (DEBUG_MODE.TOOLBAR) { if (DEBUG_MODE.TOOLBAR) {
console.log(`Redo successful: ${description}`); console.log(`Redo successful: ${description}`);
@@ -907,7 +914,7 @@ const Toolbar: React.FC = () => {
} else { } else {
toggleChatBox(); toggleChatBox();
} }
setStatus("Chat toggled"); setStatus(t('toolbar.status.chatToggled'));
}; };
// Handle settings button click // Handle settings button click
@@ -917,7 +924,7 @@ const Toolbar: React.FC = () => {
} }
toggleSettings(); toggleSettings();
setStatus("Settings toggled"); setStatus(t('toolbar.status.settingsToggled'));
}; };
// K.G.One panel toggle // K.G.One panel toggle
@@ -967,7 +974,7 @@ const Toolbar: React.FC = () => {
if (DEBUG_MODE.TOOLBAR) { if (DEBUG_MODE.TOOLBAR) {
console.log('No active or selected region; piano roll will not open'); 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; return;
} }
@@ -988,21 +995,21 @@ const Toolbar: React.FC = () => {
const handleRecordClick = async () => { const handleRecordClick = async () => {
if (isRecording) { if (isRecording) {
await stopRecording(); await stopRecording();
setStatus("Recording stopped"); setStatus(t('toolbar.status.recordingStopped'));
return; return;
} }
const selectedTrack = useProjectStore.getState().tracks.find(track => track.getId().toString() === selectedTrackId) ?? null; const selectedTrack = useProjectStore.getState().tracks.find(track => track.getId().toString() === selectedTrackId) ?? null;
if (selectedTrack instanceof KGAudioTrack) { if (selectedTrack instanceof KGAudioTrack) {
await startRecording(); await startRecording();
setStatus("Audio recording started..."); setStatus(t('toolbar.status.audioRecordingStarted'));
return; return;
} }
// Require an active or selected MIDI region // Require an active or selected MIDI region
const candidateId = activeRegionId ?? lastSelectedRegionId; const candidateId = activeRegionId ?? lastSelectedRegionId;
if (!candidateId) { if (!candidateId) {
await showAlert("Please open a MIDI region in the Piano Roll before starting recording."); await showAlert(t('toolbar.recording.openMidiRegion'));
return; return;
} }
const tracks = KGCore.instance().getCurrentProject().getTracks(); const tracks = KGCore.instance().getCurrentProject().getTracks();
@@ -1012,13 +1019,13 @@ const Toolbar: React.FC = () => {
if (region) { isMidi = region instanceof KGMidiRegion; break; } if (region) { isMidi = region instanceof KGMidiRegion; break; }
} }
if (!isMidi) { if (!isMidi) {
await showAlert("Please select a MIDI region before starting recording."); await showAlert(t('toolbar.recording.selectMidiRegion'));
return; return;
} }
// Require at least one connected MIDI device // Require at least one connected MIDI device
if (KGMidiInput.instance().getConnectedInputCount() === 0) { 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; return;
} }
@@ -1029,7 +1036,7 @@ const Toolbar: React.FC = () => {
} }
await startRecording(); await startRecording();
setStatus("Recording started..."); setStatus(t('toolbar.status.recordingStarted'));
}; };
return ( return (
@@ -1049,12 +1056,12 @@ const Toolbar: React.FC = () => {
</div> </div>
<div className="toolbar-center"> <div className="toolbar-center">
<button title="New" onClick={handleNewProject}><FaPlus /></button> <button title={t('toolbar.button.new')} onClick={handleNewProject}><FaPlus /></button>
<button title="Load" onClick={handleLoadProject}><FaFolderOpen /></button> <button title={t('toolbar.button.load')} onClick={handleLoadProject}><FaFolderOpen /></button>
<button title="Save" onClick={handleSaveProject}><FaSave /></button> <button title={t('toolbar.button.save')} onClick={handleSaveProject}><FaSave /></button>
<div style={{ position: 'relative', display: 'inline-block' }}> <div style={{ position: 'relative', display: 'inline-block' }}>
<button <button
title="Export" title={t('toolbar.button.export')}
onClick={() => setShowExportDropdown(!showExportDropdown)} onClick={() => setShowExportDropdown(!showExportDropdown)}
style={{ display: 'flex', alignItems: 'center', gap: '4px' }} 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 }}> <div style={{ position: 'absolute', top: '100%', left: 0, zIndex: 10000 }}>
<KGDropdown <KGDropdown
options={exportOptions} options={exportOptions}
value={exportOptions[0]} value={exportOptions[0].value}
onChange={handleExportProject} onChange={handleExportProject}
label="Export" label={t('toolbar.export.label')}
hideButton={true} hideButton={true}
isOpen={showExportDropdown} isOpen={showExportDropdown}
onToggle={setShowExportDropdown} onToggle={setShowExportDropdown}
@@ -1073,71 +1080,71 @@ const Toolbar: React.FC = () => {
/> />
</div> </div>
</div> </div>
<button title="Import" onClick={handleImportProject}><FaUpload /></button> <button title={t('toolbar.button.import')} onClick={handleImportProject}><FaUpload /></button>
<button <button
title="Settings" title={t('toolbar.button.settings')}
className={`tool-button ${showSettings ? 'active' : ''}`} className={`tool-button ${showSettings ? 'active' : ''}`}
onClick={handleSettingsClick} onClick={handleSettingsClick}
> >
<FaCog /> <FaCog />
</button> </button>
<div className="toolbar-separator"></div> <div className="toolbar-separator"></div>
<button title="Undo" onClick={handleUndoClick}><FaUndo /></button> <button title={t('toolbar.button.undo')} onClick={handleUndoClick}><FaUndo /></button>
<button title="Redo" onClick={handleRedoClick}><FaRedo /></button> <button title={t('toolbar.button.redo')} onClick={handleRedoClick}><FaRedo /></button>
<div className="toolbar-separator"></div> <div className="toolbar-separator"></div>
<button <button
title="Select" title={t('toolbar.button.select')}
className={`tool-button ${activeMainTool === 'pointer' ? 'active' : ''}`} className={`tool-button ${activeMainTool === 'pointer' ? 'active' : ''}`}
onClick={() => handleMainToolSelect('pointer')} onClick={() => handleMainToolSelect('pointer')}
> >
<FaMousePointer /> <FaMousePointer />
</button> </button>
<button <button
title="Pencil" title={t('toolbar.button.pencil')}
className={`tool-button ${activeMainTool === 'pencil' ? 'active' : ''}`} className={`tool-button ${activeMainTool === 'pencil' ? 'active' : ''}`}
onClick={() => handleMainToolSelect('pencil')} onClick={() => handleMainToolSelect('pencil')}
> >
<FaPencil /> <FaPencil />
</button> </button>
<button <button
title="Split Region at Playhead" title={t('toolbar.button.splitRegion')}
onClick={handleSplitClick} onClick={handleSplitClick}
> >
<FaCut /> <FaCut />
</button> </button>
<button <button
title="Merge Selected MIDI Regions" title={t('toolbar.button.mergeRegions')}
onClick={handleMergeClick} onClick={handleMergeClick}
> >
<FaCompress /> <FaCompress />
</button> </button>
<button <button
title="Snap to Grid" title={t('toolbar.button.snapToGrid')}
className={`tool-button ${isSnapping ? 'active' : ''}`} className={`tool-button ${isSnapping ? 'active' : ''}`}
onClick={handleSnappingToggle} onClick={handleSnappingToggle}
> >
<FaMagnet /> <FaMagnet />
</button> </button>
<div className="toolbar-separator"></div> <div className="toolbar-separator"></div>
<button title="Copy" onClick={handleCopyClick}><FaCopy /></button> <button title={t('toolbar.button.copy')} onClick={handleCopyClick}><FaCopy /></button>
<button title="Paste" onClick={handlePasteClick}><FaPaste /></button> <button title={t('toolbar.button.paste')} onClick={handlePasteClick}><FaPaste /></button>
<button title="Delete" onClick={handleDeleteClick}><FaTrash /></button> <button title={t('toolbar.button.delete')} onClick={handleDeleteClick}><FaTrash /></button>
<div className="toolbar-separator"></div> <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 ? ( {!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 <button
title={isRecording ? "Stop Recording" : "Record"} title={isRecording ? t('toolbar.button.stopRecording') : t('toolbar.button.record')}
className={`tool-button record-button ${isRecording ? 'active' : ''}`} className={`tool-button record-button ${isRecording ? 'active' : ''}`}
onClick={handleRecordClick} onClick={handleRecordClick}
> >
<FaCircle /> <FaCircle />
</button> </button>
<button <button
title="Loop" title={t('toolbar.button.loop')}
className={`tool-button ${isLooping ? 'active' : ''}`} className={`tool-button ${isLooping ? 'active' : ''}`}
onClick={handleLoopToggle} onClick={handleLoopToggle}
> >
@@ -1145,13 +1152,13 @@ const Toolbar: React.FC = () => {
</button> </button>
<div className="toolbar-separator"></div> <div className="toolbar-separator"></div>
<button <button
title="Metronome" title={t('toolbar.button.metronome')}
className={`tool-button ${isMetronomeEnabled ? 'active' : ''}`} className={`tool-button ${isMetronomeEnabled ? 'active' : ''}`}
onClick={handleMetronomeToggle} onClick={handleMetronomeToggle}
> >
<MetronomeIcon /> <MetronomeIcon />
</button> </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="Record"><FaCircle className="record-btn" /></button>
<button title="Metronome">🎵</button> */} <button title="Metronome">🎵</button> */}
</div> </div>
@@ -1205,7 +1212,7 @@ const Toolbar: React.FC = () => {
onClick={() => setShowKeySignatureDropdown((current) => !current)} onClick={() => setShowKeySignatureDropdown((current) => !current)}
aria-haspopup="dialog" aria-haspopup="dialog"
aria-expanded={showKeySignatureDropdown} aria-expanded={showKeySignatureDropdown}
aria-label={`Choose key signature, current ${displayedKeySignature}`} aria-label={t('toolbar.keySignatureChooser', { value: displayedKeySignature })}
> >
{displayedKeySignature} {displayedKeySignature}
</button> </button>
@@ -1216,21 +1223,21 @@ const Toolbar: React.FC = () => {
</div> </div>
</div> </div>
<button <button
title="K.G.One Music Generator" title={t('toolbar.button.kgone')}
onClick={handleKGOneClick} onClick={handleKGOneClick}
className={!showSettings && showKGOnePanel ? 'active' : ''} className={!showSettings && showKGOnePanel ? 'active' : ''}
> >
<FaWandMagicSparkles /> <FaWandMagicSparkles />
</button> </button>
<button <button
title="Chat" title={t('toolbar.button.chat')}
onClick={handleChatClick} onClick={handleChatClick}
className={!showSettings && showChatBox ? 'active' : ''} className={!showSettings && showChatBox ? 'active' : ''}
> >
<FaComments /> <FaComments />
</button> </button>
<button <button
title="Event List Editor" title={t('toolbar.button.eventList')}
onClick={handleEventListClick} onClick={handleEventListClick}
className={!showSettings && showEventListPanel ? 'active' : ''} className={!showSettings && showEventListPanel ? 'active' : ''}
> >
@@ -1244,13 +1251,13 @@ const Toolbar: React.FC = () => {
onClose={() => setShowImportModal(false)} onClose={() => setShowImportModal(false)}
onFileImport={handleFileImport} onFileImport={handleFileImport}
acceptedTypes={['.kgstudio', '.json', '.mid', '.midi']} acceptedTypes={['.kgstudio', '.json', '.mid', '.midi']}
title="Import Project" title={t('toolbar.importProject.title')}
description="Drag and drop your project file here" description={t('toolbar.importProject.description')}
/> />
<LoadingOverlay <LoadingOverlay
visible={isOpeningProject} visible={isOpeningProject}
message="Opening project..." message={t('toolbar.openingProject')}
/> />
{showOpenProject && ( {showOpenProject && (
+41 -40
View File
@@ -2,6 +2,7 @@ import React, { useState, useCallback, useRef } from 'react';
import './DialogProvider.css'; import './DialogProvider.css';
import { FaTimes } from 'react-icons/fa'; import { FaTimes } from 'react-icons/fa';
import { ConfigManager } from '../../core/config/ConfigManager'; import { ConfigManager } from '../../core/config/ConfigManager';
import { useI18n } from '../../i18n/useI18n';
import { registerDialogFns } from '../../util/dialogUtil'; import { registerDialogFns } from '../../util/dialogUtil';
import type { import type {
ChoiceOption, ChoiceOption,
@@ -44,15 +45,8 @@ const DEFAULT_TEMPO_DETECTION_OPTIONS: TempoDetectionOptionsResult = {
maxTempo: 180, 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 DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { t } = useI18n();
const [dialog, setDialog] = useState<DialogInfo | null>(null); const [dialog, setDialog] = useState<DialogInfo | null>(null);
const [isClosing, setIsClosing] = useState(false); const [isClosing, setIsClosing] = useState(false);
const [inputValue, setInputValue] = useState(''); 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 isKGOneEnabled = (ConfigManager.instance().get('general.kgone.enabled') as boolean | undefined) ?? false;
const title = isAlert const title = isAlert
? 'Notice' ? t('dialog.title.notice')
: isTimeSig : isTimeSig
? 'Time Signature' ? t('dialog.title.timeSignature')
: isTempoDetection : isTempoDetection
? 'Tempo Detection' ? t('dialog.title.tempoDetection')
: isTempoApply : isTempoApply
? 'Apply Tempo' ? t('dialog.title.applyTempo')
: (isChordDetection || isMidiChordDetection) : (isChordDetection || isMidiChordDetection)
? 'Chord Detection' ? t('dialog.title.chordDetection')
: isPrompt : isPrompt
? 'Input' ? t('dialog.title.input')
: 'Confirm'; : t('dialog.title.confirm');
const handleOverlayMouseDown = (e: React.MouseEvent) => { const handleOverlayMouseDown = (e: React.MouseEvent) => {
mouseDownOnOverlay.current = e.target === e.currentTarget; mouseDownOnOverlay.current = e.target === e.currentTarget;
@@ -281,11 +275,11 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
}; };
const detectionHintText = isChordDetection const detectionHintText = isChordDetection
? CHORD_DETECTION_HINT_TEXT ? t('dialog.chordHint.audio')
: isMidiChordDetection : isMidiChordDetection
? MIDI_CHORD_DETECTION_HINT_TEXT ? t('dialog.chordHint.midi')
: isTempoDetection : isTempoDetection
? TEMPO_DETECTION_HINT_TEXT ? t('dialog.chordHint.tempo')
: null; : null;
return ( return (
@@ -298,7 +292,7 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
<button <button
className="dialog-close-btn" className="dialog-close-btn"
onClick={handleCancel} onClick={handleCancel}
aria-label="Close dialog" aria-label={t('dialog.close')}
> >
<FaTimes /> <FaTimes />
</button> </button>
@@ -307,7 +301,7 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
<p className="dialog-message">{dialog.message}</p> <p className="dialog-message">{dialog.message}</p>
{detectionHintText && ( {detectionHintText && (
<div className="dialog-hint-card"> <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 className="dialog-hint-card-text">{detectionHintText}</div>
</div> </div>
)} )}
@@ -356,14 +350,14 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
{isChordDetection && ( {isChordDetection && (
<div className="dialog-chord-detection-form"> <div className="dialog-chord-detection-form">
<div className="dialog-hint-card"> <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"> <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> </div>
<div className="dialog-slider-group"> <div className="dialog-slider-group">
<div className="dialog-slider-header"> <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> <span className="dialog-slider-value">{chordDetectionOptions.sensitivity}</span>
</div> </div>
<input <input
@@ -380,7 +374,7 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
</div> </div>
<div className="dialog-slider-group"> <div className="dialog-slider-group">
<div className="dialog-slider-header"> <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> <span className="dialog-slider-value">{chordDetectionOptions.stability}</span>
</div> </div>
<input <input
@@ -396,7 +390,7 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
</div> </div>
<div className="dialog-slider-group"> <div className="dialog-slider-group">
<div className="dialog-slider-header"> <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> <span className="dialog-slider-value">{chordDetectionOptions.noChordThreshold}</span>
</div> </div>
<input <input
@@ -417,7 +411,7 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
checked={chordDetectionOptions.enableSevenths} checked={chordDetectionOptions.enableSevenths}
onChange={(e) => updateChordDetectionOption('enableSevenths', e.target.checked)} onChange={(e) => updateChordDetectionOption('enableSevenths', e.target.checked)}
/> />
<span>Chord Detail: Enable sevenths</span> <span>{t('dialog.label.enableSevenths')}</span>
</label> </label>
</div> </div>
)} )}
@@ -425,7 +419,7 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
<div className="dialog-chord-detection-form"> <div className="dialog-chord-detection-form">
<div className="dialog-slider-group"> <div className="dialog-slider-group">
<div className="dialog-slider-header"> <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> </div>
<select <select
id="dialog-midi-short-note-suppression" 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'])} onChange={(e) => updateMidiChordDetectionOption('shortNoteSuppression', e.target.value as MidiChordDetectionOptionsResult['shortNoteSuppression'])}
autoFocus autoFocus
> >
<option value="low">Low suppression</option> <option value="low">{t('dialog.option.suppressionLow')}</option>
<option value="medium">Medium suppression</option> <option value="medium">{t('dialog.option.suppressionMedium')}</option>
<option value="high">High suppression</option> <option value="high">{t('dialog.option.suppressionHigh')}</option>
</select> </select>
</div> </div>
<div className="dialog-slider-group"> <div className="dialog-slider-group">
<div className="dialog-slider-header"> <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> </div>
<select <select
id="dialog-midi-harmonic-focus" id="dialog-midi-harmonic-focus"
@@ -449,8 +443,8 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
value={midiChordDetectionOptions.harmonicFocus} value={midiChordDetectionOptions.harmonicFocus}
onChange={(e) => updateMidiChordDetectionOption('harmonicFocus', e.target.value as MidiChordDetectionOptionsResult['harmonicFocus'])} onChange={(e) => updateMidiChordDetectionOption('harmonicFocus', e.target.value as MidiChordDetectionOptionsResult['harmonicFocus'])}
> >
<option value="balanced">Balanced</option> <option value="balanced">{t('dialog.option.harmonicBalanced')}</option>
<option value="favor-sustained-notes">Favor sustained notes</option> <option value="favor-sustained-notes">{t('dialog.option.harmonicSustained')}</option>
</select> </select>
</div> </div>
<label className="dialog-checkbox-row" htmlFor="dialog-midi-enable-sevenths"> <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} checked={midiChordDetectionOptions.enableSevenths}
onChange={(e) => updateMidiChordDetectionOption('enableSevenths', e.target.checked)} onChange={(e) => updateMidiChordDetectionOption('enableSevenths', e.target.checked)}
/> />
<span>Chord Detail: Enable sevenths</span> <span>{t('dialog.label.enableSevenths')}</span>
</label> </label>
</div> </div>
)} )}
@@ -468,7 +462,7 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
<div className="dialog-chord-detection-form"> <div className="dialog-chord-detection-form">
<div className="dialog-slider-group"> <div className="dialog-slider-group">
<div className="dialog-slider-header"> <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> <span className="dialog-slider-value">{tempoDetectionOptions.minTempo}</span>
</div> </div>
<input <input
@@ -485,7 +479,7 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
</div> </div>
<div className="dialog-slider-group"> <div className="dialog-slider-group">
<div className="dialog-slider-header"> <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> <span className="dialog-slider-value">{tempoDetectionOptions.maxTempo}</span>
</div> </div>
<input <input
@@ -511,7 +505,7 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
onChange={(e) => setAutoAlignRegionToBeat(e.target.checked)} onChange={(e) => setAutoAlignRegionToBeat(e.target.checked)}
autoFocus autoFocus
/> />
<span>Auto-align region to beat</span> <span>{t('dialog.label.autoAlignRegionToBeat')}</span>
</label> </label>
</div> </div>
)} )}
@@ -522,7 +516,7 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
className="dialog-btn dialog-btn-cancel" className="dialog-btn dialog-btn-cancel"
onClick={handleCancel} onClick={handleCancel}
> >
{(dialog.options as ConfirmOptions | PromptOptions | undefined)?.cancelLabel ?? 'Cancel'} {(dialog.options as ConfirmOptions | PromptOptions | undefined)?.cancelLabel ?? t('dialog.cancel')}
</button> </button>
)} )}
{isChoice ? ( {isChoice ? (
@@ -553,7 +547,14 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
onClick={handleConfirm} onClick={handleConfirm}
autoFocus={!isPrompt && !isTimeSig && !isChordDetection && !isMidiChordDetection && !isTempoDetection && !isTempoApply} 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> </button>
)} )}
</div> </div>
+13 -9
View File
@@ -2,6 +2,7 @@ import React, { useCallback, useRef, useState } from 'react';
import './FileImportModal.css'; import './FileImportModal.css';
import { FaTimes } from 'react-icons/fa'; import { FaTimes } from 'react-icons/fa';
import { showAlert } from '../../util/dialogUtil'; import { showAlert } from '../../util/dialogUtil';
import { useI18n } from '../../i18n/useI18n';
interface FileImportModalProps { interface FileImportModalProps {
isVisible: boolean; isVisible: boolean;
@@ -17,9 +18,12 @@ const FileImportModal: React.FC<FileImportModalProps> = ({
onClose, onClose,
onFileImport, onFileImport,
acceptedTypes = ['.json'], acceptedTypes = ['.json'],
title = 'Import Project', title,
description = 'Drag and drop your project file here' description,
}) => { }) => {
const { t } = useI18n();
const resolvedTitle = title ?? t('toolbar.importProject.title');
const resolvedDescription = description ?? t('toolbar.importProject.description');
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
const [isClosing, setIsClosing] = useState(false); const [isClosing, setIsClosing] = useState(false);
const mouseDownOnOverlay = useRef(false); const mouseDownOnOverlay = useRef(false);
@@ -65,7 +69,7 @@ const FileImportModal: React.FC<FileImportModalProps> = ({
onFileImport(file); onFileImport(file);
startClose(); startClose();
} else { } 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]); }, [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-modal${isClosing ? ' file-import-modal-closing' : ''}`}>
<div className="file-import-header"> <div className="file-import-header">
<h3 className="file-import-title">{title}</h3> <h3 className="file-import-title">{resolvedTitle}</h3>
<button <button
className="file-import-close-btn" className="file-import-close-btn"
onClick={startClose} onClick={startClose}
aria-label="Close import modal" aria-label={t('fileImport.close')}
> >
<FaTimes /> <FaTimes />
</button> </button>
@@ -120,17 +124,17 @@ const FileImportModal: React.FC<FileImportModalProps> = ({
> >
<div className="file-import-drop-content"> <div className="file-import-drop-content">
<div className="file-import-icon">📁</div> <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"> <p className="file-import-formats">
Supported formats: {acceptedTypes.join(', ')} {t('fileImport.supportedFormats', { formats: acceptedTypes.join(', ') })}
</p> </p>
<div className="file-import-divider"> <div className="file-import-divider">
<span>or</span> <span>{t('fileImport.or')}</span>
</div> </div>
<label className="file-import-browse-btn"> <label className="file-import-browse-btn">
Browse Files {t('fileImport.browse')}
<input <input
type="file" type="file"
accept={acceptedTypes.join(',')} accept={acceptedTypes.join(',')}
+2 -2
View File
@@ -1,12 +1,13 @@
import React from 'react'; import React from 'react';
import './LoadingOverlay.css'; import './LoadingOverlay.css';
import { translate } from '../../i18n/translate';
interface LoadingOverlayProps { interface LoadingOverlayProps {
visible: boolean; visible: boolean;
message?: string; message?: string;
} }
const LoadingOverlay: React.FC<LoadingOverlayProps> = ({ visible, message = 'Loading ...' }) => { const LoadingOverlay: React.FC<LoadingOverlayProps> = ({ visible, message = translate('app.loading') }) => {
if (!visible) return null; if (!visible) return null;
return ( return (
@@ -21,4 +22,3 @@ const LoadingOverlay: React.FC<LoadingOverlayProps> = ({ visible, message = 'Loa
export default LoadingOverlay; export default LoadingOverlay;
@@ -44,6 +44,7 @@ import { isModifierKeyPressed } from '../../util/osUtil';
import { parseChordSymbol } from '../../util/chordUtil'; import { parseChordSymbol } from '../../util/chordUtil';
import { showAlert } from '../../util/dialogUtil'; import { showAlert } from '../../util/dialogUtil';
import { getSortedKeySignatureRegions, getSortedTempoRegions } from '../../util/globalTrackUtil'; import { getSortedKeySignatureRegions, getSortedTempoRegions } from '../../util/globalTrackUtil';
import { useI18n } from '../../i18n/useI18n';
interface GlobalEventListTabProps { interface GlobalEventListTabProps {
globalTracks: KGGlobalTrack[]; globalTracks: KGGlobalTrack[];
@@ -97,13 +98,6 @@ interface GlobalEditingCell {
value: string; 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> = { const GLOBAL_TYPE_ORDER: Record<GlobalRowData['type'], number> = {
marker: 0, marker: 0,
tempo: 1, 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 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) { switch (row.type) {
case 'marker': case 'marker':
return 'Marker'; return t('eventList.global.status.marker');
case 'tempo': case 'tempo':
return 'Tempo'; return t('eventList.global.status.tempo');
case 'key-signature': case 'key-signature':
return 'Key Signature'; return t('eventList.global.status.keySignature');
case 'chord': case 'chord':
return 'Chord'; return t('eventList.global.status.chord');
} }
}; };
@@ -147,16 +141,22 @@ const findRegionRowType = (region: KGGlobalRegion): GlobalRowData['type'] | null
return 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) { switch (type) {
case 'marker': case 'marker':
return 'Please enter a marker label. Expected a non-empty text label. Example: Intro'; return t('eventList.global.validation.marker');
case 'tempo': 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': 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': 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, playheadPosition,
refreshProjectState, refreshProjectState,
}) => { }) => {
const { t } = useI18n();
const [showMarkers, setShowMarkers] = useState(true); const [showMarkers, setShowMarkers] = useState(true);
const [showTempo, setShowTempo] = useState(true); const [showTempo, setShowTempo] = useState(true);
const [showKeySignature, setShowKeySignature] = useState(true); const [showKeySignature, setShowKeySignature] = useState(true);
@@ -178,6 +179,13 @@ const GlobalEventListTab: React.FC<GlobalEventListTabProps> = ({
const suppressBlurCommitRef = useRef(false); const suppressBlurCommitRef = useRef(false);
const pendingSingleClickSelectionRef = useRef<number | null>(null); 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 markerTrack = globalTracks.find(track => track.getType() === GlobalTrackType.Marker) ?? null;
const tempoTrack = globalTracks.find(track => track.getType() === GlobalTrackType.Tempo) ?? null; const tempoTrack = globalTracks.find(track => track.getType() === GlobalTrackType.Tempo) ?? null;
const signatureTrack = globalTracks.find(track => track.getType() === GlobalTrackType.Signature) ?? null; const signatureTrack = globalTracks.find(track => track.getType() === GlobalTrackType.Signature) ?? null;
@@ -366,7 +374,7 @@ const GlobalEventListTab: React.FC<GlobalEventListTabProps> = ({
try { try {
if (editingCell.column === 'position') { if (editingCell.column === 'position') {
if (isDeltaEdit) { 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) { if ('error' in parsed) {
await showAlert(parsed.error); await showAlert(parsed.error);
return; return;
@@ -375,7 +383,7 @@ const GlobalEventListTab: React.FC<GlobalEventListTabProps> = ({
for (const targetRow of targetRows) { for (const targetRow of targetRows) {
const nextBeat = targetRow.absoluteStartBeat + parsed.deltaBeats; const nextBeat = targetRow.absoluteStartBeat + parsed.deltaBeats;
if (nextBeat < 0) { 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; return;
} }
} }
@@ -404,7 +412,7 @@ const GlobalEventListTab: React.FC<GlobalEventListTabProps> = ({
return; return;
} }
if (parsed.absoluteBeat < 0) { 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; return;
} }
@@ -432,7 +440,7 @@ const GlobalEventListTab: React.FC<GlobalEventListTabProps> = ({
if (row.type === 'marker') { if (row.type === 'marker') {
const normalized = trimmedValue.replace(/\r?\n/g, ' ').trim(); const normalized = trimmedValue.replace(/\r?\n/g, ' ').trim();
if (!normalized) { if (!normalized) {
await showAlert(buildValueValidationMessage('marker')); await showAlert(buildValueValidationMessage('marker', t));
return; return;
} }
@@ -443,7 +451,7 @@ const GlobalEventListTab: React.FC<GlobalEventListTabProps> = ({
} }
} else if (row.type === 'tempo') { } else if (row.type === 'tempo') {
if (!/^\d+$/.test(trimmedValue)) { if (!/^\d+$/.test(trimmedValue)) {
await showAlert(buildValueValidationMessage('tempo')); await showAlert(buildValueValidationMessage('tempo', t));
return; return;
} }
const nextBpm = parseInt(trimmedValue, 10); const nextBpm = parseInt(trimmedValue, 10);
@@ -452,7 +460,7 @@ const GlobalEventListTab: React.FC<GlobalEventListTabProps> = ({
|| nextBpm <= TIME_CONSTANTS.MIN_BPM || nextBpm <= TIME_CONSTANTS.MIN_BPM
|| nextBpm >= TIME_CONSTANTS.MAX_BPM || nextBpm >= TIME_CONSTANTS.MAX_BPM
) { ) {
await showAlert(buildValueValidationMessage('tempo')); await showAlert(buildValueValidationMessage('tempo', t));
return; return;
} }
@@ -463,7 +471,7 @@ const GlobalEventListTab: React.FC<GlobalEventListTabProps> = ({
} }
} else if (row.type === 'key-signature') { } else if (row.type === 'key-signature') {
if (!CANONICAL_KEY_SIGNATURES.includes(trimmedValue as KeySignature)) { if (!CANONICAL_KEY_SIGNATURES.includes(trimmedValue as KeySignature)) {
await showAlert(buildValueValidationMessage('key-signature')); await showAlert(buildValueValidationMessage('key-signature', t));
return; return;
} }
const keySignature = trimmedValue as KeySignature; const keySignature = trimmedValue as KeySignature;
@@ -473,7 +481,7 @@ const GlobalEventListTab: React.FC<GlobalEventListTabProps> = ({
} }
} }
} else if (parseChordSymbol(trimmedValue) === null) { } else if (parseChordSymbol(trimmedValue) === null) {
await showAlert(buildValueValidationMessage('chord')); await showAlert(buildValueValidationMessage('chord', t));
return; return;
} else { } else {
for (const targetRow of targetRows) { for (const targetRow of targetRows) {
@@ -494,7 +502,7 @@ const GlobalEventListTab: React.FC<GlobalEventListTabProps> = ({
for (const targetRow of targetRows) { for (const targetRow of targetRows) {
if (targetRow.durationBeats + parsed.deltaBeats <= 0) { 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; return;
} }
} }
@@ -527,7 +535,7 @@ const GlobalEventListTab: React.FC<GlobalEventListTabProps> = ({
return; return;
} }
if (parsed.duration <= 0) { 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; return;
} }
@@ -752,11 +760,11 @@ const GlobalEventListTab: React.FC<GlobalEventListTabProps> = ({
return ( return (
<> <>
<div className="event-list-tabs" role="tablist" aria-label="Global event filters"> <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)}>Marker</button> <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)}>Tempo</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)}>Key Sig.</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)}>Chord</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>
<div className="event-list-toolbar"> <div className="event-list-toolbar">
@@ -765,12 +773,12 @@ const GlobalEventListTab: React.FC<GlobalEventListTabProps> = ({
className="event-list-add-button" className="event-list-add-button"
title={ title={
addGlobalItemType === 'marker' addGlobalItemType === 'marker'
? 'Add marker region at playhead' ? t('eventList.global.add.markerTitle')
: addGlobalItemType === 'tempo' : addGlobalItemType === 'tempo'
? 'Add tempo region at playhead' ? t('eventList.global.add.tempoTitle')
: addGlobalItemType === 'key-signature' : addGlobalItemType === 'key-signature'
? 'Add key signature region at playhead' ? t('eventList.global.add.keySignatureTitle')
: 'Add chord region at playhead' : t('eventList.global.add.chordTitle')
} }
type="button" type="button"
onClick={handleAddGlobalItem} onClick={handleAddGlobalItem}
@@ -778,10 +786,10 @@ const GlobalEventListTab: React.FC<GlobalEventListTabProps> = ({
<FaPlus /> <FaPlus />
</button> </button>
<KGDropdown <KGDropdown
options={ADD_GLOBAL_ITEM_OPTIONS} options={addGlobalItemOptions}
value={addGlobalItemType} value={addGlobalItemType}
onChange={(value) => setAddGlobalItemType(value as AddGlobalItemType)} onChange={(value) => setAddGlobalItemType(value as AddGlobalItemType)}
label="Add" label={t('eventList.global.add.label')}
buttonClassName="event-list-type-button" buttonClassName="event-list-type-button"
showValueAsLabel showValueAsLabel
/> />
@@ -790,7 +798,7 @@ const GlobalEventListTab: React.FC<GlobalEventListTabProps> = ({
<div className="event-list-toolbar-group event-list-toolbar-group-right"> <div className="event-list-toolbar-group event-list-toolbar-group-right">
<button <button
className="event-list-delete-button" className="event-list-delete-button"
title="Delete visible selected rows" title={t('eventList.deleteVisibleSelectedRows')}
type="button" type="button"
onClick={handleDeleteSelectedRows} onClick={handleDeleteSelectedRows}
disabled={visibleSelectedRows.length === 0} disabled={visibleSelectedRows.length === 0}
@@ -804,16 +812,16 @@ const GlobalEventListTab: React.FC<GlobalEventListTabProps> = ({
<table className="event-list-table"> <table className="event-list-table">
<thead> <thead>
<tr> <tr>
<th>Position</th> <th>{t('eventList.table.position')}</th>
<th>Status</th> <th>{t('eventList.table.status')}</th>
<th>Val</th> <th>{t('eventList.table.val')}</th>
<th>Length/Info</th> <th>{t('eventList.table.lengthInfo')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{globalRows.map((row, index) => { {globalRows.map((row, index) => {
const positionText = formatMidiEventPosition(row.absoluteStartBeat, timeSignature, MIDI_EVENT_TICKS_PER_BEAT); 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 valText = getRowValue(row);
const lengthText = formatMidiEventLength(row.durationBeats, MIDI_EVENT_TICKS_PER_BEAT); const lengthText = formatMidiEventLength(row.durationBeats, MIDI_EVENT_TICKS_PER_BEAT);
const isEditingPosition = editingCell?.rowId === row.id && editingCell.column === 'position'; 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 { UpdateNotePropertiesCommand } from '../../core/commands/note/UpdateNotePropertiesCommand';
import { UpdatePitchBendPropertiesCommand } from '../../core/commands/note/UpdatePitchBendPropertiesCommand'; import { UpdatePitchBendPropertiesCommand } from '../../core/commands/note/UpdatePitchBendPropertiesCommand';
import { showAlert } from '../../util/dialogUtil'; import { showAlert } from '../../util/dialogUtil';
import { useI18n } from '../../i18n/useI18n';
interface RegionEventListTabProps { interface RegionEventListTabProps {
activeMidiRegion: KGMidiRegion | null; activeMidiRegion: KGMidiRegion | null;
@@ -74,12 +75,6 @@ interface EditingCell {
value: string; 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 parseVelocityInput = (raw: string): { velocity: number } | { error: string } => {
const trimmed = raw.trim(); const trimmed = raw.trim();
if (!/^\d+$/.test(trimmed)) { if (!/^\d+$/.test(trimmed)) {
@@ -182,6 +177,7 @@ const parseControllerValueDeltaInput = (raw: string): { delta: number } | { erro
}; };
const RegionEventListTab: React.FC<RegionEventListTabProps> = ({ activeMidiRegion, parentTrack }) => { const RegionEventListTab: React.FC<RegionEventListTabProps> = ({ activeMidiRegion, parentTrack }) => {
const { t } = useI18n();
const { const {
selectedNoteIds, selectedNoteIds,
selectedPitchBendIds, selectedPitchBendIds,
@@ -253,6 +249,11 @@ const RegionEventListTab: React.FC<RegionEventListTabProps> = ({ activeMidiRegio
const selectedControllerEventIdSet = new Set(selectedControllerEventIds); const selectedControllerEventIdSet = new Set(selectedControllerEventIds);
const selectedEventIdSet = new Set([...selectedNoteIds, ...selectedPitchBendIds, ...selectedControllerEventIds]); const selectedEventIdSet = new Set([...selectedNoteIds, ...selectedPitchBendIds, ...selectedControllerEventIds]);
const visibleSelectedRows = eventRows.filter(row => selectedEventIdSet.has(row.id)); 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(() => { useEffect(() => {
if (editingCell) { if (editingCell) {
@@ -941,15 +942,15 @@ const RegionEventListTab: React.FC<RegionEventListTabProps> = ({ activeMidiRegio
return ( return (
<> <>
<div className="event-list-tabs" role="tablist" aria-label="Region event filters"> <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)}>Notes</button> <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)}>Pitch Bends</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)}>Controller</button> <button className={`event-list-tab${showControllers ? ' active' : ''}`} type="button" onClick={() => setShowControllers(value => !value)}>{t('eventList.region.filter.controller')}</button>
</div> </div>
{!activeMidiRegion ? ( {!activeMidiRegion ? (
<div className="event-list-empty-state"> <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> </div>
) : ( ) : (
<> <>
@@ -957,17 +958,23 @@ const RegionEventListTab: React.FC<RegionEventListTabProps> = ({ activeMidiRegio
<div className="event-list-toolbar-group"> <div className="event-list-toolbar-group">
<button <button
className="event-list-add-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" type="button"
onClick={handleAddEvent} onClick={handleAddEvent}
> >
<FaPlus /> <FaPlus />
</button> </button>
<KGDropdown <KGDropdown
options={[...ADD_EVENT_TYPE_OPTIONS]} options={[...addEventTypeOptions]}
value={addEventType} value={addEventType}
onChange={(value) => setAddEventType(value as AddEventType)} onChange={(value) => setAddEventType(value as AddEventType)}
label="Note" label={t('eventList.region.add.label')}
buttonClassName="event-list-type-button" buttonClassName="event-list-type-button"
showValueAsLabel showValueAsLabel
/> />
@@ -975,28 +982,28 @@ const RegionEventListTab: React.FC<RegionEventListTabProps> = ({ activeMidiRegio
<div className="event-list-toolbar-group event-list-toolbar-group-right"> <div className="event-list-toolbar-group event-list-toolbar-group-right">
<KGDropdown <KGDropdown
options={KGPianoRollState.QUANT_POS_OPTIONS} options={KGPianoRollState.QUANT_POS_OPTIONS.map(option => ({ label: t(option.labelKey), value: option.value }))}
value={quantPosition} value={quantPosition}
onChange={(value) => { onChange={(value) => {
setQuantPosition(value); setQuantPosition(value);
quantizeSelectedNotes(value); quantizeSelectedNotes(value);
}} }}
label="Qua. Pos." label={t('pianoRoll.quantizePositionCompact')}
buttonClassName="event-list-quant-button" buttonClassName="event-list-quant-button"
/> />
<KGDropdown <KGDropdown
options={KGPianoRollState.QUANT_LEN_OPTIONS} options={KGPianoRollState.QUANT_LEN_OPTIONS.map(option => ({ label: t(option.labelKey), value: option.value }))}
value={quantLength} value={quantLength}
onChange={(value) => { onChange={(value) => {
setQuantLength(value); setQuantLength(value);
quantizeSelectedNoteLengths(value); quantizeSelectedNoteLengths(value);
}} }}
label="Qua. Len." label={t('pianoRoll.quantizeLengthCompact')}
buttonClassName="event-list-quant-button" buttonClassName="event-list-quant-button"
/> />
<button <button
className="event-list-delete-button" className="event-list-delete-button"
title="Delete visible selected rows" title={t('eventList.deleteVisibleSelectedRows')}
type="button" type="button"
onClick={handleDeleteSelectedRows} onClick={handleDeleteSelectedRows}
disabled={visibleSelectedRows.length === 0} disabled={visibleSelectedRows.length === 0}
@@ -1010,18 +1017,22 @@ const RegionEventListTab: React.FC<RegionEventListTabProps> = ({ activeMidiRegio
<table className="event-list-table"> <table className="event-list-table">
<thead> <thead>
<tr> <tr>
<th>Position</th> <th>{t('eventList.table.position')}</th>
<th>Status</th> <th>{t('eventList.table.status')}</th>
<th>Num</th> <th>{t('eventList.table.num')}</th>
<th>Val</th> <th>{t('eventList.table.val')}</th>
<th>Length/Info</th> <th>{t('eventList.table.lengthInfo')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{eventRows.map((row, index) => { {eventRows.map((row, index) => {
const absoluteBeat = row.type === 'note' ? row.absoluteStartBeat : row.absoluteBeat; const absoluteBeat = row.type === 'note' ? row.absoluteStartBeat : row.absoluteBeat;
const positionText = formatMidiEventPosition(absoluteBeat, timeSignature, MIDI_EVENT_TICKS_PER_BEAT); 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' const numText = row.type === 'note'
? pitchToNoteNameString(row.note.getPitch()) ? pitchToNoteNameString(row.note.getPitch())
: row.type === 'controller' : row.type === 'controller'
@@ -29,6 +29,7 @@ import {
import { isModifierKeyPressed } from '../../util/osUtil'; import { isModifierKeyPressed } from '../../util/osUtil';
import { showAlert } from '../../util/dialogUtil'; import { showAlert } from '../../util/dialogUtil';
import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants'; import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
import { useI18n } from '../../i18n/useI18n';
interface TrackEventListTabProps { interface TrackEventListTabProps {
selectedTrack: KGMidiTrack | KGAudioTrack | null; selectedTrack: KGMidiTrack | KGAudioTrack | null;
@@ -119,6 +120,7 @@ const findPreviousPanValue = (points: KGTrackAutomationPoint[], beat: number): n
}; };
const TrackEventListTab: React.FC<TrackEventListTabProps> = ({ selectedTrack }) => { const TrackEventListTab: React.FC<TrackEventListTabProps> = ({ selectedTrack }) => {
const { t } = useI18n();
const { const {
tracks, tracks,
playheadPosition, playheadPosition,
@@ -164,12 +166,12 @@ const TrackEventListTab: React.FC<TrackEventListTabProps> = ({ selectedTrack })
const availableAddOptions = useMemo(() => { const availableAddOptions = useMemo(() => {
const options: Array<{ label: string; value: AddTrackItemType }> = []; const options: Array<{ label: string; value: AddTrackItemType }> = [];
if (selectedTrack instanceof KGMidiTrack) { 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: t('eventList.track.filter.volume'), value: 'volume' });
options.push({ label: 'Pan', value: 'pan' }); options.push({ label: t('eventList.track.filter.pan'), value: 'pan' });
return options; return options;
}, [selectedTrack]); }, [selectedTrack, t]);
const liveSelectedTrack = useMemo(() => { const liveSelectedTrack = useMemo(() => {
if (!selectedTrack) { if (!selectedTrack) {
@@ -659,15 +661,15 @@ const TrackEventListTab: React.FC<TrackEventListTabProps> = ({ selectedTrack })
return ( return (
<> <>
<div className="event-list-tabs" role="tablist" aria-label="Track list modes"> <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)}>Regions</button> <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)}>Volume</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)}>Pan</button> <button className={`event-list-tab${showPan ? ' active' : ''}`} aria-pressed={showPan} type="button" onClick={() => setShowPan(value => !value)}>{t('eventList.track.filter.pan')}</button>
</div> </div>
{!liveSelectedTrack ? ( {!liveSelectedTrack ? (
<div className="event-list-empty-state"> <div className="event-list-empty-state">
Please select a track to view regions and track automation. {t('eventList.track.empty')}
</div> </div>
) : ( ) : (
<> <>
@@ -675,7 +677,13 @@ const TrackEventListTab: React.FC<TrackEventListTabProps> = ({ selectedTrack })
<div className="event-list-toolbar-group"> <div className="event-list-toolbar-group">
<button <button
className="event-list-add-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" type="button"
onClick={handleAddTrackItem} onClick={handleAddTrackItem}
> >
@@ -685,7 +693,7 @@ const TrackEventListTab: React.FC<TrackEventListTabProps> = ({ selectedTrack })
options={availableAddOptions} options={availableAddOptions}
value={addTrackItemType} value={addTrackItemType}
onChange={(value) => setAddTrackItemType(value as AddTrackItemType)} onChange={(value) => setAddTrackItemType(value as AddTrackItemType)}
label="Add" label={t('eventList.track.add.label')}
buttonClassName="event-list-type-button" buttonClassName="event-list-type-button"
showValueAsLabel showValueAsLabel
/> />
@@ -694,7 +702,7 @@ const TrackEventListTab: React.FC<TrackEventListTabProps> = ({ selectedTrack })
<div className="event-list-toolbar-group event-list-toolbar-group-right"> <div className="event-list-toolbar-group event-list-toolbar-group-right">
<button <button
className="event-list-delete-button" className="event-list-delete-button"
title="Delete visible selected rows" title={t('eventList.deleteVisibleSelectedRows')}
type="button" type="button"
onClick={handleDeleteSelectedRows} onClick={handleDeleteSelectedRows}
disabled={visibleSelectedRows.length === 0} disabled={visibleSelectedRows.length === 0}
@@ -708,16 +716,20 @@ const TrackEventListTab: React.FC<TrackEventListTabProps> = ({ selectedTrack })
<table className="event-list-table"> <table className="event-list-table">
<thead> <thead>
<tr> <tr>
<th>Position</th> <th>{t('eventList.table.position')}</th>
<th>Status</th> <th>{t('eventList.table.status')}</th>
<th>Val</th> <th>{t('eventList.table.val')}</th>
<th>Length/Info</th> <th>{t('eventList.table.lengthInfo')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{trackRows.map((row, index) => { {trackRows.map((row, index) => {
const positionText = formatMidiEventPosition(row.type === 'region' ? row.absoluteStartBeat : row.absoluteBeat, timeSignature, MIDI_EVENT_TICKS_PER_BEAT); 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 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 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'; const isEditingPosition = editingCell?.rowId === row.id && editingCell.column === 'position';
@@ -4,19 +4,13 @@ import GlobalChordLane from './GlobalChordLane';
import GlobalKeySignatureLane from './GlobalKeySignatureLane'; import GlobalKeySignatureLane from './GlobalKeySignatureLane';
import GlobalMarkerLane from './GlobalMarkerLane'; import GlobalMarkerLane from './GlobalMarkerLane';
import GlobalTempoLane from './GlobalTempoLane'; import GlobalTempoLane from './GlobalTempoLane';
import { useI18n } from '../../i18n/useI18n';
interface GlobalTrackDefinition { interface GlobalTrackDefinition {
id: 'marker' | 'tempo' | 'signature' | 'chord'; id: 'marker' | 'tempo' | 'signature' | 'chord';
label: string; 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 { interface MainContentGlobalTracksSectionProps {
visible: boolean; visible: boolean;
onAddMarker: () => void; onAddMarker: () => void;
@@ -40,8 +34,15 @@ const MainContentGlobalTracksSection: React.FC<MainContentGlobalTracksSectionPro
keySignatureLaneProps, keySignatureLaneProps,
chordLaneProps, chordLaneProps,
}) => { }) => {
const { t } = useI18n();
const [shouldRender, setShouldRender] = useState(visible); const [shouldRender, setShouldRender] = useState(visible);
const [isAnimated, setIsAnimated] = useState(false); 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(() => { useEffect(() => {
if (!visible) { if (!visible) {
@@ -71,9 +72,9 @@ const MainContentGlobalTracksSection: React.FC<MainContentGlobalTracksSectionPro
return ( return (
<div <div
className="global-tracks-section" className="global-tracks-section"
aria-label="Global tracks" aria-label={t('globalTracks.label')}
aria-hidden={!visible} aria-hidden={!visible}
style={{ ['--global-track-count' as string]: String(GLOBAL_TRACKS.length) }} style={{ ['--global-track-count' as string]: String(globalTracks.length) }}
> >
<div <div
className={`global-tracks-info-shell${isAnimated ? ' expanded' : ' collapsed'}`} className={`global-tracks-info-shell${isAnimated ? ' expanded' : ' collapsed'}`}
@@ -84,14 +85,14 @@ const MainContentGlobalTracksSection: React.FC<MainContentGlobalTracksSectionPro
}} }}
> >
<div className="global-tracks-info"> <div className="global-tracks-info">
{GLOBAL_TRACKS.map(track => ( {globalTracks.map(track => (
<div key={track.id} className="global-track-info-row"> <div key={track.id} className="global-track-info-row">
<span className="global-track-name">{track.label}</span> <span className="global-track-name">{track.label}</span>
<button <button
type="button" type="button"
className="global-track-add-button" className="global-track-add-button"
aria-label={`Add ${track.label} global track item`} aria-label={t('globalTracks.addItem', { label: track.label })}
title={`Add ${track.label} global track item`} title={t('globalTracks.addItem', { label: track.label })}
onClick={(event) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
+87 -5
View File
@@ -3,6 +3,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen } from '@testing-library/react'; import { act, fireEvent, render, screen } from '@testing-library/react';
import PianoKeys from './PianoKeys'; import PianoKeys from './PianoKeys';
import { createMockMidiNote, createMockMidiRegion, createMockMidiTrack } from '../../test/utils/mock-data'; import { createMockMidiNote, createMockMidiRegion, createMockMidiTrack } from '../../test/utils/mock-data';
import { I18nContext } from '../../i18n/I18nProvider';
import { translate } from '../../i18n/translate';
type TestLiveNoteActivityListener = (...args: [{ pitch: number; isNoteOn: boolean }]) => void; type TestLiveNoteActivityListener = (...args: [{ pitch: number; isNoteOn: boolean }]) => void;
@@ -48,6 +50,24 @@ vi.mock('../../core/midi-input/KGMidiInput', () => ({
}, },
})); }));
function renderWithLocale(
ui: React.ReactElement,
resolvedLocale: 'en_us' | 'zh_cn' = 'en_us',
) {
return render(
<I18nContext.Provider
value={{
languageSetting: resolvedLocale,
resolvedLocale,
setLanguageSetting: async () => undefined,
t: (key, params) => translate(key, params, resolvedLocale),
}}
>
{ui}
</I18nContext.Provider>,
);
}
describe('PianoKeys', () => { describe('PianoKeys', () => {
const activeRegion = createMockMidiRegion({ const activeRegion = createMockMidiRegion({
trackId: '1', trackId: '1',
@@ -66,7 +86,7 @@ describe('PianoKeys', () => {
}); });
it('shows dot and background feedback for mouse preview while held', () => { 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; const key = container.querySelector('[data-note="C4"]') as HTMLElement;
fireEvent.mouseDown(key); fireEvent.mouseDown(key);
@@ -81,7 +101,7 @@ describe('PianoKeys', () => {
}); });
it('shows MIDI activity dot without background feedback', () => { 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; const key = container.querySelector('[data-note="C4"]') as HTMLElement;
expect(midiInputMock.addLiveNoteActivityListener).toHaveBeenCalledTimes(1); expect(midiInputMock.addLiveNoteActivityListener).toHaveBeenCalledTimes(1);
@@ -103,7 +123,7 @@ describe('PianoKeys', () => {
storeState.isPlaying = true; storeState.isPlaying = true;
storeState.playheadPosition = 1; storeState.playheadPosition = 1;
const { container } = render(<PianoKeys activeRegion={activeRegion} />); const { container } = renderWithLocale(<PianoKeys activeRegion={activeRegion} />);
const key = container.querySelector('[data-note="C4"]') as HTMLElement; const key = container.querySelector('[data-note="C4"]') as HTMLElement;
expect(key.className).toContain('playback-active'); expect(key.className).toContain('playback-active');
@@ -115,7 +135,7 @@ describe('PianoKeys', () => {
storeState.isPlaying = true; storeState.isPlaying = true;
storeState.playheadPosition = 1; 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; const key = container.querySelector('[data-note="C4"]') as HTMLElement;
fireEvent.mouseDown(key); fireEvent.mouseDown(key);
@@ -127,7 +147,18 @@ describe('PianoKeys', () => {
expect(screen.getByTestId('piano-key-dot-C4')).toBeInTheDocument(); expect(screen.getByTestId('piano-key-dot-C4')).toBeInTheDocument();
storeState.isPlaying = false; 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(key.className).toContain('visual-active');
expect(screen.getByTestId('piano-key-dot-C4')).toBeInTheDocument(); expect(screen.getByTestId('piano-key-dot-C4')).toBeInTheDocument();
@@ -143,4 +174,55 @@ describe('PianoKeys', () => {
expect(screen.queryByTestId('piano-key-dot-C4')).not.toBeInTheDocument(); 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();
});
}); });
+8 -5
View File
@@ -1,10 +1,12 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { KGMidiRegion } from '../../core/region/KGMidiRegion'; import { KGMidiRegion } from '../../core/region/KGMidiRegion';
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface'; import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
import { noteNameToPitch, midiPercussionKeyMap } from '../../util/midiUtil'; import { noteNameToPitch } from '../../util/midiUtil';
import { useProjectStore } from '../../stores/projectStore'; import { useProjectStore } from '../../stores/projectStore';
import { KGMidiTrack } from '../../core/track/KGMidiTrack'; import { KGMidiTrack } from '../../core/track/KGMidiTrack';
import { KGMidiInput, type LiveMidiNoteActivityEvent } from '../../core/midi-input/KGMidiInput'; import { KGMidiInput, type LiveMidiNoteActivityEvent } from '../../core/midi-input/KGMidiInput';
import { useI18n } from '../../i18n/useI18n';
import { getPercussionKeyShortLabel, isGmDrumKitInstrument } from '../../i18n/percussion';
interface PianoKeysProps { interface PianoKeysProps {
activeRegion: KGMidiRegion | null; activeRegion: KGMidiRegion | null;
@@ -30,6 +32,7 @@ function decrementPitchCount(source: Map<number, number>, pitch: number): Map<nu
} }
const PianoKeys: React.FC<PianoKeysProps> = ({ activeRegion }) => { const PianoKeys: React.FC<PianoKeysProps> = ({ activeRegion }) => {
const { t } = useI18n();
const [mouseActivePitches, setMouseActivePitches] = useState<Map<number, number>>(new Map()); const [mouseActivePitches, setMouseActivePitches] = useState<Map<number, number>>(new Map());
const [midiActivePitches, setMidiActivePitches] = useState<Map<number, number>>(new Map()); const [midiActivePitches, setMidiActivePitches] = useState<Map<number, number>>(new Map());
const mouseActivePitchesRef = useRef<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(() => { const isDrumTrack = useMemo(() => {
if (!activeRegion) return false; if (!activeRegion) return false;
const track = tracks.find(t => t.getId().toString() === activeRegion.getTrackId()); 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]); }, [activeRegion, tracks]);
const playbackActivePitches = useMemo(() => { const playbackActivePitches = useMemo(() => {
@@ -198,9 +201,9 @@ const PianoKeys: React.FC<PianoKeysProps> = ({ activeRegion }) => {
// For drum tracks, show drum labels when available // For drum tracks, show drum labels when available
let labelContent = null; let labelContent = null;
if (isDrumTrack) { if (isDrumTrack) {
const drumInfo = midiPercussionKeyMap[pitch]; const drumLabel = getPercussionKeyShortLabel(pitch, t);
if (drumInfo) { if (drumLabel) {
labelContent = <span className="key-label">{drumInfo.shortName}</span>; labelContent = <span className="key-label">{drumLabel}</span>;
} }
} else if (isC) { } else if (isC) {
labelContent = <span className="key-label">C{octave}</span>; labelContent = <span className="key-label">C{octave}</span>;
+17
View File
@@ -296,6 +296,16 @@
font-size: 10px; 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 { .piano-roll-toolbar .chord-guide-toggle-button {
margin: 0; margin: 0;
border-radius: 0; border-radius: 0;
@@ -715,10 +725,17 @@
} }
.key-label { .key-label {
box-sizing: border-box;
display: block;
flex: 1 1 auto;
font-size: 10px; font-size: 10px;
line-height: 1;
min-width: 0;
overflow: hidden;
padding-left: 5px; padding-left: 5px;
padding-right: 18px; padding-right: 18px;
position: relative; position: relative;
white-space: nowrap;
z-index: 1; z-index: 1;
} }
+25 -14
View File
@@ -13,7 +13,13 @@ import PianoRollContent from './PianoRollContent';
import { KGCore } from '../../core/KGCore'; import { KGCore } from '../../core/KGCore';
import { KGMidiNote } from '../../core/midi/KGMidiNote'; import { KGMidiNote } from '../../core/midi/KGMidiNote';
import { KGMidiTrack, type InstrumentType } from '../../core/track/KGMidiTrack'; 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 { ConfigManager } from '../../core/config/ConfigManager';
import { beatsToBar } from '../../util/midiUtil'; import { beatsToBar } from '../../util/midiUtil';
import { ReplaceChordRegionsInRangeCommand, UpdateRegionCommand } from '../../core/commands'; import { ReplaceChordRegionsInRangeCommand, UpdateRegionCommand } from '../../core/commands';
@@ -121,11 +127,11 @@ const PianoRoll: React.FC<PianoRollProps> = ({
const titleInputRef = useRef<HTMLInputElement>(null); const titleInputRef = useRef<HTMLInputElement>(null);
// Quantization state // Quantization state
const [quantPosition, setQuantPosition] = useState<string>('1/8'); const [quantPosition, setQuantPosition] = useState<PianoRollQuantizePositionValue>('1/8');
const [quantLength, setQuantLength] = useState<string>('1/8'); const [quantLength, setQuantLength] = useState<PianoRollQuantizeLengthValue>('1/8');
// Snapping state // Snapping state
const [snapping, setSnapping] = useState<string>('NO SNAP'); const [snapping, setSnapping] = useState<PianoRollSnapValue>(PIANO_ROLL_NO_SNAP);
// Chord guide state // Chord guide state
const [chordGuide, setChordGuide] = useState<ChordGuideFunction>('N'); const [chordGuide, setChordGuide] = useState<ChordGuideFunction>('N');
@@ -713,8 +719,9 @@ const PianoRoll: React.FC<PianoRollProps> = ({
// Handle snapping selection // Handle snapping selection
const handleSnappingSelect = useCallback((value: string) => { const handleSnappingSelect = useCallback((value: string) => {
setSnapping(value); const nextValue = value as PianoRollSnapValue;
KGPianoRollState.instance().setCurrentSnap(value); setSnapping(nextValue);
KGPianoRollState.instance().setCurrentSnap(nextValue);
if (DEBUG_MODE.PIANO_ROLL) { if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Selected snapping: ${value}`); console.log(`Selected snapping: ${value}`);
} }
@@ -974,19 +981,21 @@ const PianoRoll: React.FC<PianoRollProps> = ({
// Handle quantization selection // Handle quantization selection
const handleQuantSelect = useCallback((type: 'position' | 'length', value: string) => { const handleQuantSelect = useCallback((type: 'position' | 'length', value: string) => {
if (type === 'position') { if (type === 'position') {
setQuantPosition(value); const nextValue = value as PianoRollQuantizePositionValue;
setQuantPosition(nextValue);
// Apply quantization immediately when position quantization is changed // Apply quantization immediately when position quantization is changed
quantizeSelectedNotes(value); quantizeSelectedNotes(nextValue);
if (DEBUG_MODE.PIANO_ROLL) { if (DEBUG_MODE.PIANO_ROLL) {
console.log(`quant-position selected: ${value}`); console.log(`quant-position selected: ${value}`);
} }
} else { } else {
setQuantLength(value); const nextValue = value as PianoRollQuantizeLengthValue;
setQuantLength(nextValue);
// Apply length quantization immediately when length quantization is changed // Apply length quantization immediately when length quantization is changed
quantizeNoteLength(value); quantizeNoteLength(nextValue);
if (DEBUG_MODE.PIANO_ROLL) { if (DEBUG_MODE.PIANO_ROLL) {
console.log(`quant-length selected: ${value}`); console.log(`quant-length selected: ${value}`);
@@ -1407,7 +1416,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
// Check snapping hotkeys // Check snapping hotkeys
if (event.key === snap_none_key) { if (event.key === snap_none_key) {
actionType = 'snap'; actionType = 'snap';
actionValue = 'NO SNAP'; actionValue = PIANO_ROLL_NO_SNAP;
} else if (event.key === snap_1_4_key) { } else if (event.key === snap_1_4_key) {
actionType = 'snap'; actionType = 'snap';
actionValue = '1/4'; actionValue = '1/4';
@@ -1453,7 +1462,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
if (actionType === 'snap') { if (actionType === 'snap') {
// Validate the snap value exists in snap options // 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 // Change snapping value
handleSnappingSelect(actionValue); handleSnappingSelect(actionValue);
@@ -1467,9 +1476,11 @@ const PianoRoll: React.FC<PianoRollProps> = ({
} }
} else if (actionType === 'quantize' && quantType) { } else if (actionType === 'quantize' && quantType) {
// Validate the quantValue exists in the appropriate options // 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 // Apply quantization
handleQuantSelect(quantType, actionValue); handleQuantSelect(quantType, actionValue);
@@ -1,8 +1,10 @@
import React from 'react'; import React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react'; 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 { createMockMidiControllerEvent, createMockMidiPitchBend, createMockMidiRegion, createMockMidiTrack } from '../../test/utils/mock-data';
import { I18nContext } from '../../i18n/I18nProvider';
import { translate } from '../../i18n/translate';
const coreMock = { const coreMock = {
selectedItems: [] as Array<{ getId(): string }>, selectedItems: [] as Array<{ getId(): string }>,
@@ -45,6 +47,19 @@ import PianoRollAutomationLane from './PianoRollAutomationLane';
import { getControllerNumberForAutomationType } from './pianoRollAutomation'; import { getControllerNumberForAutomationType } from './pianoRollAutomation';
describe('PianoRollAutomationLane', () => { describe('PianoRollAutomationLane', () => {
const renderWithLocale = (ui: React.ReactElement, locale: 'en_us' | 'zh_cn' = 'en_us') => render(
<I18nContext.Provider
value={{
languageSetting: locale,
resolvedLocale: locale,
setLanguageSetting: async () => undefined,
t: (key, params) => translate(key, params, locale),
}}
>
{ui}
</I18nContext.Provider>
);
beforeEach(() => { beforeEach(() => {
coreMock.selectedItems = []; coreMock.selectedItems = [];
coreMock.currentProjectTracks = []; coreMock.currentProjectTracks = [];
@@ -76,7 +91,7 @@ describe('PianoRollAutomationLane', () => {
coreMock.currentProjectTracks = storeState.tracks; coreMock.currentProjectTracks = storeState.tracks;
storeState.selectedPitchBendIds = ['bend-1']; storeState.selectedPitchBendIds = ['bend-1'];
const { container } = render( const { container } = renderWithLocale(
<PianoRollAutomationLane <PianoRollAutomationLane
activeRegion={region} activeRegion={region}
automationType="pitch-bend" automationType="pitch-bend"
@@ -106,7 +121,7 @@ describe('PianoRollAutomationLane', () => {
storeState.tracks = [createMockMidiTrack({ id: 1, regions: [region] })]; storeState.tracks = [createMockMidiTrack({ id: 1, regions: [region] })];
coreMock.currentProjectTracks = storeState.tracks; coreMock.currentProjectTracks = storeState.tracks;
const { container } = render( const { container } = renderWithLocale(
<PianoRollAutomationLane <PianoRollAutomationLane
activeRegion={region} activeRegion={region}
automationType="pitch-bend" automationType="pitch-bend"
@@ -144,9 +159,9 @@ describe('PianoRollAutomationLane', () => {
}); });
storeState.tracks = [createMockMidiTrack({ id: 1, regions: [region] })]; storeState.tracks = [createMockMidiTrack({ id: 1, regions: [region] })];
coreMock.currentProjectTracks = storeState.tracks; coreMock.currentProjectTracks = storeState.tracks;
KGPianoRollState.instance().setCurrentSnap('NO SNAP'); KGPianoRollState.instance().setCurrentSnap(PIANO_ROLL_NO_SNAP);
const { container } = render( const { container } = renderWithLocale(
<PianoRollAutomationLane <PianoRollAutomationLane
activeRegion={region} activeRegion={region}
automationType="pitch-bend" automationType="pitch-bend"
@@ -188,7 +203,7 @@ describe('PianoRollAutomationLane', () => {
coreMock.currentProjectTracks = storeState.tracks; coreMock.currentProjectTracks = storeState.tracks;
storeState.selectedControllerEventIds = ['cc7-1']; storeState.selectedControllerEventIds = ['cc7-1'];
const { container } = render( const { container } = renderWithLocale(
<PianoRollAutomationLane <PianoRollAutomationLane
activeRegion={region} activeRegion={region}
automationType="cc-7" automationType="cc-7"
@@ -224,7 +239,7 @@ describe('PianoRollAutomationLane', () => {
storeState.tracks = [createMockMidiTrack({ id: 1, regions: [region] })]; storeState.tracks = [createMockMidiTrack({ id: 1, regions: [region] })];
coreMock.currentProjectTracks = storeState.tracks; coreMock.currentProjectTracks = storeState.tracks;
const { container } = render( const { container } = renderWithLocale(
<PianoRollAutomationLane <PianoRollAutomationLane
activeRegion={region} activeRegion={region}
automationType="cc-64" automationType="cc-64"
@@ -253,7 +268,7 @@ describe('PianoRollAutomationLane', () => {
coreMock.currentProjectTracks = storeState.tracks; coreMock.currentProjectTracks = storeState.tracks;
storeState.selectedPitchBendIds = ['bend-2']; storeState.selectedPitchBendIds = ['bend-2'];
const { container } = render( const { container } = renderWithLocale(
<PianoRollAutomationLane <PianoRollAutomationLane
activeRegion={region} activeRegion={region}
automationType="pitch-bend" automationType="pitch-bend"
@@ -286,7 +301,7 @@ describe('PianoRollAutomationLane', () => {
storeState.tracks = [createMockMidiTrack({ id: 1, regions: [region] })]; storeState.tracks = [createMockMidiTrack({ id: 1, regions: [region] })];
coreMock.currentProjectTracks = storeState.tracks; coreMock.currentProjectTracks = storeState.tracks;
const { container } = render( const { container } = renderWithLocale(
<PianoRollAutomationLane <PianoRollAutomationLane
activeRegion={region} activeRegion={region}
automationType="pitch-bend" automationType="pitch-bend"
@@ -308,4 +323,27 @@ describe('PianoRollAutomationLane', () => {
expect(getScrollLayer()?.classList.contains('pencil-cursor')).toBe(false); 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 { UpdatePitchBendPropertiesCommand } from '../../core/commands/note/UpdatePitchBendPropertiesCommand';
import { KGMidiControllerEvent } from '../../core/midi/KGMidiControllerEvent'; import { KGMidiControllerEvent } from '../../core/midi/KGMidiControllerEvent';
import { KGMidiPitchBend } from '../../core/midi/KGMidiPitchBend'; 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 { KGMidiRegion } from '../../core/region/KGMidiRegion';
import { import {
MIDI_PITCH_BEND_MAX, MIDI_PITCH_BEND_MAX,
@@ -19,11 +19,12 @@ import { useProjectStore } from '../../stores/projectStore';
import { PIANO_ROLL_CONSTANTS } from '../../constants'; import { PIANO_ROLL_CONSTANTS } from '../../constants';
import { getSnappedBeatPosition } from './pianoRollSnap'; import { getSnappedBeatPosition } from './pianoRollSnap';
import { import {
getAutomationLabel,
getAutomationInterpolationMode, getAutomationInterpolationMode,
getControllerNumberForAutomationType, getControllerNumberForAutomationType,
PIANO_ROLL_AUTOMATION_OPTIONS,
type PianoRollAutomationType, type PianoRollAutomationType,
} from './pianoRollAutomation'; } from './pianoRollAutomation';
import { useI18n } from '../../i18n/useI18n';
interface AutomationPoint { interface AutomationPoint {
id: string; id: string;
@@ -73,6 +74,7 @@ const PianoRollAutomationLane: React.FC<PianoRollAutomationLaneProps> = ({
horizontalScrollLeft = 0, horizontalScrollLeft = 0,
onHorizontalWheel, onHorizontalWheel,
}) => { }) => {
const { t } = useI18n();
const laneRef = useRef<HTMLDivElement | null>(null); const laneRef = useRef<HTMLDivElement | null>(null);
const isLassoSelectingRef = useRef(false); const isLassoSelectingRef = useRef(false);
const lassoShiftKeyRef = useRef(false); const lassoShiftKeyRef = useRef(false);
@@ -237,8 +239,7 @@ const PianoRollAutomationLane: React.FC<PianoRollAutomationLaneProps> = ({
? tracks.find(track => track.getId().toString() === activeRegion.getTrackId()) ?? null ? tracks.find(track => track.getId().toString() === activeRegion.getTrackId()) ?? null
: null; : null;
const selectedOption = PIANO_ROLL_AUTOMATION_OPTIONS.find(option => option.value === automationType); const laneLabel = getAutomationLabel(automationType, t);
const laneLabel = selectedOption?.label ?? automationType;
const interpolationMode = getAutomationInterpolationMode(automationType); const interpolationMode = getAutomationInterpolationMode(automationType);
const totalBeats = maxBars * timeSignature.numerator; const totalBeats = maxBars * timeSignature.numerator;
const totalWidth = 'calc(var(--max-number-of-bars) * var(--region-grid-bar-width) + var(--region-piano-key-width))'; 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 rawAbsoluteBeat = (coordinates.x - keyWidth) / beatWidth;
const snappedAbsoluteBeat = KGPianoRollState.instance().getCurrentSnap() === 'NO SNAP' const snappedAbsoluteBeat = KGPianoRollState.instance().getCurrentSnap() === PIANO_ROLL_NO_SNAP
? rawAbsoluteBeat ? rawAbsoluteBeat
: getSnappedBeatPosition(rawAbsoluteBeat, KGPianoRollState.instance().getCurrentSnap()); : getSnappedBeatPosition(rawAbsoluteBeat, KGPianoRollState.instance().getCurrentSnap());
const beatDelta = snappedAbsoluteBeat - dragState.originAbsoluteBeat; const beatDelta = snappedAbsoluteBeat - dragState.originAbsoluteBeat;
@@ -673,7 +674,7 @@ const PianoRollAutomationLane: React.FC<PianoRollAutomationLaneProps> = ({
const rawAbsoluteBeat = (coordinates.x - keyWidth) / beatWidth; const rawAbsoluteBeat = (coordinates.x - keyWidth) / beatWidth;
const currentSnap = KGPianoRollState.instance().getCurrentSnap(); const currentSnap = KGPianoRollState.instance().getCurrentSnap();
const absoluteBeat = currentSnap === 'NO SNAP' const absoluteBeat = currentSnap === PIANO_ROLL_NO_SNAP
? rawAbsoluteBeat ? rawAbsoluteBeat
: getSnappedBeatPosition(rawAbsoluteBeat, currentSnap); : getSnappedBeatPosition(rawAbsoluteBeat, currentSnap);
const relativeBeat = absoluteBeat - activeRegion.getStartFromBeat(); const relativeBeat = absoluteBeat - activeRegion.getStartFromBeat();
@@ -770,7 +771,7 @@ const PianoRollAutomationLane: React.FC<PianoRollAutomationLaneProps> = ({
<div <div
className="piano-roll-automation-lane" className="piano-roll-automation-lane"
data-testid="piano-roll-automation-lane" data-testid="piano-roll-automation-lane"
aria-label={`${laneLabel} automation lane`} aria-label={t('pianoRoll.automationLane', { label: laneLabel })}
ref={laneRef} ref={laneRef}
> >
<div className="piano-roll-automation-track" style={{ width: totalWidth }}> <div className="piano-roll-automation-track" style={{ width: totalWidth }}>
@@ -2,6 +2,8 @@ import React from 'react';
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react'; import { fireEvent, render, screen } from '@testing-library/react';
import PianoRollToolbar from './PianoRollToolbar'; import PianoRollToolbar from './PianoRollToolbar';
import { I18nContext } from '../../i18n/I18nProvider';
import { translate } from '../../i18n/translate';
vi.mock('../common', () => ({ vi.mock('../common', () => ({
KGDropdown: ({ KGDropdown: ({
@@ -34,11 +36,25 @@ vi.mock('../../core/KGCore', () => ({
FUNCTIONAL_CHORDS_DATA: { FUNCTIONAL_CHORDS_DATA: {
ionian: { name: 'Ionian' }, ionian: { name: 'Ionian' },
dorian: { name: 'Dorian' }, dorian: { name: 'Dorian' },
harmonic_minor: { name: 'Harmonic Minor' },
}, },
}, },
})); }));
describe('PianoRollToolbar', () => { describe('PianoRollToolbar', () => {
const renderWithLocale = (ui: React.ReactElement, locale: 'en_us' | 'zh_cn' = 'en_us') => render(
<I18nContext.Provider
value={{
languageSetting: locale,
resolvedLocale: locale,
setLanguageSetting: async () => undefined,
t: (key, params) => translate(key, params, locale),
}}
>
{ui}
</I18nContext.Provider>
);
const baseProps = { const baseProps = {
sheetMusicViewEnabled: false, sheetMusicViewEnabled: false,
onSheetMusicViewToggle: vi.fn(), onSheetMusicViewToggle: vi.fn(),
@@ -52,7 +68,7 @@ describe('PianoRollToolbar', () => {
quantPosition: '1/8', quantPosition: '1/8',
quantLength: '1/8', quantLength: '1/8',
onQuantSelect: vi.fn(), onQuantSelect: vi.fn(),
snapping: 'NO SNAP', snapping: 'none',
onSnappingSelect: vi.fn(), onSnappingSelect: vi.fn(),
selectedMode: 'ionian', selectedMode: 'ionian',
onModeChange: vi.fn(), onModeChange: vi.fn(),
@@ -66,7 +82,7 @@ describe('PianoRollToolbar', () => {
it('shows automation controls in midi mode and toggles the lane', () => { it('shows automation controls in midi mode and toggles the lane', () => {
const onAutomationToggle = vi.fn(); const onAutomationToggle = vi.fn();
render( renderWithLocale(
<PianoRollToolbar <PianoRollToolbar
{...baseProps} {...baseProps}
mode="midi-edit" mode="midi-edit"
@@ -89,7 +105,7 @@ describe('PianoRollToolbar', () => {
it('changes the automation type from the dropdown', () => { it('changes the automation type from the dropdown', () => {
const onAutomationTypeChange = vi.fn(); const onAutomationTypeChange = vi.fn();
render( renderWithLocale(
<PianoRollToolbar <PianoRollToolbar
{...baseProps} {...baseProps}
mode="hybrid" mode="hybrid"
@@ -107,7 +123,7 @@ describe('PianoRollToolbar', () => {
}); });
it('renders chord guide toggle buttons and marks off as active by default', () => { it('renders chord guide toggle buttons and marks off as active by default', () => {
render( renderWithLocale(
<PianoRollToolbar <PianoRollToolbar
{...baseProps} {...baseProps}
mode="midi-edit" mode="midi-edit"
@@ -126,7 +142,7 @@ describe('PianoRollToolbar', () => {
it('emits the selected chord guide value when toggle buttons are clicked', () => { it('emits the selected chord guide value when toggle buttons are clicked', () => {
const onChordGuideChange = vi.fn(); const onChordGuideChange = vi.fn();
render( renderWithLocale(
<PianoRollToolbar <PianoRollToolbar
{...baseProps} {...baseProps}
mode="midi-edit" mode="midi-edit"
@@ -147,7 +163,7 @@ describe('PianoRollToolbar', () => {
}); });
it('hides automation controls in spectrogram mode', () => { it('hides automation controls in spectrogram mode', () => {
render( renderWithLocale(
<PianoRollToolbar <PianoRollToolbar
{...baseProps} {...baseProps}
mode="spectrogram" mode="spectrogram"
@@ -162,7 +178,7 @@ describe('PianoRollToolbar', () => {
it('shows the spectrogram toggle for pure audio waveform mode and toggles it', () => { it('shows the spectrogram toggle for pure audio waveform mode and toggles it', () => {
const onAudioSpectrogramToggle = vi.fn(); const onAudioSpectrogramToggle = vi.fn();
render( renderWithLocale(
<PianoRollToolbar <PianoRollToolbar
{...baseProps} {...baseProps}
mode="audio-waveform" mode="audio-waveform"
@@ -179,7 +195,7 @@ describe('PianoRollToolbar', () => {
}); });
it('hides the spectrogram toggle in hybrid mode', () => { it('hides the spectrogram toggle in hybrid mode', () => {
render( renderWithLocale(
<PianoRollToolbar <PianoRollToolbar
{...baseProps} {...baseProps}
mode="hybrid" mode="hybrid"
@@ -193,7 +209,7 @@ describe('PianoRollToolbar', () => {
it('shows the detect chords action in spectrogram mode and triggers it', () => { it('shows the detect chords action in spectrogram mode and triggers it', () => {
const onDetectChords = vi.fn(); const onDetectChords = vi.fn();
render( renderWithLocale(
<PianoRollToolbar <PianoRollToolbar
{...baseProps} {...baseProps}
mode="spectrogram" mode="spectrogram"
@@ -211,7 +227,7 @@ describe('PianoRollToolbar', () => {
it('shows the detect tempo action when the audio callback is provided and triggers it', () => { it('shows the detect tempo action when the audio callback is provided and triggers it', () => {
const onDetectTempo = vi.fn(); const onDetectTempo = vi.fn();
render( renderWithLocale(
<PianoRollToolbar <PianoRollToolbar
{...baseProps} {...baseProps}
mode="spectrogram" mode="spectrogram"
@@ -229,7 +245,7 @@ describe('PianoRollToolbar', () => {
it('shows the detect chords action in midi mode and triggers it', () => { it('shows the detect chords action in midi mode and triggers it', () => {
const onDetectChords = vi.fn(); const onDetectChords = vi.fn();
render( renderWithLocale(
<PianoRollToolbar <PianoRollToolbar
{...baseProps} {...baseProps}
mode="midi-edit" mode="midi-edit"
@@ -247,7 +263,7 @@ describe('PianoRollToolbar', () => {
it('disables the detect chords action while detection is running', () => { it('disables the detect chords action while detection is running', () => {
const onDetectChords = vi.fn(); const onDetectChords = vi.fn();
render( renderWithLocale(
<PianoRollToolbar <PianoRollToolbar
{...baseProps} {...baseProps}
mode="spectrogram" mode="spectrogram"
@@ -267,7 +283,7 @@ describe('PianoRollToolbar', () => {
it('disables the detect tempo action while detection is running', () => { it('disables the detect tempo action while detection is running', () => {
const onDetectTempo = vi.fn(); const onDetectTempo = vi.fn();
render( renderWithLocale(
<PianoRollToolbar <PianoRollToolbar
{...baseProps} {...baseProps}
mode="spectrogram" mode="spectrogram"
@@ -286,7 +302,7 @@ describe('PianoRollToolbar', () => {
}); });
it('does not show the detect tempo action without the audio callback', () => { it('does not show the detect tempo action without the audio callback', () => {
render( renderWithLocale(
<PianoRollToolbar <PianoRollToolbar
{...baseProps} {...baseProps}
mode="midi-edit" mode="midi-edit"
@@ -299,7 +315,7 @@ describe('PianoRollToolbar', () => {
}); });
it('shows only the sheet controls when sheet mode is enabled', () => { it('shows only the sheet controls when sheet mode is enabled', () => {
render( renderWithLocale(
<PianoRollToolbar <PianoRollToolbar
{...baseProps} {...baseProps}
sheetMusicViewEnabled={true} sheetMusicViewEnabled={true}
@@ -316,7 +332,7 @@ describe('PianoRollToolbar', () => {
}); });
it('shows the zoom button outside sheet mode', () => { it('shows the zoom button outside sheet mode', () => {
render( renderWithLocale(
<PianoRollToolbar <PianoRollToolbar
{...baseProps} {...baseProps}
sheetMusicViewEnabled={false} sheetMusicViewEnabled={false}
@@ -324,13 +340,13 @@ describe('PianoRollToolbar', () => {
/> />
); );
expect(screen.getByRole('button', { name: '1x' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Zoom' })).toBeInTheDocument();
}); });
it('toggles the full-track sheet scope button', () => { it('toggles the full-track sheet scope button', () => {
const onSheetMusicTrackScopeToggle = vi.fn(); const onSheetMusicTrackScopeToggle = vi.fn();
render( renderWithLocale(
<PianoRollToolbar <PianoRollToolbar
{...baseProps} {...baseProps}
sheetMusicViewEnabled={true} sheetMusicViewEnabled={true}
@@ -344,24 +360,74 @@ describe('PianoRollToolbar', () => {
}); });
it('hides the full-track sheet scope button outside sheet mode and spectrogram mode', () => { 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( const { rerender } = render(
<PianoRollToolbar <I18nContext.Provider value={localeValue}>
{...baseProps} <PianoRollToolbar
sheetMusicViewEnabled={false} {...baseProps}
mode="midi-edit" sheetMusicViewEnabled={false}
/> mode="midi-edit"
/>
</I18nContext.Provider>
); );
expect(screen.queryByRole('button', { name: 'Show Entire Track' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Show Entire Track' })).not.toBeInTheDocument();
rerender( rerender(
<PianoRollToolbar <I18nContext.Provider value={localeValue}>
{...baseProps} <PianoRollToolbar
sheetMusicViewEnabled={true} {...baseProps}
mode="spectrogram" sheetMusicViewEnabled={true}
/> mode="spectrogram"
/>
</I18nContext.Provider>
); );
expect(screen.queryByRole('button', { name: 'Show Entire Track' })).not.toBeInTheDocument(); 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();
});
}); });
+69 -43
View File
@@ -5,23 +5,10 @@ import { KGDropdown } from '../common';
import { KGPianoRollState } from '../../core/state/KGPianoRollState'; import { KGPianoRollState } from '../../core/state/KGPianoRollState';
import { KGCore } from '../../core/KGCore'; import { KGCore } from '../../core/KGCore';
import { import {
PIANO_ROLL_AUTOMATION_OPTIONS, getTranslatedAutomationOptions,
type PianoRollAutomationType, type PianoRollAutomationType,
} from './pianoRollAutomation'; } from './pianoRollAutomation';
import { useI18n } from '../../i18n/useI18n';
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' },
];
interface PianoRollToolbarProps { interface PianoRollToolbarProps {
showAudioSpectrogramToggle?: boolean; showAudioSpectrogramToggle?: boolean;
@@ -106,11 +93,48 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
onDetectTempo, onDetectTempo,
detectingTempo = false, detectingTempo = false,
}) => { }) => {
const { t } = useI18n();
const showMidiControls = mode !== 'spectrogram' && mode !== 'audio-waveform' && !sheetMusicViewEnabled; const showMidiControls = mode !== 'spectrogram' && mode !== 'audio-waveform' && !sheetMusicViewEnabled;
const showAudioOnlyControls = mode === 'audio-waveform' && !sheetMusicViewEnabled; const showAudioOnlyControls = mode === 'audio-waveform' && !sheetMusicViewEnabled;
const showSpectrogramOnlyControls = mode === 'spectrogram' && !sheetMusicViewEnabled; const showSpectrogramOnlyControls = mode === 'spectrogram' && !sheetMusicViewEnabled;
const showSpecControls = !sheetMusicViewEnabled && (mode === 'spectrogram' || mode === 'hybrid'); const showSpecControls = !sheetMusicViewEnabled && (mode === 'spectrogram' || mode === 'hybrid');
const showSpecMenu = !sheetMusicViewEnabled && (!!onDetectChords || !!onDetectTempo); 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 [showZoomSlider, setShowZoomSlider] = React.useState(false);
const zoomSliderRef = React.useRef<HTMLDivElement>(null); const zoomSliderRef = React.useRef<HTMLDivElement>(null);
@@ -144,8 +168,8 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
<button <button
className={`tool-button icon-only sheet-mode-toggle ${audioSpectrogramEnabled ? 'active' : ''}`} className={`tool-button icon-only sheet-mode-toggle ${audioSpectrogramEnabled ? 'active' : ''}`}
onClick={() => onAudioSpectrogramToggle?.()} onClick={() => onAudioSpectrogramToggle?.()}
title="Spectrogram View" title={t('pianoRoll.spectrogramView')}
aria-label="Spectrogram View" aria-label={t('pianoRoll.spectrogramView')}
> >
<svg className="spectrogram-view-icon" width="12" height="12" viewBox="0 0 10 10" fill="currentColor"> <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" /> <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 <button
className={`tool-button sheet-mode-toggle ${sheetMusicViewEnabled ? 'active' : ''}`} className={`tool-button sheet-mode-toggle ${sheetMusicViewEnabled ? 'active' : ''}`}
onClick={() => onSheetMusicViewToggle?.()} onClick={() => onSheetMusicViewToggle?.()}
title="Sheet Music View" title={t('pianoRoll.sheetMusicView')}
aria-label="Sheet Music View" aria-label={t('pianoRoll.sheetMusicView')}
disabled={sheetMusicToggleDisabled} disabled={sheetMusicToggleDisabled}
> >
@@ -176,14 +200,14 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
<button <button
className={`tool-button ${activeTool === 'pointer' ? 'active' : ''}`} className={`tool-button ${activeTool === 'pointer' ? 'active' : ''}`}
onClick={() => onToolSelect('pointer')} onClick={() => onToolSelect('pointer')}
title="Pointer Tool" title={t('pianoRoll.pointerTool')}
> >
<FaMousePointer className="piano-roll-tool-icon" /> <FaMousePointer className="piano-roll-tool-icon" />
</button> </button>
<button <button
className={`tool-button ${activeTool === 'pencil' ? 'active' : ''}`} className={`tool-button ${activeTool === 'pencil' ? 'active' : ''}`}
onClick={() => onToolSelect('pencil')} onClick={() => onToolSelect('pencil')}
title="Pencil Tool" title={t('pianoRoll.pencilTool')}
> >
<FaPencilAlt className="piano-roll-tool-icon" /> <FaPencilAlt className="piano-roll-tool-icon" />
</button> </button>
@@ -192,30 +216,30 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
<button <button
className={`tool-button automation-toggle-button ${automationEnabled ? 'active' : ''}`} className={`tool-button automation-toggle-button ${automationEnabled ? 'active' : ''}`}
onClick={() => onAutomationToggle?.()} onClick={() => onAutomationToggle?.()}
title="Toggle automation lane" title={t('pianoRoll.toggleAutomationLane')}
aria-label="Toggle automation lane" aria-label={t('pianoRoll.toggleAutomationLane')}
> >
A A
</button> </button>
<KGDropdown <KGDropdown
options={PIANO_ROLL_AUTOMATION_OPTIONS} options={automationOptions}
value={automationType} value={automationType}
onChange={(value) => onAutomationTypeChange?.(value as PianoRollAutomationType)} onChange={(value) => onAutomationTypeChange?.(value as PianoRollAutomationType)}
label="Automation" label={t('pianoRoll.automation')}
buttonClassName="automation-type-dropdown" buttonClassName="automation-type-dropdown"
showValueAsLabel={true} showValueAsLabel={true}
/> />
</div> </div>
)} )}
<KGDropdown <KGDropdown
options={Object.entries(KGCore.FUNCTIONAL_CHORDS_DATA).map(([id, data]) => ({ label: data.name, value: id }))} options={modeOptions}
value={selectedMode} value={selectedMode}
onChange={(value) => onModeChange(value)} onChange={(value) => onModeChange(value)}
label="Mode" label={t('pianoRoll.mode')}
buttonClassName="mode-dropdown" buttonClassName="mode-dropdown"
showValueAsLabel={true} 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) => ( {CHORD_GUIDE_BUTTONS.map((button, index) => (
<button <button
key={button.value} key={button.value}
@@ -253,8 +277,8 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
<button <button
className={`tool-button icon-only sheet-track-scope-toggle ${sheetMusicTrackScopeEnabled ? 'active' : ''}`} className={`tool-button icon-only sheet-track-scope-toggle ${sheetMusicTrackScopeEnabled ? 'active' : ''}`}
onClick={() => onSheetMusicTrackScopeToggle?.()} onClick={() => onSheetMusicTrackScopeToggle?.()}
title={sheetMusicTrackScopeEnabled ? 'Show Active Region Only' : 'Show Entire Track'} title={sheetMusicTrackScopeEnabled ? t('pianoRoll.showActiveRegionOnly') : t('pianoRoll.showEntireTrack')}
aria-label={sheetMusicTrackScopeEnabled ? 'Show Active Region Only' : 'Show Entire Track'} aria-label={sheetMusicTrackScopeEnabled ? t('pianoRoll.showActiveRegionOnly') : t('pianoRoll.showEntireTrack')}
> >
<TbArrowBarToUp className="sheet-track-scope-icon" strokeWidth={2.5} /> <TbArrowBarToUp className="sheet-track-scope-icon" strokeWidth={2.5} />
</button> </button>
@@ -268,7 +292,7 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
options={sheetQuantizationOptions} options={sheetQuantizationOptions}
value={sheetQuantization} value={sheetQuantization}
onChange={(value) => onSheetQuantizationChange?.(value)} onChange={(value) => onSheetQuantizationChange?.(value)}
label="Sheet Quant." label={t('pianoRoll.sheetQuantization')}
buttonClassName="sheet-quantization" buttonClassName="sheet-quantization"
showValueAsLabel={true} showValueAsLabel={true}
/> />
@@ -276,25 +300,25 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
{showMidiControls && ( {showMidiControls && (
<> <>
<KGDropdown <KGDropdown
options={KGPianoRollState.SNAP_OPTIONS} options={snapOptions}
value={snapping} value={snapping}
onChange={(value) => onSnappingSelect(value)} onChange={(value) => onSnappingSelect(value)}
label="Snap" label={t('pianoRoll.snap')}
buttonClassName="snapping" buttonClassName="snapping"
showValueAsLabel={true} showValueAsLabel={true}
/> />
<KGDropdown <KGDropdown
options={KGPianoRollState.QUANT_POS_OPTIONS} options={quantPositionOptions}
value={quantPosition} value={quantPosition}
onChange={(value) => onQuantSelect('position', value)} onChange={(value) => onQuantSelect('position', value)}
label="Qua. Pos." label={t('pianoRoll.quantizePositionCompact')}
buttonClassName={`quant-position ${blinkButton === 'quant-position' ? 'button-blink' : ''}`} buttonClassName={`quant-position ${blinkButton === 'quant-position' ? 'button-blink' : ''}`}
/> />
<KGDropdown <KGDropdown
options={KGPianoRollState.QUANT_LEN_OPTIONS} options={quantLengthOptions}
value={quantLength} value={quantLength}
onChange={(value) => onQuantSelect('length', value)} onChange={(value) => onQuantSelect('length', value)}
label="Qua. Len." label={t('pianoRoll.quantizeLengthCompact')}
buttonClassName={`quant-length ${blinkButton === 'quant-length' ? 'button-blink' : ''}`} buttonClassName={`quant-length ${blinkButton === 'quant-length' ? 'button-blink' : ''}`}
/> />
</> </>
@@ -302,7 +326,7 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
{showSpecControls && ( {showSpecControls && (
<div className="spectrogram-toolbar-controls"> <div className="spectrogram-toolbar-controls">
<span className="spectrogram-control-label">Floor</span> <span className="spectrogram-control-label">{t('pianoRoll.floor')}</span>
<input <input
type="range" type="range"
className="spectrogram-threshold-slider" className="spectrogram-threshold-slider"
@@ -318,7 +342,7 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
options={POWER_OPTIONS} options={POWER_OPTIONS}
value={power.toString()} value={power.toString()}
onChange={v => onPowerChange?.(parseFloat(v))} onChange={v => onPowerChange?.(parseFloat(v))}
label="Curve" label={t('pianoRoll.curve')}
buttonClassName="curve-dropdown" buttonClassName="curve-dropdown"
showValueAsLabel={true} showValueAsLabel={true}
/> />
@@ -330,7 +354,8 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
<button <button
className="quant-button" className="quant-button"
onClick={() => setShowZoomSlider(!showZoomSlider)} onClick={() => setShowZoomSlider(!showZoomSlider)}
title="Zoom" title={t('pianoRoll.zoom')}
aria-label={t('pianoRoll.zoom')}
> >
{zoom}x {zoom}x
</button> </button>
@@ -355,7 +380,8 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
<button <button
className="quant-button" className="quant-button"
onClick={() => setShowMoreMenu(!showMoreMenu)} onClick={() => setShowMoreMenu(!showMoreMenu)}
title="More options" title={t('pianoRoll.moreOptions')}
aria-label={t('pianoRoll.moreOptions')}
> >
... ...
</button> </button>
@@ -373,7 +399,7 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
}} }}
aria-disabled={detectingChords} aria-disabled={detectingChords}
> >
{detectingChords ? 'Detecting chords...' : 'Detect chords...'} {detectingChords ? t('pianoRoll.detectingChords') : t('pianoRoll.detectChords')}
</div> </div>
)} )}
{onDetectTempo && ( {onDetectTempo && (
@@ -388,7 +414,7 @@ const PianoRollToolbar: React.FC<PianoRollToolbarProps> = ({
}} }}
aria-disabled={detectingTempo} aria-disabled={detectingTempo}
> >
{detectingTempo ? 'Detecting tempo...' : 'Detect tempo...'} {detectingTempo ? t('pianoRoll.detectingTempo') : t('pianoRoll.detectTempo')}
</div> </div>
)} )}
</div> </div>
@@ -6,7 +6,7 @@ import { createMockMidiRegion, createMockMidiTrack } from '../../test/utils/mock
const pianoRollState = { const pianoRollState = {
zoom: 1, zoom: 1,
getCurrentSnap: vi.fn(() => 'NO SNAP'), getCurrentSnap: vi.fn(() => 'none'),
getActiveTool: vi.fn(() => 'pointer'), getActiveTool: vi.fn(() => 'pointer'),
getAutomationViewEnabled: vi.fn(() => false), getAutomationViewEnabled: vi.fn(() => false),
getCurrentAutomationType: vi.fn(() => 'pitch-bend'), getCurrentAutomationType: vi.fn(() => 'pitch-bend'),
@@ -26,6 +26,7 @@ const pianoRollState = {
setCurrentSnap: vi.fn(), setCurrentSnap: vi.fn(),
setCurrentSuitableChords: vi.fn(), setCurrentSuitableChords: vi.fn(),
setCurrentSuitableChordsPitchClasses: vi.fn(), setCurrentSuitableChordsPitchClasses: vi.fn(),
setCurrentHoveredChordGuideCandidate: vi.fn(),
}; };
const mockProject = { const mockProject = {
@@ -71,10 +72,11 @@ vi.mock('../../stores/projectStore', () => ({
vi.mock('../../core/state/KGPianoRollState', () => ({ vi.mock('../../core/state/KGPianoRollState', () => ({
KGPianoRollState: { KGPianoRollState: {
instance: () => pianoRollState, instance: () => pianoRollState,
SNAP_OPTIONS: ['NO SNAP'], SNAP_OPTIONS: [{ value: 'none', labelKey: 'pianoRoll.snap.none' }],
QUANT_POS_OPTIONS: ['1/8'], QUANT_POS_OPTIONS: [{ value: '1/8', labelKey: 'pianoRoll.quantize.1/8' }],
QUANT_LEN_OPTIONS: ['1/8'], QUANT_LEN_OPTIONS: [{ value: '1/8', labelKey: 'pianoRoll.quantize.1/8' }],
}, },
PIANO_ROLL_NO_SNAP: 'none',
})); }));
vi.mock('../../core/KGCore', () => ({ vi.mock('../../core/KGCore', () => ({
@@ -1,3 +1,5 @@
import type { TranslationParams } from '../../i18n/types';
export type PianoRollAutomationType = export type PianoRollAutomationType =
| 'pitch-bend' | 'pitch-bend'
| 'cc-1' | 'cc-1'
@@ -7,20 +9,37 @@ export type PianoRollAutomationType =
| 'cc-64'; | 'cc-64';
export interface PianoRollAutomationOption { export interface PianoRollAutomationOption {
label: string;
value: PianoRollAutomationType; value: PianoRollAutomationType;
labelKey: string;
interpolationMode: 'linear' | 'step'; interpolationMode: 'linear' | 'step';
} }
export const PIANO_ROLL_AUTOMATION_OPTIONS: PianoRollAutomationOption[] = [ export const PIANO_ROLL_AUTOMATION_OPTIONS: PianoRollAutomationOption[] = [
{ label: 'Pitch Bend', value: 'pitch-bend', interpolationMode: 'linear' }, { value: 'pitch-bend', labelKey: 'pianoRoll.automationType.pitchBend', interpolationMode: 'linear' },
{ label: 'CC1', value: 'cc-1', interpolationMode: 'linear' }, { value: 'cc-1', labelKey: 'pianoRoll.automationType.cc1', interpolationMode: 'linear' },
{ label: 'CC2', value: 'cc-2', interpolationMode: 'linear' }, { value: 'cc-2', labelKey: 'pianoRoll.automationType.cc2', interpolationMode: 'linear' },
{ label: 'CC7', value: 'cc-7', interpolationMode: 'linear' }, { value: 'cc-7', labelKey: 'pianoRoll.automationType.cc7', interpolationMode: 'linear' },
{ label: 'CC11', value: 'cc-11', interpolationMode: 'linear' }, { value: 'cc-11', labelKey: 'pianoRoll.automationType.cc11', interpolationMode: 'linear' },
{ label: 'CC64', value: 'cc-64', interpolationMode: 'step' }, { 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 { export function getControllerNumberForAutomationType(type: PianoRollAutomationType): number | null {
switch (type) { switch (type) {
case 'cc-1': 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);
});
});
+5 -4
View File
@@ -1,7 +1,8 @@
import { PIANO_ROLL_NO_SNAP, type PianoRollSnapValue } from '../../core/state/KGPianoRollState';
import { DEBUG_MODE } from '../../constants'; import { DEBUG_MODE } from '../../constants';
export function getSnapStep(currentSnap: string): number | null { export function getSnapStep(currentSnap: PianoRollSnapValue): number | null {
if (currentSnap === 'NO SNAP') { if (currentSnap === PIANO_ROLL_NO_SNAP) {
return null; return null;
} }
@@ -15,7 +16,7 @@ export function getSnapStep(currentSnap: string): number | null {
export function getSnappedBeatPosition( export function getSnappedBeatPosition(
beatPosition: number, beatPosition: number,
currentSnap: string, currentSnap: PianoRollSnapValue,
useFloorSnapping: boolean = false, useFloorSnapping: boolean = false,
): number { ): number {
const snapStep = getSnapStep(currentSnap); const snapStep = getSnapStep(currentSnap);
@@ -36,7 +37,7 @@ export function getSnappedBeatPosition(
return snappedPosition; 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); const snapStep = getSnapStep(currentSnap);
if (snapStep === null) { if (snapStep === null) {
return length; return length;
+9 -7
View File
@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { FaTimes } from 'react-icons/fa'; import { FaTimes } from 'react-icons/fa';
import type { SettingsSection } from './SettingsPanel'; import type { SettingsSection } from './SettingsPanel';
import { useI18n } from '../../i18n/useI18n';
interface SettingsSidebarProps { interface SettingsSidebarProps {
activeSection: SettingsSection; activeSection: SettingsSection;
@@ -13,22 +14,23 @@ const SettingsSidebar: React.FC<SettingsSidebarProps> = ({
onSectionChange, onSectionChange,
onClose onClose
}) => { }) => {
const { t } = useI18n();
const sections = [ const sections = [
{ id: 'general' as SettingsSection, label: 'General' }, { id: 'general' as SettingsSection, label: t('settings.sidebar.general') },
{ id: 'audio_io' as SettingsSection, label: 'Audio I/O' }, { id: 'audio_io' as SettingsSection, label: t('settings.sidebar.audioIo') },
{ id: 'behavior' as SettingsSection, label: 'Behavior' }, { id: 'behavior' as SettingsSection, label: t('settings.sidebar.behavior') },
{ id: 'templates' as SettingsSection, label: 'Templates' }, { id: 'templates' as SettingsSection, label: t('settings.sidebar.templates') },
{ id: 'chord_guide' as SettingsSection, label: 'Chord Guide' } { id: 'chord_guide' as SettingsSection, label: t('settings.sidebar.chordGuide') }
]; ];
return ( return (
<div className="settings-sidebar"> <div className="settings-sidebar">
<div className="settings-sidebar-header"> <div className="settings-sidebar-header">
<h2>Settings</h2> <h2>{t('settings.sidebar.title')}</h2>
<button <button
className="settings-close-btn" className="settings-close-btn"
onClick={onClose} onClick={onClose}
title="Close Settings" title={t('settings.sidebar.close')}
> >
<FaTimes /> <FaTimes />
</button> </button>
@@ -2,6 +2,8 @@ import React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import GeneralSettings from './GeneralSettings'; import GeneralSettings from './GeneralSettings';
import { I18nContext } from '../../../i18n/I18nProvider';
import { translate } from '../../../i18n/translate';
const { localSeparatorModelCacheMock } = vi.hoisted(() => ({ const { localSeparatorModelCacheMock } = vi.hoisted(() => ({
localSeparatorModelCacheMock: { localSeparatorModelCacheMock: {
@@ -11,6 +13,7 @@ const { localSeparatorModelCacheMock } = vi.hoisted(() => ({
})); }));
const configState = new Map<string, unknown>([ const configState = new Map<string, unknown>([
['general.language', 'auto'],
['general.llm_provider', 'local_browser'], ['general.llm_provider', 'local_browser'],
['general.persist_api_keys_non_localhost', false], ['general.persist_api_keys_non_localhost', false],
['general.openai.api_key', ''], ['general.openai.api_key', ''],
@@ -86,7 +89,23 @@ vi.mock('../../../util/local-separator/modelCache', () => ({
})); }));
describe('GeneralSettings', () => { describe('GeneralSettings', () => {
const renderSettings = () => render(
<I18nContext.Provider
value={{
languageSetting: 'auto',
resolvedLocale: 'en_us',
setLanguageSetting: async (value) => {
await configManagerMock.set('general.language', value);
},
t: (key, params) => translate(key, params, 'en_us'),
}}
>
<GeneralSettings />
</I18nContext.Provider>,
);
beforeEach(() => { beforeEach(() => {
configState.set('general.language', 'auto');
configState.set('general.local_browser.context_length', 65536); configState.set('general.local_browser.context_length', 65536);
configManagerMock.get.mockClear(); configManagerMock.get.mockClear();
configManagerMock.set.mockClear(); configManagerMock.set.mockClear();
@@ -111,7 +130,7 @@ describe('GeneralSettings', () => {
}); });
it('renders the local context length selector and VRAM hint', async () => { 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(await screen.findByText('Gemma 4 E4B Local Runtime')).toBeTruthy();
expect(screen.getByLabelText('Context Length')).toBeTruthy(); expect(screen.getByLabelText('Context Length')).toBeTruthy();
@@ -119,14 +138,14 @@ describe('GeneralSettings', () => {
}); });
it('initializes the local context length from config', async () => { it('initializes the local context length from config', async () => {
render(<GeneralSettings />); renderSettings();
const select = await screen.findByLabelText('Context Length'); const select = await screen.findByLabelText('Context Length');
expect((select as HTMLSelectElement).value).toBe('65536'); expect((select as HTMLSelectElement).value).toBe('65536');
}); });
it('persists local context length changes', async () => { it('persists local context length changes', async () => {
render(<GeneralSettings />); renderSettings();
const select = await screen.findByLabelText('Context Length'); const select = await screen.findByLabelText('Context Length');
fireEvent.change(select, { target: { value: '131072' } }); fireEvent.change(select, { target: { value: '131072' } });
@@ -137,7 +156,7 @@ describe('GeneralSettings', () => {
}); });
it('renders and persists local runtime download URLs', async () => { 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(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(); expect(screen.getByDisplayValue('https://huggingface.co/notabilia/uvr5-models/resolve/main/UVR-MDX-NET-Inst_HQ_3.onnx')).toBeTruthy();
@@ -176,7 +195,7 @@ describe('GeneralSettings', () => {
.mockResolvedValueOnce(false) .mockResolvedValueOnce(false)
.mockResolvedValueOnce(false); .mockResolvedValueOnce(false);
render(<GeneralSettings />); renderSettings();
expect(await screen.findByText('UVR5 Web Runtime')).toBeTruthy(); expect(await screen.findByText('UVR5 Web Runtime')).toBeTruthy();
@@ -219,10 +238,24 @@ describe('GeneralSettings', () => {
reason: 'This host may not support the local browser runtime reliably because cross-origin isolation or SharedArrayBuffer is unavailable. COOP/COEP headers may be missing.', 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(await screen.findByText('Gemma 4 E4B Local Runtime')).toBeTruthy();
expect(screen.queryByText(/may not support the local browser runtime reliably/i)).toBeNull(); expect(screen.queryByText(/may not support the local browser runtime reliably/i)).toBeNull();
expect(screen.getByText(/The local model downloads automatically/i)).toBeTruthy(); expect(screen.getByText(/The local model downloads automatically/i)).toBeTruthy();
}); });
it('renders language first and persists language changes', async () => {
renderSettings();
const languageSelect = await screen.findByLabelText('Language');
expect(languageSelect).toBeTruthy();
expect(screen.getAllByRole('combobox')[0]).toBe(languageSelect);
fireEvent.change(languageSelect, { target: { value: 'zh_cn' } });
await waitFor(() => {
expect(configManagerMock.set).toHaveBeenCalledWith('general.language', 'zh_cn');
});
});
}); });
@@ -2,6 +2,8 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { ConfigManager } from '../../../core/config/ConfigManager'; import { ConfigManager } from '../../../core/config/ConfigManager';
import { LocalLLMModelManager, type LocalLLMModelState } from '../../../util/localLLMModelManager'; import { LocalLLMModelManager, type LocalLLMModelState } from '../../../util/localLLMModelManager';
import { LocalSeparatorModelCache } from '../../../util/local-separator/modelCache'; import { LocalSeparatorModelCache } from '../../../util/local-separator/modelCache';
import { useI18n } from '../../../i18n/useI18n';
import type { LanguageSetting } from '../../../i18n/types';
import { import {
formatLocalLLMContextLength, formatLocalLLMContextLength,
LOCAL_LLM_CONTEXT_LENGTH_OPTIONS, LOCAL_LLM_CONTEXT_LENGTH_OPTIONS,
@@ -18,6 +20,8 @@ import {
} from '../../../util/local-separator/config'; } from '../../../util/local-separator/config';
const GeneralSettings: React.FC = () => { const GeneralSettings: React.FC = () => {
const { t, setLanguageSetting } = useI18n();
const [language, setLanguage] = useState<LanguageSetting>('auto');
const [llmProvider, setLlmProvider] = useState<string>(LOCAL_LLM_PROVIDER_KEY); const [llmProvider, setLlmProvider] = useState<string>(LOCAL_LLM_PROVIDER_KEY);
const [openaiKey, setOpenaiKey] = useState<string>(''); const [openaiKey, setOpenaiKey] = useState<string>('');
const [openaiModel, setOpenaiModel] = useState<string>(''); const [openaiModel, setOpenaiModel] = useState<string>('');
@@ -90,6 +94,7 @@ const GeneralSettings: React.FC = () => {
await configManager.initialize(); await configManager.initialize();
} }
setLanguage(((configManager.get('general.language') as LanguageSetting | undefined) ?? 'auto'));
setLlmProvider((configManager.get('general.llm_provider') as string) || LOCAL_LLM_PROVIDER_KEY); setLlmProvider((configManager.get('general.llm_provider') as string) || LOCAL_LLM_PROVIDER_KEY);
setOpenaiKey((configManager.get('general.openai.api_key') as string) || ''); setOpenaiKey((configManager.get('general.openai.api_key') as string) || '');
setOpenaiModel((configManager.get('general.openai.model') as string) || ''); setOpenaiModel((configManager.get('general.openai.model') as string) || '');
@@ -153,6 +158,15 @@ const GeneralSettings: React.FC = () => {
} }
}; };
const handleLanguageChange = async (value: LanguageSetting) => {
setLanguage(value);
try {
await setLanguageSetting(value);
} catch (error) {
console.error('Failed to save language:', error);
}
};
const handleOpenaiKeyChange = (value: string) => { const handleOpenaiKeyChange = (value: string) => {
setOpenaiKey(value); setOpenaiKey(value);
debouncedSave('general.openai.api_key', value); debouncedSave('general.openai.api_key', value);
@@ -322,45 +336,66 @@ const GeneralSettings: React.FC = () => {
return ( return (
<div className="settings-section"> <div className="settings-section">
<div className="settings-section-header"> <div className="settings-section-header">
<h3>General</h3> <h3>{t('settings.general.title')}</h3>
</div> </div>
<div className="settings-section-content"> <div className="settings-section-content">
<div className="settings-group"> <div className="settings-group">
<h4>LLM Provider</h4> <div className="settings-item">
<label className="settings-label" htmlFor="general-language-select">
{t('settings.general.language.label')}
</label>
<select
id="general-language-select"
className="settings-select"
value={language}
onChange={(e) => void handleLanguageChange(e.target.value as LanguageSetting)}
>
<option value="auto">{t('settings.general.language.auto')}</option>
<option value="en_us">{t('settings.general.language.en_us')}</option>
<option value="zh_cn">{t('settings.general.language.zh_cn')}</option>
</select>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
{t('settings.general.language.help')}
</div>
</div>
</div>
<div className="settings-group">
<h4>{t('settings.general.llmProvider.section')}</h4>
<div className="settings-item"> <div className="settings-item">
<label className="settings-label"> <label className="settings-label">
LLM Provider {t('settings.general.llmProvider.label')}
</label> </label>
<select <select
className="settings-select" className="settings-select"
value={llmProvider} value={llmProvider}
onChange={(e) => handleLlmProviderChange(e.target.value)} onChange={(e) => handleLlmProviderChange(e.target.value)}
> >
<option value={LOCAL_LLM_PROVIDER_KEY}>Local LLM (Browser)</option> <option value={LOCAL_LLM_PROVIDER_KEY}>{t('settings.general.llmProvider.local')}</option>
<option value="openai">OpenAI</option> <option value="openai">{t('settings.general.llmProvider.openai')}</option>
{/* <option value="gemini">Gemini</option> {/* <option value="gemini">Gemini</option>
<option value="claude">Claude</option> */} <option value="claude">Claude</option> */}
<option value="claude_openrouter">Claude (via OpenRouter)</option> <option value="claude_openrouter">{t('settings.general.llmProvider.claudeOpenRouter')}</option>
<option value="openai_compatible">OpenAI Compatible (e.g. OpenRouter, Ollama)</option> <option value="openai_compatible">{t('settings.general.llmProvider.openaiCompatible')}</option>
</select> </select>
</div> </div>
<div className="settings-item"> <div className="settings-item">
<label className="settings-label"> <label className="settings-label">
Persist API Keys on Non-Localhost {t('settings.general.persistKeys.label')}
</label> </label>
<select <select
className="settings-select" className="settings-select"
value={persistApiKeysNonLocalhost ? 'yes' : 'no'} value={persistApiKeysNonLocalhost ? 'yes' : 'no'}
onChange={(e) => handlePersistApiKeysNonLocalhostChange(e.target.value)} onChange={(e) => handlePersistApiKeysNonLocalhostChange(e.target.value)}
> >
<option value="no">No</option> <option value="no">{t('settings.no')}</option>
<option value="yes">Yes</option> <option value="yes">{t('settings.yes')}</option>
</select> </select>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}> <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> </div>
</div> </div>
@@ -376,20 +411,20 @@ const GeneralSettings: React.FC = () => {
<div className="settings-item"> <div className="settings-item">
<label className="settings-label"> <label className="settings-label">
Cached Model Status {t('settings.general.localRuntime.cachedStatus')}
</label> </label>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}> <div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
{localModelState.isChecking {localModelState.isChecking
? 'Checking local model cache...' ? t('settings.general.localRuntime.cacheChecking')
: localModelState.isCached : localModelState.isCached
? 'Downloaded in browser cache.' ? t('settings.general.localRuntime.cacheDownloaded')
: 'Not downloaded yet.'} : t('settings.general.localRuntime.cacheMissing')}
</div> </div>
</div> </div>
<div className="settings-item"> <div className="settings-item">
<label className="settings-label" htmlFor="local-llm-context-length"> <label className="settings-label" htmlFor="local-llm-context-length">
Context Length {t('settings.general.localRuntime.contextLength')}
</label> </label>
<select <select
id="local-llm-context-length" id="local-llm-context-length"
@@ -404,13 +439,13 @@ const GeneralSettings: React.FC = () => {
))} ))}
</select> </select>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}> <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> </div>
<div className="settings-item"> <div className="settings-item">
<label className="settings-label"> <label className="settings-label">
Download URL {t('settings.general.localRuntime.downloadUrl')}
</label> </label>
<input <input
type="text" type="text"
@@ -420,7 +455,7 @@ const GeneralSettings: React.FC = () => {
onChange={(e) => handleLocalModelUrlChange(e.target.value)} onChange={(e) => handleLocalModelUrlChange(e.target.value)}
/> />
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}> <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 <a
href="#" href="#"
onClick={(e) => { onClick={(e) => {
@@ -429,14 +464,14 @@ const GeneralSettings: React.FC = () => {
}} }}
style={{ color: '#5a9fd4', textDecoration: 'underline', cursor: 'pointer' }} style={{ color: '#5a9fd4', textDecoration: 'underline', cursor: 'pointer' }}
> >
Restore default {t('settings.restoreDefault')}
</a> </a>
</div> </div>
</div> </div>
{!localModelState.isCached && !localModelState.isDownloading && localModelState.runtimeSupport.supported && ( {!localModelState.isCached && !localModelState.isDownloading && localModelState.runtimeSupport.supported && (
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px', marginBottom: '8px' }}> <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> </div>
)} )}
@@ -470,17 +505,17 @@ const GeneralSettings: React.FC = () => {
onClick={() => void handleDeleteLocalModel()} onClick={() => void handleDeleteLocalModel()}
disabled={localModelState.isDeleting || localModelState.isDownloading || !localModelState.isCached} disabled={localModelState.isDeleting || localModelState.isDownloading || !localModelState.isCached}
> >
{localModelState.isDeleting ? 'Deleting...' : 'Delete Cached Model'} {localModelState.isDeleting ? t('settings.deleting') : t('settings.deleteCachedModel')}
</button> </button>
</div> </div>
</div> </div>
<div className="settings-group"> <div className="settings-group">
<h4>UVR5 Web Runtime</h4> <h4>{t('settings.general.uvr5.section')}</h4>
<div className="settings-item"> <div className="settings-item">
<label className="settings-label"> <label className="settings-label">
UVR-MDX-NET-Inst_HQ_3 Download URL {t('settings.general.uvr5.downloadUrl')}
</label> </label>
<input <input
type="text" type="text"
@@ -490,7 +525,7 @@ const GeneralSettings: React.FC = () => {
onChange={(e) => handleUvr5ModelUrlChange(e.target.value)} onChange={(e) => handleUvr5ModelUrlChange(e.target.value)}
/> />
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}> <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 <a
href="#" href="#"
onClick={(e) => { onClick={(e) => {
@@ -501,7 +536,7 @@ const GeneralSettings: React.FC = () => {
}} }}
style={{ color: '#5a9fd4', textDecoration: 'underline', cursor: 'pointer' }} style={{ color: '#5a9fd4', textDecoration: 'underline', cursor: 'pointer' }}
> >
Restore default {t('settings.restoreDefault')}
</a> </a>
</div> </div>
</div> </div>
@@ -513,13 +548,13 @@ const GeneralSettings: React.FC = () => {
onClick={() => void handleDeleteUvr5Model()} onClick={() => void handleDeleteUvr5Model()}
disabled={isCheckingUvr5ModelCache || isDeletingUvr5Model || !isUvr5ModelCached} disabled={isCheckingUvr5ModelCache || isDeletingUvr5Model || !isUvr5ModelCached}
> >
{isDeletingUvr5Model ? 'Deleting...' : 'Delete Cached Model'} {isDeletingUvr5Model ? t('settings.deleting') : t('settings.deleteCachedModel')}
</button> </button>
</div> </div>
<div className="settings-item"> <div className="settings-item">
<label className="settings-label"> <label className="settings-label">
htdemucs_4s Download URL {t('settings.general.htdemucs.downloadUrl')}
</label> </label>
<input <input
type="text" type="text"
@@ -529,7 +564,7 @@ const GeneralSettings: React.FC = () => {
onChange={(e) => handleHtdemucsModelUrlChange(e.target.value)} onChange={(e) => handleHtdemucsModelUrlChange(e.target.value)}
/> />
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}> <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 <a
href="#" href="#"
onClick={(e) => { onClick={(e) => {
@@ -540,7 +575,7 @@ const GeneralSettings: React.FC = () => {
}} }}
style={{ color: '#5a9fd4', textDecoration: 'underline', cursor: 'pointer' }} style={{ color: '#5a9fd4', textDecoration: 'underline', cursor: 'pointer' }}
> >
Restore default {t('settings.restoreDefault')}
</a> </a>
</div> </div>
</div> </div>
@@ -552,37 +587,37 @@ const GeneralSettings: React.FC = () => {
onClick={() => void handleDeleteHtdemucsModel()} onClick={() => void handleDeleteHtdemucsModel()}
disabled={isCheckingUvr5ModelCache || isDeletingHtdemucsModel || !isHtdemucsModelCached} disabled={isCheckingUvr5ModelCache || isDeletingHtdemucsModel || !isHtdemucsModelCached}
> >
{isDeletingHtdemucsModel ? 'Deleting...' : 'Delete Cached Model'} {isDeletingHtdemucsModel ? t('settings.deleting') : t('settings.deleteCachedModel')}
</button> </button>
</div> </div>
</div> </div>
<div className="settings-group"> <div className="settings-group">
<h4>OpenAI</h4> <h4>{t('settings.general.openai.section')}</h4>
<div className="settings-item"> <div className="settings-item">
<label className="settings-label"> <label className="settings-label">
Key {t('settings.general.openai.key')}
</label> </label>
<input <input
type="password" type="password"
className="settings-input" className="settings-input"
placeholder="Enter your OpenAI API key" placeholder={t('settings.general.openai.keyPlaceholder')}
value={openaiKey} value={openaiKey}
onChange={(e) => handleOpenaiKeyChange(e.target.value)} onChange={(e) => handleOpenaiKeyChange(e.target.value)}
/> />
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}> <div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
{isLocalEnvironment {isLocalEnvironment
? 'Keys are persisted locally (the IndexedDB in your browser).' ? t('settings.general.keys.persisted')
: persistApiKeysNonLocalhost : persistApiKeysNonLocalhost
? 'Keys are persisted locally (the IndexedDB in your browser).' ? t('settings.general.keys.persisted')
: 'For security, keys are not persisted on non-local hosts and are kept in-memory for this session.'} : t('settings.general.keys.sessionOnly')}
</div> </div>
</div> </div>
<div className="settings-item"> <div className="settings-item">
<label className="settings-label"> <label className="settings-label">
Model {t('settings.general.openai.model')}
</label> </label>
<select <select
className="settings-select" className="settings-select"
@@ -601,18 +636,18 @@ const GeneralSettings: React.FC = () => {
<div className="settings-item"> <div className="settings-item">
<label className="settings-label"> <label className="settings-label">
Flex Mode {t('settings.general.openai.flexMode')}
</label> </label>
<select <select
className="settings-select" className="settings-select"
value={openaiFlex ? 'yes' : 'no'} value={openaiFlex ? 'yes' : 'no'}
onChange={(e) => handleOpenaiFlexChange(e.target.value)} onChange={(e) => handleOpenaiFlexChange(e.target.value)}
> >
<option value="no">No</option> <option value="no">{t('settings.no')}</option>
<option value="yes">Yes</option> <option value="yes">{t('settings.yes')}</option>
</select> </select>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}> <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> </div>
</div> </div>
@@ -678,31 +713,31 @@ const GeneralSettings: React.FC = () => {
</div> */} </div> */}
<div className="settings-group"> <div className="settings-group">
<h4>Anthropic Claude (via OpenRouter)</h4> <h4>{t('settings.general.claudeOpenRouter.section')}</h4>
<div className="settings-item"> <div className="settings-item">
<label className="settings-label"> <label className="settings-label">
Key {t('settings.general.openai.key')}
</label> </label>
<input <input
type="password" type="password"
className="settings-input" className="settings-input"
placeholder="Enter your Claude API key" placeholder={t('settings.general.claudeOpenRouter.keyPlaceholder')}
value={claudeOpenRouterKey} value={claudeOpenRouterKey}
onChange={(e) => handleClaudeOpenRouterKeyChange(e.target.value)} onChange={(e) => handleClaudeOpenRouterKeyChange(e.target.value)}
/> />
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}> <div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
{isLocalEnvironment {isLocalEnvironment
? 'Keys are persisted locally (the IndexedDB in your browser).' ? t('settings.general.keys.persisted')
: persistApiKeysNonLocalhost : persistApiKeysNonLocalhost
? 'Keys are persisted locally (the IndexedDB in your browser).' ? t('settings.general.keys.persisted')
: 'For security, keys are not persisted on non-local hosts and are kept in-memory for this session.'} : t('settings.general.keys.sessionOnly')}
</div> </div>
</div> </div>
<div className="settings-item"> <div className="settings-item">
<label className="settings-label"> <label className="settings-label">
Base URL {t('settings.general.baseUrl')}
</label> </label>
<input <input
type="text" type="text"
@@ -712,13 +747,13 @@ const GeneralSettings: React.FC = () => {
onChange={(e) => handleClaudeOpenRouterBaseUrlChange(e.target.value)} onChange={(e) => handleClaudeOpenRouterBaseUrlChange(e.target.value)}
/> />
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}> <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> </div>
<div className="settings-item"> <div className="settings-item">
<label className="settings-label"> <label className="settings-label">
Model {t('settings.general.openai.model')}
</label> </label>
<select <select
className="settings-select" className="settings-select"
@@ -736,41 +771,41 @@ const GeneralSettings: React.FC = () => {
</div> </div>
<div className="settings-group"> <div className="settings-group">
<h4>OpenAI Compatible Server</h4> <h4>{t('settings.general.openaiCompatible.section')}</h4>
<div className="settings-item"> <div className="settings-item">
<label className="settings-label"> <label className="settings-label">
Key {t('settings.general.openai.key')}
</label> </label>
<input <input
type="password" type="password"
className="settings-input" className="settings-input"
placeholder="Enter your API key" placeholder={t('settings.general.openaiCompatible.keyPlaceholder')}
value={compatibleKey} value={compatibleKey}
onChange={(e) => handleCompatibleKeyChange(e.target.value)} onChange={(e) => handleCompatibleKeyChange(e.target.value)}
/> />
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}> <div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
{isLocalEnvironment {isLocalEnvironment
? 'Keys are persisted locally (the IndexedDB in your browser).' ? t('settings.general.keys.persisted')
: persistApiKeysNonLocalhost : persistApiKeysNonLocalhost
? 'Keys are persisted locally (the IndexedDB in your browser).' ? t('settings.general.keys.persisted')
: 'For security, keys are not persisted on non-local hosts and are kept in-memory for this session.'} : t('settings.general.keys.sessionOnly')}
</div> </div>
</div> </div>
<div className="settings-item"> <div className="settings-item">
<label className="settings-label"> <label className="settings-label">
Base URL {t('settings.general.baseUrl')}
</label> </label>
<input <input
type="text" type="text"
className="settings-input" className="settings-input"
placeholder="e.g. https://openrouter.ai/api/v1" placeholder={t('settings.general.openaiCompatible.baseUrlPlaceholder')}
value={compatibleBaseUrl} value={compatibleBaseUrl}
onChange={(e) => handleCompatibleBaseUrlChange(e.target.value)} onChange={(e) => handleCompatibleBaseUrlChange(e.target.value)}
/> />
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}> <div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
Quick presets:{' '} {t('settings.general.openaiCompatible.baseUrlHelp')}{' '}
<a <a
href="#" href="#"
onClick={(e) => { onClick={(e) => {
@@ -819,12 +854,12 @@ const GeneralSettings: React.FC = () => {
<div className="settings-item"> <div className="settings-item">
<label className="settings-label"> <label className="settings-label">
Model {t('settings.general.openai.model')}
</label> </label>
<input <input
type="text" type="text"
className="settings-input" className="settings-input"
placeholder="e.g. qwen3:30b" placeholder={t('settings.general.openaiCompatible.modelPlaceholder')}
value={compatibleModel} value={compatibleModel}
onChange={(e) => handleCompatibleModelChange(e.target.value)} onChange={(e) => handleCompatibleModelChange(e.target.value)}
/> />
@@ -832,17 +867,17 @@ const GeneralSettings: React.FC = () => {
</div> </div>
<div className="settings-group"> <div className="settings-group">
<h4>Soundfont Settings</h4> <h4>{t('settings.general.soundfont.section')}</h4>
{soundfontServerManaged && ( {soundfontServerManaged && (
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px', marginBottom: '8px' }}> <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>
)} )}
<div className="settings-item"> <div className="settings-item">
<label className="settings-label"> <label className="settings-label">
Base URL {t('settings.general.soundfont.baseUrl')}
</label> </label>
<input <input
type="text" type="text"
@@ -853,7 +888,7 @@ const GeneralSettings: React.FC = () => {
disabled={soundfontServerManaged} disabled={soundfontServerManaged}
/> />
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}> <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 <a
href="#" href="#"
onClick={(e) => { onClick={(e) => {
@@ -862,24 +897,24 @@ const GeneralSettings: React.FC = () => {
}} }}
style={{ color: '#5a9fd4', textDecoration: 'underline', cursor: 'pointer' }} style={{ color: '#5a9fd4', textDecoration: 'underline', cursor: 'pointer' }}
> >
Restore default {t('settings.restoreDefault')}
</a> </a>
</div> </div>
</div> </div>
</div> </div>
<div className="settings-group"> <div className="settings-group">
<h4>K.G.One Settings</h4> <h4>{t('settings.general.kgone.section')}</h4>
{kgoneServerManaged && ( {kgoneServerManaged && (
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px', marginBottom: '8px' }}> <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>
)} )}
<div className="settings-item"> <div className="settings-item">
<label className="settings-label"> <label className="settings-label">
Enable K.G.One Integration {t('settings.general.kgone.enabled')}
</label> </label>
<select <select
className="settings-select" className="settings-select"
@@ -887,14 +922,14 @@ const GeneralSettings: React.FC = () => {
onChange={(e) => handleKgoneEnabledChange(e.target.value === 'true')} onChange={(e) => handleKgoneEnabledChange(e.target.value === 'true')}
disabled={kgoneServerManaged} disabled={kgoneServerManaged}
> >
<option value="false">Disabled</option> <option value="false">{t('settings.general.kgone.disabled')}</option>
<option value="true">Enabled</option> <option value="true">{t('settings.general.kgone.enabledOption')}</option>
</select> </select>
</div> </div>
<div className="settings-item"> <div className="settings-item">
<label className="settings-label"> <label className="settings-label">
Server Base URL {t('settings.general.kgone.serverBaseUrl')}
</label> </label>
<input <input
type="text" type="text"
@@ -905,7 +940,7 @@ const GeneralSettings: React.FC = () => {
disabled={kgoneServerManaged} disabled={kgoneServerManaged}
/> />
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}> <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> </div>
</div> </div>
+4 -1
View File
@@ -15,6 +15,8 @@ import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
import { showAlert, showConfirm } from '../../util/dialogUtil'; import { showAlert, showConfirm } from '../../util/dialogUtil';
import type { TrackAutomationType } from '../../core/track/KGTrackAutomationPoint'; import type { TrackAutomationType } from '../../core/track/KGTrackAutomationPoint';
import { AUDIO_IMPORT_ACCEPTED_TYPES } from '../../util/audioImportUtil'; import { AUDIO_IMPORT_ACCEPTED_TYPES } from '../../util/audioImportUtil';
import { useI18n } from '../../i18n/useI18n';
import { getInstrumentDisplayName } from '../../i18n/instruments';
const UNITY_POS = 750; const UNITY_POS = 750;
const SLIDER_MAX = 1000; const SLIDER_MAX = 1000;
@@ -69,6 +71,7 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
onDrop, onDrop,
onDragEnd onDragEnd
}) => { }) => {
const { t } = useI18n();
const { selectedTrackId, setSelectedTrack, removeTrack, toggleInstrumentSelectionForTrack, importAudioToTrack, tracks: allTracks } = useProjectStore(); const { selectedTrackId, setSelectedTrack, removeTrack, toggleInstrumentSelectionForTrack, importAudioToTrack, tracks: allTracks } = useProjectStore();
const activeTrackAutomationTrackId = useProjectStore(state => state.activeTrackAutomationTrackId); const activeTrackAutomationTrackId = useProjectStore(state => state.activeTrackAutomationTrackId);
const activeTrackAutomationType = useProjectStore(state => state.activeTrackAutomationType); const activeTrackAutomationType = useProjectStore(state => state.activeTrackAutomationType);
@@ -407,7 +410,7 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
) : ( ) : (
<img <img
src={`${import.meta.env.BASE_URL}resources/instruments/${String(FLUIDR3_INSTRUMENT_MAP[currentInstrument as keyof typeof FLUIDR3_INSTRUMENT_MAP]?.image || 'piano.png')}`} 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" width="64"
height="64" height="64"
/> />
+3 -2
View File
@@ -1,12 +1,13 @@
import type { ChordGuideCustomConfig } from '../ChordGuideTypes'; import type { ChordGuideCustomConfig } from '../ChordGuideTypes';
import { KGConfigStorage } from '../io/KGConfigStorage'; import { KGConfigStorage } from '../io/KGConfigStorage';
import type { LanguageSetting } from '../../i18n/types';
/** /**
* Application configuration interface * Application configuration interface
*/ */
interface AppConfig { interface AppConfig {
general: { general: {
language: string; language: LanguageSetting;
llm_provider: 'local_browser' | 'openai' | 'gemini' | 'claude' | 'claude_openrouter' | 'openai_compatible'; llm_provider: 'local_browser' | 'openai' | 'gemini' | 'claude' | 'claude_openrouter' | 'openai_compatible';
persist_api_keys_non_localhost: boolean; persist_api_keys_non_localhost: boolean;
local_browser: { local_browser: {
@@ -205,7 +206,7 @@ export class ConfigManager {
// Fallback to minimal hardcoded config // Fallback to minimal hardcoded config
this.defaultConfig = { this.defaultConfig = {
general: { general: {
language: 'en_us', language: 'auto',
llm_provider: 'local_browser', llm_provider: 'local_browser',
persist_api_keys_non_localhost: false, persist_api_keys_non_localhost: false,
openai: { openai: {
+47 -6
View File
@@ -1,5 +1,16 @@
import type { ResolvedChordGuideItem } from '../ChordGuideTypes'; 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 * KGPianoRollState - State management for the piano roll
* Implements the singleton pattern for global access * Implements the singleton pattern for global access
@@ -7,12 +18,42 @@ import type { ResolvedChordGuideItem } from '../ChordGuideTypes';
export class KGPianoRollState { export class KGPianoRollState {
private static _instance: KGPianoRollState | null = null; 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 SNAP_OPTIONS: PianoRollOptionDefinition<PianoRollSnapValue>[] = [
public static QUANT_POS_OPTIONS: string[] = ['1/3', '1/4', '1/6', '1/8', '1/12', '1/16', '1/24', '1/32']; { value: PIANO_ROLL_NO_SNAP, labelKey: 'pianoRoll.snap.none' },
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']; { 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 activeTool: string = "pointer";
private currentSnap: string = "NO SNAP"; private currentSnap: PianoRollSnapValue = PIANO_ROLL_NO_SNAP;
private lastEditedNoteLength: number = 1; // Default to 1 beat private lastEditedNoteLength: number = 1; // Default to 1 beat
private currentMode: string = "ionian"; // Default mode private currentMode: string = "ionian"; // Default mode
private automationViewEnabled: boolean = false; private automationViewEnabled: boolean = false;
@@ -51,11 +92,11 @@ export class KGPianoRollState {
this.activeTool = tool; this.activeTool = tool;
} }
public getCurrentSnap(): string { public getCurrentSnap(): PianoRollSnapValue {
return this.currentSnap; return this.currentSnap;
} }
public setCurrentSnap(snap: string): void { public setCurrentSnap(snap: PianoRollSnapValue): void {
this.currentSnap = snap; this.currentSnap = snap;
} }
+2 -2
View File
@@ -7,7 +7,7 @@ import { KGTrack } from '../core/track/KGTrack';
import { KGMidiNote } from '../core/midi/KGMidiNote'; import { KGMidiNote } from '../core/midi/KGMidiNote';
import type { MutableRefObject } from 'react'; import type { MutableRefObject } from 'react';
import { KGCore } from '../core/KGCore'; 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 { KGAudioInterface } from '../core/audio-interface/KGAudioInterface';
import { CreateNoteCommand, DeleteNotesCommand, ResizeNotesCommand, MoveNotesCommand } from '../core/commands'; import { CreateNoteCommand, DeleteNotesCommand, ResizeNotesCommand, MoveNotesCommand } from '../core/commands';
import { CreateNotesCommand } from '../core/commands/note/CreateNotesCommand'; 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 // Calculate the beat number (0-indexed) and apply floor snapping for note creation
const rawBeatNumber = x / beatWidth; const rawBeatNumber = x / beatWidth;
const currentSnap = KGPianoRollState.instance().getCurrentSnap(); 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 ? Math.floor(rawBeatNumber) // Snap to 1-beat grid when no snapping is selected
: getSnappedBeatPosition(rawBeatNumber, currentSnap, true); // Use floor snapping for note creation : getSnappedBeatPosition(rawBeatNumber, currentSnap, true); // Use floor snapping for note creation
+88
View File
@@ -0,0 +1,88 @@
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();
});
});
});
+76
View File
@@ -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>
);
};
+20
View File
@@ -0,0 +1,20 @@
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, '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, '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');
});
});
+25
View File
@@ -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);
}
+33
View File
@@ -0,0 +1,33 @@
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('zh_cn')).toBe('zh_cn');
expect(normalizeLanguageSetting('unknown')).toBe('auto');
});
it('resolves auto to English for non-Chinese locales', () => {
expect(resolveLanguageSetting('auto', ['en-US'])).toBe('en_us');
});
it('resolves auto to Simplified Chinese for Chinese locales', () => {
expect(resolveLanguageSetting('auto', ['zh-CN'])).toBe('zh_cn');
expect(resolveLanguageSetting('auto', ['zh-SG'])).toBe('zh_cn');
expect(resolveLanguageSetting('auto', ['zh-TW'])).toBe('zh_cn');
expect(resolveLanguageSetting('auto', ['zh-HK'])).toBe('zh_cn');
expect(resolveLanguageSetting('auto', ['zh-MO'])).toBe('zh_cn');
});
it('falls back to English when locale list is empty or unknown', () => {
expect(resolveLanguageSetting('auto', [])).toBe('en_us');
expect(resolveLanguageSetting('auto', ['fr-FR'])).toBe('en_us');
});
it('keeps explicit locale selections', () => {
expect(resolveLanguageSetting('en_us', ['zh-CN'])).toBe('en_us');
expect(resolveLanguageSetting('zh_cn', ['en-US'])).toBe('zh_cn');
});
});
+40
View File
@@ -0,0 +1,40 @@
import type { LanguageSetting, ResolvedLocaleCode } from './types';
const CHINESE_LANGUAGE_PREFIX = 'zh';
const DEFAULT_LOCALE: ResolvedLocaleCode = 'en_us';
export function normalizeLanguageSetting(value: unknown): LanguageSetting {
return value === 'zh_cn' || value === 'en_us' || 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') {
return setting;
}
for (const locale of browserLocales) {
const normalizedLocale = locale.trim().toLowerCase();
if (normalizedLocale === CHINESE_LANGUAGE_PREFIX || normalizedLocale.startsWith(`${CHINESE_LANGUAGE_PREFIX}-`)) {
return 'zh_cn';
}
}
return DEFAULT_LOCALE;
}
+510
View File
@@ -0,0 +1,510 @@
import type { TranslationMessages } from '../types';
export const enUsMessages: TranslationMessages = {
'app.loading': 'Loading ...',
'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.zh_cn': 'Simplified Chinese',
'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.',
'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',
'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',
'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)',
};
+8
View File
@@ -0,0 +1,8 @@
import type { ResolvedLocaleCode, TranslationMessages } from '../types';
import { enUsMessages } from './en_us';
import { zhCnMessages } from './zh_cn';
export const messagesByLocale: Record<ResolvedLocaleCode, TranslationMessages> = {
en_us: enUsMessages,
zh_cn: zhCnMessages,
};
+510
View File
@@ -0,0 +1,510 @@
import type { TranslationMessages } from '../types';
export const zhCnMessages: TranslationMessages = {
'app.loading': '加载中...',
'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.general.language.en_us': 'English',
'settings.general.language.zh_cn': '简体中文',
'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。用于整曲生成、片段生成和分轨。',
'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': '自动将片段对齐到拍点',
'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': '事件列表编辑器',
'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(科幻)',
};
+31
View File
@@ -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);
});
});
+32
View File
@@ -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;
}
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { translate } from './translate';
describe('translate', () => {
it('returns translated strings for both locales', () => {
expect(translate('settings.general.language.auto', undefined, 'en_us')).toBe('Auto');
expect(translate('settings.general.language.auto', undefined, 'zh_cn')).toBe('自动');
});
it('falls back to English when a zh-CN key is missing', () => {
expect(translate('toolbar.button.new', undefined, 'zh_cn')).toBe('新建');
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 }, 'zh_cn')).toBe('BPM 已改为 120');
});
});
+38
View File
@@ -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);
}
+14
View File
@@ -0,0 +1,14 @@
export type LanguageSetting = 'auto' | 'en_us' | 'zh_cn';
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;
}
+6
View File
@@ -0,0 +1,6 @@
import { useContext } from 'react';
import { I18nContext } from './I18nProvider';
export function useI18n() {
return useContext(I18nContext);
}
+6 -3
View File
@@ -12,6 +12,7 @@ import { ConfigManager } from './core/config/ConfigManager';
import { KGMidiInput } from './core/midi-input/KGMidiInput'; import { KGMidiInput } from './core/midi-input/KGMidiInput';
import { KGDebugger } from './core/KGDebugger'; import { KGDebugger } from './core/KGDebugger';
import { enumerateAudioDevices, validateConfiguredAudioDevices } from './util/audioDeviceUtil'; import { enumerateAudioDevices, validateConfiguredAudioDevices } from './util/audioDeviceUtil';
import { I18nProvider } from './i18n/I18nProvider';
const root = createRoot(document.getElementById('root')!); const root = createRoot(document.getElementById('root')!);
@@ -169,9 +170,11 @@ window.addEventListener('beforeunload', (event) => {
root.render( root.render(
<StrictMode> <StrictMode>
<DialogProvider> <I18nProvider>
<App /> <DialogProvider>
</DialogProvider> <App />
</DialogProvider>
</I18nProvider>
</StrictMode>, </StrictMode>,
); );
} // end isSecureContext else } // end isSecureContext else
@@ -59,6 +59,7 @@ describe('processUserMessage slash commands', () => {
configState.set('general.claude_openrouter.api_key', ''); configState.set('general.claude_openrouter.api_key', '');
configState.set('general.openai_compatible.base_url', ''); configState.set('general.openai_compatible.base_url', '');
configState.set('general.openai_compatible.model', ''); configState.set('general.openai_compatible.model', '');
configState.set('general.language', 'en_us');
configManagerMock.getIsInitialized.mockReturnValue(true); configManagerMock.getIsInitialized.mockReturnValue(true);
configManagerMock.initialize.mockClear(); configManagerMock.initialize.mockClear();
@@ -99,6 +100,17 @@ describe('processUserMessage slash commands', () => {
expect(result.pseudoAssistantResponse).toContain('welcome_local_llm.md'); 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 new-user welcome for non-local providers without required config', async () => { it('uses the new-user welcome for non-local providers without required config', async () => {
configState.set('general.llm_provider', 'openai'); configState.set('general.llm_provider', 'openai');
configState.set('general.openai.api_key', ''); configState.set('general.openai.api_key', '');
@@ -132,6 +144,59 @@ describe('processUserMessage slash commands', () => {
expect(message?.content).toContain('welcome_local_llm.md'); 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('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('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 () => { it('fetches the hotkeys guide for /hotkeys', async () => {
const result = await processUserMessage('/hotkeys'); const result = await processUserMessage('/hotkeys');
@@ -145,6 +210,15 @@ describe('processUserMessage slash commands', () => {
expect(result.pseudoAssistantResponse).toContain('chat/hotkeys.md'); expect(result.pseudoAssistantResponse).toContain('chat/hotkeys.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 () => { it('supports /hotkey as an alias of /hotkeys', async () => {
const result = await processUserMessage('/hotkey'); const result = await processUserMessage('/hotkey');
+56 -16
View File
@@ -3,6 +3,8 @@ import { useProjectStore } from '../../stores/projectStore';
import { ConfigManager } from '../../core/config/ConfigManager'; import { ConfigManager } from '../../core/config/ConfigManager';
import { SystemPrompts } from '../../agent/core/SystemPrompts'; import { SystemPrompts } from '../../agent/core/SystemPrompts';
import { detectLocalLLMRuntimeSupport, LOCAL_LLM_PROVIDER_KEY } from '../localLLMConfig'; import { detectLocalLLMRuntimeSupport, LOCAL_LLM_PROVIDER_KEY } from '../localLLMConfig';
import { normalizeLanguageSetting, resolveLanguageSetting } from '../../i18n/locale';
import type { ResolvedLocaleCode } from '../../i18n/types';
export interface UserMessageFilterResult { export interface UserMessageFilterResult {
// Whether to render the user message bubble (div.message-user) // Whether to render the user message bubble (div.message-user)
@@ -59,6 +61,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. * Process a user message before it is displayed or sent to the LLM.
* Handles slash-commands and returns a structured decision. * Handles slash-commands and returns a structured decision.
@@ -96,12 +140,8 @@ export async function processUserMessage(originalMessage: string): Promise<UserM
} }
const variant = getWelcomeVariant(configManager); const variant = getWelcomeVariant(configManager);
const url = getWelcomeUrl(variant); const baseFileName = getWelcomeUrl(variant).split('/').pop() ?? 'welcome_new.md';
const resp = await fetch(url); const md = await fetchLocalizedChatMarkdown(configManager, baseFileName);
if (!resp.ok) {
throw new Error(`Failed to fetch ${url}: ${resp.status}`);
}
const md = await resp.text();
return { return {
displayUserMessage: false, displayUserMessage: false,
sendToLLM: false, sendToLLM: false,
@@ -123,12 +163,12 @@ export async function processUserMessage(originalMessage: string): Promise<UserM
case '/help': { case '/help': {
try { try {
const url = `${import.meta.env.BASE_URL}chat/help.md`; const configManager = ConfigManager.instance();
const resp = await fetch(url); if (!configManager.getIsInitialized()) {
if (!resp.ok) { await configManager.initialize();
throw new Error(`Failed to fetch ${url}: ${resp.status}`);
} }
const md = await resp.text();
const md = await fetchLocalizedChatMarkdown(configManager, 'help.md');
return { return {
displayUserMessage: false, displayUserMessage: false,
sendToLLM: false, sendToLLM: false,
@@ -151,12 +191,12 @@ export async function processUserMessage(originalMessage: string): Promise<UserM
case '/hotkeys': case '/hotkeys':
case '/hotkey': { case '/hotkey': {
try { try {
const url = `${import.meta.env.BASE_URL}chat/hotkeys.md`; const configManager = ConfigManager.instance();
const resp = await fetch(url); if (!configManager.getIsInitialized()) {
if (!resp.ok) { await configManager.initialize();
throw new Error(`Failed to fetch ${url}: ${resp.status}`);
} }
const md = await resp.text();
const md = await fetchLocalizedChatMarkdown(configManager, 'hotkeys.md');
return { return {
displayUserMessage: false, displayUserMessage: false,
sendToLLM: false, sendToLLM: false,