feat(i18n): localize EffectsChainEditor and built-in preset names

- Effect type labels (Chorus/Flanger, Reverb, Delay, Compressor, Gain,
  High-Pass, Low-Pass, Pitch Shift) and every param label (LFO speed,
  Modulation depth, Threshold, Ratio, etc.) go through
  `effects.types.<type>.{label,params.<param>}` with the backend string
  as defaultValue fallback.
- Chain-level controls: "Load preset…", "Add effect…", "Clear",
  Power/Remove button titles.
- Built-in preset names + descriptions (Robotic, Radio, Echo Chamber,
  Deep Voice) are translated client-side; user-created presets keep
  their original names.

Backend keeps returning English — frontend intercepts and translates
via key lookup, defaulting to the backend string so unknown
effects/params don't break.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
James Pine
2026-04-20 03:12:34 -07:00
co-authored by Claude Opus 4.7
parent 2b7973df8b
commit 2e962f6599
5 changed files with 238 additions and 22 deletions
@@ -18,6 +18,7 @@ import { CSS } from '@dnd-kit/utilities';
import { useQuery } from '@tanstack/react-query';
import { ChevronDown, ChevronRight, GripVertical, Plus, Power, Trash2 } from 'lucide-react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import {
@@ -55,6 +56,7 @@ export function EffectsChainEditor({
compact = false,
showPresets = true,
}: EffectsChainEditorProps) {
const { t } = useTranslation();
const [expandedId, setExpandedId] = useState<string | null>(null);
// Maintain stable IDs for each effect across renders.
@@ -177,17 +179,27 @@ export function EffectsChainEditor({
}}
>
<SelectTrigger className="h-8 flex-1 text-xs focus:ring-0 focus:ring-offset-0">
<SelectValue placeholder="Load preset..." />
<SelectValue placeholder={t('effects.chain.loadPreset')} />
</SelectTrigger>
<SelectContent>
{presets?.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}
{p.description && (
<span className="ml-1 text-muted-foreground">- {p.description}</span>
)}
</SelectItem>
))}
{presets?.map((p) => {
const name = p.is_builtin
? t(`effects.builtinPresets.${p.name}.name`, { defaultValue: p.name })
: p.name;
const description = p.is_builtin
? t(`effects.builtinPresets.${p.name}.description`, {
defaultValue: p.description ?? '',
})
: p.description;
return (
<SelectItem key={p.id} value={p.id}>
{name}
{description && (
<span className="ml-1 text-muted-foreground">- {description}</span>
)}
</SelectItem>
);
})}
</SelectContent>
</Select>
@@ -198,7 +210,7 @@ export function EffectsChainEditor({
className="h-8 px-2 text-xs text-muted-foreground"
onClick={clearAll}
>
Clear
{t('effects.chain.clear')}
</Button>
)}
</div>
@@ -229,12 +241,12 @@ export function EffectsChainEditor({
<Select onValueChange={addEffect}>
<SelectTrigger className="h-8 border-dashed text-xs text-muted-foreground focus:ring-0 focus:ring-offset-0">
<Plus className="mr-1 h-3.5 w-3.5" />
<SelectValue placeholder="Add effect..." />
<SelectValue placeholder={t('effects.chain.addEffect')} />
</SelectTrigger>
<SelectContent>
{availableEffects.effects.map((e) => (
<SelectItem key={e.type} value={e.type}>
{e.label}
{t(`effects.types.${e.type}.label`, { defaultValue: e.label })}
</SelectItem>
))}
</SelectContent>
@@ -270,6 +282,7 @@ function SortableEffectItem({
onToggleEnabled,
onUpdateParam,
}: SortableEffectItemProps) {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id,
});
@@ -280,7 +293,9 @@ function SortableEffectItem({
zIndex: isDragging ? 10 : undefined,
};
const label = effectDef?.label ?? effect.type;
const label = t(`effects.types.${effect.type}.label`, {
defaultValue: effectDef?.label ?? effect.type,
});
return (
<div
@@ -328,7 +343,7 @@ function SortableEffectItem({
effect.enabled ? 'text-primary' : 'text-muted-foreground hover:text-foreground',
)}
onClick={onToggleEnabled}
title={effect.enabled ? 'Disable' : 'Enable'}
title={effect.enabled ? t('effects.chain.disable') : t('effects.chain.enable')}
>
<Power className="h-3.5 w-3.5" />
</button>
@@ -337,7 +352,7 @@ function SortableEffectItem({
type="button"
className="p-0.5 text-muted-foreground hover:text-destructive"
onClick={onRemove}
title="Remove"
title={t('effects.chain.remove')}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
@@ -352,7 +367,9 @@ function SortableEffectItem({
<div key={paramName} className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-[11px] text-muted-foreground">
{paramDef.description}
{t(`effects.types.${effect.type}.params.${paramName}`, {
defaultValue: paramDef.description,
})}
</Label>
<span className="text-[11px] font-mono tabular-nums text-foreground">
{currentValue.toFixed(
@@ -97,6 +97,18 @@ export function EffectsDetail() {
const isEditing = !!selectedPresetId || isCreatingNew;
const isBuiltIn = preset?.is_builtin ?? false;
const presetName = preset
? preset.is_builtin
? t(`effects.builtinPresets.${preset.name}.name`, { defaultValue: preset.name })
: preset.name
: '';
const presetDescription = preset
? preset.is_builtin
? t(`effects.builtinPresets.${preset.name}.description`, {
defaultValue: preset.description ?? '',
})
: preset.description
: '';
async function handlePreview() {
if (!previewGenId || workingChain.length === 0) return;
@@ -184,7 +196,8 @@ export function EffectsDetail() {
}
function handleSaveAsNew() {
setSaveAsName(t('effects.saveAs.suggestedName', { name }));
const sourceName = isBuiltIn ? presetName : name;
setSaveAsName(t('effects.saveAs.suggestedName', { name: sourceName }));
setSaveAsDescription(description);
setSaveAsDialogOpen(true);
}
@@ -257,7 +270,7 @@ export function EffectsDetail() {
{isCreatingNew
? t('effects.detail.newTitle')
: isBuiltIn
? preset?.name
? presetName
: t('effects.detail.editTitle')}
</h2>
<div className="flex items-center gap-2">
@@ -334,8 +347,8 @@ export function EffectsDetail() {
</div>
)}
{isBuiltIn && preset?.description && (
<p className="text-sm text-muted-foreground">{preset.description}</p>
{isBuiltIn && presetDescription && (
<p className="text-sm text-muted-foreground">{presetDescription}</p>
)}
<EffectsChainEditor value={workingChain} onChange={setWorkingChain} showPresets={false} />
+10 -2
View File
@@ -124,6 +124,14 @@ function PresetCard({
}) {
const { t } = useTranslation();
const effectCount = preset.effects_chain.length;
const name = preset.is_builtin
? t(`effects.builtinPresets.${preset.name}.name`, { defaultValue: preset.name })
: preset.name;
const description = preset.is_builtin
? t(`effects.builtinPresets.${preset.name}.description`, {
defaultValue: preset.description ?? '',
})
: preset.description;
return (
<button
@@ -140,7 +148,7 @@ function PresetCard({
<Wand2
className={cn('h-4 w-4 shrink-0', isSelected ? 'text-accent' : 'text-muted-foreground')}
/>
<span className="text-sm font-medium truncate">{preset.name}</span>
<span className="text-sm font-medium truncate">{name}</span>
{preset.is_builtin && (
<span className="text-[10px] bg-muted text-muted-foreground px-1.5 py-0.5 rounded-full shrink-0">
{t('effects.badge.builtin')}
@@ -148,7 +156,7 @@ function PresetCard({
)}
</div>
<p className="text-xs text-muted-foreground mt-1 line-clamp-1 pl-6">
{preset.description || t('effects.noDescription')}
{description || t('effects.noDescription')}
</p>
<div className="flex items-center gap-2 mt-1.5 pl-6">
<span className="text-[10px] text-muted-foreground">
+89
View File
@@ -90,6 +90,95 @@
"deleteFailed": "Failed to delete",
"previewFailed": "Preview failed",
"nameRequired": "Name required"
},
"chain": {
"loadPreset": "Load preset…",
"addEffect": "Add effect…",
"clear": "Clear",
"enable": "Enable",
"disable": "Disable",
"remove": "Remove"
},
"types": {
"chorus": {
"label": "Chorus / Flanger",
"params": {
"rate_hz": "LFO speed (Hz)",
"depth": "Modulation depth",
"feedback": "Feedback amount",
"centre_delay_ms": "Centre delay (ms)",
"mix": "Wet/dry mix"
}
},
"reverb": {
"label": "Reverb",
"params": {
"room_size": "Room size",
"damping": "High frequency damping",
"wet_level": "Wet level",
"dry_level": "Dry level",
"width": "Stereo width"
}
},
"delay": {
"label": "Delay",
"params": {
"delay_seconds": "Delay time (seconds)",
"feedback": "Feedback amount",
"mix": "Wet/dry mix"
}
},
"compressor": {
"label": "Compressor",
"params": {
"threshold_db": "Threshold (dB)",
"ratio": "Compression ratio",
"attack_ms": "Attack time (ms)",
"release_ms": "Release time (ms)"
}
},
"gain": {
"label": "Gain",
"params": {
"gain_db": "Gain (dB)"
}
},
"highpass": {
"label": "High-Pass Filter",
"params": {
"cutoff_frequency_hz": "Cutoff frequency (Hz)"
}
},
"lowpass": {
"label": "Low-Pass Filter",
"params": {
"cutoff_frequency_hz": "Cutoff frequency (Hz)"
}
},
"pitch_shift": {
"label": "Pitch Shift",
"params": {
"semitones": "Semitones to shift"
}
}
},
"builtinPresets": {
"Robotic": {
"name": "Robotic",
"description": "Metallic robotic voice (flanger with slow LFO and high feedback)"
},
"Radio": {
"name": "Radio",
"description": "Thin AM-radio voice with band-pass filtering and light compression"
},
"Echo Chamber": {
"name": "Echo Chamber",
"description": "Spacious reverb with trailing echo"
},
"Deep Voice": {
"name": "Deep Voice",
"description": "Lower pitch with added warmth"
}
}
},
"stories": {
@@ -90,6 +90,95 @@
"deleteFailed": "删除失败",
"previewFailed": "预览失败",
"nameRequired": "请输入名称"
},
"chain": {
"loadPreset": "加载预设……",
"addEffect": "添加效果……",
"clear": "清空",
"enable": "启用",
"disable": "禁用",
"remove": "移除"
},
"types": {
"chorus": {
"label": "合唱 / 镶边",
"params": {
"rate_hz": "LFO 速度(Hz)",
"depth": "调制深度",
"feedback": "反馈量",
"centre_delay_ms": "中心延迟(毫秒)",
"mix": "干湿混合"
}
},
"reverb": {
"label": "混响",
"params": {
"room_size": "房间大小",
"damping": "高频阻尼",
"wet_level": "湿声电平",
"dry_level": "干声电平",
"width": "立体声宽度"
}
},
"delay": {
"label": "延迟",
"params": {
"delay_seconds": "延迟时间(秒)",
"feedback": "反馈量",
"mix": "干湿混合"
}
},
"compressor": {
"label": "压缩器",
"params": {
"threshold_db": "阈值(dB)",
"ratio": "压缩比",
"attack_ms": "起音时间(毫秒)",
"release_ms": "释放时间(毫秒)"
}
},
"gain": {
"label": "增益",
"params": {
"gain_db": "增益(dB)"
}
},
"highpass": {
"label": "高通滤波器",
"params": {
"cutoff_frequency_hz": "截止频率(Hz)"
}
},
"lowpass": {
"label": "低通滤波器",
"params": {
"cutoff_frequency_hz": "截止频率(Hz)"
}
},
"pitch_shift": {
"label": "音高变换",
"params": {
"semitones": "半音移动"
}
}
},
"builtinPresets": {
"Robotic": {
"name": "机器人",
"description": "金属机器人嗓音(慢速 LFO 加高反馈的镶边效果)"
},
"Radio": {
"name": "收音机",
"description": "带通滤波加轻度压缩的 AM 收音机薄嗓音"
},
"Echo Chamber": {
"name": "回声室",
"description": "宽广的混响加尾随回声"
},
"Deep Voice": {
"name": "低沉嗓音",
"description": "降低音高并增添温暖"
}
}
},
"stories": {