前言

不同歌曲的音量大小不同,如果直接导入游戏做自定义电台的话很容易出现音乐时大时小的问题。因此,需要先将音量均衡。

我们仨

多线程转换为MP3

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
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
# FLAC to MP3 并行转换脚本
# 使用ffmpeg将User Music文件夹中的所有FLAC文件并行转换为MP3格式

# 设置路径变量
$currentDir = $PSScriptRoot
$userMusicDir = Join-Path $currentDir "Music"
$mp3OutputDir = Join-Path $currentDir "MP3"

# 并行处理参数
$maxConcurrentJobs = [Environment]::ProcessorCount # 默认使用CPU核心数
$throttleLimit = $maxConcurrentJobs / 2

Write-Host "并行转换脚本启动" -ForegroundColor Cyan
Write-Host "CPU核心数: $([Environment]::ProcessorCount)" -ForegroundColor Gray
Write-Host "并行任务数: $throttleLimit" -ForegroundColor Gray

# 检查ffmpeg是否可用
try {
$ffmpegVersion = ffmpeg -version 2>$null
if (-not $ffmpegVersion) {
Write-Host "错误: 未找到ffmpeg。请确保ffmpeg已安装并添加到系统PATH中。" -ForegroundColor Red
exit 1
}
Write-Host "ffmpeg检查通过" -ForegroundColor Green
} catch {
Write-Host "错误: 未找到ffmpeg。请确保ffmpeg已安装并添加到系统PATH中。" -ForegroundColor Red
exit 1
}

# 检查User Music文件夹是否存在
if (-not (Test-Path $userMusicDir)) {
Write-Host "错误: 未找到User Music文件夹: $userMusicDir" -ForegroundColor Red
exit 1
}

# 创建MP3输出文件夹(如果不存在)
if (-not (Test-Path $mp3OutputDir)) {
New-Item -ItemType Directory -Path $mp3OutputDir -Force | Out-Null
Write-Host "已创建MP3输出文件夹: $mp3OutputDir" -ForegroundColor Green
}

# 获取所有FLAC文件
$flacFiles = Get-ChildItem -Path $userMusicDir -Filter "*.flac" -File

if ($flacFiles.Count -eq 0) {
Write-Host "在User Music文件夹中未找到FLAC文件。" -ForegroundColor Yellow
exit 0
}

Write-Host "找到 $($flacFiles.Count) 个FLAC文件" -ForegroundColor Cyan

# 过滤出需要转换的文件(跳过已存在的MP3文件)
$filesToConvert = @()
$skippedCount = 0

foreach ($flacFile in $flacFiles) {
$outputFileName = [System.IO.Path]::ChangeExtension($flacFile.Name, ".mp3")
$outputPath = Join-Path $mp3OutputDir $outputFileName

if (Test-Path $outputPath) {
Write-Host "跳过 (已存在): $outputFileName" -ForegroundColor Yellow
$skippedCount++
} else {
$filesToConvert += $flacFile
}
}

if ($filesToConvert.Count -eq 0) {
Write-Host "所有文件已存在,无需转换。" -ForegroundColor Green
Read-Host "按回车键退出"
exit 0
}

Write-Host "需要转换: $($filesToConvert.Count) 个文件" -ForegroundColor White
Write-Host "跳过文件: $skippedCount 个文件" -ForegroundColor Yellow
Write-Host "开始并行转换..." -ForegroundColor Cyan

# 记录开始时间
$overallStartTime = Get-Date

