mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-10-03 00:55:14 -07:00
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:
co-authored by
Claude Opus 4.7
parent
2b7973df8b
commit
2e962f6599
@@ -18,6 +18,7 @@ import { CSS } from '@dnd-kit/utilities';
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { ChevronDown, ChevronRight, GripVertical, Plus, Power, Trash2 } from 'lucide-react';
|
import { ChevronDown, ChevronRight, GripVertical, Plus, Power, Trash2 } from 'lucide-react';
|
||||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import {
|
import {
|
||||||
@@ -55,6 +56,7 @@ export function EffectsChainEditor({
|
|||||||
compact = false,
|
compact = false,
|
||||||
showPresets = true,
|
showPresets = true,
|
||||||
}: EffectsChainEditorProps) {
|
}: EffectsChainEditorProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Maintain stable IDs for each effect across renders.
|
// 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">
|
<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>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{presets?.map((p) => (
|
{presets?.map((p) => {
|
||||||
<SelectItem key={p.id} value={p.id}>
|
const name = p.is_builtin
|
||||||
{p.name}
|
? t(`effects.builtinPresets.${p.name}.name`, { defaultValue: p.name })
|
||||||
{p.description && (
|
: p.name;
|
||||||
<span className="ml-1 text-muted-foreground">- {p.description}</span>
|
const description = p.is_builtin
|
||||||
)}
|
? t(`effects.builtinPresets.${p.name}.description`, {
|
||||||
</SelectItem>
|
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>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
@@ -198,7 +210,7 @@ export function EffectsChainEditor({
|
|||||||
className="h-8 px-2 text-xs text-muted-foreground"
|
className="h-8 px-2 text-xs text-muted-foreground"
|
||||||
onClick={clearAll}
|
onClick={clearAll}
|
||||||
>
|
>
|
||||||
Clear
|
{t('effects.chain.clear')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -229,12 +241,12 @@ export function EffectsChainEditor({
|
|||||||
<Select onValueChange={addEffect}>
|
<Select onValueChange={addEffect}>
|
||||||
<SelectTrigger className="h-8 border-dashed text-xs text-muted-foreground focus:ring-0 focus:ring-offset-0">
|
<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" />
|
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||||
<SelectValue placeholder="Add effect..." />
|
<SelectValue placeholder={t('effects.chain.addEffect')} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{availableEffects.effects.map((e) => (
|
{availableEffects.effects.map((e) => (
|
||||||
<SelectItem key={e.type} value={e.type}>
|
<SelectItem key={e.type} value={e.type}>
|
||||||
{e.label}
|
{t(`effects.types.${e.type}.label`, { defaultValue: e.label })}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -270,6 +282,7 @@ function SortableEffectItem({
|
|||||||
onToggleEnabled,
|
onToggleEnabled,
|
||||||
onUpdateParam,
|
onUpdateParam,
|
||||||
}: SortableEffectItemProps) {
|
}: SortableEffectItemProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
id,
|
id,
|
||||||
});
|
});
|
||||||
@@ -280,7 +293,9 @@ function SortableEffectItem({
|
|||||||
zIndex: isDragging ? 10 : undefined,
|
zIndex: isDragging ? 10 : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const label = effectDef?.label ?? effect.type;
|
const label = t(`effects.types.${effect.type}.label`, {
|
||||||
|
defaultValue: effectDef?.label ?? effect.type,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -328,7 +343,7 @@ function SortableEffectItem({
|
|||||||
effect.enabled ? 'text-primary' : 'text-muted-foreground hover:text-foreground',
|
effect.enabled ? 'text-primary' : 'text-muted-foreground hover:text-foreground',
|
||||||
)}
|
)}
|
||||||
onClick={onToggleEnabled}
|
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" />
|
<Power className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -337,7 +352,7 @@ function SortableEffectItem({
|
|||||||
type="button"
|
type="button"
|
||||||
className="p-0.5 text-muted-foreground hover:text-destructive"
|
className="p-0.5 text-muted-foreground hover:text-destructive"
|
||||||
onClick={onRemove}
|
onClick={onRemove}
|
||||||
title="Remove"
|
title={t('effects.chain.remove')}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -352,7 +367,9 @@ function SortableEffectItem({
|
|||||||
<div key={paramName} className="space-y-1">
|
<div key={paramName} className="space-y-1">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-[11px] text-muted-foreground">
|
<Label className="text-[11px] text-muted-foreground">
|
||||||
{paramDef.description}
|
{t(`effects.types.${effect.type}.params.${paramName}`, {
|
||||||
|
defaultValue: paramDef.description,
|
||||||
|
})}
|
||||||
</Label>
|
</Label>
|
||||||
<span className="text-[11px] font-mono tabular-nums text-foreground">
|
<span className="text-[11px] font-mono tabular-nums text-foreground">
|
||||||
{currentValue.toFixed(
|
{currentValue.toFixed(
|
||||||
|
|||||||
@@ -97,6 +97,18 @@ export function EffectsDetail() {
|
|||||||
|
|
||||||
const isEditing = !!selectedPresetId || isCreatingNew;
|
const isEditing = !!selectedPresetId || isCreatingNew;
|
||||||
const isBuiltIn = preset?.is_builtin ?? false;
|
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() {
|
async function handlePreview() {
|
||||||
if (!previewGenId || workingChain.length === 0) return;
|
if (!previewGenId || workingChain.length === 0) return;
|
||||||
@@ -184,7 +196,8 @@ export function EffectsDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleSaveAsNew() {
|
function handleSaveAsNew() {
|
||||||
setSaveAsName(t('effects.saveAs.suggestedName', { name }));
|
const sourceName = isBuiltIn ? presetName : name;
|
||||||
|
setSaveAsName(t('effects.saveAs.suggestedName', { name: sourceName }));
|
||||||
setSaveAsDescription(description);
|
setSaveAsDescription(description);
|
||||||
setSaveAsDialogOpen(true);
|
setSaveAsDialogOpen(true);
|
||||||
}
|
}
|
||||||
@@ -257,7 +270,7 @@ export function EffectsDetail() {
|
|||||||
{isCreatingNew
|
{isCreatingNew
|
||||||
? t('effects.detail.newTitle')
|
? t('effects.detail.newTitle')
|
||||||
: isBuiltIn
|
: isBuiltIn
|
||||||
? preset?.name
|
? presetName
|
||||||
: t('effects.detail.editTitle')}
|
: t('effects.detail.editTitle')}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -334,8 +347,8 @@ export function EffectsDetail() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isBuiltIn && preset?.description && (
|
{isBuiltIn && presetDescription && (
|
||||||
<p className="text-sm text-muted-foreground">{preset.description}</p>
|
<p className="text-sm text-muted-foreground">{presetDescription}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<EffectsChainEditor value={workingChain} onChange={setWorkingChain} showPresets={false} />
|
<EffectsChainEditor value={workingChain} onChange={setWorkingChain} showPresets={false} />
|
||||||
|
|||||||
@@ -124,6 +124,14 @@ function PresetCard({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const effectCount = preset.effects_chain.length;
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -140,7 +148,7 @@ function PresetCard({
|
|||||||
<Wand2
|
<Wand2
|
||||||
className={cn('h-4 w-4 shrink-0', isSelected ? 'text-accent' : 'text-muted-foreground')}
|
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 && (
|
{preset.is_builtin && (
|
||||||
<span className="text-[10px] bg-muted text-muted-foreground px-1.5 py-0.5 rounded-full shrink-0">
|
<span className="text-[10px] bg-muted text-muted-foreground px-1.5 py-0.5 rounded-full shrink-0">
|
||||||
{t('effects.badge.builtin')}
|
{t('effects.badge.builtin')}
|
||||||
@@ -148,7 +156,7 @@ function PresetCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-1 pl-6">
|
<p className="text-xs text-muted-foreground mt-1 line-clamp-1 pl-6">
|
||||||
{preset.description || t('effects.noDescription')}
|
{description || t('effects.noDescription')}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2 mt-1.5 pl-6">
|
<div className="flex items-center gap-2 mt-1.5 pl-6">
|
||||||
<span className="text-[10px] text-muted-foreground">
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
|||||||
@@ -90,6 +90,95 @@
|
|||||||
"deleteFailed": "Failed to delete",
|
"deleteFailed": "Failed to delete",
|
||||||
"previewFailed": "Preview failed",
|
"previewFailed": "Preview failed",
|
||||||
"nameRequired": "Name required"
|
"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": {
|
"stories": {
|
||||||
|
|||||||
@@ -90,6 +90,95 @@
|
|||||||
"deleteFailed": "删除失败",
|
"deleteFailed": "删除失败",
|
||||||
"previewFailed": "预览失败",
|
"previewFailed": "预览失败",
|
||||||
"nameRequired": "请输入名称"
|
"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": {
|
"stories": {
|
||||||
|
|||||||
Reference in New Issue
Block a user