Files
atlas/transcode_music/split_cue.md
2026-09-08 06:47:00 +08:00

1153 lines
43 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# `split_cue.py` 使用手册
批量用 CUE 文件把"整轨大文件 + CUE"式专辑切成分轨的 Python 脚本,支持断点续跑、CJK 编码 CUE、多种源格式,以 shntool + cuetools 作为切割/标签后端。
设计上是 [`transcode_music.py`](./transcode_music.md) 的**前置伴生工具**:先跑 `split_cue.py` 把整轨拆开,再跑 `transcode_music.py` 转 MP3,两者组合无缝衔接。
---
## 目录
- [1. 简介](#1-简介)
- [2. 系统要求](#2-系统要求)
- [3. 快速上手](#3-快速上手)
- [4. 命令行参数详解](#4-命令行参数详解)
- [5. 常用使用场景](#5-常用使用场景)
- [6. Manifest 文件说明](#6-manifest-文件说明)
- [7. 支持的源格式与依赖矩阵](#7-支持的源格式与依赖矩阵)
- [8. CUE 检测与匹配逻辑](#8-cue-检测与匹配逻辑)
- [9. 分轨命名与元数据](#9-分轨命名与元数据)
- [10. 与 transcode_music.py 的组合工作流](#10-与-transcode_musicpy-的组合工作流)
- [11. 错误处理与恢复](#11-错误处理与恢复)
- [12. 已知局限](#12-已知局限)
- [13. FAQ](#13-faq)
---
## 1. 简介
**这个工具解决什么问题?**
你有很多"整轨型"专辑:一张 CD 打包成一个大 FLAC / WAV / APE 文件,配一个 CUE 索引,例如:
```
王菲 - 唱游 [FLAC+CUE]/
├── CDImage.flac (整张 CD 的一个 40 分钟大文件)
└── CDImage.cue (12 个 TRACK 条目, 标注每首歌的起止)
```
这类文件不方便用普通播放器逐首播放(除非播放器专门支持 CUE),也不方便直接转成 MP3(`transcode_music.py` 会检测出这种情况并报警跳过)。
**`split_cue.py` 就是用来把这类专辑批量拆成分轨的**:扫描整个源目录树,找出所有"整轨 CUE"型专辑,用 `shnsplit` 按 CD 帧边界(1/75 秒精度)切成 `01 - 红豆.flac`、`02 - 催眠.flac` 这样的分轨文件,用 `cuetag` 写入 CUE 里的标题/艺人/专辑等元数据,全部**原地切割**、**保留原文件不动**。
**核心特性**:
| 特性 | 说明 |
|---|---|
| 原地切割 | 分轨文件写在原专辑目录里,与整轨大文件并存 |
| 保留原文件 | 整轨的 `CDImage.flac` 从不被删改,可事后自己决定去留 |
| 保持源格式 | FLAC → FLAC 无损、WAV → WAV、APE → FLAC(ffmpeg 无 APE 编码器) |
| Sample-exact 切割 | shntool 按 CD 帧对齐,每首歌起止精确到 1/75 秒 |
| 完整元数据 | cuetag 自动写入 TITLE / ALBUM / ARTIST / track / TRACKTOTAL / DATE / GENRE |
| CJK 支持 | CUE 编码自动识别(utf-8/utf-8-sig/gbk/big5/shift_jis/cp936),中日韩音源都能处理 |
| 断点续跑 | `.split_cue_manifest.json` 记录状态,任意时刻中断都能从上次位置继续 |
| 幂等 | 已完成的专辑再次运行秒跳过;新增专辑增量处理 |
| 自动排除已分轨 | 若目录里已有分轨文件(situation C),自动 SKIP 不误切 |
| 依赖自适应 | 启动时 probe 依赖,缺 `mac` 时把 APE 源标 UNSUPPORTED 而非崩溃 |
---
## 2. 系统要求
| 组件 | 版本 | 必需/可选 | 说明 |
|---|---|---|---|
| Python | 3.10+ | 必需 | 只用标准库,无需 pip install |
| shntool | 3.0+ | **必需** | 提供 `shnsplit` 切割命令 |
| cuetools | 1.4+ | **必需** | 提供 `cuetag` 元数据写入命令 |
| flac | 1.3+ | 处理 FLAC 时必需 | shntool 编码 FLAC 输出时调用 |
| mac (Monkey's Audio) | 4.x+ | 处理 APE 时必需 | shntool 解码 APE 输入时调用 |
| ffprobe | 6.0+ | 可选 | 仅用于源文件元数据展示(时长、采样率),不影响切割 |
**检查环境**:
```bash
python3 --version
shnsplit -v | head -1
cuetag --help 2>&1 | head -1
flac --version | head -1
mac 2>&1 | head -1 # 无输出/命令不存在 = APE 源将标 UNSUPPORTED
ffprobe -version | head -1
```
**安装依赖(Ubuntu / Debian / WSL)**:
```bash
sudo apt install shntool cuetools flac
# APE 支持(可选):Ubuntu 官方仓库通常没有,需要第三方 PPA 或源码编译
sudo apt install monkeys-audio # 如果仓库有的话
```
**macOS**:
```bash
brew install shntool cuetools flac
# APE:需从 https://monkeysaudio.com 下载或用其他方式
```
**Windows**:直接跑不推荐(有 `os.fsync(dir_fd)` 的 POSIX 依赖),建议 WSL2。
**依赖缺失时的行为**:
启动时脚本会 probe 所有依赖并打印状态。缺 `shnsplit` 或 `cuetag` 直接退出(exit code 3)。缺 `mac` 或 `flac` 只影响对应格式,其他格式仍能处理。
---
## 3. 快速上手
**最常用的三个命令**:
```bash
# 1. 先分析看看哪些专辑需要切割 (不实际切)
python3 split_cue.py /mnt/z/我的音乐 --analyze-only
# 2. 确认没问题后正式切割 (中断可再跑, 自动 resume)
python3 split_cue.py /mnt/z/我的音乐
# 3. 切完了继续转 MP3 (transcode_music.py 会自动跳过整轨大文件)
python3 transcode_music.py /mnt/z/我的音乐 /mnt/x/mp3输出
```
就这么简单。整轨型专辑切成分轨后,`transcode_music.py` 会自动识别为"situation C"(CUE 引用整轨但存在分轨),跳过整轨文件只转分轨——两个脚本天然衔接。
---
## 4. 命令行参数详解
### 完整语法
```
split_cue.py [SOURCE] [选项...]
```
或
```
split_cue.py --source SOURCE [选项...]
```
### 参数列表
| 参数 | 短选项 | 类型 | 是否必需 | 默认 | 说明 |
|---|---|---|---|---|---|
| `SOURCE` | — | 位置参数 | 必需¹ | — | 源目录,含整轨型专辑的位置 |
| `--source` | `-s` | 字符串 | 必需¹ | — | 源目录(与位置参数 SOURCE 等价) |
| `--analyze-only` | — | 开关 | 可选 | 关 | 只分析生成 manifest,不实际切割 |
| `--force` | — | 开关 | 可选 | 关 | 忽略现有 manifest,全部重新分析并**覆盖**已有分轨 |
| `--verbose` | `-v` | 开关 | 可选 | 关 | 显示 DEBUG 级别日志(含跳过项) |
| `--shnsplit` | — | 字符串 | 可选 | `shnsplit` | shnsplit 可执行路径 |
| `--cuetag` | — | 字符串 | 可选 | `cuetag` | cuetag 可执行路径 |
| `--ffprobe` | — | 字符串 | 可选 | `ffprobe` | ffprobe 可执行路径 |
| `--split-timeout` | — | 整数 | 可选 | `1800` | 单个专辑 shnsplit 超时秒数 |
| `--help` | `-h` | 开关 | — | — | 显示帮助并退出 |
¹ 源目录必须提供,可用位置参数或 flag 两种形式:
```bash
# 位置参数 (更简洁)
split_cue.py /mnt/z/music
# flag 长形式
split_cue.py --source /mnt/z/music
# flag 短形式
split_cue.py -s /mnt/z/music
```
注意:**`split_cue.py` 不需要目标目录**,因为它是原地切割——分轨文件直接写到原专辑目录里。这与 `transcode_music.py` 需要 `-s / -t` 两个参数不同。
### 参数详细说明
#### `SOURCE` / `--source` / `-s`
**源目录**。脚本会递归扫描此目录及所有子目录,找出每个"整轨 CUE"型专辑处理。
- 支持含空格、中文、特殊字符的路径
- 网盘挂载(`/mnt/z/`、`/mnt/x/` 等)都可以
- Manifest 文件 `.split_cue_manifest.json` 写入此目录
**特殊约定**:如果 SOURCE 本身就是一个专辑目录(而不是一堆专辑的父目录),也能正常工作。脚本会把源目录自身也作为分析候选。
#### `--analyze-only`
**只分析,不切割**。用途:
- 首次跑一个新的大目录时,先看看总共有几个专辑需要切、切成几轨
- 检查 CUE 解析是否有问题(编码、缺 FILE 引用等)
- 生成 manifest 后可以手动查看/编辑
**强烈推荐**每次对新目录都先跑一次 `--analyze-only`。切割是**原地写文件**的操作,先看清楚计划再动手更安全。
#### `--force`
**强制重跑**。忽略现有 manifest,重新分析并**覆盖**已存在的分轨文件。
**什么时候用**:
- 之前切错了想重来
- CUE 修改过了,想重新按新 CUE 切
- 想清理并重新开始
**警告**:`--force` 会覆盖已存在的同名分轨文件。如果之前有你手动编辑过的分轨,会丢失。**不确定时先备份**。
**边界情况**:如果专辑的 status 已经因为"situation C"被判为 `skipped`(因为目录里已有分轨),`--force` 也**不会**自动 un-skip。因为脚本无法可靠区分"我上次生成的分轨"和"用户已有的分轨"——安全起见不动。想强制重切,请先手动删掉旧的分轨文件再跑。
#### `--verbose` / `-v`
打开 DEBUG 级别日志。默认 INFO 级别只显示每首切割和错误。加上 `-v` 会额外显示:
- `[已完成 skip] ...` 每个跳过的专辑
- `[SKIP (无需切割): ...]` 每个 skipped 目录
- `[已存在, 保留] ...` 每个已有分轨文件
大量输出,通常只在排查问题时用。
#### `--shnsplit` / `--cuetag` / `--ffprobe`
指定可执行文件路径。默认从 `PATH` 查找。
**什么时候用**:
- 装了多个版本想指定用哪个
- 二进制不在标准 PATH 里
- 用自己编译的版本
示例:
```bash
split_cue.py /src --shnsplit /opt/shntool-3.0.10/bin/shnsplit
```
#### `--split-timeout`
单个专辑 shnsplit 命令的超时时间(秒),默认 `1800`(30 分钟)。超时算失败,写入 manifest 后继续下一个。
**通常不需要调**。除非你有超长专辑(比如一个 3 小时的整轨 audiobook)在慢网盘上,可以调大到 3600 或更多。
注意:shnsplit 是**单次调用切完整个专辑**的,不像 ffmpeg 是逐轨调用,所以超时值应该覆盖整个专辑的切割时间。
---
## 5. 常用使用场景
### 场景 1:第一次处理新目录(推荐流程)
假设你刚下载了一堆 `[FLAC+CUE]` 到 `/mnt/z/新收藏/`。
**第一步:先分析**
```bash
python3 split_cue.py /mnt/z/新收藏 --analyze-only
```
输出会告诉你:
- 总共扫了几个目录
- 有几个专辑属于"整轨型"需要切割(`analyzed`)
- 有几个源格式不支持(`unsupported`)
- 有几个已经是分轨的(`skipped`)
**第二步:查看切割计划**
```bash
python3 -c "
import json
d = json.load(open('/mnt/z/新收藏/.split_cue_manifest.json'))
for rel, a in d['albums'].items():
if a['status'] == 'analyzed':
print(f'{rel}: {a[\"source_audio\"]} → {len(a[\"tracks\"])} 轨')
for t in a['tracks'][:3]:
print(f' · {t[\"target_name\"]}')
if len(a['tracks']) > 3:
print(f' ... 及另外 {len(a[\"tracks\"]) - 3} 轨')
"
```
**第三步:确认无误后正式切割**
```bash
python3 split_cue.py /mnt/z/新收藏
```
已经 `analyzed` 的专辑会开始切割,`skipped` / `unsupported` 的自动跳过。
**第四步:接着跑 transcode_music.py 转 MP3**
```bash
python3 transcode_music.py /mnt/z/新收藏 /mnt/x/music/新收藏
```
`transcode_music.py` 会自动识别切出来的分轨(situation C),跳过整轨大文件只转分轨。
### 场景 2:中断后恢复
切到一半按 Ctrl+C 中断(或断电、脚本崩溃)。只需要**再跑同样的命令**:
```bash
python3 split_cue.py /mnt/z/新收藏
```
脚本会读取 manifest,跳过所有 `completed` 状态的专辑,从上次中断的地方继续。不需要任何特殊参数。
### 场景 3:增量同步(新增了整轨专辑)
以后又下载了几个 `[FLAC+CUE]` 到 `/mnt/z/新收藏/`:
```bash
python3 split_cue.py /mnt/z/新收藏
```
已完成的秒跳过,只有新目录会被分析和切割。最舒服的用法。
### 场景 4:修复失败的专辑
如果某个专辑因为 CUE 解析错误、shnsplit 崩溃等失败了(`status: failed`),修好后:
```bash
# 直接再跑, failed 的会被自动重试
python3 split_cue.py /mnt/z/新收藏
```
已成功的不动,只重试失败的。
### 场景 5:只切一个专辑
比如只想切某一张:
```bash
python3 split_cue.py "/mnt/z/新收藏/王菲 - 唱游 [FLAC+CUE]"
```
Manifest 会写到那个专辑目录里,其他专辑不受影响。
### 场景 6:想重新切某个已完成的专辑
比如切完发现 CUE 元数据有错,改了 CUE 想重切:
**方法一:删掉分轨文件 + 编辑 manifest**
删除该专辑目录里的分轨文件(`01 - Title.flac` 等),然后编辑 `.split_cue_manifest.json`,把该专辑的 status 改成 `analyzed`、每个 track 的 status 改成 `pending`。再跑一次即可。
**方法二:全局 --force**
```bash
python3 split_cue.py /mnt/z/新收藏 --force
```
会重新分析所有专辑并覆盖分轨。但注意:**如果目录里已有旧分轨且脚本无法区分**,可能会被判为 situation C 而 skip。最保险的做法还是先手动删旧分轨。
### 场景 7:排查某个专辑的问题
某个专辑 status=failed,用 verbose 模式跑:
```bash
python3 split_cue.py "/mnt/z/新收藏/有问题的专辑" -v
```
输出会显示 shnsplit 的完整错误、cuetag 警告等。
### 场景 8:切完不满意,回滚
因为原文件从不被删改,回滚很简单:
```bash
# 删掉所有分轨文件, 只留原整轨 + CUE
cd "/mnt/z/新收藏/王菲 - 唱游"
rm -f *.flac # 删所有 flac
# 找回原整轨 (脚本没删过它, 只是你上面 rm 一起删了; 如果只想删分轨:)
# 或更精确: 只删符合 "NN - Title.flac" 命名的
```
更精确的清理:
```bash
# 只删 "01 - ...", "02 - ..." 这种格式的文件
find "/mnt/z/新收藏/王菲 - 唱游" -maxdepth 1 -regex '.*/[0-9]\{2\} - .*\.\(flac\|wav\)$' -delete
# 编辑 manifest 把该专辑 status 改回 analyzed
```
---
## 6. Manifest 文件说明
### 位置
Manifest 保存在**源目录**下,文件名 `.split_cue_manifest.json`(隐藏文件)。
### 顶层结构
```json
{
"manifest_version": 1,
"created_at": "2026-09-06T13:41:03+08:00",
"updated_at": "2026-09-06T13:41:04+08:00",
"source_root": "/mnt/z/新收藏",
"stats": {
"total_albums": 4,
"by_album_status": {"completed": 1, "skipped": 3},
"by_track_status": {"completed": 3},
"total_tracks": 3
},
"albums": {
"专辑相对路径1": { ... },
"专辑相对路径2": { ... }
}
}
```
### 单个专辑 (album) 结构
```json
{
"status": "completed",
"source_audio": "CDImage.flac",
"cue": "CDImage.cue",
"album_title": "唱游",
"album_performer": "王菲",
"album_date": "1998",
"album_genre": "Chinese Pop",
"source_codec": "flac",
"source_sample_rate": 44100,
"source_channels": 2,
"duration_sec": 40.0,
"output_format": "flac",
"tracks": [
{
"num": 1,
"shnsplit_index": 1,
"title": "红豆",
"performer": "王菲",
"target_name": "01 - 红豆.flac",
"total_tracks": 3,
"status": "completed",
"error": null,
"converted_at": "2026-09-06T13:41:04+08:00"
},
{ "num": 2, "shnsplit_index": 2, ... },
{ "num": 3, "shnsplit_index": 3, ... }
],
"issues": [],
"started_at": "2026-09-06T13:41:03+08:00",
"completed_at": "2026-09-06T13:41:04+08:00"
}
```
**字段含义**:
| 字段 | 含义 |
|---|---|
| `status` | 专辑级状态,见下表 |
| `source_audio` | 检测到的整轨大文件名(相对路径) |
| `cue` | 匹配的 CUE 文件名 |
| `album_title` / `album_performer` | 从 CUE 的顶级 TITLE / PERFORMER 解析 |
| `album_date` / `album_genre` | 从 CUE 的 REM DATE / REM GENRE 解析 |
| `source_codec` / `source_sample_rate` / `source_channels` / `duration_sec` | 由 ffprobe 探测(仅用于展示) |
| `output_format` | shnsplit `-o` 参数(`flac` 或 `wav`) |
| `tracks[]` | 切割计划,每首歌一项 |
| `tracks[].num` | CUE 里声明的 TRACK 编号 |
| `tracks[].shnsplit_index` | shnsplit 内部计数器(1-based,用于对应 tmp 文件名) |
| `tracks[].title` / `tracks[].performer` | 从 CUE 的 TRACK 级 TITLE / PERFORMER 解析 |
| `tracks[].target_name` | 最终文件名(已 sanitize) |
| `tracks[].status` | 分轨级状态 |
| `issues[]` | 人类可读的警告/说明 |
### 状态值 (status)
**专辑级状态**:
| status | 说明 | 下次运行时行为 |
|---|---|---|
| `pending` | 初始状态(一般用不到) | 重新分析 |
| `analyzed` | 已识别为整轨 CUE 型,切割计划就绪 | 执行切割 |
| `processing` | 正在切割(若看到,说明上次中断了) | 重新切割整个专辑 |
| `completed` | 所有分轨都成功输出 | **秒跳过** |
| `failed` | 切割失败(shnsplit / cuetag / 重命名任一失败) | 重试整个专辑 |
| `skipped` | 无需切割(无 CUE / 已分轨 situation C / CUE 引用多文件 / 引用不存在) | 跳过 |
| `unsupported` | 源格式当前依赖无法处理(如 APE 但 `mac` 未装、DSF) | 跳过 |
**分轨级状态**(tracks[] 里):
| status | 说明 |
|---|---|
| `pending` | 待处理 |
| `completed` | 已完成 |
| `failed` | 失败(`error` 字段有原因) |
### 手动干预 manifest
Manifest 是纯 JSON,可以用文本编辑器直接改。
**例子 1:让某个专辑重新切割**
```json
"status": "completed" → "status": "analyzed"
```
同时把该专辑内所有 track 的 status 从 `completed` 改成 `pending`,删掉现有分轨文件,再跑脚本。
**例子 2:跳过某个有问题的专辑**
```json
"status": "analyzed" → "status": "skipped"
```
**例子 3:删除某个专辑的记录重新扫描**
删除对应的 album 条目,下次运行会自动重新扫描添加。
**改完保存直接跑**:
```bash
python3 split_cue.py /mnt/z/新收藏
```
### Manifest 损坏怎么办?
如果 JSON 损坏(编辑错、盘故障),脚本启动时会自动检测,把坏的 manifest 重命名为 `.split_cue_manifest.json.corrupt-<时间戳>`,然后**重新生成新的 manifest**。
已切出来的分轨文件不会丢,但脚本会重新扫描分析。因为已切分的目录会呈现为 situation C(有分轨 + 有整轨),会自动 SKIP,不会重复切割。**幂等性保护**。
---
## 7. 支持的源格式与依赖矩阵
### 支持矩阵
| 源格式 | 输出格式 | 依赖 | 备注 |
|---|---|---|---|
| `.flac` | `.flac` | `shntool` + `flac` | 无损 re-encode,sample-exact |
| `.wav` | `.wav` | `shntool` 原生 | 无外部依赖,PCM 样本直切 |
| `.ape` | `.flac` | `shntool` + `mac` + `flac` | mac 解码 APE,flac 编码输出(ffmpeg 无 APE 编码器) |
| `.dsf` / `.dff` | — | 无 | **不支持** (DSD)。请先手动转 FLAC 再切 |
### 依赖启动时 probe
启动时会打印所有依赖状态:
```
未找到 mac (APE 解码器) → APE 源将标记 UNSUPPORTED. 安装可选: apt install monkeys-audio
支持源格式: ['.flac', '.wav']
```
**缺 shnsplit 或 cuetag**:直接退出(exit 3)。这是硬依赖。
**缺 flac**:FLAC 源会被标为 UNSUPPORTED。只有 WAV 源能处理。
**缺 mac**:APE 源会被标为 UNSUPPORTED,其他格式不受影响。
**缺 ffprobe**:警告但不影响切割,只是 manifest 里 `source_codec` / `duration_sec` 等字段是空的。
### 为什么不支持 DSD?
`shntool` 本身不支持 DSD 格式。理论上可以用 ffmpeg 走另一条路,但:
1. DSD 打包成整轨很少见(DSD 音源通常出货就是分轨的)
2. DSD 解码涉及低通滤波、降采样等,输出到 FLAC 会显著改变数据
3. 处理逻辑复杂度不值得
如果你确实有 DSD 整轨 + CUE,建议先手动用 ffmpeg 转成 FLAC,再跑 split_cue.py:
```bash
ffmpeg -i album.dsf -c:a flac -ar 88200 album.flac
# 然后修改 CUE 里的 FILE 指向 album.flac
split_cue.py /path/to/album
```
---
## 8. CUE 检测与匹配逻辑
### 什么样的目录会被识别为"整轨 CUE 型"?
严格判定条件(**全部满足**才算):
1. 目录里**至少**有 1 个 CUE 文件
2. 目录里**至少**有 1 个无损音频文件(FLAC/WAV/APE/DSF/DFF)
3. 某个 CUE 里 `FILE "xxx" WAVE` 条目**恰好 1 个**
4. 这个 FILE 引用能匹配到目录里的音频文件
5. 目录里**没有其他无损音频文件**(除了这个被引用的)
6. CUE 里**有 TRACK 条目**(不能是空 CUE)
只要有一项不满足,就不会被切割:
| 情况 | 判定 | 原因 |
|---|---|---|
| 无 CUE | `skipped` | 没有切割依据 |
| 无无损音频 | `skipped` | 没东西可切 |
| CUE 无 FILE 条目 | `skipped` + issues 记录 | CUE 无效 |
| CUE 有多个 FILE (>1) | `skipped` | 已经是分轨 CUE 索引 |
| CUE 引用的文件不存在 | `skipped` + issues 记录 | 可能是错配的 CUE |
| CUE 引用整轨但同时存在分轨 | `skipped` (situation C) | 已切过或用户手动分轨了 |
| 匹配成功但源格式依赖缺失 | `unsupported` | 例如 APE 但 mac 未装 |
### CUE FILE 引用的匹配策略
CUE 里的 `FILE "album.wav" WAVE` 未必和实际文件名完全一致。脚本用三级 fallback:
1. **精确匹配**:文件名完全一致
2. **不区分大小写**:`ALBUM.FLAC` 匹配 `album.flac`
3. **忽略扩展名**:`FILE "album.wav" WAVE` 匹配到 `album.flac`(假设目录里只有一个 `album.*` 无损文件)
三级都不匹配才判为"引用不存在"。
### CUE 编码识别
CUE 文件常见的编码:
- `utf-8-sig`(有 BOM 的 UTF-8)
- `utf-8`
- `gbk`(简体中文)
- `big5`(繁体中文)
- `shift_jis`(日文)
- `cp936`(GBK 别名)
- `latin1`(最后兜底)
脚本按顺序尝试解码,第一个成功的即采用。中日韩音源基本都能识别。
### CUE 里 TITLE / PERFORMER 的语义
CUE 里 `TITLE "..."` 和 `PERFORMER "..."` 可能出现在两个位置:
- **顶级**(在任何 TRACK 之前):整张专辑的标题和艺人
- **TRACK 块内**:这一首歌的标题和艺人
脚本区分这两种上下文,分别写入 `album_title` / `album_performer` 和每首歌的 `title` / `performer`。
### REM 元数据
CUE 里 `REM DATE "1998"` 和 `REM GENRE "Chinese Pop"` 在顶级出现时,会被解析为专辑的年份和流派,写入 manifest 并通过 cuetag 传给分轨的 tag。
### CUE 与 transcode_music.py 的区别
`transcode_music.py` 也有 CUE 检测逻辑(在其文档第 8 节详述了三种情况 A/B/C)。它的目的是**报警**"这个专辑需要 CUE split",而 `split_cue.py` 是**执行**这个 split。
两者对 CUE 的看法一致:
| 情况 | transcode_music.py 视角 | split_cue.py 视角 |
|---|---|---|
| A: CUE + 整轨大文件 | `needs_cue_split` 报警跳过 | **`analyzed`,执行切割** |
| B: CUE + 分轨 | 正常转码 | `skipped` |
| C: CUE 引用整轨 + 分轨都在 | 跳过整轨只转分轨 | `skipped` |
这就是为什么它们能天然衔接:`split_cue.py` 把 A 转成 C,然后 `transcode_music.py` 处理 C。
---
## 9. 分轨命名与元数据
### 文件命名规则
分轨文件名格式:**`NN - Title.ext`**
- **NN**:CUE 里 TRACK 声明的编号,零填充到至少 2 位(如果总轨数 ≥ 100 会用 3 位)
- **Title**:CUE 里 TRACK 块内的 TITLE,经过文件名净化
- **ext**:由源格式决定的输出扩展名(见第 7 节表格)
**Title 净化规则**:
1. 替换文件系统非法字符 `<>:"/\|?*` 以及 ASCII 控制字符(`\x00-\x1f`)为 `_`
2. 折叠连续空白为单个空格
3. 去掉结尾的点号和空格(Windows 要求)
4. 截断到最多 200 字符(避免文件系统限制)
5. 净化后为空则用 `untitled` 兜底
**示例**:
| CUE 里的 TITLE | 输出文件名(假设 num=2) |
|---|---|
| `红豆` | `02 - 红豆.flac` |
| `催眠 / 静夜` | `02 - 催眠 _ 静夜.flac`(`/` → `_`) |
| `"Track: A"` | `02 - _Track_ A_.flac`(`"` 和 `:` → `_`) |
| (无 TITLE) | `02 - Track 02.flac`(兜底) |
**注意**:**文件名**里的非法字符被替换,但**元数据 tag**(写入文件内部)保留 CUE 里的原样。所以 `催眠 / 静夜.flac` 里的 `TITLE` tag 仍然是 `催眠 / 静夜`(含 `/`),播放器显示正常。
### 命名冲突处理
极少数情况下多首歌的净化后名字撞车(比如两首歌都叫 `未命名` 而且 num 不同但被截断)。脚本会在冲突时追加 `(2)`、`(3)`:
- `05 - 未命名.flac`
- `06 - 未命名 (2).flac`
### 元数据 (Tag) 映射
由 `cuetag` 自动写入。对 FLAC 输出,写的是 Vorbis Comment tags:
| Tag | 来源 |
|---|---|
| `TITLE` | CUE 里 TRACK 块内的 `TITLE` |
| `ARTIST` / `PERFORMER` | CUE 里 TRACK 块内的 `PERFORMER`(若无则用专辑 PERFORMER) |
| `ALBUM` | CUE 里顶级 `TITLE` |
| `track` | CUE 里 TRACK 编号(补零至 2 位,如 `01`) |
| `TRACKTOTAL` | CUE 里总 TRACK 数 |
| `DATE` | CUE 里顶级 `REM DATE` |
| `GENRE` | CUE 里顶级 `REM GENRE` |
**注意**:cuetag 的行为是**按位置对应**:`cuetag album.cue file1.flac file2.flac file3.flac` 会把 CUE 的 TRACK 1 写到 file1,TRACK 2 写到 file2,以此类推。脚本按 shnsplit_index 严格排序传参,保证对应正确。
### cuetag 失败的后果
如果 cuetag 因某种原因失败(罕见),脚本会**警告但不算专辑失败**——分轨音频文件本身是完整的,只是没有元数据 tag。可以事后手动跑一次:
```bash
cd "专辑目录"
cuetag *.cue 01*.flac 02*.flac 03*.flac # 按顺序传
```
---
## 10. 与 transcode_music.py 的组合工作流
### 典型完整流程
```bash
# ┌─ 前期整理 (split_cue.py)
python3 split_cue.py /mnt/z/新收藏 --analyze-only
python3 split_cue.py /mnt/z/新收藏
# └─ 转码到便携格式 (transcode_music.py)
python3 transcode_music.py /mnt/z/新收藏 /mnt/x/mp3输出 --analyze-only
python3 transcode_music.py /mnt/z/新收藏 /mnt/x/mp3输出
```
### 两个脚本对同一个专辑的看法
举例:`王菲 - 唱游 [FLAC+CUE]/` 目录里有 `CDImage.flac` + `CDImage.cue`(12 轨)。
**只跑 transcode_music.py**:
```
[!] 1 个专辑需先手动 CUE split (已跳过转码):
• 王菲 - 唱游 [FLAC+CUE]
CDImage.cue: 整轨型 (单文件 'CDImage.flac', 12 轨) — 需先手动 CUE split 再转码
```
**先跑 split_cue.py 再跑 transcode_music.py**:
```
[split_cue.py]
[1/1] 切割: 王菲 - 唱游 [FLAC+CUE]
源: CDImage.flac → 12 轨 (flac)
✓ [01/12] 01 - 红豆.flac
✓ [02/12] 02 - 催眠.flac
...
[transcode_music.py]
CDImage.cue: 引用 'CDImage.flac' 但存在 12 个分轨 → 跳过 'CDImage.flac' 只转分轨
转码: 01 - 红豆.flac (flac, 44100Hz)
转码: 02 - 催眠.flac (flac, 44100Hz)
...
```
`transcode_music.py` 自动检测到 situation C,只转 12 个分轨、不动整轨,产生 12 个 MP3。
### Manifest 隔离
两个脚本的 manifest 文件名不同:
- `split_cue.py` → `.split_cue_manifest.json`
- `transcode_music.py` → `.transcode_manifest.json`
互不干扰。可以随时来回跑。
### 输出结构对比
**运行前**:
```
/mnt/z/新收藏/
└── 王菲 - 唱游 [FLAC+CUE]/
├── CDImage.flac (整张 CD 一个大文件)
└── CDImage.cue
```
**运行 split_cue.py 后**:
```
/mnt/z/新收藏/ ← 源目录, 分轨写这里
├── .split_cue_manifest.json ← split_cue 状态
└── 王菲 - 唱游 [FLAC+CUE]/
├── CDImage.flac ← 原文件保留
├── CDImage.cue ← 保留
├── 01 - 红豆.flac ← 新: split_cue 输出
├── 02 - 催眠.flac
└── ... (12 个分轨)
```
**接着运行 transcode_music.py 后**:
```
/mnt/z/新收藏/
├── .split_cue_manifest.json
├── .transcode_manifest.json ← transcode_music 状态
└── 王菲 - 唱游 [FLAC+CUE]/ (未变)
/mnt/x/mp3输出/ ← 目标目录
└── 王菲 - 唱游 [FLAC+CUE]/
├── CDImage.cue ← CUE 也复制过去
├── 01 - 红豆.mp3 ← 320k CBR
├── 02 - 催眠.mp3
└── ... (12 个 MP3)
```
注意:`CDImage.flac` 不会转成 MP3(因为 situation C 检测跳过);`CDImage.cue` 会被复制到目标目录(作为资源)。
### 关于 CUE 的一个细节
切完后,`CDImage.cue` 里的 `FILE "CDImage.flac" WAVE` 仍指向原整轨。如果用户在目标目录里想用 CUE 播放分轨 MP3,这个 CUE 是不正确的——它指向整轨、但目标目录里没有那个整轨。
如果你实际会用 CUE 索引,考虑:
1. 手动编辑复制到目标的 CUE,把 FILE 指向 MP3(但 CUE 传统上不支持多 FILE 分轨映射,改起来麻烦)
2. 直接删除目标目录里的 CUE(分轨 MP3 有 tag,播放器不需要 CUE 也能显示正确信息)
**推荐做法**:直接删。分轨 MP3 的 ID3 tag 已经包含所有 CUE 元数据。
---
## 11. 错误处理与恢复
### 错误捕获原则
**单个专辑失败不影响其他**。shnsplit / cuetag / 重命名任一失败时:
1. 该专辑内所有未完成的 track 状态设为 `failed`,错误消息写入 `error` 字段
2. 该专辑整体状态设为 `failed`
3. `issues[]` 追加人类可读的说明
4. **继续处理**下一个专辑
5. 最后在报告里列出所有失败项
### 再次运行时
`failed` 状态的专辑会**自动重试**(整个专辑重切,因为 shnsplit 是全轨一次调用的原子操作,无法只重切失败的分轨)。
```bash
# 修好源文件后, 直接再跑
python3 split_cue.py /mnt/z/新收藏
```
### 常见错误
**"shnsplit 失败: unknown format"**
CUE 里 `FILE "xxx" TYPE` 的 TYPE 值 shntool 不认。常见于 `FLAC` / `WAVE` / `MP3` / `BINARY`,其他值可能有问题。检查 CUE 内容并手动修正。
**"shnsplit 失败: cannot open file"**
CUE 的 FILE 引用文件名和实际文件名不一致(尽管脚本会 fallback 匹配到实际文件,但 shnsplit 用的是 CUE 里的原字符串)。可能因为脚本 fallback 匹配到了但 shntool 找不到。手动把 CUE 里的 FILE 名改成实际文件名,再跑。
**"cuetag 失败, 音频仍可用但可能无标签"**
cuetag 有时对某些 CUE 或 FLAC 版本行为异常。不影响切割本身,可事后手动跑 cuetag 或用别的 tagger(Picard、mp3tag 等)补 tag。
**"预期临时文件缺失/过小"**
shnsplit 返回 0 但实际没写全所有分轨。极罕见,通常是磁盘满或权限问题。检查目标目录空间和权限。
**"临时目录创建失败"**
无法在专辑目录里创建 `.split_cue_tmp_<pid>`。通常是目录只读或磁盘满。
**"源文件消失"**
分析阶段之后、切割阶段之前源文件被删/移动。删除对应专辑 manifest 条目重新扫描即可。
### 中断(Ctrl+C)
脚本捕获 SIGINT,保存 manifest 后退出。**中断时临时目录 `.split_cue_tmp_<pid>` 里的 shnsplit 中间产物会保留**(因为 finally 清理只在 split_album 函数正常返回时执行)——下次跑会重建。中断专辑的状态可能停留在 `processing`,重跑时会被视为需要重切。
如果发现有遗留的 `.split_cue_tmp_*` 目录(比如脚本被 kill -9 强杀),可以手动清理:
```bash
find /mnt/z/新收藏 -maxdepth 2 -type d -name '.split_cue_tmp_*' -exec rm -rf {} +
```
### 查看失败列表
```bash
# 用 jq 快速查看失败的专辑
jq -r '.albums | to_entries[] | select(.value.status=="failed") | .key' \
/mnt/z/新收藏/.split_cue_manifest.json
# 查看某专辑的具体失败原因
jq '.albums["专辑名"] | {status, issues, tracks: [.tracks[] | select(.status=="failed") | {num, error}]}' \
/mnt/z/新收藏/.split_cue_manifest.json
```
### 关于 unsupported
`unsupported` 状态的专辑不算失败,是主动跳过。原因写在 `issues[]` 里,例如:
- `缺少依赖: mac (APE 解码器)` → 装 `mac` 后再跑,会自动切
- `shntool 不支持 DSD, 请先手动转 FLAC` → 手动预处理
装好依赖后,需要用 `--force` 或删掉对应 manifest 条目才会重新分析。
---
## 12. 已知局限
- **不支持 DSD**(`.dsf` / `.dff`):shntool 不识别 DSD。若有整轨 DSD + CUE,需先手动 ffmpeg 转 FLAC 再切。
- **shnsplit 是一次调用切完整个专辑**:无法只重切失败的某一首歌。失败时整个专辑重切(幂等,无副作用)。
- **不做 replaygain / 章节标记 / 高级 tag**:只做 cuetag 支持的标准 tag(TITLE/ARTIST/ALBUM/DATE/GENRE/track/TRACKTOTAL)。
- **不支持并行切割**:为保证 manifest 状态一致性,专辑串行处理。可对不同子目录同时跑多个脚本实例。
- **CUE 里 track 编号必须连续从 1 开始**:极少数非标准 CUE(比如从 TRACK 03 开始)会导致 shnsplit 内部计数器和 CUE 编号错位。目前未特殊处理,可能造成 tag 错乱。
- **网盘 mtime 不能保留**:分轨是新建文件,mtime 是当下时间。原文件不动。
- **--force 不会自动清理旧分轨**:如果目录里已有旧分轨且脚本判为 situation C 而 skip,`--force` 也不会强制切。需手动删旧分轨。
- **shnsplit 依赖 `flac` 二进制来编码 FLAC**:这是 shntool 3.x 的架构选择(它调用外部 encoder)。装了 `flac` 才能切 FLAC 源;装了 `mac` 才能切 APE 源。
- **APE 输出为 FLAC,不能原格式**:ffmpeg 和 shntool 都没 APE 编码器(APE 是私有格式)。所以 APE → FLAC 是唯一路径。
- **Windows 直接跑未测试**:脚本用了 POSIX `os.fsync(dir_fd)`,Windows 上会 best-effort。理论可用,实际推荐 Linux / WSL2 / macOS。
---
## 13. FAQ
**Q: 为什么要单独一个 split_cue.py?直接让 transcode_music.py 顺便切了不好吗?**
A: 分离关注点。切割是**修改源目录**的操作(写新文件),转码是**读源目录写目标目录**的操作。两者错误影响面不同:切错了改源,转错了改目标。分开可以:
1. 先充分 review 切割计划再动手(`--analyze-only`)
2. 切割失败不牵连转码,反之亦然
3. 用户可以只切不转(本地播放器直接消费分轨 FLAC)
4. 用户可以直接手动切好再让 transcode_music 转(跳过 split_cue)
**Q: 切完 CUE 文件删不删?**
A: 保留。原文件(`CDImage.flac`)也保留。用户想删的话手动来,脚本不做破坏性操作。删原文件前建议:
1. 先播放几首分轨确认没问题
2. 用 `metaflac -l 01*.flac` 确认 tag 完整
3. 备份 CUE(换新 CUE 想重切时有用)
**Q: 切割精度和音质怎么样?**
A: shntool 是按 CD 帧(1/75 秒)对齐切的,与原始 CUE INDEX 完全一致。对 FLAC → FLAC,音频数据无损(decode + re-encode 是数学等价的 PCM 转换)。对 WAV → WAV 是纯样本操作,字节级精确。对 APE → FLAC,音频数据也无损(FLAC 压缩是无损的),只是容器格式变了。
**Q: 分轨没有 gapless 播放会不会有咔嗒声?**
A: 用了 shntool 的默认 `--append-gaps` 模式,pregap 追加到上一轨结尾。相邻曲目之间的过渡应该 sample-exact 无缝,播放器如支持 gapless 就无咔嗒声。
**Q: 切完后音频总长度对不上原文件?**
A: 应该是精确对上的(每首歌的 duration 加起来 = 原文件 duration)。如果偏差 < 1 秒,可能是取整误差(ffprobe 显示的 duration 有精度限制)。偏差大就是有 bug,请提 issue。
**Q: 中文 / 日文 / 韩文的 CUE 处理有问题吗?**
A: 应该没问题。脚本会按 `utf-8 / gbk / big5 / shift_jis / cp936 / latin1` 顺序尝试解码,绝大部分 CJK CUE 都能识别。切割输出的 tag(Vorbis Comment)总是 UTF-8。如果发现某个 CUE 解码错乱,可以用别的工具(比如 `iconv` 或文本编辑器)先把 CUE 转成 UTF-8 再跑脚本。
**Q: `cuetag` 输出 warning 说什么 metaflac 不 exist?**
A: 有些 cuetools 版本的 cuetag 是 Python 或 shell 脚本,内部调用 `metaflac`(来自 `flac` 包)。装了 `flac` 就有 `metaflac`。如果 `cuetag` 抱怨 metaflac 不存在,装 `flac` 包即可(`sudo apt install flac`)。
**Q: 我的 CUE 有奇怪的编码/结构 shntool 处理不了怎么办?**
A: 先用文本编辑器把 CUE 转成 UTF-8 无 BOM,去掉不必要的 REM 行,看 TRACK / FILE / INDEX 结构是否规范(每个 TRACK 至少要有 `INDEX 01 MM:SS:FF`)。规范化后重试。
**Q: 能不能自定义分轨文件名格式?比如 "%p - %t.flac"?**
A: 目前不能。命名格式固定为 `NN - Title.ext`。这是刻意的:
1. 保证目录内文件按 track 顺序排列
2. 简化文件名,避免复杂替换的 bug
3. 元数据 tag 里已经有完整信息(艺人、专辑等),不需要塞进文件名
**Q: 支持 track 里带 gap(HTOA / hidden track)吗?**
A: 部分支持。shnsplit 默认 `--append-gaps` 会把 gap 归到前一轨末尾。TRACK 01 之前的 pregap(HTOA)目前算作 TRACK 01 的开头。如果你需要独立提取 HTOA,得手动调 shnsplit 参数(脚本目前不暴露此选项)。
**Q: 切错了想撤回?**
A: 因为**原文件从不被删改**,撤回很简单:找出符合 `NN - Title.ext` 格式的新生成分轨全部删除即可。示例见场景 8。
**Q: 支持嵌套目录(比如多碟 CD1/CD2/)吗?**
A: 支持。脚本会 `os.walk` 全递归,每一层子目录独立分析。多碟专辑的每个 CDx 目录都会被视为一个独立的专辑单元。
**Q: 我可以在 Docker 里跑吗?**
A: 可以。写个简单 Dockerfile:
```dockerfile
FROM debian:stable-slim
RUN apt-get update && apt-get install -y \
python3 shntool cuetools flac ffmpeg \
&& rm -rf /var/lib/apt/lists/*
COPY split_cue.py /usr/local/bin/
ENTRYPOINT ["python3", "/usr/local/bin/split_cue.py"]
```
然后:
```bash
docker run --rm -v /mnt/z/music:/music my-split-cue /music --analyze-only
```
**Q: 我想只切某一个格式(比如只 FLAC 不 APE)怎么办?**
A: 目前没提供 filter 参数。可以用 shell 循环:
```bash
find /mnt/z/新收藏 -type d | while read d; do
if ls "$d"/*.flac >/dev/null 2>&1; then
python3 split_cue.py "$d"
fi
done
```
**Q: 生成的 manifest 会很大吗?**
A: 一般不大。50 个专辑的 manifest 约 30 KB。上千个专辑估计几 MB。不影响使用。
**Q: transcode_music.py 和 split_cue.py 能同时跑吗?**
A: 不建议。两者都会写源目录(split_cue 写分轨、transcode_music 写自己的 manifest)。虽然写的是不同文件不会冲突,但 transcode_music 分析时看到 split_cue 正在切的中间状态可能判断错误。**串行跑更安全**:split_cue 先跑完,transcode_music 再跑。
---
## 附录 A:完整命令示例集
```bash
# 分析 (不切割)
python3 split_cue.py /mnt/z/music --analyze-only
# 正式切割 (自动 resume)
python3 split_cue.py /mnt/z/music
# 详细日志
python3 split_cue.py /mnt/z/music -v
# 强制重跑 (覆盖已有分轨)
python3 split_cue.py /mnt/z/music --force
# 使用长参数
python3 split_cue.py --source /mnt/z/music
# 使用短参数
python3 split_cue.py -s /mnt/z/music
# 组合: 位置 + 选项
python3 split_cue.py /mnt/z/music -v --force
# 指定 shnsplit / cuetag 路径
python3 split_cue.py /mnt/z/music \
--shnsplit /opt/shntool-3/bin/shnsplit \
--cuetag /opt/cuetools/bin/cuetag
# 加大超时 (超长专辑)
python3 split_cue.py /mnt/z/music --split-timeout 3600
# 只处理某一个专辑
python3 split_cue.py "/mnt/z/music/王菲 - 唱游 [FLAC+CUE]"
# 完整工作流: split 然后 transcode
python3 split_cue.py /mnt/z/music && \
python3 transcode_music.py /mnt/z/music /mnt/x/mp3
```
## 附录 B:文件结构示例
**运行前**(典型的整轨型音乐盘):
```
/mnt/z/新收藏/
├── 王菲 - 唱游 [FLAC+CUE]/
│ ├── CDImage.flac ← 整张 CD 一个大文件, 40 分钟
│ └── CDImage.cue ← 12 个 TRACK
├── 蔡琴 - 老歌 [FLAC+CUE]/
│ ├── album.flac ← 整张 CD 一个大文件
│ ├── album.cue
│ ├── cover.jpg
│ └── booklet.pdf
├── 张学友精选 [已分轨]/ ← 已经是分轨的, 会 SKIP
│ ├── 01 吻别.flac
│ ├── 02 一路上有你.flac
│ ├── 03 情网.flac
│ └── (无 CUE)
└── Beatles - White Album [WAV+CUE]/ ← WAV 源, 会切成 WAV
├── disc1.wav
└── disc1.cue
```
**运行 `split_cue.py /mnt/z/新收藏` 后**:
```
/mnt/z/新收藏/
├── .split_cue_manifest.json ← 状态记录
├── 王菲 - 唱游 [FLAC+CUE]/
│ ├── CDImage.flac ← 保留不动
│ ├── CDImage.cue ← 保留
│ ├── 01 - 红豆.flac ← 新: 分轨输出 (含 tag)
│ ├── 02 - 催眠.flac
│ ├── 03 - 无常.flac
│ ├── 04 - 童 (国语).flac
│ ├── ... (共 12 个)
│ └── 12 - 精彩.flac
├── 蔡琴 - 老歌 [FLAC+CUE]/
│ ├── album.flac ← 保留
│ ├── album.cue ← 保留
│ ├── cover.jpg ← 保留
│ ├── booklet.pdf ← 保留
│ ├── 01 - 恰似你的温柔.flac ← 新: 分轨
│ ├── 02 - 你的眼神.flac
│ └── ... (12 个)
├── 张学友精选 [已分轨]/ ← 无变化 (SKIP)
│ ├── 01 吻别.flac
│ ├── 02 一路上有你.flac
│ └── 03 情网.flac
└── Beatles - White Album [WAV+CUE]/
├── disc1.wav ← 保留 WAV 整轨
├── disc1.cue ← 保留
├── 01 - Back In The U.S.S.R..wav ← 新: WAV 分轨
├── 02 - Dear Prudence.wav
└── ... (30 个)
```
**接着运行 `transcode_music.py /mnt/z/新收藏 /mnt/x/mp3`**:
```
/mnt/z/新收藏/ ← 未变
├── .split_cue_manifest.json
├── .transcode_manifest.json ← 新: transcode 状态
└── ... (专辑目录未变)
/mnt/x/mp3/ ← 目标目录
├── 王菲 - 唱游 [FLAC+CUE]/
│ ├── CDImage.cue ← CUE 复制过来
│ ├── 01 - 红豆.mp3 ← 320k CBR
│ ├── 02 - 催眠.mp3
│ └── ... (12 个 MP3)
├── 蔡琴 - 老歌 [FLAC+CUE]/
│ ├── album.cue
│ ├── cover.jpg
│ ├── booklet.pdf
│ ├── 01 - 恰似你的温柔.mp3
│ └── ...
├── 张学友精选 [已分轨]/
│ ├── 01 吻别.mp3
│ ├── 02 一路上有你.mp3
│ └── 03 情网.mp3
└── Beatles - White Album [WAV+CUE]/
├── disc1.cue
├── 01 - Back In The U.S.S.R..mp3
└── ... (30 个 MP3)
```
**注意**:
- 整轨大文件(`CDImage.flac`、`album.flac`、`disc1.wav`)**不会**被转成 MP3,因为 transcode_music.py 检测到 situation C 会跳过它们
- 所有 CUE 文件会被复制到目标目录(作为资源文件)
- 分轨 MP3 保留完整 ID3 tag(继承自分轨 FLAC 的 Vorbis Comment)
**Manifest 位置**:
```
/mnt/z/新收藏/.split_cue_manifest.json ← split_cue 的状态
/mnt/z/新收藏/.transcode_manifest.json ← transcode_music 的状态
```
两个 manifest 都在源目录里,文件名不同,互不冲突。