登录|注册
论坛 > 若闲小阁
发帖|看图模式| 收藏 |打赏
看744|回14|收藏|打赏
1# ssh-buanshishi 只看他
2025-12-21 20:12:51 No. 77213102
无损格式之所以无损,是因为压缩前后的文件,最终解码为原始PCM音频数据后(或者说wav格式除去头部的定义封装的部分,剩下来的data块),得到的数据都是相同的。

同样一份无压缩的音频(wav文件)转换为flac、alac、ape(位深度、采样率不变的情况下,使用正常的编解码程序例如ffmpeg),压缩后虽然形态各异,但最终解码成原始PCM音频数据后都是一样的(基于DSD的音频文件除外),而音乐播放器软件最终发送到音频播放设备的也正是原始的PCM音频数据,所以说只要编解码的软件不出岔子,理论上讲这么转换后听到的声音是一样的。所以如果可能的话,无损编码时选择较高的压缩率有利于减少文件大小节省空间。

格式和算法的选择上,个人觉得flac还是最优的,首先是开源和通用性,这点不用多说,好像连单片机都有编解码的库,还有就是校验上不仅有帧内的CRC,还有对整段编码前的原始PCM音频数据进行的MD5校验(MediaInfo显示的【MD5 of the unencoded content】就是),能够判断音频是否损坏或篡改,而且容错率高,即便坏了一部分也能放(不像ape那样一有损坏就放不了了),此外还支持流特性(比如直播、实时传输)和快速定位表(seektable),总之十分科学和强大。有人可能会说flac的压缩率可能不太行,但经过我前段时间与ape的对比测试发现,同样采用最大压缩率(flac --best/mac -c5000(Insane)),ape只有在CD音质的规格下稍微有点领先,一旦规格上到24bit/48kHz及以上时,flac的表现基本持平甚至有时能反超ape,且flac的解码速度比ape快得多,同一份文件,flac嗖的一下就解码好了,而ape要多等好几秒,这么一对比该选哪个一目了然。

能知道flac的优势在哪也是因为我对它的特性比较熟悉,甚至研究过它的标准文件(RFC 9639)。其他的格式比如tak、alac我也了解过,下载过官方的编解码的命令行程序,看过一些里面的说明,发现这些格式能处理的音频规格范围(采样率、位深度)大都比flac要窄,alac、ape的这个范围虽然和flac基本一致,不过还是相对小众了些,alac苹果专用,也是最近几年才开源,ape要不是这次专门研究的话,基本已经淡出视野了。flac应该也是应用最广泛的。

以上算是一些科普,了解了这些,就很容易明白为什么选flac,也能放心转换无损格式的音频了。

研究这个【flac、wav文件无损压缩瘦身工具】也是最近在mora自购了3张专辑,发现用flac --best将mora原先零压缩的flac文件压缩后,原先总共2.17GiB的文件压缩到了1.53GiB,省了接近600MiB,也算是相当可观了,自己也很清楚这么操作完全不会对最终解码播放出的原始PCM音频数据产生任何改动,当然后续分享的时候还是要按照版规以原文件的形式分享出去,所以就在想能不能做个批量无损压缩工具出来分享给各位,虽然说动手能力强的朋友直接命令行批处理就完事了,不过我这里还研究了如何预分配文件空间减少碎片的方法(
【Python】Windows上创建文件时进行空间预分配,使之具有连续非碎片化空间的正确方法),而且知道虽然flac.exe能处理wav文件,但是原wav文件有图片封面或metadata文字标签的话(有“ID3”块),输出的flac文件播放器和mediainfo里是看不到这些的,虽然说用ffmpeg就能把这些转移过来,但是ffmpeg没有flac.exe的“-0(--fast)”~“-8(--best)”的预设来得方便,尤其是flac.exe的“--best”能最大化压缩率而ffmpeg就很难实现(ffmpeg帮助里的编码选项和参数连我都看晕了),想着能不能整合这些实用的特征,于是研究了这个“flac、wav文件无损压缩瘦身工具”。


除了这个工具以外,还有之前研究的音频转wav、ape的工具,都在下面的百度云里有python脚本
https://pan.baidu.com/s/15UlvYMQouQcNptXas1Ns6Q?pwd=0000

双击这些脚本即可看到帮助,运行环境Python3.8及以上(因为脚本所调用的外部工具ffmpeg的原因,不太能找到新的32位版本,所以建议至少win7_64位)。

所要安装的Python第三方库和我这边用的版本如下:
Send2Trash==1.8.3
mutagen==1.47.0
psutil==7.0.0
pillow==10.4.0
pywin32==308
pywin32-ctypes==0.2.3

