【Python】解析cue文件并切割媒体文件
# 前言
很久以前搞到一些无损音频资源。然而并没有切割,只不过给了个多合一的`wav`文件和一个`cue`文件。
如果用播放器打开`cue`可以实现切割效果,但不代表所有播放器都支持这种操作。
本文的实现基于解析`cue`文件并切割。
# 文件格式
`cue`文件本质上是文本,以双空格为缩进,格式可以大致理解为:
```
元数据类型 元数据
元数据类型 元数据
...
FILE 文件名
TRACK 编号 类型
元数据类型 元数据
元数据类型 元数据
...
TRACK 编号 类型
元数据类型 元数据
元数据类型 元数据
...
...
FILE 文件名
...
```
详情可以看[维基百科的介绍](https://en.wikipedia.org/wiki/Cue_sheet_(computing))。
# 程序设计
基于此,我设计了几个类:`cue_list`, `media_file`, `track`, `media_time`。
此外,由于`cue`里字段和数据是用空格分隔的,因此可以用`split`,但要注意引号的情况。这里建议用`shlex`库来省事。
另外,我觉得不论是`if-elif-else`语句还是3.9之后有的`match-case`语句,都太繁琐了。我选择用字典+方法来解决问题。
还有就是Python 3.9开始可以指定类型了,虽然在执行上没啥用,但是在vscode里就方便Pylance进行提示了。
## `cue_list`类
这个类用于描述整个`cue`文件。
我这里解析了`REM`,也就是注释。虽然原则上不必解析,但我手头的音乐资源把一些元数据写进了注释里。
此外,我用列表砍头的方式进行解析,再由方法返回字段所占的行数。如此一来便可以顺序解析多行的数据。
```Python
class cue_list:
def __init__(self,file_lines:list):
# Initialization
i=0
work_dict:dict={"REM":self._rem,"CATALOG":self._catalog,"PERFORMER":self._performer,"TITLE":self._title,"FILE":self._file}
self.genre="unknown"
self.date="unknown"
self.disc_id="unknown"
self.comment="unknown"
self.catalog="unknown"
self.performer="unknown"
self.media_files:list=[]
# Parse
while i<len(file_lines):
s=shlex.split(file_lines)
if s in work_dict:
i+=work_dict](file_lines)
else:
print(" Unknown directive: {}!".format(s))
break
def _file(self,rest_of_content:list)->int:
# Search until indentation is over
i=1
while i<len(rest_of_content):
if not rest_of_content.startswith(' '*2):
break
i+=1
self.media_files.append(media_file(rest_of_content[:i]))
return i
def _rem(self,rest_of_content:list)->int:
cur=shlex.split(rest_of_content)
match cur:
case "GENRE":
self.genre=cur
case "DATE":
self.date=cur
case "DISCID":
self.disc_id=cur
case "COMMENT":
self.comment=cur
case _:
print(" Unknown directive: {}!".format(cur))
return 1
def _catalog(self,rest_of_content:list)->int:
cur=shlex.split(rest_of_content)
self.catalog=cur
return 1
def _performer(self,rest_of_content:list)->int:
cur=shlex.split(rest_of_content)
self.performer=cur
return 1
def _title(self,rest_of_content:list)->int:
cur=shlex.split(rest_of_content)
self.title=cur
return 1
```
## `media_file`类
这个类用于描述`FILE`字段展开的数据。
以我手中的音乐资源来看,`FILE`字段下只有`TRACK`字段。但为了扩展性,我仍然使用了字典+方法。
```Python
class media_file:
def __init__(self,file_lines:list):
# Initialization
first_directive=shlex.split(file_lines)
self.file_name=first_directive
self.file_type=first_directive
self.tracks:list=[]
i=1
work_dict:dict={"TRACK":self._track}
# Parse
while i<len(file_lines):
s=shlex.split(file_lines)
if s in work_dict:
i+=work_dict](file_lines)
else:
print(" Unknown directive: {}!".format(s))
break
i=0
while i<len(self.tracks)-1:
self.tracks.end_time=self.tracks.start_time
i+=1
def _track(self,rest_of_content:list)->int:
# Search until indentation is over
i=1
while i<len(rest_of_content):
if not rest_of_content.startswith(' '*4):
break
i+=1
self.tracks.append(track(rest_of_content[:i]))
return i
```
## `track`类
这个类用于描述`FILE`字段中,`TRACK`字段展开的数据。
```Python
class track:
def __init__(self,file_lines:list):
# Initialization
work_dict:dict={"TITLE":self._title,"PERFORMER":self._performer,"ISRC":self._isrc,"INDEX":self._index}
first_directive=shlex.split(file_lines)
self.track_id=first_directive
self.track_type=first_directive
self.title="unknown"
self.performer="unknown"
self.isrc="unknown"
self.index=""
self.start_time:media_time=None
self.end_time:media_time=None
# Parse
for statement in file_lines:
s=shlex.split(statement)
if s in work_dict:
work_dict](s)
else:
print(" Unknown directive: {}!".format(s))
break
def _title(self,directives:list)->None:
self.title=directives
def _performer(self,directives:list)->None:
self.performer=directives
def _isrc(self,directives:list)->None:
self.isrc=directives
def _index(self,directives:list)->None:
self.index=directives
self.start_time=media_time(directives)
```
## `media_file`类
这个类用于描述`TRACK`字段中,`INDEX`字段所描述的起始时间。
```Python
class media_time:
def __init__(self,s:str=None,seconds=None):
if not s is None:
_s=s.split(':')
self.hour=int(_s)//60
self.minute=int(_s)%60
self.second=int(_s)
self.frame=int(_s)
elif not seconds is None:
self.hour=seconds//3600
self.minute=(seconds%3600)//60
self.second=seconds%60
self.frame=0
def __str__(self):
return "{:02d}:{:02d}:{:02d}.{:03d}".format(self.hour,self.minute,self.second,self.frame)
```
## 构造FFmpeg命令行
Python的`subprocess`可以让我们直接喂一个list进去来创建进程,这样一来就不用摆平空格和引号造成的困扰了。
```Python
def make_ffmpeg_cmdline(dir_root:str,cue:cue_list,media:media_file,trk:track,prefix:str,suffix:str,out_dir:str)->str:
cmd_lines=["ffmpeg","-i",os.path.join(dir_root,media.file_name),"-hide_banner","-ss"]
cmd_lines.append(str(trk.start_time))
if not trk.end_time is None:
cmd_lines.append("-to")
cmd_lines.append(str(trk.end_time))
cmd_lines+=["-metadata","artist={}".format(trk.performer)]
cmd_lines+=["-metadata","title={}".format(trk.title)]
cmd_lines+=["-metadata","year={}".format(cue.date)]
cmd_lines+=["-metadata","track={}".format(trk.track_id)]
cmd_lines+=["-y"]
cmd_lines.append(os.path.join(out_dir,"{}{}. {} - {}.{}".format(prefix,trk.track_id,trk.performer,trk.title,suffix)))
return cmd_lines
```
## 主函数
由于我手头的cue文件,它™竟然是UTF8-BOM编码的!所以程序的参数还得稍做打磨一下来支持各种字符串编码。顺便支持一下输出目录,以及后缀名啥的
```Python
def main()->None:
if len(sys.argv)<2:
print("Error: missing input file!")
return
else:
files:list=[]
prefix=""
suffix="flac"
out_dir=None
text_codec=None
i=1
# Parse Arguments...
while i<len(sys.argv):
if sys.argv.startswith("--"):
if sys.argv=="--prefix":
i+=1
prefix=sys.argv
elif sys.argv=="--output":
i+=1
out_dir=sys.argv
elif sys.argv=="--suffix":
i+=1
suffix=sys.argv
elif sys.argv=="--text-codec":
i+=1
text_codec=sys.argv
else:
print("Unknown argument: {}!".format(sys.argv),file=sys.stderr)
else:
files.append(sys.argv)
i+=1
# Process the cue files...
for fn in files:
dir_root=os.path.dirname(fn)
if out_dir is None:
out_dir=dir_root
fs=open(fn,'r',encoding=text_codec)
lines=fs.readlines()
fs.close()
# Analyze the cue file.
playlist=cue_list(lines)
for f in playlist.media_files:
for t in f.tracks:
# Construct a cmdline.
cmd_line=make_ffmpeg_cmdline(dir_root,playlist,f,t,prefix,suffix,out_dir)
print(cmd_line)
ffmpeg_ret=subprocess.call(cmd_line)
if ffmpeg_ret:
print("FFmpeg failed! Return value: ",ffmpeg_ret)
return
if __name__=="__main__":
t1=time.time()
main()
t2=time.time()
print("Total processing time: {} seconds...".format(t2-t1))
```
# 总结
好像也没啥好总结的。
调用方式:
```
python split_by_cue.py <cue文件> --text-codec <编码> --output <输出目录> --suffix <后缀名>
```
由于我的cue是™的UTF8-BOM,所以编码这里填`utf-8-sig`。
页:
[1]