# 使用Foreach-Object -Parallel进行并行处理
$results = $filesToConvert | ForEach-Object -ThrottleLimit $throttleLimit -Parallel {
$outputDir = $using:mp3OutputDir
$currentFile = $_

$inputPath = $currentFile.FullName
$outputFileName = [System.IO.Path]::ChangeExtension($currentFile.Name, ".mp3")
$outputPath = Join-Path $outputDir $outputFileName

$result = @{
InputFile = $currentFile.Name
OutputFile = $outputFileName
Success = $false
ErrorMessage = $null
StartTime = Get-Date
EndTime = $null
Duration = $null
}

try {
# 使用ffmpeg进行转换
$ffmpegArgs = @(
"-i", "`"$inputPath`""
"-codec:a", "libmp3lame"
"-b:a", "192k"
"-y"
"`"$outputPath`""
)

$process = Start-Process -FilePath "ffmpeg" -ArgumentList $ffmpegArgs -Wait -NoNewWindow -PassThru -RedirectStandardError "NUL"

$result.EndTime = Get-Date
$result.Duration = $result.EndTime - $result.StartTime

if ($process.ExitCode -eq 0) {
$result.Success = $true
} else {
$result.ErrorMessage = "ffmpeg退出码: $($process.ExitCode)"
}
} catch {
$result.EndTime = Get-Date
$result.Duration = $result.EndTime - $result.StartTime
$result.ErrorMessage = $_.Exception.Message
}

# 实时显示进度
$hostInfo = $using:Host
if ($result.Success) {
$hostInfo.UI.WriteLine("✓ 转换成功: $($result.OutputFile) (耗时: $($result.Duration.TotalSeconds.ToString('F1'))秒)")
} else {
$hostInfo.UI.WriteLine("✗ 转换失败: $($result.InputFile) - $($result.ErrorMessage)")
}

return $result
}

# 计算总体统计
$overallEndTime = Get-Date
$totalDuration = $overallEndTime - $overallStartTime

$successCount = ($results | Where-Object { $_.Success }).Count
$errorCount = ($results | Where-Object { -not $_.Success }).Count
$totalProcessed = $results.Count

# 显示最终统计
Write-Host "`n======== 转换完成统计 ========" -ForegroundColor Cyan
Write-Host "总处理文件: $totalProcessed" -ForegroundColor White
Write-Host "成功转换: $successCount" -ForegroundColor Green
Write-Host "转换失败: $errorCount" -ForegroundColor Red
Write-Host "跳过文件: $skippedCount" -ForegroundColor Yellow
Write-Host "总耗时: $($totalDuration.TotalMinutes.ToString('F1')) 分钟" -ForegroundColor White
Write-Host "平均每文件: $($totalDuration.TotalSeconds / $totalProcessed | ForEach-Object { $_.ToString('F1') }) 秒" -ForegroundColor White

if ($errorCount -gt 0) {
Write-Host "`n失败的文件:" -ForegroundColor Red
$results | Where-Object { -not $_.Success } | ForEach-Object {
Write-Host " - $($_.InputFile): $($_.ErrorMessage)" -ForegroundColor Red
}
}

if ($errorCount -eq 0) {
Write-Host "`n🎉 所有FLAC文件已成功转换为MP3格式!" -ForegroundColor Green
} else {
Write-Host "`n⚠️ 转换过程中遇到 $errorCount 个错误。" -ForegroundColor Yellow
}

# 显示性能提升信息
$estimatedSequentialTime = ($results | Measure-Object -Property { $_.Duration.TotalSeconds } -Sum).Sum
$speedupRatio = $estimatedSequentialTime / $totalDuration.TotalSeconds

Write-Host "`n性能统计:" -ForegroundColor Cyan
Write-Host "估计顺序处理时间: $($estimatedSequentialTime / 60 | ForEach-Object { $_.ToString('F1') }) 分钟" -ForegroundColor Gray
Write-Host "实际并行处理时间: $($totalDuration.TotalMinutes.ToString('F1')) 分钟" -ForegroundColor Gray
Write-Host "速度提升: $($speedupRatio.ToString('F1'))x" -ForegroundColor Green

# 暂停,让用户查看结果
Read-Host "`n按回车键退出"

多线程分析响度

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
param(
[string]$Path = (Get-Location).Path,
#[string]$Path = ".\User Music",
[string]$Output = "loudness_report.csv",
[switch]$Recurse,
[int]$ThrottleLimit = 8 # 并行任务数,根据 CPU 核心数调整
)

# 尽量让 PowerShell 对外部程序的输出使用 UTF-8
try {
$utf8 = New-Object System.Text. UTF8Encoding($false)
[Console]::OutputEncoding = $utf8
$OutputEncoding = $utf8
} catch { }

$ffmpegCmd = Get-Command ffmpeg -ErrorAction SilentlyContinue
if (-not $ffmpegCmd) {
throw "找不到 ffmpeg:请先安装并确保 ffmpeg 在 PATH 里。"
}
$ffmpeg = $ffmpegCmd. Source

