tangptr@126.com 发表于 2023-4-2 10:06:59

【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]
查看完整版本: 【Python】解析cue文件并切割媒体文件