Asroads'Blog 君子不器
游戏素材优化之压缩音频文件
发布于: 2023-01-09 更新于: 2025-01-01 分类于: tool 阅读次数: 

游戏优化一般来说分为几种优化,常见的有数据传输优化,包括加载优化和网络通信优化,其他的还有数据的算法优化和增删改查优化,以及和渲染密切相关的渲染优化。其中素材优化本身是一个老生常谈的问题,本篇重点说一下如何批量压缩游戏s项目内的音频文件,主要以MP3格式作为代表来谈如何压缩和覆盖

音频知识介绍

格式

音频文件格式专指存放音频数据的文件的格式。存在多种不同的格式,有两类主要的音频文件格式:

  • 无损格式,例如WAV,PCM,ALS,ALAC,TAK,FLAC,APE,WavPack(WV)
  • 有损格式,例如MP3,AAC,WMA,Ogg Vorbis

有损文件格式是基于声学心理学的模型,除去人类很难或根本听不到的声音,例如:一个音量很高的声音后面紧跟着一个音量很低的声音。MP3就属于这一类文件。

无损的音频格式(例如FLAC)压缩比大约是2:1,解压时不会产生数据/质量上的损失,解压产生的数据与未压缩的数据完全相同。如需要保证音乐的原始质量,应当选择无损音频编解码器。例如,用免费的FLAC无损音频编解码器你可以在一张DVD-R碟上存储相当于20张CD的音乐。

MP3全称是动态影像专家压缩标准音频层面3(Moving Picture Experts Group Audio Layer III)。是当今较流行的一种数字音频编码和有损压缩格式,它设计用来大幅度地降低音频数据量,而对于大多数用户来说重放的音质与最初的不压缩音频相比没有明显的下降。它是在1991年由位于德国埃尔朗根的研究组织Fraunhofer-Gesellschaft的一组工程师发明和标准化的。