# 将函数定义为字符串(避免脚本块传递问题)
$functionDefsString = @'
function Try-ParseJsonFromText {
param([string]$Text)
$end = $Text.LastIndexOf('}')
if ($end -lt 0) { return $null }
for ($i = $Text. LastIndexOf('{'); $i -ge 0; $i = $Text.LastIndexOf('{', $i - 1)) {
if ($i -gt $end) { continue }
$candidate = $Text.Substring($i, $end - $i + 1)
try {
return ($candidate | ConvertFrom-Json -ErrorAction Stop)
} catch {
continue
}
}
return $null
}

function Get-LoudnessViaFfmpeg {
param([string]$InputFile, [string]$FfmpegPath)
$args = @(
"-hide_banner", "-nostats", "-v", "info",
"-i", $InputFile,
"-vn", "-sn", "-dn",
"-af", "loudnorm=print_format=json",
"-f", "null", "-"
)
$raw = & $FfmpegPath @args 2>&1 | Out-String
$exit = $LASTEXITCODE

if ($exit -ne 0) {
return [pscustomobject]@{
Success = $false; Raw = $raw
Error = "ffmpeg 退出码=$exit"; Json = $null
}
}

$json = Try-ParseJsonFromText -Text $raw
if (-not $json) {
return [pscustomobject]@{
Success = $false; Raw = $raw
Error = "未能解析 loudnorm JSON"; Json = $null
}
}

return [pscustomobject]@{
Success = $true; Raw = $raw; Error = $null; Json = $json
}
}
'@

# 收集 mp3 文件
$files = Get-ChildItem -LiteralPath $Path -Filter "*.mp3" -File -Recurse: $Recurse
if (-not $files -or $files.Count -eq 0) {
"No mp3 files found under: $Path" | Set-Content -LiteralPath $Output -Encoding UTF8
Write-Host "没有找到 mp3:$Path 😶"
exit 0
}

Write-Host "找到 $($files.Count) 个文件,开始并行处理(并行度=$ThrottleLimit)..."

# 并行处理
$results = $files | ForEach-Object -ThrottleLimit $ThrottleLimit -Parallel {
# 导入函数定义(从字符串)
. ([scriptblock]:: Create($using:functionDefsString))

$f = $_
$ffmpeg = $using:ffmpeg

Write-Host "⏳ 测量中: $($f.Name)"

$r = Get-LoudnessViaFfmpeg -InputFile $f.FullName -FfmpegPath $ffmpeg

# 兜底:路径编码问题时复制到临时文件
$usedTemp = $false
$tempPath = $null
if (-not $r.Success -and ($r. Raw -match "No such file or directory|Invalid argument|Error opening input")) {
$tempPath = Join-Path $env:TEMP ("ffmpeg_loudness_" + [guid]::NewGuid().ToString("N") + ".mp3")
try {
Copy-Item -LiteralPath $f.FullName -Destination $tempPath -Force
$usedTemp = $true
$r = Get-LoudnessViaFfmpeg -InputFile $tempPath -FfmpegPath $ffmpeg
} catch { }
}

if ($usedTemp -and $tempPath) {
try { Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue } catch { }
}

if ($r.Success) {
$j = $r. Json
$inputI = $j.input_i; if ($null -eq $inputI) { $inputI = $j.measured_I }
$inputTP = $j.input_tp; if ($null -eq $inputTP) { $inputTP = $j.measured_TP }
$inputLRA = $j. input_lra; if ($null -eq $inputLRA) { $inputLRA = $j.measured_LRA }
$thresh = $j.input_thresh; if ($null -eq $thresh) { $thresh = $j.measured_thresh }
$offset = $j.target_offset; if ($null -eq $offset) { $offset = $j.offset }

Write-Host "✅ 完成: $($f.Name) | LUFS=$inputI"

[pscustomobject]@{
FilePath = $f.FullName
Integrated_LUFS = $inputI
TruePeak_dBTP = $inputTP
LRA_LU = $inputLRA
Threshold_dB = $thresh
TargetOffset_dB = $offset
Status = "OK"
Note = ""
}
} else {
Write-Host "❌ 失败: $($f.Name)"

[pscustomobject]@{
FilePath = $f.FullName
Integrated_LUFS = ""
TruePeak_dBTP = ""
LRA_LU = ""
Threshold_dB = ""
TargetOffset_dB = ""
Status = "FAIL"
Note = $r.Error
}
}
}

