惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

P
Proofpoint News Feed
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
Microsoft Security Blog
Microsoft Security Blog
云风的 BLOG
云风的 BLOG
MongoDB | Blog
MongoDB | Blog
Know Your Adversary
Know Your Adversary
K
Kaspersky official blog
人人都是产品经理
人人都是产品经理
IT之家
IT之家
Recorded Future
Recorded Future
Recent Announcements
Recent Announcements
D
DataBreaches.Net
C
CXSECURITY Database RSS Feed - CXSecurity.com
U
Unit 42
博客园 - 【当耐特】
I
Intezer
Cisco Talos Blog
Cisco Talos Blog
Spread Privacy
Spread Privacy
L
LINUX DO - 热门话题
H
Help Net Security
L
LangChain Blog
T
The Exploit Database - CXSecurity.com
P
Palo Alto Networks Blog
Scott Helme
Scott Helme
The GitHub Blog
The GitHub Blog
博客园_首页
P
Proofpoint News Feed
大猫的无限游戏
大猫的无限游戏
S
Secure Thoughts
The Register - Security
The Register - Security
Engineering at Meta
Engineering at Meta
T
Threatpost
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
Blog — PlanetScale
Blog — PlanetScale
C
Cybersecurity and Infrastructure Security Agency CISA
T
Troy Hunt's Blog
罗磊的独立博客
腾讯CDC
Simon Willison's Weblog
Simon Willison's Weblog
Security Latest
Security Latest
Last Week in AI
Last Week in AI
P
Privacy & Cybersecurity Law Blog
Latest news
Latest news
The Cloudflare Blog
Jina AI
Jina AI
小众软件
小众软件
Cloudbric
Cloudbric
TaoSecurity Blog
TaoSecurity Blog
F
Full Disclosure
V
V2EX

Cyberia

《C(金钱掌控)》观后感 - Cyberia C#在Windows UI开发中的衰落 - Cyberia 存储退潮 字体padding计算器 开源已死 如何写出优雅完整的音频元数据 自动化EAC音频抓取 起夜级家庭网络正在变得越来越重要 优化Powershell的启动时间 无JavaScript其实没有很重要 你不需要一个电纸书阅读器 文件分享的难题 为什么我不敢看都市 某科学的 SSH 算号指南 时代的辉夜姬 为什么 Pixi 是神 关于神椿的过去,当下以及未来,我的一点看法 Tahoe 的图标令人难评 The EVE of PUSHING LIMITS 在 Word 中通过制表符实现居中对齐等操作 sHELL 即地狱 WR1800K AX NAND 路由器刷入 Immortalwrt 一个 PWM 的世界 沉痛悼念复选框同志 字体焕新 独立博客自省问卷15题 答一答 解决 Visual Studio Code 内终端启动报错问题 (1) 简评《鱿鱼游戏》 Sword Art Online,以及它带来了什么 解决 Visual Studio Code 内终端启动报错问题 2024 年度总结 轻小说重度依赖 铃音之后 解决部分应用在 Windows 平台下端口被随机占用的问题 计算机科学中最困难的事:居中 宿舍场景下多人共用路由器下流量分流实践 Extra Decal Pre Processor 自动化处理 Amazon Music 文件 MD5 不一致问题 解决 MultiMC 无 Forge 版本的 Optifine 安装问题
编码之殇:前 Unicode 时代的 WAV 处理
Nullpinter · 2025-08-19 · via Cyberia

前置知识#

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

}