6231 字
31 分钟

编码之殇:前 Unicode 时代的 WAV 处理

前置知识#

Unicode#

Unicode,全称为 Unicode 标准(The Unicode Standard),是信息技术领域的业界标准,其整理、编码了世界上大部分的文字系统,使得电脑能以通用划一的字符集来处理和显示文字,不但减轻在不同编码系统间切换和转换的困扰,更提供了一种跨平台的乱码问题解决方案。Unicode 由非营利机构Unicode联盟(Unicode Consortium)负责维护,该机构致力让 Unicode 标准取代既有的字符编码方案,因为既有方案编码空间有限,亦不适用于多语环境。

在2024年9月公布的最新版本中,Unicode 已经收录超过15万个字符。Unicode 标准不仅仅只是为文字指定代码。除了涵盖视觉上的字形、编码方法、标准的字符编码资料外,联盟官方出版品还包含了关于各书写系统的细节及呈现方式,如规范化的准则、拆分、测序、绘制、双向文本显示、书写方向、字符特性(如大小写字母)等等。此外还提供参考资料和视觉图像,以帮助开发者和设计师正确应用标准。

虽然因为历史原因,Unicode 也是一个非常复杂的系统,下面是相关的文章,有兴趣的读者可以自行阅读,但是在今天的语境下,这仍然是一个非常进步的编码方式。

Code Page(代码页)#

MSDN

Code PagesCode Page IdentifierschcpUnicode

如今编写的大多数应用程序主要以 Unicode 的形式处理字符数据,使用 UTF-16 编码。然而,许多遗老应用程序仍然使用基于代码页的字符集。即便是新的应用程序,有时也不得不使用代码页,通常是出于以下原因之一:

  • 与遗留应用程序进行通信。
  • 与较老的邮件和新闻服务器通信,这些服务器可能并不总是支持 Unicode。
  • 出于兼容遗留目的与 Windows 控制台通信。(控制台确实支持 Unicode,但某些旧的命令行工具可能并不支持。)

Active Code Page(活动代码页)#

顾名思义,被激活的代码页,在中文环境下大概率是 936 或 65001。

WAV 的元数据#

WAV(Waveform Audio File Format)本质上是基于 RIFF (Resource Interchange File Format) 的一种容器格式。主体为原始 PCM 音频数据,但也可以在文件中携带元数据信息。常见的几类元数据方案包括 XMP、RIFF、ID3 等。

XMP (Extensible Metadata Platform)#

  • 来源:由 Adobe 提出,用于在各种文件(图像、音频、视频、PDF 等)中存放结构化的元数据。
  • 使用 XML 格式 存储,灵活且可扩展。
  • 能描述丰富的信息:标题、作者、版权、关键字、地理位置、制作软件信息等。
  • 在 WAV 文件中通常以 RIFF chunk 的形式嵌入(XMP chunk)。
  • 应用场景:
    • 更适合跨平台、跨应用的场合。
    • 在多媒体内容管理系统(如 Adobe 系列软件)中较常见。

RIFF 标签 (LIST/INFO)#

  • 来源:RIFF 文件标准的一部分,微软和 IBM 在 1991 年的 WAV 规范中引入。
  • 使用 LIST chunk 下的 INFO 子块 存储键值对。
  • 属于比较古老但广泛支持的方式。
  • 应用场景:
    • 与 Windows 多媒体 API、一些音频工具兼容性好。
    • 常见于较早期的 WAV 文件。

ID3 (ID3v2)#

  • 来源:最初为 MP3 文件设计的标签标准,后也被部分厂商用于 WAV 文件。
  • ID3v2 标签通常被放在文件开头(header),与 WAV 原始结构并不完全匹配。
  • 并不是 WAV 标准的一部分,属于“不规范”的元数据写法。
  • 应用场景:
    • 一些音频播放器和库为了统一处理,直接复用 ID3。
    • 在严格依赖 RIFF 规范的程序中,可能被忽略或导致兼容性问题。

UTF-8 与 UTF-16#

UTF-8 和 UTF-16 是两种主要的 Unicode 编码方式。UTF-8 最重要的特点就是它与 ASCII 的兼容性坑点,这个特性使得它在现代计算环境中获得了广泛的采用。UTF-8 采用变长编码方式,用 1 到 4 个字节来表示不同的字符,其中前 128 个字符(0x00-0x7F)与 ASCII 编码完全相同,这意味着任何传统的英文文本文件都可以直接作为 UTF-8 文件使用,无需任何转换。

UTF-16 则采用了不同的编码策略,主要使用 2 个字节来表示字符,对于基本多语言平面内的字符可以直接用 16 位表示,而超出这个范围的字符则需要使用代理对机制,占用 4 个字节。UTF-16 的一个存在字节序问题,因为 16 位的数据在不同的计算机架构上可能以不同的顺序存储,通常需要字节顺序标记来指定是大端序还是小端序。(从技术层面上是这样的,但是 Windows 与 Linux 都使用小端序,默认也猜测为小端序。只有苹果使用大端。)

UTF-16 在处理 CJK 字符的时候拥有更高的编码效率,但是今天不关注这些。

GBK、GB 18030和Shift JIS#

GBK是中国于1995年发布的汉字编码标准,完整的名字是《汉字内码扩展规范》。它是基于早期的GB 2312 编码所做的扩展。是由于GB 2312只能处理 6763 个汉字,无法满足日益增长的中文信息处理需求。GBK 采用双字节编码,能够表示 21003 个汉字和图形符号,简体、繁体中文字符均受其包含。GBK 仍然和GB 2312保持向后兼容,增大字符集的同时更能够处理汉字、标点符号、特殊字符。GBK 在很长一段时间内是中文 Windows 系统的默认编码。