# 输出结果
$results | Export-Csv -LiteralPath $Output -NoTypeInformation -Encoding UTF8

Write-Host "`n🎉 全部完成!已保存到: $Output"

根据响度分析结果进行音量均衡(LUFS标准值)

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
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
param(
[string]$InputDir = (Get-Location).Path,
[string]$OutputDir = ".\normalized",
[string]$TargetLUFS = "-8",
[string]$ReportFile = "loudness_report.csv",
[string]$LogFile = "normalize_log.txt",
[int]$ThrottleLimit = 16,
[switch]$Recurse,
[switch]$Verbose
)

# 强制 UTF-8 编码
$PSDefaultParameterValues['*:Encoding'] = 'utf8'
try {
$utf8 = New-Object System.Text.UTF8Encoding($false)
[Console]::OutputEncoding = $utf8
$OutputEncoding = $utf8
# 设置进程级别的 UTF-8
[System.Environment]::SetEnvironmentVariable("PYTHONIOENCODING", "utf-8")
} catch { }

# 检查 ffmpeg
$ffmpegCmd = Get-Command ffmpeg -ErrorAction SilentlyContinue
if (-not $ffmpegCmd) {
throw "找不到 ffmpeg"
}
$ffmpeg = $ffmpegCmd.Source

# 创建输出目录
if (-not (Test-Path $OutputDir)) {
New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
}

# 清空旧日志
"=" * 80 | Set-Content -LiteralPath $LogFile -Encoding UTF8
"音频标准化处理日志" | Add-Content -LiteralPath $LogFile -Encoding UTF8
"开始时间: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" | Add-Content -LiteralPath $LogFile -Encoding UTF8
"目标响度: $TargetLUFS LUFS" | Add-Content -LiteralPath $LogFile -Encoding UTF8
"=" * 80 | Add-Content -LiteralPath $LogFile -Encoding UTF8
"`n" | Add-Content -LiteralPath $LogFile -Encoding UTF8

# 读取测量报告
$measurements = $null
if (Test-Path $ReportFile) {
try {
$measurements = Import-Csv $ReportFile -Encoding UTF8
Write-Host "✅ 已加载测量数据,将使用双通道精确标准化"
"已加载测量数据: $ReportFile" | Add-Content -LiteralPath $LogFile -Encoding UTF8
} catch {
Write-Host "⚠️ 测量报告加载失败: $_"
"警告: 测量报告加载失败 - $_" | Add-Content -LiteralPath $LogFile -Encoding UTF8
}
} else {
Write-Host "⚠️ 未找到测量报告,将使用单通道标准化"
"警告: 未找到测量报告 $ReportFile" | Add-Content -LiteralPath $LogFile -Encoding UTF8
}

# 收集文件
$files = Get-ChildItem -LiteralPath $InputDir -Filter "*.mp3" -File -Recurse: $Recurse
if (-not $files) {
Write-Host "没有找到 mp3 文件"
"错误: 未找到 mp3 文件" | Add-Content -LiteralPath $LogFile -Encoding UTF8
exit 0
}

Write-Host "找到 $($files.Count) 个文件,开始标准化处理(目标: $TargetLUFS LUFS)..."
"找到 $($files.Count) 个文件`n" | Add-Content -LiteralPath $LogFile -Encoding UTF8

# 创建线程安全的日志写入
$mutex = $null
try {
$mutex = New-Object System.Threading.Mutex($false, "NormalizeLogMutex_" + [guid]::NewGuid().ToString("N"))
} catch {
Write-Host "⚠️ 无法创建互斥锁,日志可能不完整: $_"
}

