Skip to content

Commit ad5c65d

Browse files
committed
Add RoninPro audio compression module
1 parent 417243e commit ad5c65d

6 files changed

Lines changed: 881 additions & 0 deletions

File tree

frontend/public/vendor/lame.min.js

Lines changed: 307 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/src/components/RoninPro.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@ import {
1010
GiftOutlined,
1111
MergeCellsOutlined,
1212
ScissorOutlined,
13+
SoundOutlined,
1314
} from '@ant-design/icons'
1415
import { useLanguage } from '../i18n/context'
1516
import RoninProAdvancedPixel from './RoninProAdvancedPixel'
17+
import RoninProAudioCompress from './RoninProAudioCompress'
1618
import RoninProCustomScale from './RoninProCustomScale'
1719
import RoninProCustomSlice from './RoninProCustomSlice'
1820
import RoninProCustomWorkflow from './RoninProCustomWorkflow'
@@ -44,6 +46,12 @@ const RONIN_FEATURE_ENTRIES = [
4446
titleKey: 'roninProCustomScale',
4547
descKey: 'roninProCustomScaleHint',
4648
},
49+
{
50+
id: 'audioCompress' as const,
51+
Icon: SoundOutlined,
52+
titleKey: 'roninProAudioCompress',
53+
descKey: 'roninProAudioCompressCardDesc',
54+
},
4755
{
4856
id: 'unifySize' as const,
4957
Icon: MergeCellsOutlined,
@@ -243,6 +251,8 @@ export default function RoninPro({
243251
<RoninProCustomSlice />
244252
) : displayedFeature === 'customScale' ? (
245253
<RoninProCustomScale />
254+
) : displayedFeature === 'audioCompress' ? (
255+
<RoninProAudioCompress />
246256
) : displayedFeature === 'unifySize' ? (
247257
<RoninProUnifySize />
248258
) : displayedFeature === 'duplicateFrames' ? (
Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
import { useEffect, useMemo, useState } from 'react'
2+
import { AudioOutlined, DownloadOutlined, InboxOutlined } from '@ant-design/icons'
3+
import { Button, Card, Progress, Radio, Space, Typography, Upload, message } from 'antd'
4+
import type { UploadFile } from 'antd'
5+
import JSZip from 'jszip'
6+
import { useLanguage } from '../i18n/context'
7+
import { compressWavToMp3 } from '../lib/audioMp3'
8+
9+
const { Dragger } = Upload
10+
const { Text } = Typography
11+
12+
const WAV_ACCEPT = ['.wav']
13+
const MAX_AUDIO_MB = 100
14+
const BITRATE_OPTIONS = [64, 96, 128, 192, 256, 320]
15+
16+
interface AudioResultItem {
17+
key: string
18+
file: File
19+
blob: Blob
20+
url: string
21+
durationSec: number
22+
sampleRate: number
23+
channels: number
24+
}
25+
26+
function fileKey(file: File): string {
27+
return `${file.name}_${file.size}_${file.lastModified}`
28+
}
29+
30+
function formatBytes(bytes: number): string {
31+
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
32+
const units = ['B', 'KB', 'MB', 'GB']
33+
let value = bytes
34+
let unitIndex = 0
35+
while (value >= 1024 && unitIndex < units.length - 1) {
36+
value /= 1024
37+
unitIndex++
38+
}
39+
return `${value >= 100 ? value.toFixed(0) : value.toFixed(2)} ${units[unitIndex]}`
40+
}
41+
42+
function formatDuration(seconds: number): string {
43+
if (!Number.isFinite(seconds) || seconds <= 0) return '0:00'
44+
const total = Math.max(0, Math.round(seconds))
45+
const minutes = Math.floor(total / 60)
46+
const remain = total % 60
47+
return `${minutes}:${String(remain).padStart(2, '0')}`
48+
}
49+
50+
function downloadBlob(blob: Blob, filename: string) {
51+
const url = URL.createObjectURL(blob)
52+
const a = document.createElement('a')
53+
a.href = url
54+
a.download = filename
55+
a.click()
56+
window.setTimeout(() => URL.revokeObjectURL(url), 1000)
57+
}
58+
59+
export default function RoninProAudioCompress() {
60+
const { t } = useLanguage()
61+
const [files, setFiles] = useState<File[]>([])
62+
const [bitrate, setBitrate] = useState(128)
63+
const [progress, setProgress] = useState(0)
64+
const [activeFileName, setActiveFileName] = useState('')
65+
const [loading, setLoading] = useState(false)
66+
const [zipLoading, setZipLoading] = useState(false)
67+
const [results, setResults] = useState<AudioResultItem[]>([])
68+
69+
useEffect(() => {
70+
return () => {
71+
results.forEach((item) => URL.revokeObjectURL(item.url))
72+
}
73+
}, [results])
74+
75+
const uploadFileList = useMemo<UploadFile[]>(
76+
() =>
77+
files.map((file) => ({
78+
uid: fileKey(file),
79+
name: file.name,
80+
size: file.size,
81+
})),
82+
[files]
83+
)
84+
85+
const totalOriginalBytes = useMemo(() => files.reduce((sum, file) => sum + file.size, 0), [files])
86+
const totalOutputBytes = useMemo(() => results.reduce((sum, item) => sum + item.blob.size, 0), [results])
87+
const totalSavedBytes = Math.max(0, totalOriginalBytes - totalOutputBytes)
88+
const totalSavedPercent =
89+
totalOriginalBytes > 0 ? Math.max(0, Math.round((totalSavedBytes / totalOriginalBytes) * 100)) : 0
90+
91+
const clearResults = () => {
92+
results.forEach((item) => URL.revokeObjectURL(item.url))
93+
setResults([])
94+
setProgress(0)
95+
setActiveFileName('')
96+
}
97+
98+
const handleAddFile = (nextFile: File) => {
99+
const ext = `.${(nextFile.name.split('.').pop() || '').toLowerCase()}`
100+
if (!WAV_ACCEPT.includes(ext)) {
101+
message.error(t('formatError', { formats: WAV_ACCEPT.join(' ') }))
102+
return Upload.LIST_IGNORE
103+
}
104+
if (nextFile.size > MAX_AUDIO_MB * 1024 * 1024) {
105+
message.error(t('roninProAudioCompressTooLarge', { max: MAX_AUDIO_MB }))
106+
return Upload.LIST_IGNORE
107+
}
108+
setFiles((current) => {
109+
const nextKey = fileKey(nextFile)
110+
if (current.some((file) => fileKey(file) === nextKey)) return current
111+
return [...current, nextFile]
112+
})
113+
clearResults()
114+
return false
115+
}
116+
117+
const runCompress = async () => {
118+
if (files.length === 0) {
119+
message.warning(t('roninProAudioCompressNeedFile'))
120+
return
121+
}
122+
123+
setLoading(true)
124+
clearResults()
125+
126+
try {
127+
const nextResults: AudioResultItem[] = []
128+
for (let index = 0; index < files.length; index++) {
129+
const file = files[index]!
130+
setActiveFileName(file.name)
131+
const result = await compressWavToMp3(file, bitrate, (filePercent) => {
132+
const overall = Math.round(((index + filePercent / 100) / files.length) * 100)
133+
setProgress(overall)
134+
})
135+
nextResults.push({
136+
key: fileKey(file),
137+
file,
138+
blob: result.blob,
139+
url: URL.createObjectURL(result.blob),
140+
durationSec: result.durationSec,
141+
sampleRate: result.sampleRate,
142+
channels: result.channels,
143+
})
144+
}
145+
setResults(nextResults)
146+
message.success(
147+
files.length > 1
148+
? t('roninProAudioCompressBatchSuccess', { count: files.length })
149+
: t('roninProAudioCompressSuccess')
150+
)
151+
} catch (error) {
152+
message.error(`${t('exportFailed')}: ${error instanceof Error ? error.message : String(error)}`)
153+
} finally {
154+
setLoading(false)
155+
setActiveFileName('')
156+
}
157+
}
158+
159+
const downloadSingle = (item: AudioResultItem) => {
160+
downloadBlob(item.blob, `${item.file.name.replace(/\.[^.]+$/, '')}_${bitrate}kbps.mp3`)
161+
message.success(t('downloadStarted'))
162+
}
163+
164+
const downloadZip = async () => {
165+
if (results.length === 0) return
166+
setZipLoading(true)
167+
try {
168+
const zip = new JSZip()
169+
for (const item of results) {
170+
zip.file(`${item.file.name.replace(/\.[^.]+$/, '')}_${bitrate}kbps.mp3`, item.blob)
171+
}
172+
const zipBlob = await zip.generateAsync({ type: 'blob' })
173+
downloadBlob(zipBlob, `audio_mp3_${bitrate}kbps.zip`)
174+
message.success(t('downloadStarted'))
175+
} catch (error) {
176+
message.error(`${t('exportFailed')}: ${error instanceof Error ? error.message : String(error)}`)
177+
} finally {
178+
setZipLoading(false)
179+
}
180+
}
181+
182+
return (
183+
<Space direction="vertical" size="large" style={{ width: '100%', maxWidth: 900 }}>
184+
<Dragger
185+
accept={WAV_ACCEPT.join(',')}
186+
multiple
187+
disabled={loading}
188+
fileList={uploadFileList}
189+
beforeUpload={handleAddFile}
190+
onRemove={(uploadFile) => {
191+
setFiles((current) => current.filter((file) => fileKey(file) !== uploadFile.uid))
192+
clearResults()
193+
}}
194+
style={{ padding: 28 }}
195+
>
196+
<p className="ant-upload-drag-icon">
197+
<InboxOutlined style={{ fontSize: 48, color: '#b55233' }} />
198+
</p>
199+
<p className="ant-upload-text">{t('roninProAudioCompressUploadHint')}</p>
200+
<p className="ant-upload-hint">
201+
WAV only · MP3 output · {MAX_AUDIO_MB} MB max / file
202+
</p>
203+
</Dragger>
204+
205+
<Card size="small" title={t('roninProAudioCompressBitrate')}>
206+
<Radio.Group
207+
optionType="button"
208+
buttonStyle="solid"
209+
value={bitrate}
210+
onChange={(e) => setBitrate(e.target.value)}
211+
options={BITRATE_OPTIONS.map((value) => ({
212+
label: `${value} kbps`,
213+
value,
214+
}))}
215+
/>
216+
</Card>
217+
218+
<Space wrap>
219+
<Button
220+
type="primary"
221+
icon={<AudioOutlined />}
222+
loading={loading}
223+
disabled={files.length === 0}
224+
onClick={runCompress}
225+
>
226+
{loading
227+
? t('roninProAudioCompressRunning')
228+
: files.length > 1
229+
? t('roninProAudioCompressRunBatch')
230+
: t('roninProAudioCompressRun')}
231+
</Button>
232+
{results.length > 0 && (
233+
<Button icon={<DownloadOutlined />} loading={zipLoading} onClick={downloadZip}>
234+
{t('roninProAudioCompressDownloadZip')}
235+
</Button>
236+
)}
237+
</Space>
238+
239+
{files.length > 0 && (
240+
<Text type="secondary">
241+
{t('roninProAudioCompressFileCount')}: {files.length}
242+
</Text>
243+
)}
244+
245+
{loading && (
246+
<Space direction="vertical" style={{ width: '100%' }}>
247+
<Progress percent={progress} status="active" />
248+
{activeFileName ? (
249+
<Text type="secondary">
250+
{t('roninProAudioCompressCurrent')}: {activeFileName}
251+
</Text>
252+
) : null}
253+
</Space>
254+
)}
255+
256+
{results.length > 0 && (
257+
<Card size="small" title={t('roninProAudioCompressResults')}>
258+
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
259+
<Text type="secondary">
260+
{t('roninProAudioCompressSaved')}: {formatBytes(totalSavedBytes)} ({totalSavedPercent}%)
261+
</Text>
262+
<Text type="secondary">
263+
{t('roninProAudioCompressOriginalSize')}: {formatBytes(totalOriginalBytes)} · {t('roninProAudioCompressOutputSize')}:{' '}
264+
{formatBytes(totalOutputBytes)}
265+
</Text>
266+
{results.map((item) => {
267+
const savedBytes = Math.max(0, item.file.size - item.blob.size)
268+
const savedPercent =
269+
item.file.size > 0 ? Math.max(0, Math.round((savedBytes / item.file.size) * 100)) : 0
270+
return (
271+
<Card
272+
key={item.key}
273+
size="small"
274+
title={item.file.name}
275+
extra={
276+
<Button type="primary" size="small" icon={<DownloadOutlined />} onClick={() => downloadSingle(item)}>
277+
{t('roninProAudioCompressDownload')}
278+
</Button>
279+
}
280+
>
281+
<Space direction="vertical" style={{ width: '100%' }}>
282+
<audio controls src={item.url} style={{ width: '100%' }} />
283+
<Text type="secondary">
284+
{t('roninProAudioCompressOriginalSize')}: {formatBytes(item.file.size)} · {t('roninProAudioCompressOutputSize')}:{' '}
285+
{formatBytes(item.blob.size)}
286+
</Text>
287+
<Text type="secondary">
288+
{t('roninProAudioCompressSaved')}: {formatBytes(savedBytes)} ({savedPercent}%)
289+
</Text>
290+
<Text type="secondary">
291+
{t('roninProAudioCompressDuration')}: {formatDuration(item.durationSec)}
292+
</Text>
293+
<Text type="secondary">
294+
{t('roninProAudioCompressSampleRate')}: {item.sampleRate} Hz · {item.channels === 1 ? 'Mono' : 'Stereo'}
295+
</Text>
296+
</Space>
297+
</Card>
298+
)
299+
})}
300+
</Space>
301+
</Card>
302+
)}
303+
</Space>
304+
)
305+
}

0 commit comments

Comments
 (0)