所谓的MP3也就是指的是MPEG标准中的音频部分,也就是MPEG音频层。根据压缩质量和编码处理的不同分为3层,分别对应*.mp1/*.mp2/*.mp3这3种声音文件。需要提醒大家注意的地方是:MPEG音频文件的压缩是一种有损压缩,MPEG3音频编码具有10:1~12:1的高压缩率,同时基本保持低音频部分不失真,但是牺牲了声音文件中12KHz到16KHz高音频这部分的质量来换取文件的尺寸,相同长度的音乐文件,用*.mp3格式来储存,一般只有*.wav文件的1/10,而音质要次于CD格式或WAV格式的声音文件。由于其文件尺寸小,音质好;所以在它问世之初还没有什么别的音频格式可以与之匹敌,因而为*.mp3格式的发展提供了良好的条件。

目前最为常用的音频格式是MP3,MP3是一种有损压缩的音频格式,设计这种格式的目的就是为了大幅度的减小音频的数据量,它舍弃PCM音频数据中人类听觉不敏感的部分。

音频相关参数

采样率

采样率(也称为采样速度或者采样频率)定义了每秒从模拟信号中提取并组成数字信号的采样个数,它用赫兹(Hz)来表示。采样频率的倒数叫作采样周期或采样时间,它是采样之间的时间间隔。

采样频率只能用于周期性采样的采样器,对于非周期性采样的采样器没有规则限制

在数字音频领域,常用的采样率有:

  • 8,000 Hz - 电话所用采样率,对于人的说话已经足够
  • 11,025 Hz
  • 22,050 Hz - 无线电广播所用采样率
  • 32,000 Hz - miniDV数码视频camcorder、DAT(LP mode)所用采样率
  • 44,100 Hz - 音频CD,也常用于MPEG-1音频(VCD, SVCD, MP3)所用采样率
  • 47,250 Hz - Nippon Columbia(Denon)开发的世界上第一个商用PCM录音机所用采样率
  • 48,000 Hz - miniDV、数字电视、DVD、DAT、电影和专业音频所用的数字声音所用采样率
  • 50,000 Hz - 二十世纪七十年代后期出现的3M和Soundstream开发的第一款商用数字录音机所用采样率
  • 50,400 Hz - 三菱X-80数字录音机所用所用采样率
  • 96,000或者192,000 Hz - DVD-Audio、一些LPCM DVD音轨、Blu-ray Disc(藍光碟)音轨、和HD-DVD(高清晰度DVD)音轨所用所用采样率
  • 2.8224 MHz - SACD、索尼和飞利浦联合开发的称为Direct Stream Digital的1位sigma-delta modulation过程所用采样率。

比特率

比特率是指每秒传送的比特(bit)数。单位为 bps(Bit Per Second),比特率越高,传送的数据越大,音质越好。

采样深度

比特深度决定了文件的动态分辨率,类似数码照片那样。每个“比特”可以传送4个振幅数值(两个正值两个负值),因此每个样本所含的比特越多,也就代表着动态范围越大。 这并不意味着,比特深度越高,音量就会越大;但是,更高的比特深度听起来会更加真实,因为它们可以做到更加准确地再现声音(就好比高分辨率的照片)。以下是常见采样率及其统计数据的概述:

  • 4-bit:16个数值,24dB的动态范围。有时也会用于极低保真的“bitcrushed”效果器上。
  • 8-bit:256个数值,48dB的动态范围。经常用于早期的经典的视频游戏系统。
  • 16-bit:65536个数值,96dB的动态范围,CD音频的标准比特深度。
  • 24-bit:16777216个数值,145dB的动态范围,最常用的比特深度。
  • 32或者 64-bit:“浮点”,目前可以做到提供最佳信噪比的数值,但是尚未被广泛采用。

通道数

由于音频的采集和播放是可以叠加的,因此,可以同时从多个音频源采集声音,并分别输出到不同的扬声器,故声道数一般表示声音录制时的音源数量或回放时相应的扬声器数量。

常见的单声道和立体声(双声道),现在发展到了四声环绕(四声道)和5.1声道等更多声道。

1.单声道(mono) 单声道是比较原始的声音复制形式,早期的声卡采用的比较普遍。单声道的声音只能使用一个扬声器发声,有的也处理成两个扬声器输出同一个声道的声音,当通过两个扬声器回放单声道信息的时候,我们可以明显感觉到声音是从两个音箱中间传递到我们耳朵里的,无法判断声源的具体位置。 2.立体声(stereo)

双声道就是有两个声音通道,其原理是人们听到声音时可以根据左耳和右耳对声音相位差来判断声源的具体位置。声音在录制过程中被分配到两个独立的声道,从而达到了很好的声音定位效果。这种技术在音乐欣赏中显得尤为有用,听众可以清晰地分辨出各种乐器来自的方向,从而使音乐更富想象力,更加接近于临场感受。

根据采样率和比特率算音频大小

CD音质的文件:

44.1kHz * 16bit * 2通道 = 1411200 bit/s = 1411 kbps

这就是CD音质音频文件,每秒有141万位信息,换算一下: 1411200bps/8/1024/1024 = 0.168MB

一首3分钟20秒的音乐算下来就是33.6MB,这大概是无损音乐的大小

MP3 格式可以把每秒钟的数据量压缩到 128kbps(即 16KB),一分钟只有 960KB,比起 CD 无损 格式小了很多。

压缩的可能

首先我们要知道自己拿到的音频文件,相关的参数是什么,就大概知道了是否还有压缩的空间和可能,如果拿到的本身就是压缩后的,基本上再次压缩素材文件本身变化不是很大。其次我们知道压缩不是无限小的,压缩越大,音频失真越大,所以留给我们的空间就是在不影响游戏品质(或者可以理解我们可以接受的下限)

因为游戏本身都是交互性的,有游戏界面,用户交互界面,一般来说音频基本有两种使用方式,背景音乐和游戏音效,这就说明了音频本身作为游戏的锦上添花的作用,所以不会像CD音乐那样要求很高,这就给开发者带来了压缩的可能。

项目中音频压缩

一般来说开发早期游戏很少做正规的素材压缩规划,很多项目都是初期没有这些规划,游戏上线后,突然大量用户用户涌入,此时网络流量和加载时长就被提上了优化日程,此时我们可以地毯式一个个素材优化,也可以根据游戏本身的特点,分成几个版本优化,在每个版本中,我们一般都是做某些模块的优化,比如音频文件的素材优化。

一般来说上线后的游戏项目有很多个游戏目录,我们要找到这些音频文件,然后进行压缩,然后在进行覆盖操作,所以重点分为两大步骤:

  • 找到音频文件,并压缩导出,保持原目录结构
  • 使用导出的目录的文件和原项目的文件逐个对比,如果压缩后文件变小,进行覆盖操作,如果压缩后变化不大,保持不动

用的相关技术

  • shell 压缩脚本
  • ffmpeg 技术支持
  • python

找出音频并压缩

这里要介绍的是一条命令,它依赖于ffmpeg。具体如下:

1
ffmpeg -i ${f} -vn -ar 22050 -ac 1 -ab 128 -f mp3 ${dst}

其中$f为源文件,$dst为目标文件

下面是 audio.sh 的全部用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
#!/bin/bash
# http://ffmpeg.org/download.html to get binary distribution for ogg codex


usage()
{
echo """
Usage:
Script to compress audio resource for distribution!

-s <path to source dir>
-d <path to dest dir>
"""
}

#跳转到上一级目录
curpath=$(cd "$(dirname "$0")/"; pwd)

res_path=""
des_path=""
while getopts ":s::d:" opt
do
case $opt in
s ) res_path=$OPTARG;;
d ) des_path=$OPTARG;;
? ) echo "invalid param"
exit 1;;
esac
done

if [[ ${#res_path} -eq 0 ]]; then
#statements
usage
exit 1
fi

if [[ ${#des_path} -eq 0 ]]; then
des_path="mp3"
fi

if [[ ! -d ${des_path} ]];then
mkdir ${des_path}
else
echo "clean directory ${des_path}.."
rm ${des_path}/*
echo "clean done!~"
fi

CMD_MUSIC="$curpath/bin/ffmpeg -i"

echo "processing music..."


function audioMin(){
f=$1
file="${f##[./0-9a-zA-Z_-]*/}"
res_folder=${f%/*}
dist_folder=${res_folder/$res_path/$des_path}
dst="${f/$res_path/$des_path}"
if [ ! -d ${dist_folder} ];then
echo "${dist_folder} 文件夹不存在"
mkdir "${dist_folder}"
else
echo "文件夹存在"
fi
echo "Convert: ${f} ======> ${dst}..."
# ${CMD_MUSIC} ${f} -vn -ar 44100 -ac 2 -ab 128000 -f mp3 ${dst}
${CMD_MUSIC} "${f}" -vn -ar 22050 -ac 1 -ab 128 -f mp3 "${dst}"
if [[ $? -eq 0 ]]; then
#statements
echo "done!~"
else
echo "${f} failed!~"
fi
}

function read_dir(){
for file in `ls $1 | tr " " "\?"` #注意此处这是两个反引号,表示运行系统命令
do
file=`tr "\?" " " <<<$file`
if [ -d $1"/""$file" ] #注意此处之间一定要加上空格,否则会报错
then
read_dir $1"/""$file"
else
#echo "$1$file" #在此处处理文件即可
res="${file##*.}" # 获取后缀名为 MP3的
if [ "${file##*.}" = "mp3" ] || [ "${file##*.}" = "MP3" ]; then
echo "-------------$1$file"
audioMin "$1$file"
fi
fi
done
}
#读取第一个参数
read_dir $res_path

echo "music done!~"
echo "--------------------Well Done--------------------------------"

exec /bin/bash

用法:格式将输出为mp3格式

1
audio.sh -s <音频源目录> -d <音频资源目录>

例如:

1
audio.sh -s src -d out

脚本参考1 地址:游戏优化之音频压缩 在此基础上 添加了 子目录递归 和 文件名字有空格的处理操作。

脚本参考2 地址:文件名、文件后缀获取

脚本参考3 地址:关于Shell 脚本中的”too many arguments”错误

压缩后文件对比覆盖

由于本人对shell 语言不是特别熟悉,这里为了操作就改用了相对比较熟悉的python脚本处理

CopyDiffSize.py 代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
import os
import shutil
import sys
import time
import math

copyFileCounts = 0
sourceFileTotalSize = 0
targetFileTotalSize = 0
def copyFiles(sourceDir, targetDir):
global copyFileCounts
global sourceFileTotalSize
global targetFileTotalSize
print (u"%s 当前处理文件夹 %s 已处理 %s 个文件" %(time.strftime('%Y-%m-%d %H:%M:%S',time.localtime(time.time())), sourceDir,copyFileCounts))
for f in os.listdir(sourceDir):
sourceF = os.path.join(sourceDir, f)
targetF = os.path.join(targetDir, f)
if os.path.isfile(sourceF):
if os.path.exists(targetF):
srcSize = os.stat(sourceF).st_size
dstSize = os.stat(targetF).st_size
# 误差 1kb内的 忽略
if (srcSize + 1024) < dstSize:
sourceFileTotalSize += srcSize
targetFileTotalSize += dstSize
os.remove(targetF)
print(f, "比较前后", srcSize / 1024, "KB", " ======> ", dstSize / 1024, "KB", "-----覆盖")
shutil.copy2(sourceF, targetF)
copyFileCounts += 1
else:
print(f, "比较前后", srcSize / 1024, "KB", " ======> ", dstSize / 1024, "KB","-----文件比目标文件大倍忽略")
if os.path.isdir(sourceF):
copyFiles(sourceF, targetF)
def copyDir(src, dst):
names = os.listdir(src)
for name in names:
srcname = os.path.join(src, name)
dstname = os.path.join(dst, name)
if os.path.exists(dstname):
srcSize = os.stat(srcname).st_size
dstSize = os.stat(dstname).st_size
if srcSize < dstSize:
os.remove(dstname)
print(name, "比较前后", srcSize / 1024, "KB", " ======> ", dstSize / 1024, "KB","-----覆盖")
shutil.copy2(srcname, dstname)
else:
print(name,"比较前后",srcSize/1024,"KB" ," ======> ",dstSize/1024,"KB","-----文件比目标文件大倍忽略" )
else:
shutil.copy2(srcname, dstname)


def print_hi(name):
# Use a breakpoint in the code line below to debug your script.
print(f'Hi, {name}') # Press Ctrl+F8 to toggle the breakpoint.


# Press the green button in the gutter to run the script.
if __name__ == '__main__':
print_hi('PyCharm')
args = sys.argv
if len(args) < 3:
print("param length error")
sys.exit()
src_path = args[1]
dst_path = args[2]
copyFiles(src_path,dst_path)
# copyDir(src_path,dst_path)
print(u"%s 复制完毕 总共处理%s 个文件" % (time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time())), copyFileCounts))
reValue = math.floor((targetFileTotalSize-sourceFileTotalSize)/1024)
print("处理前后", targetFileTotalSize / 1024, "KB", " ======> ", sourceFileTotalSize / 1024, "KB 压缩掉了",reValue,"KB")

脚本参考

使用演示

image-20230124224352434

可以看到有空格也可以成功的压缩

image-20230124224941106

最后输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
λpython3 CopyDiffSize.py out Sounds
Hi, PyCharm
2023-01-24 23:02:42 当前处理文件夹 out 已处理 0 个文件
2023-01-24 23:02:42 当前处理文件夹 out\1_32 已处理 0 个文件
38du6.mp3 比较前后 29.4052734375 KB ======> 29.634765625 KB -----文件比目标文件大倍忽略
9277.mp3 比较前后 30.52734375 KB ======> 30.8583984375 KB -----文件比目标文件大倍忽略
98k.mp3 比较前后 29.7119140625 KB ======> 30.04296875 KB -----文件比目标文件大倍忽略
baiyang.mp3 比较前后 35.3232421875 KB ======> 35.552734375 KB -----文件比目标文件大倍忽略
bujinjinshixihuan.mp3 比较前后 22.3642578125 KB ======> 22.6953125 KB -----文件比目标文件大倍忽略
panama.mp3 比较前后 28.79296875 KB ======> 29.0224609375 KB -----文件比目标文件大倍忽略
ruguotianturanxiaqileyu.mp3 比较前后 37.2626953125 KB ======> 37.59375 KB -----文件比目标文件大倍忽略
shouqianshouyiqizouzaixingfudedajie.mp3 比较前后 24.7119140625 KB ======> 24.9404296875 KB -----文件比目标文件大倍忽略
Tcha Tcha Tcha.mp3 比较前后 30.42578125 KB ======> 30.654296875 KB -----文件比目标文件大倍忽略
wodejiangjuna.mp3 比较前后 41.5478515625 KB ======> 41.87890625 KB -----文件比目标文件大倍忽略
.... 忽略不重要 信息
2023-01-24 23:02:44 当前处理文件夹 out\5_44 已处理 136 个文件
dangniditoudeshunjian.mp3 比较前后 14.8134765625 KB ======> 17.900390625 KB -----覆盖
dangniditoudeshunjian1.mp3 比较前后 4.328125 KB ======> 15.5771484375 KB -----覆盖
dangniduiwoshuobupayouwozai.mp3 比较前后 13.9970703125 KB ======> 16.884765625 KB -----覆盖
dangniduiwoshuobupayouwozai1.mp3 比较前后 1.650390625 KB ======> 1.5234375 KB -----文件比目标文件大倍忽略
dangnigudannihuixiangqishui.mp3 比较前后 19.8134765625 KB ======> 24.12109375 KB -----覆盖
dangnigudannihuixiangqishui1.mp3 比较前后 2.7724609375 KB ======> 2.919921875 KB -----文件比目标文件大倍忽略
dangnilaole.mp3 比较前后 15.42578125 KB ======> 18.662109375 KB -----覆盖
dangnilaole1.mp3 比较前后 3.5888671875 KB ======> 3.935546875 KB -----文件比目标文件大倍忽略
dangnirengranhaizaihuanxiang.mp3 比较前后 13.5888671875 KB ======> 16.376953125 KB -----覆盖
dangnirengranhaizaihuanxiang1.mp3 比较前后 2.568359375 KB ======> 2.666015625 KB -----文件比目标文件大倍忽略
dangnizaichuanshanyuelingdelingyibian.mp3 比较前后 17.875 KB ======> 21.708984375 KB -----覆盖
dangnizaichuanshanyuelingdelingyibian1.mp3 比较前后 3.384765625 KB ======> 3.681640625 KB -----文件比目标文件大倍忽略
dangnizoujinzhehuanlechang.mp3 比较前后 15.7314453125 KB ======> 19.04296875 KB -----覆盖
dangnizoujinzhehuanlechang1.mp3 比较前后 3.5888671875 KB ======> 3.935546875 KB -----文件比目标文件大倍忽略
dangshanfengmeiyoulengjiaodeshihou.mp3 比较前后 12.466796875 KB ======> 14.98046875 KB -----覆盖
dangshanfengmeiyoulengjiaodeshihou1.mp3 比较前后 1.5478515625 KB ======> 1.396484375 KB -----文件比目标文件大倍忽略
dangwobuzainihuibuhuinanguo.mp3 比较前后 13.79296875 KB ======> 16.630859375 KB -----覆盖
dangwobuzainihuibuhuinanguo1.mp3 比较前后 2.16015625 KB ======> 2.158203125 KB -----文件比目标文件大倍忽略
dangwohaikeyizaigennifeixing.mp3 比较前后 23.9970703125 KB ======> 29.326171875 KB -----覆盖
dangwohaikeyizaigennifeixing1.mp3 比较前后 2.466796875 KB ======> 2.5390625 KB -----文件比目标文件大倍忽略
2023-01-24 23:02:44 复制完毕 总共处理147 个文件
处理前后 4177.9921875 KB ======> 1994.1923828125 KB 压缩掉了 2183 KB

源码地址:点击前往

nodejs压缩版本-更新于2024-11-18

最近重新写了音频压缩脚本,从Python脚本修改为了nodejs实现

核心代码如下:

chalk_audio_compression.js 和下面的 colors_audio_compression.js文件二选一即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const readline = require('readline');

(async () => {
// Dynamically import chalk for colored output
const { default: chalk } = await import('chalk');

// Define the ffmpeg command
const CMD_MUSIC = path.join(__dirname, 'bin', 'ffmpeg');

// Function to compress audio files
function compressAudioFiles(sourceDir, destDir = 'output', compressionThreshold = 1) {
// Check if destination directory exists, if not create it
if (destDir === 'output' && fs.existsSync(destDir)) {
fs.rmdirSync(destDir, { recursive: true });
console.log(`Cleared directory: ${destDir}`);
}
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true });
console.log(`Created directory: ${destDir}`);
}

let totalMp3Files = 0;
let filesMeetingCompressionCriteria = 0;
let sizeReductionMetThreshold = 0; // Size reduction for files that meet the threshold
let compressionResults = []; // Array to store results

// Function to compress audio file
function audioMin(srcFile, destFile) {
const cmd = `${CMD_MUSIC} -i "${srcFile}" -vn -ar 22050 -ac 1 -ab 128k -f mp3 "${destFile}"`;
try {
execSync(cmd);
return true;
} catch (error) {
console.error(`Error compressing ${srcFile}: ${error.message}`);
return false;
}
}

// Function to process directory
function processDirectory(srcDir, destDir) {
fs.readdirSync(srcDir).forEach(file => {
const srcPath = path.join(srcDir, file);
const relativePath = path.relative(sourceDir, srcPath); // Compute the relative path from the sourceDir
const destPath = path.join(destDir, relativePath); // Append it to the destDir
const destDirPath = path.dirname(destPath); // Directory of the destination file

if (fs.statSync(srcPath).isFile() && srcPath.toLowerCase().endsWith('.mp3')) {
totalMp3Files++;
const srcSize = fs.statSync(srcPath).size;
let shouldDelete = false; // Flag to determine if the directory should be deleted

// Ensure destination directory exists before processing
if (!fs.existsSync(destDirPath)) {
fs.mkdirSync(destDirPath, { recursive: true });
}

if (audioMin(srcPath, destPath)) {
const destSize = fs.statSync(destPath).size;
const sizeDifference = (srcSize - destSize) / 1024; // Size difference in KB
const srcSizeKB = (srcSize / 1024).toFixed(2); // Original size in KB
const destSizeKB = (destSize / 1024).toFixed(2); // New size in KB

if (sizeDifference >= compressionThreshold) {
filesMeetingCompressionCriteria++;
sizeReductionMetThreshold += sizeDifference;
compressionResults.push({
type: 'green',
message: `File: ${relativePath}, Original: ${srcSizeKB} KB, New: ${destSizeKB} KB, Change: ${sizeDifference.toFixed(2)} KB`
});
} else if (sizeDifference > 0 && sizeDifference < compressionThreshold) {
compressionResults.push({
type: 'blue',
message: `File: ${relativePath}, Original: ${srcSizeKB} KB, New: ${destSizeKB} KB, Change: ${sizeDifference.toFixed(2)} KB but did not meet threshold`
});
fs.unlinkSync(destPath); // Remove the file if it doesn't meet the compression threshold
shouldDelete = true;
} else if (sizeDifference < 0) {
compressionResults.push({
type: 'yellow',
message: `File: ${relativePath}, Original: ${srcSizeKB} KB, New: ${destSizeKB} KB, Change: ${sizeDifference.toFixed(2)} KB`
});
fs.unlinkSync(destPath); // Remove the file if it doesn't meet the compression threshold
shouldDelete = true;
}
} else {
shouldDelete = true;
}

// Check and remove the directory if it was created but is empty
if (shouldDelete && fs.existsSync(destDirPath) && fs.readdirSync(destDirPath).length === 0) {
fs.rmdirSync(destDirPath);
}
} else if (fs.statSync(srcPath).isDirectory()) {
processDirectory(srcPath, destDir);
}
});
}

processDirectory(sourceDir, destDir);

const sizeReduction = sizeReductionMetThreshold / 1024; // Convert to MB for files that met the threshold
console.log(`Total MP3 files in directory: ${totalMp3Files}`);
console.log(`Total MP3 files meeting compression criteria (>= ${compressionThreshold}KB): ${filesMeetingCompressionCriteria}`);
console.log(`Total size reduction after compression: ${sizeReduction.toFixed(2)} MB`);

// Output results with color coding
compressionResults.forEach(result => {
if (result.type === 'green') {
console.log(chalk.green(result.message));
} else if (result.type === 'blue') {
console.log(chalk.blue(result.message));
} else if (result.type === 'yellow') {
console.log(chalk.yellow(result.message));
}
});
}

// Main function
function main() {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});

rl.question("请输入要压缩的包含音频文件的目录路径: ", (sourceDirectory) => {
sourceDirectory = sourceDirectory.trim();
if (!sourceDirectory || !fs.existsSync(sourceDirectory)) {
console.error('Error: Source directory is not specified or does not exist.');
rl.close();
process.exit(1);
}

rl.question("请输入要导出的目录路径(默认输出到当前目录下的output文件夹): ", (targetDirectory) => {
targetDirectory = targetDirectory.trim();
if (!targetDirectory) {
targetDirectory = 'output';
}

rl.question("请输入压缩阈值(单位KB,默认1KB): ", (compressionThreshold) => {
compressionThreshold = parseFloat(compressionThreshold.trim()) || 1;
compressAudioFiles(sourceDirectory, targetDirectory, compressionThreshold);
rl.close();
});
});
});
}

// Entry point of the script
if (require.main === module) {
main();
}
})();

colors_audio_compression.js 上面的文件 二选一即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const readline = require('readline');
const colors = require('colors'); // 引入 colors 库

// Define the ffmpeg command
const CMD_MUSIC = path.join(__dirname, 'bin', 'ffmpeg');

// Function to compress audio files
function compressAudioFiles(sourceDir, destDir = 'output', compressionThreshold = 1) {
// Check if destination directory exists, if not create it
if (destDir === 'output' && fs.existsSync(destDir)) {
fs.rmdirSync(destDir, { recursive: true });
console.log(`Cleared directory: ${destDir}`);
}
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true });
console.log(`Created directory: ${destDir}`);
}

let totalMp3Files = 0;
let filesMeetingCompressionCriteria = 0;
let sizeReductionMetThreshold = 0; // Size reduction for files that meet the threshold
let compressionResults = []; // Array to store results

// Function to compress audio file
function audioMin(srcFile, destFile) {
const cmd = `${CMD_MUSIC} -i "${srcFile}" -vn -ar 22050 -ac 1 -ab 128k -f mp3 "${destFile}"`;
try {
execSync(cmd);
return true;
} catch (error) {
console.error(`Error compressing ${srcFile}: ${error.message}`);
return false;
}
}

// Function to process directory
function processDirectory(srcDir, destDir) {
fs.readdirSync(srcDir).forEach(file => {
const srcPath = path.join(srcDir, file);
const relativePath = path.relative(sourceDir, srcPath); // Compute the relative path from the sourceDir
const destPath = path.join(destDir, relativePath); // Append it to the destDir
const destDirPath = path.dirname(destPath); // Directory of the destination file

if (fs.statSync(srcPath).isFile() && srcPath.toLowerCase().endsWith('.mp3')) {
totalMp3Files++;
const srcSize = fs.statSync(srcPath).size;
let shouldDelete = false; // Flag to determine if the directory should be deleted

// Ensure destination directory exists before processing
if (!fs.existsSync(destDirPath)) {
fs.mkdirSync(destDirPath, { recursive: true });
}

if (audioMin(srcPath, destPath)) {
const destSize = fs.statSync(destPath).size;
const sizeDifference = (srcSize - destSize) / 1024; // Size difference in KB
const srcSizeKB = (srcSize / 1024).toFixed(2); // Original size in KB
const destSizeKB = (destSize / 1024).toFixed(2); // New size in KB

if (sizeDifference >= compressionThreshold) {
filesMeetingCompressionCriteria++;
sizeReductionMetThreshold += sizeDifference;
compressionResults.push({
type: 'green',
message: `File: ${relativePath}, Original: ${srcSizeKB} KB, New: ${destSizeKB} KB, Change: ${sizeDifference.toFixed(2)} KB`
});
} else if (sizeDifference > 0 && sizeDifference < compressionThreshold) {
compressionResults.push({
type: 'blue',
message: `File: ${relativePath}, Original: ${srcSizeKB} KB, New: ${destSizeKB} KB, Change: ${sizeDifference.toFixed(2)} KB but did not meet threshold`
});
fs.unlinkSync(destPath); // Remove the file if it doesn't meet the compression threshold
shouldDelete = true;
} else if (sizeDifference < 0) {
compressionResults.push({
type: 'yellow',
message: `File: ${relativePath}, Original: ${srcSizeKB} KB, New: ${destSizeKB} KB, Change: ${sizeDifference.toFixed(2)} KB`
});
fs.unlinkSync(destPath); // Remove the file if it doesn't meet the compression threshold
shouldDelete = true;
}
} else {
shouldDelete = true;
}

// Check and remove the directory if it was created but is empty
if (shouldDelete && fs.existsSync(destDirPath) && fs.readdirSync(destDirPath).length === 0) {
fs.rmdirSync(destDirPath);
}
} else if (fs.statSync(srcPath).isDirectory()) {
processDirectory(srcPath, destDir);
}
});
}

processDirectory(sourceDir, destDir);

const sizeReduction = sizeReductionMetThreshold / 1024; // Convert to MB for files that met the threshold
console.log(`Total MP3 files in directory: ${totalMp3Files}`);
console.log(`Total MP3 files meeting compression criteria (>= ${compressionThreshold}KB): ${filesMeetingCompressionCriteria}`);
console.log(`Total size reduction after compression: ${sizeReduction.toFixed(2)} MB`);

// Output results with color coding
compressionResults.forEach(result => {
if (result.type === 'green') {
console.log(result.message.green);
} else if (result.type === 'blue') {
console.log(result.message.blue);
} else if (result.type === 'yellow') {
console.log(result.message.yellow);
}
});
}

// Main function
function main() {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});

rl.question("请输入要压缩的包含音频文件的目录路径: ", (sourceDirectory) => {
sourceDirectory = sourceDirectory.trim();
if (!sourceDirectory || !fs.existsSync(sourceDirectory)) {
console.error('Error: Source directory is not specified or does not exist.');
rl.close();
process.exit(1);
}

rl.question("请输入要导出的目录路径(默认输出到当前目录下的output文件夹): ", (targetDirectory) => {
targetDirectory = targetDirectory.trim();
if (!targetDirectory) {
targetDirectory = 'output';
}

rl.question("请输入压缩阈值(单位KB,默认1KB): ", (compressionThreshold) => {
compressionThreshold = parseFloat(compressionThreshold.trim()) || 1;
compressAudioFiles(sourceDirectory, targetDirectory, compressionThreshold);
rl.close();
});
});
});
}

// Entry point of the script
if (require.main === module) {
main();
}

压缩后,替换会原来的目录 脚本文件:replace_audio_files.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
const fs = require('fs');
const path = require('path');
const readline = require('readline');

let totalFilesCompared = 0;
let totalFilesReplaced = 0;
let totalFilesNotReplaced = 0;
let originalTotalSize = 0;
let replacedTotalSize = 0;
let replacedFilesList = [];

// Function to replace original files with compressed ones
function replaceOriginalFilesWithCompressed(srcDir, tgtDir, sizeDifferenceThreshold) {
// Iterate over all files and directories in the current directory
fs.readdirSync(srcDir).forEach(entry => {
const sourceEntry = path.join(srcDir, entry);
const targetEntry = path.join(tgtDir, entry);

if (fs.statSync(sourceEntry).isDirectory()) {
if (!fs.existsSync(targetEntry)) {
fs.mkdirSync(targetEntry);
}
replaceOriginalFilesWithCompressed(sourceEntry, targetEntry, sizeDifferenceThreshold);
} else if (sourceEntry.endsWith('.mp3') || sourceEntry.endsWith('.MP3')) {
const originalSize = fs.statSync(sourceEntry).size;
const compressedSize = fs.statSync(targetEntry).size;
const sizeDifference = compressedSize - originalSize;

originalTotalSize += originalSize;
replacedTotalSize += compressedSize;

console.log(`比较文件: ${entry} 原: ${(originalSize / 1024).toFixed(2)}KB 后: ${(compressedSize / 1024).toFixed(2)}KB - 大小差异: ${(sizeDifference / 1024).toFixed(2)} KB`);

totalFilesCompared++;

if (sizeDifference >= sizeDifferenceThreshold) {
fs.copyFileSync(sourceEntry, targetEntry);
console.log(`已替换文件: ${entry}`);
totalFilesReplaced++;
replacedFilesList.push(entry);
} else {
console.log(`未替换文件: ${entry}`);
totalFilesNotReplaced++;
}
}
});
}

// Main function
function main() {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});

rl.question("请输入原资源目录路径: ", (sourceDirectory) => {
sourceDirectory = sourceDirectory.trim();
if (!sourceDirectory || !fs.existsSync(sourceDirectory)) {
console.error('错误: 原资源目录未指定或不存在。');
rl.close();
process.exit(1);
}

rl.question("请输入被覆盖的资源目录路径: ", (targetDirectory) => {
targetDirectory = targetDirectory.trim();
if (!targetDirectory || !fs.existsSync(targetDirectory)) {
console.error('错误: 被覆盖的资源目录未指定或不存在。');
rl.close();
process.exit(1);
}

rl.question("请输入覆盖阈值(单位KB,默认全部覆盖): ", (sizeDifferenceThreshold) => {
sizeDifferenceThreshold = parseFloat(sizeDifferenceThreshold.trim());
if (isNaN(sizeDifferenceThreshold)) {
sizeDifferenceThreshold = -Infinity; // 全部覆盖
} else {
sizeDifferenceThreshold *= 1024; // 转换为字节
}

replaceOriginalFilesWithCompressed(sourceDirectory, targetDirectory, sizeDifferenceThreshold);

console.log(`\n总结:`);
console.log(`比较的总文件数: ${totalFilesCompared}`);
console.log(`已替换的文件数: ${totalFilesReplaced}`);
console.log(`未替换的文件数: ${totalFilesNotReplaced}`);
console.log(`覆盖前总大小: ${(replacedTotalSize / (1024 * 1024)).toFixed(2)} MB`);
console.log(`覆盖后总大小: ${(originalTotalSize/ (1024 * 1024)).toFixed(2)} MB`);
console.log(`替换的文件:\n${replacedFilesList.join('\n')}`);

rl.close();
});
});
});
}

// Entry point of the script
if (require.main === module) {
main();
}

参考

--- 本文结束 The End ---