# 并行处理
$results = $files | ForEach-Object -ThrottleLimit $ThrottleLimit -Parallel {
$f = $_
$ffmpeg = $using:ffmpeg
$outputDir = $using:OutputDir
$targetLUFS = $using:TargetLUFS
$measurements = $using:measurements
$logFile = $using:LogFile
$verboseMode = $using:Verbose
$mutex = $using:mutex

# 线程安全的日志函数
function Write-ThreadSafeLog {
param([string]$Message)
$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm: ss'
$logLine = "[$timestamp] $Message"

if ($null -ne $mutex) {
try {
$mutex.WaitOne(5000) | Out-Null
$logLine | Add-Content -LiteralPath $logFile -Encoding UTF8
} catch {
$logLine | Add-Content -LiteralPath $logFile -Encoding UTF8
} finally {
try { $mutex.ReleaseMutex() } catch { }
}
} else {
$logLine | Add-Content -LiteralPath $logFile -Encoding UTF8
}
}

# 构建输出路径
$relativePath = $f. FullName.Substring($using:InputDir.Length).TrimStart('\', '/')
$outputFile = Join-Path $outputDir $relativePath
$outputFolder = Split-Path $outputFile -Parent

if (-not (Test-Path $outputFolder)) {
New-Item -ItemType Directory -Path $outputFolder -Force | Out-Null
}

Write-Host "🔧 处理: $($f.Name)"
Write-ThreadSafeLog "开始处理: $($f.FullName)"

# 查找测量数据
$measure = $measurements | Where-Object { $_. FilePath -eq $f. FullName } | Select-Object -First 1

# 构建 loudnorm 滤镜参数(关键修复)
$targetI = [double]$targetLUFS
$targetTP = -1.5
$targetLRA = 11.0

if ($measure -and $measure.Status -eq "OK") {
# 双通道标准化 - 修复参数格式
Write-ThreadSafeLog " 使用双通道模式 | 当前响度: $($measure.Integrated_LUFS) LUFS"

# 确保所有参数都是有效数字
try {
$mI = [double]$measure. Integrated_LUFS
$mTP = [double]$measure.TruePeak_dBTP
$mLRA = [double]$measure.LRA_LU
$mThresh = [double]$measure. Threshold_dB
$mOffset = [double]$measure.TargetOffset_dB

# 使用 dual_mono=true 代替 linear=true(更兼容)
$filterComplex = "loudnorm=I=$targetI`:TP=$targetTP`:LRA=$targetLRA`:measured_I=$mI`:measured_TP=$mTP`:measured_LRA=$mLRA`:measured_thresh=$mThresh`:offset=$mOffset`:dual_mono=true: print_format=summary"
} catch {
Write-ThreadSafeLog " 警告: 测量数据解析失败,改用单通道模式"
$filterComplex = "loudnorm=I=$targetI`:TP=$targetTP`:LRA=$targetLRA:print_format=summary"
}
} else {
# 单通道标准化
Write-ThreadSafeLog " 使用单通道模式(无预测量数据)"
$filterComplex = "loudnorm=I=$targetI`:TP=$targetTP`:LRA=$targetLRA:print_format=summary"
}

# 使用临时文件避免中文路径问题
$useTemp = $false
$tempOutput = $null

# 检查路径是否包含非ASCII字符
if ($outputFile -match '[^\x00-\x7F]') {
$useTemp = $true
$tempOutput = Join-Path $env:TEMP ("ffmpeg_norm_" + [guid]::NewGuid().ToString("N") + ".mp3")
Write-ThreadSafeLog " 检测到非ASCII路径,使用临时文件"
} else {
$tempOutput = $outputFile
}

# 构建 ffmpeg 参数
$args = @(
"-hide_banner"
"-nostats"
"-i", $f.FullName
"-af", $filterComplex
"-ar", "44100"
"-codec:a", "libmp3lame"
"-b:a", "192k"
"-write_xing", "0"
"-id3v2_version", "3"
"-y"
$tempOutput
)

# 记录完整命令(调试用)
if ($verboseMode) {
Write-ThreadSafeLog " 滤镜: $filterComplex"
Write-ThreadSafeLog " 输出: $tempOutput"
}

# 执行转换
try {
$output = & $ffmpeg @args 2>&1 | Out-String
$exitCode = $LASTEXITCODE

# 如果使用了临时文件,复制回目标位置
if ($useTemp -and $exitCode -eq 0 -and (Test-Path $tempOutput)) {
try {
Copy-Item -LiteralPath $tempOutput -Destination $outputFile -Force
Remove-Item -LiteralPath $tempOutput -Force -ErrorAction SilentlyContinue
} catch {
Write-ThreadSafeLog " 警告: 复制临时文件失败 - $_"
$exitCode = 1
}
}

if ($exitCode -eq 0 -and (Test-Path $outputFile)) {
$fileSize = (Get-Item $outputFile).Length
Write-Host "✅ 完成: $($f.Name) ($([math]::Round($fileSize/1MB, 2)) MB)"
Write-ThreadSafeLog " ✅ 成功 | 大小: $([math]::Round($fileSize/1MB, 2)) MB"

[pscustomobject]@{
SourceFile = $f.FullName
OutputFile = $outputFile
Status = "OK"
Error = ""
}
} else {
Write-Host "❌ 失败: $($f.Name) (退出码: $exitCode)"
Write-ThreadSafeLog " ❌ 失败 | 退出码: $exitCode"

# 提取关键错误信息
$errorLines = $output -split "`n" | Where-Object { $_ -match "Error|Invalid|failed" } | Select-Object -First 5
$errorSummary = $errorLines -join "`n"
Write-ThreadSafeLog " 错误摘要:`n$errorSummary"

# 清理临时文件
if ($useTemp -and (Test-Path $tempOutput)) {
Remove-Item -LiteralPath $tempOutput -Force -ErrorAction SilentlyContinue
}

[pscustomobject]@{
SourceFile = $f.FullName
OutputFile = $outputFile
Status = "FAIL"
Error = "退出码: $exitCode`n$errorSummary"
}
}
} catch {
Write-Host "❌ 异常: $($f.Name) - $_"
Write-ThreadSafeLog " ❌ 异常: $_"

# 清理临时文件
if ($useTemp -and $tempOutput -and (Test-Path $tempOutput)) {
Remove-Item -LiteralPath $tempOutput -Force -ErrorAction SilentlyContinue
}

[pscustomobject]@{
SourceFile = $f.FullName
OutputFile = $outputFile
Status = "FAIL"
Error = $_.Exception.Message
}
}
}

# 写入最终统计
$successCount = ($results | Where-Object { $_.Status -eq "OK" }).Count
$failCount = ($results | Where-Object { $_.Status -eq "FAIL" }).Count

"`n" + "=" * 80 | Add-Content -LiteralPath $LogFile -Encoding UTF8
"处理完成" | Add-Content -LiteralPath $LogFile -Encoding UTF8
"成功: $successCount / $($files.Count)" | Add-Content -LiteralPath $LogFile -Encoding UTF8
"失败: $failCount" | Add-Content -LiteralPath $LogFile -Encoding UTF8
"结束时间: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" | Add-Content -LiteralPath $LogFile -Encoding UTF8
"=" * 80 | Add-Content -LiteralPath $LogFile -Encoding UTF8

# 保存失败列表
if ($failCount -gt 0) {
$failedFiles = $results | Where-Object { $_.Status -eq "FAIL" }
$failedListFile = "failed_files.csv"
$failedFiles | Export-Csv -LiteralPath $failedListFile -NoTypeInformation -Encoding UTF8

"`n失败的文件详情:" | Add-Content -LiteralPath $LogFile -Encoding UTF8
foreach ($failed in $failedFiles) {
$errorPreview = ($failed.Error -split "`n")[0.. 2] -join " "
if ($errorPreview.Length -gt 200) { $errorPreview = $errorPreview.Substring(0, 200) + "..." }
" - $($failed.SourceFile)" | Add-Content -LiteralPath $LogFile -Encoding UTF8
" 错误: $errorPreview" | Add-Content -LiteralPath $LogFile -Encoding UTF8
}

Write-Host "`n⚠️ 有 $failCount 个文件处理失败,详情已保存到:"
Write-Host " 日志: $LogFile"
Write-Host " 列表: $failedListFile"
}

Write-Host "`n🎉 处理完成!成功: $successCount / $($files.Count)"
Write-Host "📁 输出目录: $OutputDir"
Write-Host "📄 完整日志: $LogFile"

# 清理互斥锁
if ($null -ne $mutex) {
try {
$mutex. Dispose()
} catch { }
}

什么是LUFS?

LUFS 是 Loudness Units relative to Full Scale 的缩写,中文常作 “相对满刻度的响度单位”。

欧盟/EBU 广播选择−23.0 LUFS为标准,而Spotify 播放归一化参考选择−14 LUFS integrated 作为关键阈值与参考。