前言
很久以前搞到一些无损音频资源。然而并没有切割,只不过给了个多合一的wav
文件和一个cue
文件。
如果用播放器打开cue
可以实现切割效果,但不代表所有播放器都支持这种操作。
本文的实现基于解析cue
文件并切割。
文件格式
cue
文件本质上是文本,以双空格为缩进,格式可以大致理解为:
元数据类型 元数据
元数据类型 元数据
...
FILE 文件名
TRACK 编号 类型
元数据类型 元数据
元数据类型 元数据
...
TRACK 编号 类型
元数据类型 元数据
元数据类型 元数据
...
...
FILE 文件名
...
详情可以看维基百科的介绍。
程序设计
基于此,我设计了几个类: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
,也就是注释。虽然原则上不必解析,但我手头的音乐资源把一些元数据写进了注释里。
此外,我用列表砍头的方式进行解析,再由方法返回字段所占的行数。如此一来便可以顺序解析多行的数据。
class cue_list:
def __init__(self,file_lines:list[str]):
# 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[media_file]=[]
# Parse
while i<len(file_lines):
s=shlex.split(file_lines[i])
if s[0] in work_dict:
i+=work_dict[s[0]](file_lines[i:])
else:
print("[CUE] Unknown directive: {}!".format(s[0]))
break
def _file(self,rest_of_content:list[str])->int:
# Search until indentation is over
i=1
while i<len(rest_of_content):
if not rest_of_content[i].startswith(' '*2):
break
i+=1
self.media_files.append(media_file(rest_of_content[:i]))
return i
def _rem(self,rest_of_content:list[str])->int:
cur=shlex.split(rest_of_content[0])
match cur[1]:
case "GENRE":
self.genre=cur[2]
case "DATE":
self.date=cur[2]
case "DISCID":
self.disc_id=cur[2]
case "COMMENT":
self.comment=cur[2]
case _:
print("[CUE-REM] Unknown directive: {}!".format(cur[1]))
return 1
def _catalog(self,rest_of_content:list[str])->int:
cur=shlex.split(rest_of_content[0])
self.catalog=cur[1]
return 1
def _performer(self,rest_of_content:list[str])->int:
cur=shlex.split(rest_of_content[0])
self.performer=cur[1]
return 1
def _title(self,rest_of_content:list[str])->int:
cur=shlex.split(rest_of_content[0])
self.title=cur[1]
return 1
这个类用于描述FILE
字段展开的数据。
以我手中的音乐资源来看,FILE
字段下只有TRACK
字段。但为了扩展性,我仍然使用了字典+方法。
class media_file:
def __init__(self,file_lines:list[str]):
# Initialization
first_directive=shlex.split(file_lines[0])
self.file_name=first_directive[1]
self.file_type=first_directive[2]
self.tracks:list[track]=[]
i=1
work_dict:dict={"TRACK":self._track}
# Parse
while i<len(file_lines):
s=shlex.split(file_lines[i])
if s[0] in work_dict:
i+=work_dict[s[0]](file_lines[i:])
else:
print("[Media] Unknown directive: {}!".format(s[0]))
break
i=0
while i<len(self.tracks)-1:
self.tracks[i].end_time=self.tracks[i+1].start_time
i+=1
def _track(self,rest_of_content:list[str])->int:
# Search until indentation is over
i=1
while i<len(rest_of_content):
if not rest_of_content[i].startswith(' '*4):
break
i+=1
self.tracks.append(track(rest_of_content[:i]))
return i
track
类
这个类用于描述FILE
字段中,TRACK
字段展开的数据。
class track:
def __init__(self,file_lines:list[str]):
# Initialization
work_dict:dict={"TITLE":self._title,"PERFORMER":self._performer,"ISRC":self._isrc,"INDEX":self._index}
first_directive=shlex.split(file_lines[0])
self.track_id=first_directive[1]
self.track_type=first_directive[2]
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[1:]:
s=shlex.split(statement)
if s[0] in work_dict:
work_dict[s[0]](s)
else:
print("[Track] Unknown directive: {}!".format(s[0]))
break
def _title(self,directives:list[str])->None:
self.title=directives[1]
def _performer(self,directives:list[str])->None:
self.performer=directives[1]
def _isrc(self,directives:list[str])->None:
self.isrc=directives[1]
def _index(self,directives:list[str])->None:
self.index=directives[1]
self.start_time=media_time(directives[2])
这个类用于描述TRACK
字段中,INDEX
字段所描述的起始时间。
class media_time:
def __init__(self,s:str=None,seconds=None):
if not s is None:
_s=s.split(':')
self.hour=int(_s[0])//60
self.minute=int(_s[0])%60
self.second=int(_s[1])
self.frame=int(_s[2])
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进去来创建进程,这样一来就不用摆平空格和引号造成的困扰了。
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编码的!所以程序的参数还得稍做打磨一下来支持各种字符串编码。顺便支持一下输出目录,以及后缀名啥的
def main()->None:
if len(sys.argv)<2:
print("Error: missing input file!")
return
else:
files:list[str]=[]
prefix=""
suffix="flac"
out_dir=None
text_codec=None
i=1
# Parse Arguments...
while i<len(sys.argv):
if sys.argv[i].startswith("--"):
if sys.argv[i]=="--prefix":
i+=1
prefix=sys.argv[i]
elif sys.argv[i]=="--output":
i+=1
out_dir=sys.argv[i]
elif sys.argv[i]=="--suffix":
i+=1
suffix=sys.argv[i]
elif sys.argv[i]=="--text-codec":
i+=1
text_codec=sys.argv[i]
else:
print("Unknown argument: {}!".format(sys.argv[i]),file=sys.stderr)
else:
files.append(sys.argv[i])
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
。