这些脚本其中:
【to_wav.py】(转wav格式)、【flac_compress.py】(wav、flac瘦身工具)是用windows文件API达成文件预分配(无碎片)输出的;
【ape_enc.py】(ape编码工具)由于无法让APE官方的编解码程序【MAC.exe】输出到stdout,所以程序无法一次性输出无碎片的ape文件(再整理一遍磁盘碎片显得有点多余且更耗时,所以放弃了)。
(PS:【MAC.exe】就是个文件碎片制造机,编码成ape文件产生的磁盘碎片比flac.exe多多了,同一份wav文件,flac.exe出来的flac文件有3-4个磁盘文件碎片,【MAC.exe】产生的碎片要乘以10左右,而且编解码速度比flac慢得多,压缩率也才堪堪超过flac一丢丢,所以【ape_enc.py】只适合拿来研究和学习,当“花瓶”用,不推荐实际使用

音频metadata的支持情况:
【flac_compress.py】支持保留原音频文件的封面和metadata文本标签;
【ape_enc.py】仅支持保留metadata文本标签,不支持保留封面图片;
【to.wav】不保留任何metadata标签,算是个纯解码的。

【to_wav.py】、【ape_enc.py】是之前花了很长一段时间搞出来的,可以说很大程度上是给monkeys audio官方擦屁股的工程(代码的注释里有说明)。
【flac_compress.py】使最近搞出来的,时间有点仓促。

因为论坛的代码编辑功能体验不是太好,复制下来会有多余的东西出现,所以仅展示一个脚本的代码,反正百度云分享的就是py源代码文件。

以下是其中【flac、wav文件无损压缩瘦身工具】的源码,其中里面的【struct_flac_vorbis_comment_block】、【struct_flac_picture_block】、【rewrite_flac_PCM_MD5_checksum】、【get_source_file_metadata】等函数及其调用的子函数比较有研究价值:
  1. import os,sys,subprocess,io,msvcrt,time,hashlib,re
  2. from contextlib import suppress
  3. from copy import copy
  4. # 第三方库
  5. import psutil,win32file
  6. from PIL import Image

  7. # 程序cmd标题
  8. cmd_title = "flac、wav文件无损压缩瘦身工具"
  9. # 程序所在文件夹
  10. program_dir = os.path.dirname(__file__)
  11. # 程序可执行文件名
  12. app_exe = os.path.basename(sys.argv[0])

  13. # 管道缓冲大小
  14. buf_size=1*1024*1024 # 1 MiB

  15. # 如果不是以分隔符结尾,要补上分隔符“;”,
  16. # 在刚刚装好的win7虚拟机上吃了一亏,然后补上的
  17. if not (os.environ['PATH']).endswith(";"):
  18.     os.environ['PATH'] += ";"
  19. # 添加外部工具路径到临时环境变量,方便运行
  20. os.environ['PATH'] += f"{program_dir}\\External_Tools;"

  21. # 文件系统信息
  22. fs_info_dict = dict()
  23. for partition in psutil.disk_partitions(all=True):
  24.     section = getattr(partition,"mountpoint","") # 盘符
  25.     fs_type = getattr(partition,"fstype","") # 文件系统类型
  26.     try:
  27.         # 获取每扇区字节数,和每簇的扇区数
  28.         sectors_per_cluster , bytes_per_sector , _ ,_ =win32file.GetDiskFreeSpace(section)
  29.     except:
  30.         cluster_size = 0
  31.     else:
  32.         # 相乘得到簇大小
  33.         cluster_size = bytes_per_sector * sectors_per_cluster
  34.    
  35.     fs_info_dict[copy(section)]=(copy(fs_type) , copy(cluster_size))

  36. # 检查是否是资源管理器文件拖曳处理的模式
  37. # 如果是的话,结束前额外暂停3秒展示信息
  38. parent_process = psutil.Process().ppid()
  39. # 脚本文件还需要查【py.exe】的父进程
  40. if app_exe.lower().endswith(".py"):
  41.     parent_process = psutil.Process(parent_process).ppid()
  42. start_from_explorer = True if ((psutil.Process(parent_process).name()) == "explorer.exe") else False

  43. # 如果不是以分隔符结尾,要补上分隔符“;”,
  44. # 在刚刚装好的win7虚拟机上吃了一亏,然后补上的
  45. if not (os.environ['PATH']).endswith(";"):
  46.     os.environ['PATH'] += ";"
  47. # 添加外部工具路径到临时环境变量,方便运行
  48. os.environ['PATH'] += f"{program_dir}\\External_Tools;"

  49. # metadata标签修正和排序顺序
  50. flac_meta_tag_ref_dict = {
  51.     "track":      (0,"TRACKNUMBER"),
  52.     "tracknumber":(0,"TRACKNUMBER"),
  53.     "title": (1,"TITLE"),
  54.     "name":  (2,"name"),
  55.     "artist":(3,"ARTIST"),
  56.     "performer":(4,"performer"),
  57.     "composer": (5,"COMPOSER"),
  58.     "date":       (6,"DATE"),
  59.     "year":       (7,"year"),
  60.     "mood":       (8,"MOOD"),
  61.     "genre":      (9,"GENRE"),
  62.     "genrenumber":(10,"GENRENUMBER"),
  63.     "album":        (11,"ALBUM"),
  64.     "albumartist":  (12,"ALBUMARTIST"),
  65.     "album_artist": (12,"ALBUMARTIST"),
  66.     "album artist": (12,"ALBUMARTIST"),
  67.     "tracktotal":   (13,"TRACKTOTAL"),
  68.     "disc":         (14,"DISCNUMBER"),
  69.     "discnumber":   (14,"DISCNUMBER"),
  70.     "disk":         (14,"DISCNUMBER"),
  71.     "disknumber":   (14,"DISCNUMBER"),
  72.     "copyright":    (15,"COPYRIGHT"),
  73.     "organization": (16,"ORGANIZATION"),
  74.     "comment":      (17,"COMMENT"),
  75.     "discription":  (18,"DISCRIPTION"),
  76.     "lyrics":       (19,"LYRICS"),
  77. }


  78. def meta_filter(key:str) -> tuple:
  79.     if (ret:=flac_meta_tag_ref_dict.get(key.lower())):
  80.         pass
  81.     else:
  82.         ret = (100,copy(key))
  83.     return ret


  84. # 恢复标题
  85. def set_title() -> None:
  86.     os.system(f"title {cmd_title}")
  87.     return


  88. # 需要手动构建vorbis_comment_block以支持多行文本标签,
  89. # metaflac提供的--import-tags-from选项不支持多行文本标签
  90. def struct_flac_vorbis_comment_block(meta_dict:dict, vendor_string="") -> bytearray:
  91.     vorbis_comment_block = b""

  92.     fields = [(f"{key}={value}").encode(encoding="utf-8", errors="replace") for key, value in meta_dict.items()]
  93.     field_part = b"".join([
  94.         (len(single_field).to_bytes(length=4, byteorder="little", signed=False) + single_field) \
  95.         for single_field in fields
  96.     ])

  97.     number_of_fields_part = len(fields).to_bytes(length=4, byteorder="little", signed=False)
  98.    
  99.     vendor_string_part = vendor_string.encode(encoding="utf-8", errors="replace")
  100.     vendor_string_length_part = len(vendor_string_part).to_bytes(length=4, byteorder="little", signed=False)

  101.     block_size = sum([
  102.         len(i) for i in \
  103.         [
  104.             vendor_string_length_part, vendor_string_part,
  105.             number_of_fields_part, field_part
  106.         ]
  107.     ])
  108.     block_size_part = block_size.to_bytes(length=3, byteorder="big", signed=False)
  109.    
  110.     block_head_part = b"\x04"
  111.    
  112.     vorbis_comment_block = b"".join([
  113.         block_head_part, block_size_part,
  114.         vendor_string_length_part, vendor_string_part,
  115.         number_of_fields_part, field_part
  116.     ])

  117.     return bytearray(vorbis_comment_block)


  118. # 获取flac.exe的vendor_string
  119. # MediaInfo软件显示的【Writing library: libFLAC 1.X.X】就是由vendor_string决定的,
  120. # 手动构建vorbis_comment_block是需要这个的,这样输出的flac就知道是哪个版本的flac.exe输出的。
  121. def get_flac_tool_vendor_string() -> str:
  122.     process = subprocess.Popen(
  123.         # “flac -l 0 -0 -b 4608 …… --no-mid-side” 这部分命令用于生成和原wav文件大小一致的flac,
  124.         # 是从一个国外的论坛里找到的
  125.         # 虽然这里实际上不需要做得这么“绝”,不过还是列出来供各位参考
  126.         args="flac -l 0 -0 -b 4608 --disable-constant-subframes --disable-fixed-subframes --no-md5 --no-padding --no-seektable --no-adaptive-mid-side --no-mid-side --force-raw-format --endian=little --sign=signed --channels=2 --bps=16 --sample-rate=44100 --stdout - 2>nul",
  127.         stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=None,
  128.         shell=True,
  129.     )
  130.     # 给100个对齐的sample让flac编码,输出的flac文件中的vorbis_comment里就有vendor_string
  131.     stdout_bytes, _ = process.communicate(b"\x00"*4*100)
  132.     stdout_bytes = bytearray(stdout_bytes)
  133.     if (ret:=stdout_bytes.find(b"reference\x20libFLAC")) != -1:
  134.         vendor_string_length = int.from_bytes(bytes=stdout_bytes[ret-4:ret], byteorder="little", signed="False")
  135.         vendor_string = (stdout_bytes[ret : ret+vendor_string_length]).decode(encoding="utf-8", errors="replace")
  136.    
  137.     return vendor_string


  138. # 获取metadata
  139. def get_source_file_metadata(input_file:str) -> dict:
  140.     meta_dict = dict()

  141.     with suppress(Exception):
  142.         p = subprocess.run(f"ffmpeg -loglevel quiet -i "{input_file}" -bitexact -map_metadata:g 0:g -map_metadata:g 0:s:a:0 -map_chapters -1 -f ffmetadata - " , shell=True , capture_output=True , check=True)
  143.         
  144.         # ffmpeg输出的ffmetadata文件固定是utf-8
  145.         content = p.stdout.decode("utf-8",errors="replace")
  146.         # 替换多行注释和特殊字符【ffmpeg-all.html#toc-Metadata-2】
  147.         content = content.replace("\r\\\n","<↵>").replace("\\\n","<↵>").replace("\\\","\") # \r\\\n是因为ffmpeg似乎只替换\n,漏掉\r\n的情况(歌词里)
  148.         content = content.replace("\\#","#").replace("\\=","=").replace("\\;",";")
  149.         # 分行并过滤
  150.         content = [ spl for i in content.splitlines() \
  151.                     if (line := i.strip()) and (not line.startswith((";","#"))) \
  152.                     and len(spl := line.split("=",1))==2 \
  153.                     and spl[-1] ]
  154.         # 查找替换正式key值,分行过后还原换行符
  155.         content = [[ meta_filter(key), value.replace("<↵>","\n") ] for key,value in content]
  156.         # 排序
  157.         content.sort(key=lambda x:x[0][0])
  158.         # 转换并添加到字典
  159.         for key, value in content:
  160.             meta_dict[copy(key[-1])]=copy(value)
  161.    
  162.     return meta_dict


  163. def try_decode(input_bytes:bytes) -> str:
  164.     # 可能的控制台输入输出编码
  165.     possible_encodings = ["utf-8-sig" , "gb18030" , "utf-16" , "cp932"]
  166.     output_string = ""
  167.     for i in possible_encodings:
  168.         try:
  169.             output_string = input_bytes.decode(encoding=i, errors="strict")
  170.         except:
  171.             pass
  172.         else:
  173.             break
  174.     if not output_string:
  175.         output_string = input_bytes.decode(encoding="utf-8-sig",errors="replace")
  176.    
  177.     return output_string


  178. # 获取源音频文件的附加图片数据
  179. def get_source_file_top_attached_pic_data(file:str) -> io.BytesIO:
  180.     data_io = io.BytesIO()

  181.     with suppress(Exception):
  182.         p = subprocess.run(f"ffprobe -hide_banner -i "{file}"" , shell=True , capture_output=True)
  183.         stderr_content = try_decode(p.stderr)

  184.     # 从stderr中解析附加图片所在的位置
  185.     pic_pos_list = []
  186.     for line in [line.strip() for line in stderr_content.splitlines()]:
  187.         if (tmp := re.match(r"^Stream[\s]*#0[:](\d+)[:][\s]*Video:(.+)\(attached pic\)[        DISCUZ_CODE_0        ]quot;, line, re.I)):
  188.             pic_pos_list.append(int(tmp.group(1)))

  189.     # 定位排在最前面的附加图片,然后尝试导出
  190.     pic_pos_list.sort()
  191.     if pic_pos_list:
  192.         top_pic_pos = pic_pos_list[0]

  193.         readed_size = 0
  194.         readed_size_limit = 0xFFFFFF-64 # 超过这个大小flac的picture_block塞不下(3个byte的表达范围 - 64个Byte的格式需要预留的空间)
  195.         need_terminate = False

  196.         p = subprocess.Popen(
  197.             args=f"ffmpeg -loglevel quiet -i "{file}" -map 0:{top_pic_pos} -frames:v 1 -update 1 -c copy -f image2 -",
  198.             bufsize=buf_size*2,
  199.             stdin=None, stdout=subprocess.PIPE, stderr=None,
  200.             shell=True,
  201.         )
  202.         while (not p.stdout.closed) and (buf := p.stdout.read(buf_size)):
  203.             if readed_size <= readed_size_limit:
  204.                 data_io.write(buf)
  205.             else:
  206.                 # 放弃数据
  207.                 data_io.truncate(0)
  208.                 # 需要结束进程
  209.                 need_terminate = True
  210.                 break
  211.             readed_size += len(buf)
  212.         
  213.         # 如果输出图片数据超出大小,结束ffmpeg进程
  214.         if need_terminate:
  215.             with suppress(Exception): p.stdout.close()
  216.             with suppress(Exception): p.kill()
  217.         else:
  218.             # 管道里没东西后,尝试关闭
  219.             with suppress(Exception): p.stdout.close()
  220.             # 等待进程结束
  221.             while (p.poll() is None):
  222.                 time.sleep(0.1)

  223.     return data_io


  224. def struct_flac_picture_block(pic_data:io.BytesIO, picture_type=3, description="") -> bytearray:
  225.     picture_block = b""
  226.    
  227.     pixel_bit_depth_mode_dict = {
  228.         "1":1,
  229.         "L":8,
  230.         "P":8,
  231.         "RGB":3*8,
  232.         "RGBA":4*8,
  233.         "CMYK":4*8,
  234.         "YCbCr":3*8,
  235.         "LAB":3*8,
  236.         "HSV":3*8,
  237.         "I":32,
  238.         "F":32,
  239.     }

  240.     with pic_data:
  241.         try:
  242.             img = Image.open(pic_data)
  243.         except:
  244.             img = None

  245.         if img:
  246.             # 获取图片信息
  247.             with img:
  248.                 mimetype = tmp if ((tmp:=img.get_format_mimetype()) and isinstance(tmp,str)) else ""
  249.                 width, height = img.size
  250.                 bits_per_pixel = tmp if (tmp:=pixel_bit_depth_mode_dict.get(img.mode)) else 0
  251.                 if img.format == "GIF":
  252.                     if img.mode != 'P':
  253.                         img = img.convert('P')
  254.                         colors_used = 256 # GIF max colors is 256
  255.                         with suppress(Exception): colors_used = len(img.getcolors(maxcolors=256)) # GIF max colors is 256
  256.                 else:
  257.                     colors_used = 0 #“0” for non-indexed pictures
  258.             
  259.             # 构建picture_block
  260.             picture_type_part = \
  261.                 picture_type.to_bytes(length=4, byteorder="big", signed=False)
  262.             media_type_string_part = \
  263.                 mimetype.encode(encoding="utf-8", errors="replace")
  264.             media_type_string_length_part = \
  265.                 len(media_type_string_part).to_bytes(length=4, byteorder="big", signed=False)
  266.             description_string_part = \
  267.                 description.encode(encoding="utf-8", errors="replace")
  268.             description_string_length_part = \
  269.                 len(description_string_part).to_bytes(length=4, byteorder="big", signed=False)
  270.             width_part, height_part = \
  271.                 width.to_bytes(length=4, byteorder="big", signed=False), \
  272.                 height.to_bytes(length=4, byteorder="big", signed=False)
  273.             bits_per_pixel_part = \
  274.                 bits_per_pixel.to_bytes(length=4, byteorder="big", signed=False)
  275.             colors_used_part = \
  276.                 colors_used.to_bytes(length=4, byteorder="big", signed=False)
  277.             picture_data_part = \
  278.                 pic_data.getvalue()
  279.             picture_data_length_part = \
  280.                 len(picture_data_part).to_bytes(length=4, byteorder="big", signed=False)
  281.             
  282.             # 计算block_size
  283.             block_size = sum([
  284.                 len(i) for i in [
  285.                     picture_type_part,
  286.                     media_type_string_length_part, media_type_string_part,
  287.                     description_string_length_part, description_string_part,
  288.                     width_part, height_part, bits_per_pixel_part, colors_used_part,
  289.                     picture_data_length_part, picture_data_part,
  290.                 ]
  291.             ])
  292.             block_size_part = \
  293.                 block_size.to_bytes(length=3, byteorder="big", signed=False)

  294.             block_head_part = b"\x06"

  295.             # 组装picture_block
  296.             picture_block = b"".join([
  297.                 block_head_part, block_size_part,
  298.                 picture_type_part,
  299.                 media_type_string_length_part, media_type_string_part,
  300.                 description_string_length_part, description_string_part,
  301.                 width_part, height_part, bits_per_pixel_part, colors_used_part,
  302.                 picture_data_length_part, picture_data_part,
  303.             ])
  304.    
  305.     return bytearray(picture_block)


  306. def restruct_flac_meta_part_in_std_pipe(std_pipe:io.BytesIO, vorbis_comment_block:bytearray, picture_block:bytearray, padding_size=-1) -> bytes:
  307.     flac_frame_start = b""
  308.     block_list = [(4,vorbis_comment_block),(6,picture_block)]
  309.    
  310.     while (not std_pipe.closed):

  311.         # 读取假想的block_type和block_size
  312.         buf = bytearray(std_pipe.read(4))
  313.         if len(buf) != 4:
  314.             break
  315.         
  316.         block_type = buf[0] & 0b01111111
  317.         is_last_block = bool(buf[0] & 0b10000000)

  318.         # flac文件头
  319.         if bytes(buf)==b"fLaC":
  320.             #什么都不做,等下一轮读取下一个block的size
  321.             pass
  322.         # meta部分结束,flac的frame部分开始
  323.         # (最后一个meta_block最高位没有置1的后备方案)
  324.         elif buf.startswith( (b"\xFF\xF8",b"\xFF\xF9") ):
  325.             flac_frame_start = bytes(buf)
  326.             break
  327.         # 舍弃原先的vorbis_comment_block、picture_block、seektable_block、Application_block、cuesheet_block(cuesheet不太可能有,所以舍去)
  328.         elif block_type in (2,3,4,5,6):
  329.             # 读取并丢弃
  330.             read_count = int.from_bytes(bytes=buf[1:], byteorder="big", signed=False)
  331.             if (not std_pipe.closed):
  332.                 std_pipe.read(read_count)
  333.             # 如果是最后一个块,就没有必要继续读取了
  334.             if is_last_block:
  335.                 break
  336.         # steaminfo_block
  337.         elif block_type==0:
  338.             read_count = int.from_bytes(bytes=buf[1:], byteorder="big", signed=False)
  339.             if (not std_pipe.closed):
  340.                 data = std_pipe.read(read_count)
  341.                 if len(data) == read_count:
  342.                     buf.extend(data)
  343.                     block_list.append( (0, copy(buf)) )
  344.             # 如果是最后一个块,就没有必要继续读取了
  345.             if is_last_block:
  346.                 break
  347.         # padding_block(虽然block_type==1,但排序上这里给到所有block的最后:9)
  348.         elif block_type==1:
  349.             # 自定义padding_block大小
  350.             if padding_size > 0:
  351.                 # 读取并丢弃原来的padding_block
  352.                 read_count = int.from_bytes(bytes=buf[1:], byteorder="big", signed=False)
  353.                 if (not std_pipe.closed):
  354.                     std_pipe.read(read_count)
  355.                 # 替换为新的padding_block
  356.                 padding_size = min(0xFFFFFF , padding_size)# 限制大小
  357.                 padding_size_part = padding_size.to_bytes(length=3, byteorder="big", signed=False)
  358.                 block_list.append( (9, bytearray(b"\x01" + padding_size_part + b"\x00"*padding_size)) )
  359.             # padding_block大小不变
  360.             elif padding_size < 0:
  361.                 read_count = int.from_bytes(bytes=buf[1:], byteorder="big", signed=False)
  362.                 if (not std_pipe.closed):
  363.                     data = std_pipe.read(read_count)
  364.                     if len(data) == read_count:
  365.                         buf.extend(data)
  366.                         block_list.append( (9, copy(buf)) )
  367.             # 为零,删除padding_block
  368.             else:
  369.                 # 读取并丢弃原来的padding_block
  370.                 read_count = int.from_bytes(bytes=buf[1:], byteorder="big", signed=False)
  371.                 if (not std_pipe.closed):
  372.                     std_pipe.read(read_count)
  373.             
  374.             # 如果是最后一个块,就没有必要继续读取了
  375.             if is_last_block:
  376.                 break
  377.         # 现阶段没有上面列出的以外的类型
  378.         else:
  379.             raise Exception("读取到未知类型的meta_block")

  380.    
  381.     # 滤除空白block
  382.     block_list = [i for i in block_list if i[-1]]
  383.     # 按给定的序号从前到后排序
  384.     block_list.sort(key=lambda x:x[0])
  385.     # 扔掉排序用的序号
  386.     block_list = [i[-1] for i in block_list]
  387.     # 先全部标记为不是最后一个块
  388.     for block in block_list:
  389.         block[0] = block[0] & 0b01111111
  390.     # 再给最后一个块标记
  391.     block_list[-1][0] = block_list[-1][0] | 0b10000000
  392.     # 检查必要的第一个块streaminfo_block是否存在
  393.     if block_list[0][0] not in (0x00,0x80):
  394.         raise Exception("未获取到必要的streaminfo_block")

  395.     result_flac_meta_part = b"fLaC" + b"".join(block_list) + flac_frame_start

  396.     return result_flac_meta_part


  397. def rewrite_flac_PCM_MD5_checksum(flac_file:str) -> None:
  398.     md5_checksum_obj = hashlib.md5()

  399.     command = f"flac -d --force-raw-format --sign=signed --endian=little --stdout "{flac_file}" 2>nul"
  400.     process = subprocess.Popen(
  401.         args=command,
  402.         bufsize=2*buf_size,
  403.         stdin=None, stdout=subprocess.PIPE, stderr=None,
  404.         shell=True,
  405.     )
  406.     while (not process.stdout.closed) and (buf := process.stdout.read(buf_size)):
  407.         md5_checksum_obj.update(buf)
  408.    
  409.     # 管道里没东西后,尝试关闭
  410.     with suppress(Exception): process.stdout.close()
  411.     # 等待进程结束
  412.     while (process.poll() is None):
  413.         time.sleep(0.1)
  414.     # 检查是否返回非零
  415.     if process.returncode:
  416.         raise Exception("回写【MD5 of the unencoded content】,flac解码时返回非零")

  417.     # 获取MD5的bytes表达(切片,确保不超出范围)
  418.     result = md5_checksum_obj.digest()[0:16]

  419.     # 根据flac文件的标准,MD5的位置是固定不变的,
  420.     # 0x1a的位置用winhex打开对照MediaInfo找的。
  421.     with open(flac_file, mode="br+") as f:
  422.         f.seek(0x1a , os.SEEK_SET)
  423.         f.write(result)
  424.    
  425.     return
  426.    


  427. # 转移修改时间
  428. def transfer_modify_time(input_file:str, output_file:str) -> None:
  429.     with suppress(Exception):
  430.         # access_time是来凑数的,毕竟time的tuple必须要两个参数
  431.         access_time = os.path.getctime(output_file)
  432.         modify_time = os.path.getmtime(input_file)
  433.         os.utime(output_file , (access_time , modify_time))
  434.     # 无返回值
  435.     return


  436. # 退出时的行为
  437. def app_exit(return_code:int=0, message:str="") -> None:
  438.     if message:
  439.         if return_code:
  440.             sys.stderr.write(f"\n\n{message}\n")
  441.         else:
  442.             sys.stdout.write(f"\n\n{message}\n")
  443.     # stderr无缓冲,是即时的
  444.     sys.stdout.flush()
  445.     if start_from_explorer:
  446.         sys.stdout.write("\n\n★ 按任意键结束 ★\n")
  447.         sys.stdout.flush()
  448.         os.system("@pause>nul")
  449.     sys.exit(return_code)


  450. # 是否为flac文件或wav文件
  451. def is_flac_or_wav(file:str) -> int:
  452.     if os.path.isfile(file):
  453.         with open(file,mode="rb") as f:
  454.             buf = f.read(4)
  455.             if buf == b"fLaC":
  456.                 return 1
  457.             elif buf == b"RIFF":
  458.                 with suppress(Exception):
  459.                     f.seek(8,os.SEEK_SET)
  460.                     buf = f.read(8)
  461.                     if buf == b"WAVEfmt\x20":
  462.                         return 2
  463.    
  464.     return 0


  465. # Windows上创建文件时进行空间预分配,使之具有连续非碎片化空间
  466. # 【无VDL解锁(无管理员权限时)、无debug功能简化版、固定为“虚模式”】
  467. # 原理和更多详情可见:https://zhuanlan.zhihu.com/p/1943261864013309766
  468. def win_preallocate_newfile(
  469.     # 日常使用参数
  470.     file:str, size:int, exist_ok:bool=False,
  471.     buffering:int=-1,
  472.     text_mode:bool=False, encoding:str="utf-8-sig",
  473.     errors=None, newline="\r\n",
  474. ) -> io.BytesIO:

  475.     # 分区名
  476.     drive_name = os.path.splitdrive(os.path.abspath(file))[0] + "\"
  477.     """
  478.     # 文件系统
  479.     fs_type = tmp if ( tmp := (fs_info_dict.get(drive_name))[0] ) else ""
  480.     new_fs = True if (fs_type in {"NTFS","ReFS"}) else False
  481.     """
  482.     # 簇大小
  483.     cluster_size = tmp if ( tmp := (fs_info_dict.get(drive_name))[-1] ) else 1
  484.     # 与簇大小对齐的文件分配空间
  485.     al_size = (size + (cluster_size - remain_size)) if (remain_size := size%cluster_size) else size

  486.     # 检查文件是否已经存在
  487.     if os.path.isfile(file) and (not exist_ok):
  488.         raise Exception("文件已存在,且未设置覆盖")
  489.    
  490.    
  491.     # 上面为止文件都没有正式打开
  492.     # 下面套个try是为了方便在失败时关掉句柄和删除残留
  493.     try:
  494.         # 打开一个python文件句柄
  495.         if text_mode:
  496.             py_fh = open(file, mode="wt+", encoding=encoding, buffering=buffering, errors=errors, newline=newline)
  497.         else:
  498.             py_fh = open(file, mode="wb+", buffering=buffering)
  499.         
  500.         # 转换为windows的句柄方便操作
  501.         win_hf = msvcrt.get_osfhandle(py_fh.fileno())
  502.         
  503.         # 设置文件的磁盘分配空间
  504.         win32file.SetFileInformationByHandle(win_hf , win32file.FileAllocationInfo , al_size)
  505.         
  506.         """
  507.         # 根据上面的配置结果,选择是否在一开始就移动EOF至文件的分配大小
  508.         if new_fs:
  509.             # 移动EOF至分配的文件大小
  510.             # 虽然EOF的大小(文件大小)不需要对齐簇大小,
  511.             # 不过这里设置成对齐簇大小的al_size,多一丢丢文件的实际大小,问题也不大
  512.             win32file.SetFileInformationByHandle(win_hf , win32file.FileEndOfFileInfo , al_size)
  513.         """

  514.     except Exception as x:
  515.         e = copy(x) # 如果不找个新变量copy过来,下面的with suppress(Exception)会使存储异常的变量“人间蒸发”
  516.         with suppress(Exception): py_fh.close()
  517.         with suppress(Exception): os.remove(file)
  518.         raise e
  519.    
  520.     return py_fh



  521. '''
  522.         ........       .......                    .....                                                      
  523.         =@@@@@@@.     ,@@@@@@@                    =@@@@                                                      
  524.         =@@@@@@@^     /@@@@@@@                    =@@@@                                                      
  525.         =@@@@@@@@.   =@@@@@@@@      .]]]]]]`              .]]]`  ,]]]].                                       
  526.         =@@@@=@@@^   @@@@=@@@@    /@@@@@@@@@@@.   =@@@@   =@@@@/@@@@@@@@`                                    
  527.         =@@@@.@@@@. =@@@^=@@@@   /@@@/` .,@@@@^   =@@@@   =@@@@@/. ,@@@@@.                                    
  528.         =@@@@.=@@@\ @@@@.=@@@@          ,]/@@@@   =@@@@   =@@@@^    =@@@@.         ,]]]]]]]]]]]]]]]]]]]`      
  529.         =@@@@. @@@@/@@@^ =@@@@    ,@@@@@@@@@@@@   =@@@@   =@@@@.    =@@@@.         \@@@@@@@@@@@@@@@@@@@@.     
  530.         =@@@@. =@@@@@@@. =@@@@  .@@@@@/[`.=@@@/   =@@@@   =@@@@.    =@@@@.                           =@@^     
  531.         =@@@@.  @@@@@@^  =@@@@  =@@@@`   ,@@@@\   =@@@@   =@@@@.    =@@@@.                           =@@^     
  532.         =@@@@.  =@@@@@.  =@@@@   \@@@@@@@@@@@@@   =@@@@   =@@@@.    =@@@@.                        @@@@@@@@@@  
  533.         ,@@@@.   @@@@/   =@@@@    ,\@@@@[` \@@@^  =@@@O   ,@@@@.    ,@@@@.                        ,@@@@@@@@`  
  534.                                                                                                    =@@@@@@^   
  535.                                                                                                     @@@@@@   
  536.                                                                                                     .@@@@^   
  537.                                                                                                      =@@/     
  538.                                                                                                       \@.     
  539.                                                                                                        `      
  540. '''

  541. set_title()

  542. # 排除异常的情况
  543. if (os.system("flac --version >nul 2>nul")):
  544.     app_exit(4,"未找到flac官方命令行工具【flac.exe】")

  545. if (os.system("metaflac --version >nul 2>nul")):
  546.     app_exit(4,"未找到flac官方命令行工具【metaflac.exe】")

  547. if (os.system("ffmpeg -L >nul 2>nul")):
  548.     app_exit(4,"未找到ffmpeg")

  549. if (len_argv := len(sys.argv)) > 2:
  550.     app_exit(2,"\n\n参数个数过多。\n\n请拖放单个文件夹至此程序图标上,无损压缩此文件夹中的flac、wav文件;\n或复制此脚本到目标目录下双击运行,以无损压缩目标目录下的flac、wav文件。\n")

  551. # 获取flac工具vorbis_comment里的vendor_string
  552. vendor_string = get_flac_tool_vendor_string()

  553. if len_argv == 1:
  554.     input_folder = "."
  555. else:
  556.     if (not os.path.isdir(input_folder:=sys.argv[1])):
  557.         app_exit(1,f"文件夹【{input_folder}】不存在")

  558. os.chdir(input_folder)
  559. print_out_folder = "当前脚本所在目录" if input_folder=="." else os.getcwd()
  560. if start_from_explorer:
  561.     sys.stdout.write(f"\n帮助说明:\n\n此工具对flac、wav文件使用“flac --best”命令全部压制为尺寸最优化的flac文件\n以到达无损瘦身的目的(同时保留封面和metadata信息)。\n\n拖放单个文件夹至此程序图标上,无损压缩瘦身此文件夹中的flac、wav文件;\n\n或复制此脚本到目标目录下双击运行,以无损压缩瘦身目标目录下的flac、wav文件。\n\n")
  562.     sys.stdout.write(f"\n\n★ 即将无损压缩【{print_out_folder}】下的所有flac和wav文件 ★\n\n★ 按键盘任意键继续,或鼠标点击右上角关闭按钮退出。 ★\n\n")
  563.     os.system("@pause>nul")

  564. filelist = []
  565. for root , _ , filesets in os.walk("."):
  566.     for file in filesets:
  567.         filelist.append(f"{root}\\{file}")
  568. flac_processable_filelist = [(i,ret) for i in filelist if (ret := is_flac_or_wav(i))]

  569. total = len(flac_processable_filelist)
  570. completed = 0
  571. os.system(f"title 进度:{completed}/{total}")

  572. for src_file, file_type in flac_processable_filelist:
  573.    
  574.     try:
  575.         # flac输出时文件名会冲突,先重命名
  576.         filename = os.path.splitext(src_file)[0]
  577.         tmp_src = f"{filename}.bak.flac" if file_type==1 else f"{filename}.bak.wav"
  578.         output = f"{filename}.flac"
  579.         os.rename(src_file, tmp_src)

  580.         # 获取源文件大小
  581.         filesize = os.path.getsize(tmp_src)
  582.         # 预分配文件的大小比原文件大50MiB备用
  583.         pre_allocate_size = filesize + 50 * 1024**2
  584.         ## 配置指令。
  585.         # flac需要输出到stdout才能控制文件句柄进行预分配
  586.         # flac输出到stdout是没法回写【MD5 of the unencoded content】的,“--no-md5”可以在编码时关闭pcm裸流MD5的实时更新计算,节省CPU
  587.         # 同样,seektable也是没法回写的,用【--no-seektable】关掉
  588.         # 【--no-keep-foreign-metadata】是wav编码为flac时是否保留可能存在的ID3的元数据区,我这边试过,保留了播放器也显示不出封面图片和歌手标题什么的,所以关闭。
  589.         command = f"flac --no-md5 --no-seektable --no-keep-foreign-metadata --best --stdout "{tmp_src}""
  590.         # 执行压缩
  591.         process = subprocess.Popen(
  592.             args = command,
  593.             bufsize = buf_size*2,
  594.             stdout=subprocess.PIPE, stderr=None,
  595.             shell=True,
  596.         )

  597.         # 预分配写出
  598.         with win_preallocate_newfile(output, size=pre_allocate_size) as f:
  599.             # 如果输入的是wav文件的话,还需要转移meta
  600.             if file_type == 2:
  601.                 buf = \
  602.                     restruct_flac_meta_part_in_std_pipe(
  603.                       std_pipe = process.stdout,
  604.                       vorbis_comment_block = \
  605.                         struct_flac_vorbis_comment_block(
  606.                             get_source_file_metadata(tmp_src), vendor_string
  607.                         ),
  608.                       picture_block = \
  609.                         struct_flac_picture_block(
  610.                             get_source_file_top_attached_pic_data(tmp_src)
  611.                         ),
  612.                     )
  613.                 f.write(buf)

  614.             while (not process.stdout.closed) and (buf := process.stdout.read(buf_size)):
  615.                 f.write(buf)
  616.             # 如果在“实模式”预分配下,文件EOF一开始就移动好了,就需要截断,不然尾部会残留空白00空间;
  617.             # 不过这里用的是虚模式,文件EOF由系统根据写入的数据长度实时更新,截不截断就无所谓了,系统会帮你“善后”的
  618.             f.truncate()

  619.         # 管道里没东西后,尝试关闭
  620.         with suppress(Exception): process.stdout.close()
  621.         # 等待进程结束
  622.         while (process.poll() is None):
  623.             time.sleep(0.1)
  624.         # 检查是否返回非零
  625.         if process.returncode:
  626.             raise Exception("flac编码时返回非零")
  627.         
  628.         # 回写【MD5 of the unencoded content】
  629.         rewrite_flac_PCM_MD5_checksum(output)
  630.         
  631.         # 建立seektable
  632.         # 与上同理,有padding_block的存在,一般不会产生碎片
  633.         process = subprocess.run(args=f"metaflac --add-seekpoint=5s "{output}"",shell=True)
  634.         # 检查是否返回非零
  635.         if process.returncode:
  636.             raise Exception("建立seektable时metaflac返回非零")
  637.         
  638.         # 转移修改时间
  639.         transfer_modify_time(tmp_src, output)

  640.    
  641.     except Exception as e:
  642.         with suppress(Exception): os.remove(output)
  643.         sys.stderr.write(f"\n×  【{output}】出错了,详情:{e}  ×\n\n")
  644.     else:
  645.         sys.stderr.write(f"\n√  【{output}】成功  √\n\n")
  646.     finally:
  647.         completed += 1
  648.         os.system(f"title 进度:{completed}/{total}")


  649. app_exit(0,"\n处理完毕,如输出文件(不带“.bak.”字样的)没有问题,可以手动在目标目录下按住shift右键唤出cmd,\n然后输入以下命令按回车删除源文件:del /s /q *.bak.*\n")
复制代码


2# a353080017 只看他
2025-12-22 09:45:23 No. 77217126
直接用foobar2000设置最省事了,本身就支持自定义压缩命令,不在乎时间改成flac -8 -e -p还能再省点空间
已有 3 人评分天然 腹黑 理由
ding.suihong + 1 + 1 -p 浪費的時間換來的回報過小 不值得.
Chyou + 1 + 1
ssh-buanshishi + 5 + 15 -p和-e确实开眼界了,不过确实慢多了.

总评分: 天然 + 7  腹黑 + 17   查看全部评分

3# ding.suihong 只看他
2025-12-27 22:48:30 No. 77258015
嗯,寫了一堆,但麻煩還是乖乖 flac -8fe --delete-input-file *.wav / flac -8fe *.flac 吧,就算要改 tag 或者圖檔,用 flac 的 --tag-from-file=tmp.txt / --picture=tmp.jpg 或 metaflac 的 --export-tags-to=tmp.txt --no-utf8-convert / --import-tags-fram=tmp.txt --no-utf8-convert --preserve-modtime / --export-picture-to=tmp.jpg / --import-picture-fram=tmp.jpg --preserve-modtime,千萬別自己折騰自己...
已有 1 人评分天然 腹黑 理由
ssh-buanshishi + 5 + 5

总评分: 天然 + 5  腹黑 + 5   查看全部评分

4# ssh-buanshishi 只看他
2025-12-28 10:18:11 No. 77261362
ding.suihong 发表于 2025-12-27 22:48 [查看图片]
嗯,寫了一堆,但麻煩還是乖乖 flac -8fe --delete-input-file *.wav / flac -8fe *.flac 吧,就算要改 tag ...


虽然现在看起来麻烦,不过确实为以后准备写的任意格式转flac铺了路(原本我是准备写一个任意格式转flac的,时间不够就临时拼起来了这个),其他格式只能用ffmpeg解码和读取metadata和附加图片(比如这次支持的wav),况且metaflac还不支持多行文本tag输入,搞明白flac的vorbis_comment是怎么构建的也有意义在(虽然用foorbar也可以内嵌歌词),而且我这个脚本是支持文件预分配写出的,正常情况下是没有磁盘文件碎片的,这一点应该是“独一份”的。
5# ding.suihong 只看他
2025-12-28 13:02:20 No. 77262396
ssh-buanshishi 发表于 2025-12-28 10:18 [查看图片]
虽然现在看起来麻烦,不过确实为以后准备写的任意格式转flac铺了路(原本我是准备写一个任意格式转flac的 ...

多行是可以的 https://github.com/xiph/flac/issues/232#issuecomment-917700076
已有 1 人评分天使币 天然 腹黑 理由
ssh-buanshishi + 2 + 5 + 5 赞一个!

总评分: 天使币 + 2  天然 + 5  腹黑 + 5   查看全部评分

6# qx006 只看他
2025-12-28 15:55:06 No. 77263148
多谢科普,回头尝试一下~
7# iFoxy 只看他
2025-12-28 16:00:32 No. 77263188
在我使用32bit fixed-point flac之前都是用wv格式存放的(这两年flac才支持32bit fix-point的,比alac还晚OTL
已有 1 人评分天使币 天然 腹黑 理由
ssh-buanshishi + 2 + 5 + 5

总评分: 天使币 + 2  天然 + 5  腹黑 + 5   查看全部评分

8# 古明地三鲜 只看他
2025-12-28 21:47:33 No. 77264770
感谢科普,佬可以考虑考虑把源码放GitHub,比某盘好使(
已有 1 人评分天使币 天然 腹黑 理由
ssh-buanshishi + 2 + 5 + 5 赞一个!

总评分: 天使币 + 2  天然 + 5  腹黑 + 5   查看全部评分

9# ding.suihong 只看他
2025-12-28 22:16:50 No. 77264950
iFoxy 发表于 2025-12-28 16:00 [查看图片]
在我使用32bit fixed-point flac之前都是用wv格式存放的(这两年flac才支持32bit fix-point的,比alac还晚O ...

flac壓不了f32喔,你是不是其實是指s32(32bit signed)
10# iFoxy 只看他
2025-12-28 22:22:23 No. 77264983
ding.suihong 发表于 2025-12-28 22:16 [查看图片]
flac壓不了f32喔,你是不是其實是指s32(32bit signed)

不就都是int嘛,一样的(

其实float之前比较多,只是我现在拿到的都是int(可能int要晚于float?

天使动漫论坛|手机版错误报错

字幕组★|手机客户端

Powered by Discuz! TSDM SP

首页|标准版|精简版|电脑版

Processed in 0.519554 second(s), 30 queries .