GB 18030 是中国最新的国家标准。该标准采用可变长度编码方案,使用 1、2 或 4 个字节来表示一个字符。GB 18030 不仅与 GBK 和 GB 2312 完全兼容,而且编码超过一百万个字符,几乎所有已知的汉字和一些少数民族字符。GB 18030 与 Unicode 建立了完整的映射关系。

Shift JIS 是用来处理日文字符的编码标准,混合了日语平假名与片假名,汉字,甚至罗马字,这些文字的复杂的多元性在日文的编码上独具特色。Shift JIS 不同于其他编码标准,汉字和假名使用了双字节,英文字符的使用则是单字节,这是变长编码。这在日文与英文的混合文本时,使用了双字节,在日本的计算机系统上得到了广泛的使用。

为什么有问题#

终端笑传之 Character Conversion Bomb#

在现代操作系统内核中,一般都使用 Unicode 作为字符的编码方式。这看起来很美好,听起来也很美好,但是实现上就不是了。

Linux 和 OSX 的选择是直接橄榄所有不使用 UTF-8/UTF-16 的程序,用强制的破坏兼容性变更解决问题,但是 Windows 为了保证对前 Unicode 时代的兼容性,选择了如果应用程序没有主动支持 UTF-16 的输入输出,则自动将 UTF-16 转换为当前活动代码页的编码提交给程序。

由于 UTF-8 极其优秀的 ASCII 兼容性,西方国家的开发者们实际上不会发现任何问题:因为输入的编码是一致的,可以直接将输入流按 UTF-8 解释。但是在 CJK 环境下,就会出现将 GB 18030,Shift JIS错误的按照 UTF-8 解码的情况。

于是就出现了乱码。简单过个表格:

名称示例特点产生原因
古文码甽辨漆珉佶ソ漳蒸冶纟污お渣十愕涕?大都为不认识的古文,并加杂日韩文以 GBK 方式读取 UTF-8 编码的中文
口字码����C•`²�Ä¥���大部分字符为小方块以 UTF-8 的方式读取 GBK 编码的中文
符号码Ç” ± æœˆè¦ á ¥ ½å•ä1 å¤©å¤©å‹ á‚Š大部分字符为各种符号以 ISO8859-1 方式读取 UTF-8 编码的中文
拼音码óéÔÅåøoÃoÃÑŠï¨ïììöÈÏ大部分字符为头顶带有各种类似声调符号的字母以 ISO8859-1 方式读取 GBK 编码的中文
问句码由月要好好学习天天向??字符串长度为偶数时正确,长度为奇数时最后的字符变为问号以 GBK 方式读取 UTF-8 编码的中文,然后又用 UTF-8 的格式再次读取
银拷码锟斤拷锟斤拷锟斤拷学习锟斤拷锟斤拷锟斤拷锟斤拷锟斤拷锟斤拷全中文字符,且大部分字符为“锟斤拷”这几个字符以 UTF-8 方式读取 GBK 编码的中文,然后又用 GBK 的格式再次读取

RIFF 与 XMP#

ID3 不是问题,因为 ID3 足够新,已经规定了使用 UTF-8 来存储元数据,但是剩下两个真正可用的元数据是按照当前活动代码页来读写的。

Adobe Audition、索尼播放器以及其他能够正确读取 WAV 元数据的播放器,都会选择当前代码页来正确解码,但是命令行工具不会。

Ffprobe 与 exiftool#

两大重量级软件:Ffprobe 能够正确处理输入的文件名,支持 UTF-16 的输入,但是!读取的元数据强制按照 UTF-8 输出,不可用。exiftool 甚至不支持 UTF-16 的文件名输入,这就是上文提到的古老软件。

解决问题#

在 PowerShell 中写 C##

得益于 pwsh 强大的对象和互操作能力,可以使用 C# 来写对命令行工具的调用 wrapper,从而解决输入输出流编码的问题。

人工指定读取编码#

没办法,这个真的太难探测了!

完整代码
#Requires -Version 7
<#
.SYNOPSIS
Converts WAV files to FLAC format with metadata preservation.
.DESCRIPTION
This script batch converts WAV files to FLAC format while preserving and normalizing audio metadata.
It extracts metadata from various tag formats (RIFF, ID3, XMP, Vorbis) and transfers them to the FLAC files.
Cover art is also extracted and embedded into the FLAC files if available.
.PARAMETER Folder
Directory containing WAV files to process. Defaults to current directory.
.PARAMETER Force
If specified, overwrites existing FLAC files. Otherwise, existing files are skipped.
.PARAMETER ListEncodings
Displays a list of supported character encodings and exits.
.PARAMETER Encoding
Specifies the character encoding to use for RIFF metadata. If not provided, the script will
prompt for encoding selection when needed.
.EXAMPLE
.\Compress-All-Wav.ps1
Converts all WAV files in the current directory to FLAC format.
.EXAMPLE
.\Compress-All-Wav.ps1 -Folder "D:\Music\Albums" -Force
Converts all WAV files in the specified directory, overwriting any existing FLAC files.
.EXAMPLE
.\Compress-All-Wav.ps1 -ListEncodings
Shows a list of supported encodings for metadata conversion.
.EXAMPLE
.\Compress-All-Wav.ps1 -Encoding "gbk"
Converts WAV files using GBK encoding for RIFF metadata.
.NOTES
Requires PowerShell 7+ and the following external tools:
- flac: For audio conversion
- exiftool: For metadata extraction
- metaflac: For metadata application to FLAC files
Original WAV files are removed after successful conversion unless errors occur.
#>
830 collapsed lines
Param(
[string]$Folder = (Get-Location),
[switch]$Force,
[switch]$ListEncodings,
[string]$Encoding
)
# Set UTF-8 output encoding to properly display non-ASCII characters
[Console]::OutputEncoding = [Text.Encoding]::UTF8
# Register code page provider to support various encodings
try { [System.Text.Encoding]::RegisterProvider([System.Text.CodePagesEncodingProvider]::Instance) } catch {}
# Define C# utility class for process handling and encoding operations
if (-not ("Wav2FlacProcUtil" -as [type])) {
Add-Type -Language CSharp -TypeDefinition @"
using System;
using System.Text;
using System.Diagnostics;
using System.IO;
public static class Wav2FlacProcUtil {
public static string Run(string exe, string args, string encName, string stdinText) {
var psi = new ProcessStartInfo(exe, args) {
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
try { psi.StandardOutputEncoding = Encoding.GetEncoding(encName); } catch {}
try { psi.StandardErrorEncoding = Encoding.UTF8; } catch {}
var p = Process.Start(psi);
using (var sw = new StreamWriter(p.StandardInput.BaseStream, new UTF8Encoding(false))) {
if (!string.IsNullOrEmpty(stdinText)) sw.Write(stdinText);
}
string stdout = p.StandardOutput.ReadToEnd();
string stderr = p.StandardError.ReadToEnd();
p.WaitForExit();
if (p.ExitCode != 0 && string.IsNullOrWhiteSpace(stdout)) {
throw new Exception($"exit {p.ExitCode}: {stderr}");
}
return stdout;
}
public static string DecodeBase64With(string base64Text, string encodingName) {
try {
var cleanB64 = base64Text.Replace(" ", "").Replace("\n", "").Replace("\r", "").Replace("\t", "");
var bytes = Convert.FromBase64String(cleanB64);
var encoding = Encoding.GetEncoding(encodingName);
return encoding.GetString(bytes);
}
catch {
return null;
}
}
public static bool ExtractCoverToFileStdout(string audioPath, string outputPath, out string error)
{
error = null;
if (string.IsNullOrWhiteSpace(audioPath)) { error = "audioPath is empty."; return false; }
if (string.IsNullOrWhiteSpace(outputPath)) { error = "outputPath is empty."; return false; }
// 确保目录存在 & 先删旧文件
var dir = Path.GetDirectoryName(outputPath);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) Directory.CreateDirectory(dir);
try { if (File.Exists(outputPath)) File.Delete(outputPath); } catch { }
var psi = new ProcessStartInfo("exiftool",
"-b -Picture -@ -" // 关键:二进制输出、从 stdin 读文件名列表
) {
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
// 只给 stderr 指定 UTF-8,stdout 用 BaseStream 读原始字节,不设置编码
try { psi.StandardErrorEncoding = Encoding.UTF8; } catch { }
try
{
using var p = Process.Start(psi);
// 异步吃掉 stderr,避免缓冲区阻塞
var sbErr = new StringBuilder();
p.ErrorDataReceived += (_, e) => { if (e.Data != null) sbErr.AppendLine(e.Data); };
p.BeginErrorReadLine();
// stdin:先 "--"(终止选项),再文件路径,末尾换行
using (var sw = new StreamWriter(p.StandardInput.BaseStream, new UTF8Encoding(false)))
{
sw.WriteLine("--");
sw.WriteLine(audioPath);
}
// 直接把 stdout 的原始字节写到目标文件(无任何编码参与)
using (var fs = new FileStream(outputPath, FileMode.Create, FileAccess.Write, FileShare.None))
{
p.StandardOutput.BaseStream.CopyTo(fs);
}
p.WaitForExit();
if (p.ExitCode != 0)
{
error = $"exiftool exit {p.ExitCode}: {sbErr.ToString().Trim()}";
return false;
}
// 校验文件非空
try
{
var len = new FileInfo(outputPath).Length;
if (len > 0) return true;
error = "No embedded picture found or output is empty.";
return false;
}
catch (Exception ex)
{
error = "Verify output failed: " + ex.Message;
return false;
}
}
catch (Exception ex)
{
error = "Start exiftool failed: " + ex.Message;
return false;
}
}
/// <summary>
/// 仅获取封面字节(不落盘)。需要你自己决定扩展名/保存路径。
/// </summary>
public static bool TryGetCoverBytesStdout(string audioPath, out byte[] imageBytes, out string error)
{
imageBytes = null;
error = null;
if (string.IsNullOrWhiteSpace(audioPath)) { error = "audioPath is empty."; return false; }
var psi = new ProcessStartInfo("exiftool",
"-b -Picture -@ -"
) {
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
try { psi.StandardErrorEncoding = Encoding.UTF8; } catch { }
try
{
using var p = Process.Start(psi);
var sbErr = new StringBuilder();
p.ErrorDataReceived += (_, e) => { if (e.Data != null) sbErr.AppendLine(e.Data); };
p.BeginErrorReadLine();
using (var sw = new StreamWriter(p.StandardInput.BaseStream, new UTF8Encoding(false)))
{
sw.WriteLine("--");
sw.WriteLine(audioPath);
}
using var ms = new MemoryStream();
p.StandardOutput.BaseStream.CopyTo(ms);
p.WaitForExit();
if (p.ExitCode != 0)
{
error = $"exiftool exit {p.ExitCode}: {sbErr.ToString().Trim()}";
return false;
}
var buf = ms.ToArray();
if (buf.Length == 0)
{
error = "No embedded picture found.";
return false;
}
imageBytes = buf;
return true;
}
catch (Exception ex)
{
error = "Start exiftool failed: " + ex.Message;
return false;
}
}
}
"@
}
# Map of common encodings with their display names
$EncodingDisplayMap = @{
'utf-8' = 'UTF-8'
'gbk' = 'GBK(简体中文)'
'gb18030' = 'GB18030(简体中文)'
'shift_jis' = 'Shift_JIS(日文)'
'big5' = 'Big5(繁体中文)'
'euc-kr' = 'EUC-KR(韩文)'
}
# Display list of supported encodings
function Show-SupportedEncodings {
Write-Host ""
Write-Host "Common Encodings:" -ForegroundColor Cyan
$EncodingDisplayMap.GetEnumerator() | Sort-Object Name | ForEach-Object {
$left = ($_.Key).PadRight(10)
Write-Host "$left $($_.Value)"
}
Write-Host ""
}
if ($ListEncodings) { Show-SupportedEncodings; exit 0 }
# Test if the specified encoding is supported by the system
function Test-EncodingSupport([string]$name) {
if ([string]::IsNullOrWhiteSpace($name)) { return $false }
try { $null = [Text.Encoding]::GetEncoding($name); return $true } catch { return $false }
}
# Normalize tag keys to standard format
function Limit-TagKey([string]$k) {
if ([string]::IsNullOrWhiteSpace($k)) { return $k }
$u = $k.ToUpperInvariant() -replace '[\s\-]+', '_'
switch ($u) {
'TRACK' { 'TRACKNUMBER' }
'TRACKNUM' { 'TRACKNUMBER' }
'ALBUM_ARTIST' { 'ALBUMARTIST' }
default { $u }
}
}
# Remove control characters and trim whitespace from tag values
function Limit-TagValue([string]$value) {
if ([string]::IsNullOrWhiteSpace($value)) { return '' }
return ($value -replace '[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]', '').Trim()
}
# Parse track or disc number format like "3/12" into [number, total]
function Limit-TrackOrDisc([string]$s) {
if ([string]::IsNullOrWhiteSpace($s)) { return @('', '') }
# Format like "3/12" or "3" → main value + total count
$m = [regex]::Match($s.Trim(), '^\s*(\d+)(?:\s*/\s*(\d+))?')
if ($m.Success) { return @($m.Groups[1].Value, $m.Groups[2].Value) }
return @($s.Trim(), '')
}
# Merge tag hashtables, with overlay values taking precedence if not empty
function Merge-TagsOverride([hashtable]$base, [hashtable]$overlay) {
# Values in overlay override those in base when not empty
foreach ($k in $overlay.Keys) {
$v = $overlay[$k]
if ($null -ne $v -and -not [string]::IsNullOrWhiteSpace([string]$v)) {
$base[$k] = $v
}
}
return $base
}
# Normalize various tag formats to standard Vorbis Comment format
function ConvertTo-Vorbis([hashtable]$tagsIn) {
# Standardize to Vorbis Comment conventional keys
$out = @{}
# 1) 直接映射(如果存在)
$map = @{
'TITLE' = 'TITLE'
'ARTIST' = 'ARTIST'
'ALBUM' = 'ALBUM'
'ALBUMARTIST' = 'ALBUMARTIST'
'GENRE' = 'GENRE'
'COMMENT' = 'COMMENT'
'COMPOSER' = 'COMPOSER'
'PUBLISHER' = 'LABEL' # 常见播放器也识别 PUBLISHER;两者都写更保险
'LABEL' = 'LABEL'
'ISRC' = 'ISRC'
'BPM' = 'BPM'
'ENCODER' = 'ENCODER'
'DESCRIPTION' = 'DESCRIPTION'
'LYRICS' = 'LYRICS' # 无标准键,LYRICS/UNSYNCEDLYRICS 都有人用
'DATE' = 'DATE'
'YEAR' = 'YEAR'
'TRACKNUMBER' = 'TRACKNUMBER'
'TRACKTOTAL' = 'TRACKTOTAL' # 也有 TOTALTRACKS / TOTALTRACKNUMBER 等变体
'DISCNUMBER' = 'DISCNUMBER'
'DISCTOTAL' = 'DISCTOTAL' # 也有 TOTALDISCS 等变体
'PRODUCT' = 'ALBUM' # 兜底:PRODUCT→ALBUM(若上层未覆盖)
}
foreach ($k in $tagsIn.Keys) {
$nk = Limit-TagKey $k
$v = $tagsIn[$k]
if ($map.ContainsKey($nk)) {
$out[$map[$nk]] = $v
}
else {
# 保留其它非标准键,尽量不要丢
$out[$nk] = $v
}
}
# 2) 处理 Track/Disc 的 "x/total"
if ($tagsIn.ContainsKey('TRACK') -and -not $out.ContainsKey('TRACKNUMBER')) {
$pair = Limit-TrackOrDisc $tagsIn['TRACK']; $out['TRACKNUMBER'] = $pair[0]; if ($pair[1]) { $out['TRACKTOTAL'] = $pair[1] }
}
if ($tagsIn.ContainsKey('TRACKNUMBER')) {
$pair = Limit-TrackOrDisc $tagsIn['TRACKNUMBER']; $out['TRACKNUMBER'] = $pair[0]; if ($pair[1]) { $out['TRACKTOTAL'] = $pair[1] }
}
if ($tagsIn.ContainsKey('DISC') -and -not $out.ContainsKey('DISCNUMBER')) {
$pair = Limit-TrackOrDisc $tagsIn['DISC']; $out['DISCNUMBER'] = $pair[0]; if ($pair[1]) { $out['DISCTOTAL'] = $pair[1] }
}
if ($tagsIn.ContainsKey('DISCNUMBER')) {
$pair = Limit-TrackOrDisc $tagsIn['DISCNUMBER']; $out['DISCNUMBER'] = $pair[0]; if ($pair[1]) { $out['DISCTOTAL'] = $pair[1] }
}
# 3) 日期归一:优先 DATE,没有则用 YEAR → DATE
if (-not $out.ContainsKey('DATE') -and $out.ContainsKey('YEAR')) { $out['DATE'] = $out['YEAR'] }
return $out
}
# Run ExifTool to extract metadata from file in XML format
function Invoke-ExifTool([string]$wavPath) {
$exiftool = 'exiftool'
$argList = @(
'-charset', 'riff=', # Don't specify charset for RIFF chunks (we'll decode manually)
# '-charset', 'id3=', # Don't specify charset for ID3 tags
'-charset', 'filename=UTF8', # Use UTF-8 for filenames
'-X', # Output as XML
'-@', '-' # Read file list from stdin
)
$exifArgs = ($argList -join ' ')
try {
$xmlText = [Wav2FlacProcUtil]::Run($exiftool, $exifArgs, 'utf-8', $wavPath + "`n")
return [xml]$xmlText
}
catch {
Write-Verbose "Invoke-ExifTool error: $($_.Exception.Message)"
return $null
}
}
# Extract XMP metadata tags from XML
function ConvertFrom-XmpXml([xml]$doc) {
$tags = @{}
if (-not $doc) { return $tags }
$desc = $doc.SelectSingleNode('/*/*[local-name()="Description"]')
if (-not $desc) { return $tags }
foreach ($n in $desc.ChildNodes) {
if ($n.NodeType -ne [System.Xml.XmlNodeType]::Element) { continue }
$ns = $n.NamespaceURI
if ($ns -notlike '*ns.exiftool.org/XMP*') { continue }
$k = $n.LocalName.ToUpperInvariant()
$value = Limit-TagValue $n.InnerText
# 常见 XMP 键位到通用键
switch ($k) {
'TITLE' { $tags['TITLE'] = $value }
'ALBUM' { $tags['ALBUM'] = $value }
'ARTIST' { $tags['ARTIST'] = $value }
'ALBUMARTIST' { $tags['ALBUMARTIST'] = $value }
'TRACKNUMBER' { $tags['TRACKNUMBER'] = $value }
'GENRE' { $tags['GENRE'] = $value }
'DESCRIPTION' { $tags['DESCRIPTION'] = $value }
default {
# XMP-dc:Title / Creator 之类也纳入(dc:Creator 常见为作者)
if ($n.LocalName -eq 'Title' -or $n.LocalName -eq 'title') { $tags['TITLE'] = $value }
if ($n.LocalName -eq 'Creator' -or $n.LocalName -eq 'creator') {
if (-not $tags.ContainsKey('ARTIST')) { $tags['ARTIST'] = $value }
}
if ($n.LocalName -eq 'MetadataDate' -or $n.LocalName -eq 'ModifyDate') {
if ($value -match '^\d{4}') { $tags['DATE'] = $value.Substring(0, 4) }
}
}
}
}
return $tags
}
# Extract ID3 metadata tags from XML
function ConvertFrom-Id3Xml([xml]$doc) {
$tags = @{}
if (-not $doc) { return $tags }
$desc = $doc.SelectSingleNode('/*/*[local-name()="Description"]')
if (-not $desc) { return $tags }
foreach ($n in $desc.ChildNodes) {
if ($n.NodeType -ne [System.Xml.XmlNodeType]::Element) { continue }
$ns = $n.NamespaceURI
if ($ns -notlike '*ns.exiftool.org/ID3*') { continue } # 兼容 ID3v2_3 / ID3v2_4
$local = $n.LocalName
$val = Limit-TagValue $n.InnerText
switch ($local) {
'Title' { $tags['TITLE'] = $val }
'Artist' { $tags['ARTIST'] = $val }
'Album' { $tags['ALBUM'] = $val }
'Track' { $tags['TRACKNUMBER'] = $val } # 可能是 "3/12"
'Year' { $tags['YEAR'] = $val }
'Date' { $tags['DATE'] = $val }
'Genre' { $tags['GENRE'] = $val }
'Composer' { $tags['COMPOSER'] = $val }
'Publisher' { $tags['PUBLISHER'] = $val }
'AlbumArtist' { $tags['ALBUMARTIST'] = $val } # 部分文件会有
'Band' { if (-not $tags.ContainsKey('ALBUMARTIST')) { $tags['ALBUMARTIST'] = $val } }
'DiscNumber' { $tags['DISCNUMBER'] = $val } # 可能是 "1/2"
'ISRC' { $tags['ISRC'] = $val }
'BPM' { $tags['BPM'] = $val }
default {
# 歌词键位(ExifTool 会把不同描述后缀体现在 LocalName 里)
if ($local -like 'Lyrics*' -or $local -like 'UnsyncedLyrics*') {
# 多段歌词保留换行
$tags['LYRICS'] = if ($tags.ContainsKey('LYRICS')) { ($tags['LYRICS'] + "`n" + $val) } else { $val }
}
}
}
}
return $tags
}
# Extract RIFF metadata tags from XML with specified encoding for base64 content
function ConvertFrom-RiffXml([xml]$doc, [string]$chosenEncoding) {
$tags = @{}
if (-not $doc) { return $tags }
$rdfNS = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'
$skip = @('NUMCHANNELS', 'SAMPLERATE', 'AVGBYTESPERSEC', 'BITSPERSAMPLE', 'ENCODING', 'DURATION', 'MIMETYPE')
$desc = $doc.SelectSingleNode('/*/*[local-name()="Description"]')
if (-not $desc) { return $tags }
foreach ($n in $desc.ChildNodes) {
if ($n.NodeType -ne [System.Xml.XmlNodeType]::Element) { continue }
if ($n.NamespaceURI -notlike '*ns.exiftool.org/RIFF*') { continue }
$local = $n.LocalName
$key = Limit-TagKey $local
if ($skip -contains $key) { continue }
$datatypeAttr = $n.Attributes.GetNamedItem('datatype', $rdfNS)
$isB64 = $false
if ($datatypeAttr -and $datatypeAttr.Value -eq 'http://www.w3.org/2001/XMLSchema#base64Binary') { $isB64 = $true }
$value = $null
if ($isB64) {
$value = [Wav2FlacProcUtil]::DecodeBase64With($n.InnerText, $chosenEncoding)
}
else {
$value = $n.InnerText
}
$value = Limit-TagValue $value
if (-not [string]::IsNullOrWhiteSpace($value)) {
$tags[$key] = $value
}
}
return $tags
}
# Extract Vorbis metadata tags from XML
function ConvertFrom-VorbisXml([xml]$doc) {
$tags = @{}
if (-not $doc) { return $tags }
$desc = $doc.SelectSingleNode('/*/*[local-name()="Description"]')
if (-not $desc) { return $tags }
foreach ($n in $desc.ChildNodes) {
if ($n.NodeType -ne [System.Xml.XmlNodeType]::Element) { continue }
$ns = $n.NamespaceURI
if ($ns -notlike '*ns.exiftool.org/Vorbis*') { continue }
$local = $n.LocalName
$val = Limit-TagValue $n.InnerText
$key = Limit-TagKey $local
if (-not [string]::IsNullOrWhiteSpace($val)) {
$tags[$key] = $val
}
}
return $tags
}
# Extract embedded cover image from audio file to a temporary file
function Export-CoverImage([string]$wavPath) {
$tmpPath = [System.IO.Path]::GetTempFileName()
$err = $null
try {
$success = [Wav2FlacProcUtil]::ExtractCoverToFileStdout($wavPath, $tmpPath, [ref]$err)
if (-not $success) {
if (Test-Path $tmpPath) { Remove-Item $tmpPath -Force -ErrorAction SilentlyContinue }
# Write-Error $err
return $null
}
return $tmpPath
}
catch {
if (Test-Path $tmpPath) { Remove-Item $tmpPath -Force -ErrorAction SilentlyContinue }
return $null
}
}
# Add metadata tags and cover image to FLAC file
function Set-FlacMetadata([string]$flacPath, [hashtable]$tags, [string]$coverImagePath) {
$metaflac = 'metaflac'
# First remove any existing tags
& $metaflac --remove-all-tags "$flacPath" 2>$null
# Add each tag
foreach ($k in $tags.Keys) {
$val = $tags[$k]
if ($val -is [System.Array]) { $val = $val -join '; ' }
if ([string]::IsNullOrWhiteSpace([string]$val)) { continue }
# Escape special characters
$safeVal = $val -replace '"', '""'
& $metaflac --set-tag="$k=$safeVal" "$flacPath" 2>$null
}
# Add cover art - let metaflac determine the MIME type
if ($coverImagePath -and (Test-Path $coverImagePath)) {
& $metaflac --import-picture-from="$coverImagePath" "$flacPath" 2>$null
}
}
# Preview base64-encoded metadata with different encodings and let user choose
function Select-FileEncoding([string]$wavPath, [xml]$doc, [ref]$globalEncoding) {
$desc = $doc.SelectSingleNode('/*/*[local-name()="Description"]')
$rdfNS = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'
$b64Node = $null
$hasRiffBase64 = $false
# Check if there are base64-encoded RIFF tags
if ($desc) {
# 先找 Title
foreach ($n in $desc.ChildNodes) {
if ($n.NodeType -ne 'Element') { continue }
if ($n.NamespaceURI -notlike '*ns.exiftool.org/RIFF*') { continue }
if ($n.LocalName -eq 'Title') {
$datatypeAttr = $n.Attributes.GetNamedItem('datatype', $rdfNS)
if ($datatypeAttr -and $datatypeAttr.Value -eq 'http://www.w3.org/2001/XMLSchema#base64Binary') {
$b64Node = $n
$hasRiffBase64 = $true
break
}
else {
return 'gbk' # 不是Base64,直接返回默认
}
}
}
# 如果没找到Title,找任意Base64节点
if (-not $b64Node) {
foreach ($n in $desc.ChildNodes) {
if ($n.NodeType -ne 'Element') { continue }
if ($n.NamespaceURI -notlike '*ns.exiftool.org/RIFF*') { continue }
$datatypeAttr = $n.Attributes.GetNamedItem('datatype', $rdfNS)
if ($datatypeAttr -and $datatypeAttr.Value -eq 'http://www.w3.org/2001/XMLSchema#base64Binary') {
$b64Node = $n
$hasRiffBase64 = $true
break
}
}
}
}
# 如果没有RIFF的Base64标签或已经通过参数指定了编码,直接返回默认或指定的编码
if (-not $hasRiffBase64 -or $script:EncodingParam) {
return $script:EncodingParam ?? 'gbk'
}
# 如果已经有全局编码设置(用户选择了"全部使用相同编码"),直接返回全局编码
if ($globalEncoding.Value) {
return $globalEncoding.Value
}
$fileName = [System.IO.Path]::GetFileName($wavPath)
$cands = @(
@{ Name = 'gbk'; Label = 'GBK(简体中文)' },
@{ Name = 'utf-8'; Label = 'UTF-8' },
@{ Name = 'gb18030'; Label = 'GB18030(简体中文)' },
@{ Name = 'shift_jis'; Label = 'Shift_JIS(日文)' },
@{ Name = 'big5'; Label = 'Big5(繁体中文)' }
)
Write-Host "`nFile: $fileName" -ForegroundColor Yellow
Write-Host "Preview decoding for `"$($b64Node.LocalName)`":" -ForegroundColor Cyan
$i = 1
foreach ($c in $cands) {
$txt = [Wav2FlacProcUtil]::DecodeBase64With($b64Node.InnerText, $c.Name)
if ($null -eq $txt) { $txt = '<解码失败>' }
$preview = if ($txt.Length -gt 60) { $txt.Substring(0, 57) + '...' } else { $txt }
$pad = ($c.Label).PadRight(15)
Write-Host "$i. $pad $preview"
$i++
}
Write-Host "$i. 自定义编码名"
Write-Host "A. 全部使用此编码"
do {
$choice = Read-Host "请选择解码 [1-$i/A] (默认GBK,直接回车)"
if ([string]::IsNullOrWhiteSpace($choice)) { return 'gbk' }
# 处理"全部使用此编码"选项
if ($choice -eq 'A' -or $choice -eq 'a') {
$globalEnc = Read-Host "请选择要应用到所有文件的编码 [1-$($i-1)] (默认GBK)"
if ([string]::IsNullOrWhiteSpace($globalEnc)) {
$globalEncoding.Value = 'gbk'
return 'gbk'
}
if ($globalEnc -match '^\d+$') {
$idx = [int]$globalEnc
if ($idx -ge 1 -and $idx -le ($i - 1)) {
$globalEncoding.Value = $cands[$idx - 1].Name
return $cands[$idx - 1].Name
}
}
Write-Host "Invalid choice, using default encoding GBK" -ForegroundColor Yellow
$globalEncoding.Value = 'gbk'
return 'gbk'
}
if ($choice -match '^\d+$') {
$idx = [int]$choice
if ($idx -ge 1 -and $idx -le ($i - 1)) { return $cands[$idx - 1].Name }
elseif ($idx -eq $i) {
do {
$custom = Read-Host "请输入编码名"
if (Test-EncodingSupport $custom) { return $custom }
Write-Host "Unsupported encoding: $custom" -ForegroundColor Red
} while ($true)
}
}
Write-Host "Invalid choice, please try again." -ForegroundColor Red
} while ($true)
}
# Main program execution
# If encoding is specified via parameter, validate and store it for later use
$script:EncodingParam = $null
if (-not [string]::IsNullOrWhiteSpace($Encoding)) {
# Validate that the specified encoding is supported
if (Test-EncodingSupport $Encoding) {
$script:EncodingParam = $Encoding
Write-Host "Using specified encoding: $Encoding" -ForegroundColor Cyan
}
else {
Write-Warning "Specified encoding '$Encoding' is not supported, will use default encoding selection logic"
}
}
try { $targetDir = (Resolve-Path -LiteralPath $Folder -ErrorAction Stop).ProviderPath } catch { Write-Error "Directory does not exist or is not accessible: `"$Folder`""; exit 1 }
Write-Host "Processing directory: $targetDir" -ForegroundColor Green
# Check for required tools
$flacExe = 'flac'; $exifExe = 'exiftool'; $metaflacExe = 'metaflac'
try { $null = & $flacExe --version 2>$null } catch { Write-Error "flac not found."; exit 1 }
try { $null = & $exifExe -ver 2>$null } catch { Write-Error "exiftool not found."; exit 1 }
try { $null = & $metaflacExe --version 2>$null } catch { Write-Error "metaflac not found."; exit 1 }
$wavFiles = Get-ChildItem -LiteralPath $targetDir -Filter '*.wav' -File
if ($wavFiles.Count -eq 0) { Write-Warning "No WAV files found."; exit 0 }
Write-Host "Found $($wavFiles.Count) WAV files" -ForegroundColor Cyan
# Phase 1: Convert WAV files to FLAC (audio only, no metadata)
Write-Host "`n[Phase 1] Audio Conversion" -ForegroundColor Cyan
$flacBaseArgs = @('-V8p', '-A', "'tukey(0.5); flattop; kaiser_bessel(6); bartlett_hann'", '--totally-silent')
$conversionResults = @()
foreach ($f in $wavFiles) {
$wav = $f.FullName
$flac = Join-Path $f.DirectoryName ($f.BaseName + '.flac')
Write-Host "Converting: $($f.Name)" -NoNewline
if ((Test-Path -LiteralPath $flac) -and (-not $Force)) {
Write-Host " (already exists, skipping)" -ForegroundColor Yellow
$conversionResults += @{ WavPath = $wav; FlacPath = $flac; Success = $true }
continue
}
try {
$flacArgs = $flacBaseArgs + @('-o', $flac, $wav)
& $flacExe @flacArgs
if ($LASTEXITCODE -eq 0) {
Write-Host " ✓" -ForegroundColor Green
$conversionResults += @{ WavPath = $wav; FlacPath = $flac; Success = $true }
}
else {
Write-Host " ✗ (encoding failed)" -ForegroundColor Red
$conversionResults += @{ WavPath = $wav; FlacPath = $flac; Success = $false }
}
}
catch {
Write-Host " ✗ (exception: $($_.Exception.Message))" -ForegroundColor Red
$conversionResults += @{ WavPath = $wav; FlacPath = $flac; Success = $false }
}
}
$successfulConversions = $conversionResults | Where-Object { $_.Success }
Write-Host "`nAudio conversion complete: $($successfulConversions.Count)/$($conversionResults.Count)" -ForegroundColor Green
if ($successfulConversions.Count -eq 0) {
Write-Warning "No successful conversions, exiting."
exit 0
}
# Phase 2: Metadata processing
Write-Host "`n[Phase 2] Metadata Processing" -ForegroundColor Cyan
$KeyRemap = @{
'PRODUCT' = 'ALBUM'
}
$metadataSuccess = 0
$metadataFail = 0
# 全局编码选择变量,用于"全部使用此编码"选项
$globalEncoding = $null
foreach ($result in $successfulConversions) {
$wav = $result.WavPath
$flac = $result.FlacPath
$fileName = [System.IO.Path]::GetFileName($wav)
Write-Host "`nProcessing metadata: $fileName" -ForegroundColor Yellow
try {
$doc = Invoke-ExifTool $wav
if (-not $doc) {
Write-Warning "Unable to read ExifTool XML, skipping metadata."
$metadataFail++
continue
}
$riffEncoding = Select-FileEncoding $wav $doc ([ref]$globalEncoding)
$riffTags = ConvertFrom-RiffXml -doc $doc -chosenEncoding $riffEncoding
$xmpTags = ConvertFrom-XmpXml -doc $doc
$vorbisTags = ConvertFrom-VorbisXml -doc $doc
$id3Tags = ConvertFrom-Id3Xml -doc $doc
# (RIFF -> XMP -> Vorbis -> ID3)
$merged = @{}
$merged = Merge-TagsOverride $merged $riffTags
$merged = Merge-TagsOverride $merged $xmpTags
$merged = Merge-TagsOverride $merged $vorbisTags
$merged = Merge-TagsOverride $merged $id3Tags
$tags = ConvertTo-Vorbis $merged
foreach ($k in $KeyRemap.Keys) {
$dst = $KeyRemap[$k]
if ($tags.ContainsKey($k) -and -not $tags.ContainsKey($dst)) {
$tags[$dst] = $tags[$k]
}
}
$coverImagePath = Export-CoverImage $wav
Write-Host "Parsed tags:" -ForegroundColor Cyan
if ($tags.Count -gt 0) {
foreach ($k in ($tags.Keys | Sort-Object)) {
$v = $tags[$k]
if ($v.Length -gt 80) { $v = $v.Substring(0, 77) + '...' }
Write-Host " $k = $v" -ForegroundColor Gray
}
}
else {
Write-Host " (no tags)" -ForegroundColor Gray
}
if ($coverImagePath) {
Write-Host " Cover: Extracted to temporary file" -ForegroundColor Gray
}
Set-FlacMetadata $flac $tags $coverImagePath
if ($coverImagePath -and (Test-Path $coverImagePath)) {
try { Remove-Item $coverImagePath -Force -ErrorAction SilentlyContinue } catch {}
}
Write-Host "Metadata processing complete" -ForegroundColor Green
$metadataSuccess++
try {
Remove-Item $wav -Force -ErrorAction SilentlyContinue
Write-Host "Deleted source file: $fileName" -ForegroundColor DarkGray
}
catch {
Write-Warning "Failed to delete WAV file: $($_.Exception.Message)"
}
}
catch {
Write-Error "Metadata processing error: $($_.Exception.Message)"
if ($coverImagePath -and (Test-Path $coverImagePath)) {
try { Remove-Item $coverImagePath -Force -ErrorAction SilentlyContinue } catch {}
}
$metadataFail++
}
}
Write-Host "`nProcess Complete" -ForegroundColor Cyan
Write-Host "Audio conversion: $($successfulConversions.Count)/$($conversionResults.Count)" -ForegroundColor Green
Write-Host "Metadata processing: $metadataSuccess/$($successfulConversions.Count)" -ForegroundColor Green
if ($metadataFail -gt 0) {
Write-Host "Metadata failures: $metadataFail" -ForegroundColor Red
}
编码之殇:前 Unicode 时代的 WAV 处理
https://nptr.cc/posts/2025-08/convert-wavflac/
作者
Nullpinter
发布于
2025-08-19
许可协议
All Right Reserved.