feat(react-ui): localize MediaHistory, AudioTransform, and Sound components (#10802)

- Replace hardcoded text with useTranslation hook in UI components
- Add localization support for both English (en) and Indonesian (id) locales

Signed-off-by: Dedy F. Setyawan <dedyfajars@gmail.com>
This commit is contained in:
Dedy F. Setyawan
2026-07-13 18:51:23 +07:00
committed by GitHub
parent 4056283aa4
commit b10e330590
5 changed files with 164 additions and 65 deletions

View File

@@ -264,14 +264,22 @@
"model": "Model",
"prompt": "Prompt",
"promptPlaceholder": "Describe the sound you want to generate...",
"instrumental": "Instrumental",
"bpm": "BPM",
"duration": "Duration (seconds)",
"language": "Language",
"timesignature": "Time Signature",
"keyscale": "Key/Scale",
"vocalLanguage": "Vocal language",
"lyrics": "Lyrics (optional)",
"vocalLanguagePlaceholder": "e.g. English",
"caption": "Caption",
"lyrics": "Lyrics",
"lyricsPlaceholder": "Lyrics for vocal generation",
"advanced": "Advanced Settings",
"simple": "Simple",
"advanced": "Advanced",
"seed": "Seed",
"seedPlaceholder": "Random"
"seedPlaceholder": "Random",
"thinkMode": "Think mode"
},
"actions": {
"generate": "Generate",
@@ -284,6 +292,45 @@
"generateFailed": "Generation failed"
}
},
"audioTransform": {
"title": "Audio Transform",
"labels": {
"model": "Model",
"audio": "Audio (required)",
"reference": "Reference (optional)",
"referenceHelp": "Loopback / far-end signal for echo cancellation, target speaker for voice conversion. Leave empty for unconditional transform.",
"advancedParameters": "Advanced parameters",
"advancedParametersHelp": "backend-specific (one key=value per line, e.g. noise_gate=true)",
"advancedParametersPlaceholder": "# Optional. For LocalVQE:\n# noise_gate=true\n# noise_gate_threshold_dbfs=-50"
},
"input": {
"upload": "Upload",
"uploadDescription": "Drop a file here or",
"uploadBrowse": "browse",
"record": "Record",
"startRecording": "Start recording",
"stop": "Stop",
"encoding": "Encoding...",
"clear": "Clear",
"microphoneUnavailable": "Microphone capture is unavailable in this browser.",
"echoNotice": "Browsers often apply their own WebRTC echo cancellation and noise suppression by default. This usually results in worse performance than running LocalVQE on the raw audio.",
"echoTest": "Echo test (record mic while playing reference)",
"stopEchoTest": "Stop echo test"
},
"result": {
"audio": "Audio",
"reference": "Reference",
"inputSpectrum": "Input spectrum",
"outputSpectrum": "Output spectrum",
"outputSpectrumHint": "Transform to compare attenuation",
"output": "Output"
},
"actions": {
"transform": "Transform",
"processing": "Processing..."
},
"empty": "Choose an audio file (and optional reference) to transform"
},
"talk": {
"title": "Talk",
"subtitle": "Realtime voice conversation",

View File

@@ -96,26 +96,73 @@
"model": "Model",
"prompt": "Prompt",
"promptPlaceholder": "Deskripsikan suara yang ingin Anda buat...",
"instrumental": "Instrumental",
"bpm": "Tempo",
"duration": "Durasi (detik)",
"language": "Bahasa",
"timesignature": "Tanda birama",
"keyscale": "Tangga nada",
"vocalLanguage": "Bahasa vokal",
"lyrics": "Lirik (opsional)",
"lyricsPlaceholder": "Lirik untuk generasi vokal",
"advanced": "Pengaturan Tingkat Lanjutan",
"vocalLanguagePlaceholder": "cth. Bahasa Indonesia",
"caption": "Takarir",
"lyrics": "Lirik",
"lyricsPlaceholder": "Lirik untuk menghasilkan vokal",
"simple": "Sederhana",
"advanced": "Tingkat Lanjutan",
"seed": "Seed",
"seedPlaceholder": "Acak"
"seedPlaceholder": "Acak",
"thinkMode": "Mode penalaran"
},
"actions": {
"generate": "Hasilkan",
"generating": "Menghasilkan..."
},
"empty": "Audio yang dihasilkan akan muncul di sini",
"empty": "Suara yang dihasilkan akan muncul di sini",
"toasts": {
"noPrompt": "Silahkan masukkan prompt",
"noModel": "Silahkan pilih model",
"generateFailed": "Gagal menghasilkan"
}
},
"audioTransform": {
"title": "Transformasi Audio",
"labels": {
"model": "Model",
"audio": "Audio (wajib)",
"reference": "Referensi (opsional)",
"referenceHelp": "Sinyal loopback / ujung jauh untuk pembatalan gema, atau pembicara target untuk konversi suara. Kosongkan untuk transformasi tanpa syarat.",
"advancedParameters": "Parameter tingkat lanjut",
"advancedParametersHelp": "Spesifik backend (satu key=value per baris, contoh: noise_gate=true)",
"advancedParametersPlaceholder": "# Opsional. Untuk LocalVQE:\n# noise_gate=true\n# noise_gate_threshold_dbfs=-50"
},
"input": {
"upload": "Unggah",
"uploadDescription": "Tarik file di sini atau",
"uploadBrowse": "jelajahi",
"record": "Rekam",
"startRecording": "Mulai merekam",
"stop": "Selesai",
"encoding": "Pengenkodean...",
"clear": "Hapus",
"microphoneUnavailable": "Perekaman mikrofon tidak tersedia di peramban ini.",
"echoNotice": "Peramban sering kali menerapkan pembatalan gema dan penekanan derau WebRTC secara bawaan. Hal ini biasanya menghasilkan performa yang lebih buruk dibandingkan menjalankan LocalVQE pada audio mentah.",
"echoTest": "Tes gema (rekam mik saat memutar referensi)",
"stopEchoTest": "Hentikan tes gema"
},
"result": {
"audio": "Audio",
"reference": "Referensi",
"inputSpectrum": "Input spektrum",
"outputSpectrum": "Output spektrum",
"outputSpectrumHint": "Transformasi untuk membandingkan atenuasi",
"output": "Output"
},
"actions": {
"transform": "Transformasi",
"processing": "Memproses..."
},
"empty": "Pilih file audio (dan referensi opsional) untuk ditransformasikan"
},
"talk": {
"title": "Percakapan",
"subtitle": "Percakapan suara realtime",

View File

@@ -1,5 +1,6 @@
import { memo, useState } from 'react'
import { relativeTime } from '../utils/format'
import { useTranslation } from 'react-i18next'
const ICONS = {
image: 'fa-image',
@@ -10,6 +11,7 @@ const ICONS = {
}
export default memo(function MediaHistory({ entries, selectedId, onSelect, onDelete, onClearAll, mediaType }) {
const { t } = useTranslation('media')
const [expanded, setExpanded] = useState(true)
return (
@@ -20,11 +22,11 @@ export default memo(function MediaHistory({ entries, selectedId, onSelect, onDel
style={{ display: 'flex', alignItems: 'center' }}
>
<i className="fas fa-chevron-right" />
<span style={{ flex: 1 }}>History ({entries.length})</span>
<span style={{ flex: 1 }}>{t('history.title')} ({entries.length})</span>
{entries.length > 0 && (
<button
className="media-history-clear-btn"
title="Clear all"
title={t('history.clearTitle')}
onClick={(e) => { e.stopPropagation(); onClearAll() }}
>
<i className="fas fa-trash" />
@@ -34,7 +36,7 @@ export default memo(function MediaHistory({ entries, selectedId, onSelect, onDel
{expanded && (
<div className="media-history-list">
{entries.length === 0 ? (
<div className="media-history-empty">No history yet</div>
<div className="media-history-empty">{t('history.empty')}</div>
) : (
entries.map(entry => (
<div
@@ -59,7 +61,7 @@ export default memo(function MediaHistory({ entries, selectedId, onSelect, onDel
</div>
<button
className="media-history-item-delete"
title="Delete"
title={t('history.deleteEntry')}
onClick={(e) => { e.stopPropagation(); onDelete(entry.id) }}
data-testid="media-history-delete"
>

View File

@@ -12,6 +12,7 @@ import { useMediaCapture } from '../hooks/useMediaCapture'
import useObjectUrl from '../hooks/useObjectUrl'
import { useMediaHistory } from '../hooks/useMediaHistory'
import MediaHistory from '../components/MediaHistory'
import { useTranslation } from 'react-i18next'
// AudioTransform — Studio tab for the audio_transform capability. Takes a
// primary audio file plus an optional reference (loopback for AEC, target
@@ -22,6 +23,7 @@ import MediaHistory from '../components/MediaHistory'
// bleed of the reference, giving the user a real (mic, ref) pair to test
// echo cancellation against.
export default function AudioTransform() {
const { t } = useTranslation('media')
const { model: urlModel } = useParams()
const { addToast } = useOutletContext()
@@ -167,22 +169,22 @@ export default function AudioTransform() {
return (
<div className="media-layout">
<div className="media-controls">
<PageHeader title={<><i className="fas fa-wave-square" /> Audio Transform</>} />
<PageHeader title={<><i className="fas fa-wave-square" /> {t('audioTransform.title')}</>} />
<form onSubmit={handleProcess}>
<div className="form-group">
<label className="form-label">Model</label>
<label className="form-label">{t('audioTransform.labels.model')}</label>
<ModelSelector value={model} onChange={setModel} capability={CAP_AUDIO_TRANSFORM} />
</div>
<AudioInput
label="Audio (required)"
label={t('audioTransform.labels.audio')}
file={audioFile}
onChange={setAudioFile}
/>
<AudioInput
label="Reference (optional)"
help="Loopback / far-end signal for echo cancellation, target speaker for voice conversion. Leave empty for unconditional transform."
label={t('audioTransform.labels.reference')}
help={t('audioTransform.labels.referenceHelp')}
file={referenceFile}
onChange={setReferenceFile}
/>
@@ -192,9 +194,7 @@ export default function AudioTransform() {
<p className="audio-transform-echo__notice" role="note">
<i className="fas fa-circle-info" aria-hidden="true" />
<span>
Browsers often apply their own WebRTC echo cancellation and
noise suppression by default. This usually results in worse
performance than running LocalVQE on the raw audio.
{t('audioTransform.input.echoNotice')}
</span>
</p>
<div className="audio-transform-echo__row">
@@ -204,8 +204,8 @@ export default function AudioTransform() {
onClick={echoActive ? stopEchoTest : startEchoTest}
>
{echoActive
? <><i className="fas fa-stop" /> Stop echo test</>
: <><i className="fas fa-headphones-alt" /> Echo test (record mic while playing reference)</>}
? <><i className="fas fa-stop" /> {t('audioTransform.input.stopEchoTest')}</>
: <><i className="fas fa-headphones-alt" /> {t('audioTransform.input.echoTest')}</>}
</button>
{echoActive && echoCap.recording && (
<span className="audio-transform-echo__elapsed">
@@ -222,20 +222,20 @@ export default function AudioTransform() {
<div className="form-group">
<label className="form-label">
Advanced parameters
<span className="form-help"> &mdash; backend-specific (one <code>key=value</code> per line, e.g. <code>noise_gate=true</code>)</span>
{t('audioTransform.labels.advancedParameters')}
<span className="form-help"> &mdash; {t('audioTransform.labels.advancedParametersHelp')}</span>
</label>
<textarea
className="textarea"
value={paramsText}
onChange={(e) => setParamsText(e.target.value)}
placeholder={`# Optional. For LocalVQE:\n# noise_gate=true\n# noise_gate_threshold_dbfs=-50`}
placeholder={t('audioTransform.labels.advancedParametersPlaceholder')}
rows={4}
/>
</div>
<button type="submit" className="btn btn-primary btn-full" disabled={loading}>
{loading ? <><LoadingSpinner size="sm" /> Processing...</> : <><i className="fas fa-wand-magic-sparkles" /> Transform</>}
{loading ? <><LoadingSpinner size="sm" /> {t('audioTransform.actions.processing')}</> : <><i className="fas fa-wand-magic-sparkles" /> {t('audioTransform.actions.transform')}</>}
</button>
</form>
<MediaHistory {...historyProps} />
@@ -251,7 +251,7 @@ export default function AudioTransform() {
<WaveformPlayer
key={r.kind || r.url}
src={r.url}
label={resultLabel(r)}
label={resultLabel(r, t)}
height={r.kind === 'output' ? 120 : 96}
dimmed={r.kind === 'reference'}
download={r.kind === 'output' ? `audio-transform-${selectedEntry.model || 'output'}.wav` : undefined}
@@ -263,28 +263,28 @@ export default function AudioTransform() {
<div className="audio-transform-stack">
{audioUrl && (
<div className="audio-spectrogram-pair">
<Spectrogram src={audioUrl} label="Input spectrum" testId="spectrogram-input" />
<Spectrogram src={audioUrl} label={t('audioTransform.result.inputSpectrum')} testId="spectrogram-input" />
{outputUrl ? (
<Spectrogram src={outputUrl} label="Output spectrum" testId="spectrogram-output" />
<Spectrogram src={outputUrl} label={t('audioTransform.result.outputSpectrum')} testId="spectrogram-output" />
) : (
<div className="audio-spectrogram">
<div className="audio-spectrogram__label">Output spectrum</div>
<div className="audio-spectrogram__label">{t('audioTransform.result.outputSpectrum')}</div>
<div
className="audio-spectrogram__canvas-wrap audio-spectrogram__canvas-wrap--empty"
style={{ height: 140 }}
>
<span className="audio-spectrogram__hint">Transform to compare attenuation</span>
<span className="audio-spectrogram__hint">{t('audioTransform.result.outputSpectrumHint')}</span>
</div>
</div>
)}
</div>
)}
<WaveformPlayer src={audioUrl} label="Audio" height={96} />
<WaveformPlayer src={referenceUrl} label="Reference" height={96} dimmed={!referenceFile} />
<WaveformPlayer src={audioUrl} label={t('audioTransform.result.audio')} height={96} />
<WaveformPlayer src={referenceUrl} label={t('audioTransform.result.reference')} height={96} dimmed={!referenceFile} />
{outputUrl && (
<WaveformPlayer
src={outputUrl}
label="Output"
label={t('audioTransform.result.output')}
height={120}
download={`audio-transform-${model || 'output'}-${new Date().toISOString().slice(0, 10)}.wav`}
/>
@@ -292,7 +292,7 @@ export default function AudioTransform() {
{!audioUrl && !outputUrl && (
<div className="media-empty">
<i className="fas fa-wave-square media-empty__icon" />
<p>Choose an audio file (and optional reference) to transform</p>
<p>{t('audioTransform.empty')}</p>
</div>
)}
</div>
@@ -324,6 +324,7 @@ function resultLabel(r) {
// as `File([blob], 'recording-XXX.wav', { type: 'audio/wav' })` so callers
// can treat them identically to uploaded files).
function AudioInput({ label, help, file, onChange }) {
const { t } = useTranslation('media')
const [tab, setTab] = useState('upload') // 'upload' | 'record'
const cap = useMediaCapture('audio')
const [recordPending, setRecordPending] = useState(false)
@@ -371,7 +372,7 @@ function AudioInput({ label, help, file, onChange }) {
className={`audio-transform-input__tab${tab === 'upload' ? ' active' : ''}`}
onClick={() => setTab('upload')}
>
<i className="fas fa-upload" /> Upload
<i className="fas fa-upload" /> {t('audioTransform.input.upload')}
</button>
<button
type="button"
@@ -380,7 +381,7 @@ function AudioInput({ label, help, file, onChange }) {
className={`audio-transform-input__tab${tab === 'record' ? ' active' : ''}`}
onClick={() => setTab('record')}
>
<i className="fas fa-microphone" /> Record
<i className="fas fa-microphone" /> {t('audioTransform.input.record')}
</button>
</div>
@@ -395,14 +396,14 @@ function AudioInput({ label, help, file, onChange }) {
{hasFile ? (
<div className="audio-transform-drop__file">
<i className="fas fa-file-audio" /> {file.name}
<button type="button" className="btn btn-secondary btn-sm" onClick={() => onChange(null)}>Clear</button>
<button type="button" className="btn btn-secondary btn-sm" onClick={() => onChange(null)}>{t('audioTransform.input.clear')}</button>
</div>
) : (
<>
<i className="fas fa-upload" /> Drop a file here or
<i className="fas fa-upload" /> {t('audioTransform.input.uploadDescription')}
<label className="audio-transform-drop__pick">
<input type="file" accept="audio/*" onChange={onPick} hidden />
browse
{t('audioTransform.input.uploadBrowse')}
</label>
</>
)}
@@ -413,23 +414,23 @@ function AudioInput({ label, help, file, onChange }) {
<div className="audio-transform-rec">
{!cap.supported && (
<div className="audio-transform-rec__notice">
<i className="fas fa-circle-info" /> Microphone capture is unavailable in this browser.
<i className="fas fa-circle-info" /> {t('audioTransform.input.microphoneUnavailable')}
</div>
)}
{cap.supported && (
<>
{!cap.recording && !recordPending && (
<button type="button" className="btn btn-primary btn-sm" onClick={startRecord}>
<i className="fas fa-circle" style={{ color: '#e25555' }} /> Start recording
<i className="fas fa-circle" style={{ color: '#e25555' }} /> {t('audioTransform.input.startRecording')}
</button>
)}
{cap.recording && (
<button type="button" className="btn btn-secondary btn-sm" onClick={stopRecord}>
<i className="fas fa-stop" /> Stop ({cap.elapsed.toFixed(1)}s)
<i className="fas fa-stop" /> {t('audioTransform.input.stop')} ({cap.elapsed.toFixed(1)}s)
</button>
)}
{recordPending && !cap.recording && (
<div className="audio-transform-rec__pending">Encoding</div>
<div className="audio-transform-rec__pending">{t('audioTransform.input.encoding')}</div>
)}
{cap.error && (
<div className="audio-transform-rec__notice audio-transform-rec__notice--error">
@@ -439,7 +440,7 @@ function AudioInput({ label, help, file, onChange }) {
{hasFile && !cap.recording && (
<div className="audio-transform-drop__file" style={{ marginTop: 'var(--spacing-sm)' }}>
<i className="fas fa-file-audio" /> {file.name}
<button type="button" className="btn btn-secondary btn-sm" onClick={() => onChange(null)}>Clear</button>
<button type="button" className="btn btn-secondary btn-sm" onClick={() => onChange(null)}>{t('audioTransform.input.clear')}</button>
</div>
)}
</>

View File

@@ -10,8 +10,10 @@ import MediaHistory from '../components/MediaHistory'
import WaveformPlayer from '../components/audio/WaveformPlayer'
import { soundApi } from '../utils/api'
import { useMediaHistory } from '../hooks/useMediaHistory'
import { useTranslation } from 'react-i18next'
export default function Sound() {
const { t } = useTranslation('media')
const { model: urlModel } = useParams()
const { addToast } = useOutletContext()
const [model, setModel] = useState(urlModel || '')
@@ -79,63 +81,63 @@ export default function Sound() {
return (
<div className="media-layout">
<div className="media-controls">
<PageHeader title={<><i className="fas fa-music" /> Sound Generation</>} />
<PageHeader title={<><i className="fas fa-music" /> {t('sound.title')}</>} />
<form onSubmit={handleGenerate}>
<div className="form-group">
<label className="form-label">Model</label>
<label className="form-label">{t('sound.labels.model')}</label>
<ModelSelector value={model} onChange={setModel} capability={CAP_SOUND_GENERATION} />
</div>
{/* Mode toggle */}
<div className="segmented">
<button type="button" className={`segmented__item${mode === 'simple' ? ' is-active' : ''}`} onClick={() => setMode('simple')}>Simple</button>
<button type="button" className={`segmented__item${mode === 'advanced' ? ' is-active' : ''}`} onClick={() => setMode('advanced')}>Advanced</button>
<button type="button" className={`segmented__item${mode === 'simple' ? ' is-active' : ''}`} onClick={() => setMode('simple')}>{t('sound.labels.simple')}</button>
<button type="button" className={`segmented__item${mode === 'advanced' ? ' is-active' : ''}`} onClick={() => setMode('advanced')}>{t('sound.labels.advanced')}</button>
</div>
{mode === 'simple' ? (
<>
<div className="form-group">
<label className="form-label">Description</label>
<textarea className="textarea" value={text} onChange={(e) => setText(e.target.value)} placeholder="Describe the sound..." rows={3} />
<label className="form-label">{t('sound.labels.prompt')}</label>
<textarea className="textarea" value={text} onChange={(e) => setText(e.target.value)} placeholder={t('sound.labels.promptPlaceholder')} rows={3} />
</div>
<div className="form-grid-2col">
<label className="checkbox-row">
<input type="checkbox" checked={instrumental} onChange={(e) => setInstrumental(e.target.checked)} />
<span>Instrumental</span>
<span>{t('sound.labels.instrumental')}</span>
</label>
<div className="form-group">
<label className="form-label">Vocal language</label>
<input className="input" value={vocalLanguage} onChange={(e) => setVocalLanguage(e.target.value)} placeholder="e.g. English" />
<label className="form-label">{t('sound.labels.vocalLanguage')}</label>
<input className="input" value={vocalLanguage} onChange={(e) => setVocalLanguage(e.target.value)} placeholder={t('sound.labels.vocalLanguagePlaceholder')} />
</div>
</div>
</>
) : (
<>
<div className="form-group">
<label className="form-label">Caption</label>
<label className="form-label">{t('sound.labels.caption')}</label>
<textarea className="textarea" value={caption} onChange={(e) => setCaption(e.target.value)} rows={2} />
</div>
<div className="form-group">
<label className="form-label">Lyrics</label>
<textarea className="textarea" value={lyrics} onChange={(e) => setLyrics(e.target.value)} rows={3} />
<label className="form-label">{t('sound.labels.lyrics')}</label>
<textarea className="textarea" value={lyrics} onChange={(e) => setLyrics(e.target.value)} placeholder={t('sound.labels.lyricsPlaceholder')} rows={3} />
</div>
<div className="form-grid-2col">
<div className="form-group"><label className="form-label">BPM</label><input className="input" type="number" value={bpm} onChange={(e) => setBpm(e.target.value)} /></div>
<div className="form-group"><label className="form-label">Duration (s)</label><input className="input" type="number" step="0.1" value={duration} onChange={(e) => setDuration(e.target.value)} /></div>
<div className="form-group"><label className="form-label">Key/Scale</label><input className="input" value={keyscale} onChange={(e) => setKeyscale(e.target.value)} /></div>
<div className="form-group"><label className="form-label">Language</label><input className="input" value={language} onChange={(e) => setLanguage(e.target.value)} /></div>
<div className="form-group"><label className="form-label">Time Signature</label><input className="input" value={timesignature} onChange={(e) => setTimesignature(e.target.value)} /></div>
<div className="form-group"><label className="form-label">{t('sound.labels.bpm')}</label><input className="input" type="number" value={bpm} onChange={(e) => setBpm(e.target.value)} /></div>
<div className="form-group"><label className="form-label">{t('sound.labels.duration')}</label><input className="input" type="number" step="0.1" value={duration} onChange={(e) => setDuration(e.target.value)} /></div>
<div className="form-group"><label className="form-label">{t('sound.labels.keyscale')}</label><input className="input" value={keyscale} onChange={(e) => setKeyscale(e.target.value)} /></div>
<div className="form-group"><label className="form-label">{t('sound.labels.language')}</label><input className="input" value={language} onChange={(e) => setLanguage(e.target.value)} /></div>
<div className="form-group"><label className="form-label">{t('sound.labels.timesignature')}</label><input className="input" value={timesignature} onChange={(e) => setTimesignature(e.target.value)} /></div>
</div>
<label className="checkbox-row">
<input type="checkbox" checked={think} onChange={(e) => setThink(e.target.checked)} />
<span>Think mode</span>
<span>{t('sound.labels.thinkMode')}</span>
</label>
</>
)}
<button type="submit" className="btn btn-primary btn-full" disabled={loading}>
{loading ? <><LoadingSpinner size="sm" /> Generating...</> : <><i className="fas fa-music" /> Generate Sound</>}
{loading ? <><LoadingSpinner size="sm" /> {t('sound.actions.generating')}</> : <><i className="fas fa-music" /> {t('sound.actions.generate')}</>}
</button>
</form>
<MediaHistory {...historyProps} />
@@ -163,7 +165,7 @@ export default function Sound() {
) : (
<div className="media-empty">
<i className="fas fa-music media-empty__icon" />
<p>Generated sound will appear here</p>
<p>{t('sound.empty')}</p>
</div>
)}
</div>