websocket mp3测试代码
author孙春阳 <snickersun@163.com>
Tue, 21 Oct 2025 11:17:57 +0000 (19:17 +0800)
committer孙春阳 <snickersun@163.com>
Tue, 21 Oct 2025 11:17:57 +0000 (19:17 +0800)
26 files changed:
code/sdk/zhihui/websocket/websockets_py/src/convert_mp3_to_16k.py [new file with mode: 0644]
code/sdk/zhihui/websocket/websockets_py/src/mp3/01 - Out Of The Cavern And Into The Light_16k.mp3 [new file with mode: 0644]
code/sdk/zhihui/websocket/websockets_py/src/mp3/02 - Catch Me!_16k.mp3 [new file with mode: 0644]
code/sdk/zhihui/websocket/websockets_py/src/mp3/03 - Forgotten Bonds_16k.mp3 [new file with mode: 0644]
code/sdk/zhihui/websocket/websockets_py/src/mp3/04 - Infiltration_16k.mp3 [new file with mode: 0644]
code/sdk/zhihui/websocket/websockets_py/src/mp3/05 - The Call_16k.mp3 [new file with mode: 0644]
code/sdk/zhihui/websocket/websockets_py/src/mp3/06 - Envers_16k.mp3 [new file with mode: 0644]
code/sdk/zhihui/websocket/websockets_py/src/mp3/07 - Gardens of Plenty_16k.mp3 [new file with mode: 0644]
code/sdk/zhihui/websocket/websockets_py/src/mp3/09 - Temple Of Knowledge_16k.mp3 [new file with mode: 0644]
code/sdk/zhihui/websocket/websockets_py/src/mp3/10 - The Cogs Of Science_16k.mp3 [new file with mode: 0644]
code/sdk/zhihui/websocket/websockets_py/src/mp3/11 - Exil_16k.mp3 [new file with mode: 0644]
code/sdk/zhihui/websocket/websockets_py/src/mp3/13 - The Chase_16k.mp3 [new file with mode: 0644]
code/sdk/zhihui/websocket/websockets_py/src/mp3/14 - Smell Of Rain_16k.mp3 [new file with mode: 0644]
code/sdk/zhihui/websocket/websockets_py/src/mp3/15 - Bonds Reforged_16k.mp3 [new file with mode: 0644]
code/sdk/zhihui/websocket/websockets_py/src/mp3/INN 1K_16k.mp3 [new file with mode: 0644]
code/sdk/zhihui/websocket/websockets_py/src/mp3/Zaho de Sagazan - Sous le ciel de Paris (巴黎天空下)(2024巴黎奥运会闭幕式现场)_16k.mp3 [new file with mode: 0644]
code/sdk/zhihui/websocket/websockets_py/src/mp3/better-day-186374_16k.mp3 [new file with mode: 0644]
code/sdk/zhihui/websocket/websockets_py/src/mp3/night-detective-226857_16k.mp3 [new file with mode: 0644]
code/sdk/zhihui/websocket/websockets_py/src/mp3/哎呦我的姑娘 (cover, 野哥)-妖妹_16k.mp3 [new file with mode: 0644]
code/sdk/zhihui/websocket/websockets_py/src/mp3/唱支山歌给党听-宋祖英_16k.mp3 [new file with mode: 0644]
code/sdk/zhihui/websocket/websockets_py/src/mp3/好大一棵树-那英_16k.mp3 [new file with mode: 0644]
code/sdk/zhihui/websocket/websockets_py/src/mp3/少游纪 - 海中央的山_16k.mp3 [new file with mode: 0644]
code/sdk/zhihui/websocket/websockets_py/src/mp3/祈祷文-快颂10遍_16k.mp3 [new file with mode: 0644]
code/sdk/zhihui/websocket/websockets_py/src/mp3_stream_client.py [new file with mode: 0644]
code/sdk/zhihui/websocket/websockets_py/src/mp3_stream_server.py [new file with mode: 0644]
code/sdk/zhihui/websocket/websockets_py/src/websockets/version.py

diff --git a/code/sdk/zhihui/websocket/websockets_py/src/convert_mp3_to_16k.py b/code/sdk/zhihui/websocket/websockets_py/src/convert_mp3_to_16k.py
new file mode 100644 (file)
index 0000000..6810360
--- /dev/null
@@ -0,0 +1,262 @@
+#!/usr/bin/env python3
+"""
+MP3音频转换工具
+将任意MP3文件转换为16kHz采样率、单声道的MP3文件
+"""
+
+import os
+import sys
+import argparse
+import subprocess
+from pathlib import Path
+import logging
+
+# 配置日志
+logging.basicConfig(
+    level=logging.INFO,
+    format='%(asctime)s - %(levelname)s - %(message)s'
+)
+logger = logging.getLogger(__name__)
+
+
+def check_ffmpeg():
+    """检查ffmpeg是否已安装"""
+    try:
+        result = subprocess.run(
+            ['ffmpeg', '-version'],
+            stdout=subprocess.PIPE,
+            stderr=subprocess.PIPE,
+            check=False
+        )
+        if result.returncode == 0:
+            logger.info("✓ ffmpeg已安装")
+            return True
+        else:
+            logger.error("✗ ffmpeg未正确安装")
+            return False
+    except FileNotFoundError:
+        logger.error("✗ 未找到ffmpeg,请先安装ffmpeg")
+        logger.error("  Ubuntu/Debian: sudo apt-get install ffmpeg")
+        logger.error("  CentOS/RHEL: sudo yum install ffmpeg")
+        logger.error("  macOS: brew install ffmpeg")
+        return False
+
+
+def convert_mp3(input_file, output_file=None, sample_rate=16000, channels=1, bitrate='64k'):
+    """
+    转换MP3文件
+    
+    Args:
+        input_file: 输入MP3文件路径
+        output_file: 输出MP3文件路径(可选,默认为输入文件名_16k.mp3)
+        sample_rate: 采样率,默认16000 (16kHz)
+        channels: 声道数,默认1(单声道)
+        bitrate: 比特率,默认64k
+    
+    Returns:
+        bool: 转换是否成功
+    """
+    input_path = Path(input_file)
+    
+    # 检查输入文件是否存在
+    if not input_path.exists():
+        logger.error(f"输入文件不存在: {input_file}")
+        return False
+    
+    # 检查文件是否为MP3
+    if input_path.suffix.lower() != '.mp3':
+        logger.warning(f"文件不是MP3格式: {input_file}")
+    
+    # 生成输出文件名
+    if output_file is None:
+        output_path = input_path.parent / f"{input_path.stem}_16k.mp3"
+    else:
+        output_path = Path(output_file)
+    
+    # 确保输出目录存在
+    output_path.parent.mkdir(parents=True, exist_ok=True)
+    
+    # 构建ffmpeg命令
+    cmd = [
+        'ffmpeg',
+        '-i', str(input_path),           # 输入文件
+        '-ar', str(sample_rate),          # 采样率
+        '-ac', str(channels),             # 声道数
+        '-b:a', bitrate,                  # 比特率
+        '-y',                             # 覆盖输出文件
+        str(output_path)                  # 输出文件
+    ]
+    
+    logger.info(f"转换: {input_path.name} -> {output_path.name}")
+    logger.debug(f"命令: {' '.join(cmd)}")
+    
+    try:
+        # 执行转换
+        result = subprocess.run(
+            cmd,
+            stdout=subprocess.PIPE,
+            stderr=subprocess.PIPE,
+            check=False
+        )
+        
+        if result.returncode == 0:
+            # 获取文件大小
+            input_size = input_path.stat().st_size / 1024  # KB
+            output_size = output_path.stat().st_size / 1024  # KB
+            logger.info(f"✓ 转换成功: {output_path}")
+            logger.info(f"  输入大小: {input_size:.1f} KB")
+            logger.info(f"  输出大小: {output_size:.1f} KB")
+            return True
+        else:
+            logger.error(f"✗ 转换失败: {input_file}")
+            logger.error(f"  错误信息: {result.stderr.decode('utf-8', errors='ignore')}")
+            return False
+            
+    except Exception as e:
+        logger.error(f"✗ 转换过程出错: {e}")
+        return False
+
+
+def convert_directory(input_dir, output_dir=None, sample_rate=16000, channels=1, bitrate='64k', recursive=False):
+    """
+    批量转换目录中的所有MP3文件
+    
+    Args:
+        input_dir: 输入目录
+        output_dir: 输出目录(可选)
+        sample_rate: 采样率
+        channels: 声道数
+        bitrate: 比特率
+        recursive: 是否递归处理子目录
+    
+    Returns:
+        tuple: (成功数量, 失败数量)
+    """
+    input_path = Path(input_dir)
+    
+    if not input_path.exists() or not input_path.is_dir():
+        logger.error(f"输入目录不存在: {input_dir}")
+        return 0, 0
+    
+    # 查找所有MP3文件
+    if recursive:
+        mp3_files = list(input_path.rglob('*.mp3'))
+    else:
+        mp3_files = list(input_path.glob('*.mp3'))
+    
+    if not mp3_files:
+        logger.warning(f"未找到MP3文件: {input_dir}")
+        return 0, 0
+    
+    logger.info(f"找到 {len(mp3_files)} 个MP3文件")
+    
+    success_count = 0
+    fail_count = 0
+    
+    for mp3_file in mp3_files:
+        # 跳过已经转换过的文件(文件名包含_16k)
+        if '_16k' in mp3_file.stem:
+            logger.info(f"跳过已转换文件: {mp3_file.name}")
+            continue
+        
+        # 确定输出文件路径
+        if output_dir:
+            output_path = Path(output_dir)
+            # 保持相对路径结构
+            rel_path = mp3_file.relative_to(input_path)
+            out_file = output_path / rel_path.parent / f"{mp3_file.stem}_16k.mp3"
+        else:
+            out_file = None  # 使用默认命名
+        
+        # 转换文件
+        if convert_mp3(mp3_file, out_file, sample_rate, channels, bitrate):
+            success_count += 1
+        else:
+            fail_count += 1
+    
+    return success_count, fail_count
+
+
+def main():
+    parser = argparse.ArgumentParser(
+        description='将MP3文件转换为16kHz单声道格式',
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+        epilog="""
+示例:
+  # 转换单个文件
+  %(prog)s input.mp3
+  
+  # 指定输出文件
+  %(prog)s input.mp3 -o output.mp3
+  
+  # 批量转换目录
+  %(prog)s -d /path/to/mp3/
+  
+  # 递归转换目录及子目录
+  %(prog)s -d /path/to/mp3/ -r
+  
+  # 自定义参数
+  %(prog)s input.mp3 --sample-rate 8000 --bitrate 32k
+        """
+    )
+    
+    parser.add_argument('input', nargs='?', help='输入MP3文件或目录')
+    parser.add_argument('-o', '--output', help='输出MP3文件或目录')
+    parser.add_argument('-d', '--directory', help='批量转换目录')
+    parser.add_argument('-r', '--recursive', action='store_true', help='递归处理子目录')
+    parser.add_argument('--sample-rate', type=int, default=16000, help='采样率(默认: 16000)')
+    parser.add_argument('--channels', type=int, default=1, choices=[1, 2], help='声道数(默认: 1单声道)')
+    parser.add_argument('--bitrate', default='64k', help='比特率(默认: 64k)')
+    parser.add_argument('-v', '--verbose', action='store_true', help='详细输出')
+    
+    args = parser.parse_args()
+    
+    # 设置日志级别
+    if args.verbose:
+        logger.setLevel(logging.DEBUG)
+    
+    # 检查ffmpeg
+    if not check_ffmpeg():
+        return 1
+    
+    # 处理目录模式
+    if args.directory:
+        logger.info(f"批量转换模式")
+        success, fail = convert_directory(
+            args.directory,
+            args.output,
+            args.sample_rate,
+            args.channels,
+            args.bitrate,
+            args.recursive
+        )
+        logger.info(f"转换完成: 成功 {success}, 失败 {fail}")
+        return 0 if fail == 0 else 1
+    
+    # 处理单文件模式
+    elif args.input:
+        input_path = Path(args.input)
+        
+        # 如果输入是目录,提示使用-d参数
+        if input_path.is_dir():
+            logger.error("输入是目录,请使用 -d 参数进行批量转换")
+            return 1
+        
+        # 转换单个文件
+        success = convert_mp3(
+            args.input,
+            args.output,
+            args.sample_rate,
+            args.channels,
+            args.bitrate
+        )
+        return 0 if success else 1
+    
+    else:
+        parser.print_help()
+        return 1
+
+
+if __name__ == '__main__':
+    sys.exit(main())
+
diff --git a/code/sdk/zhihui/websocket/websockets_py/src/mp3/01 - Out Of The Cavern And Into The Light_16k.mp3 b/code/sdk/zhihui/websocket/websockets_py/src/mp3/01 - Out Of The Cavern And Into The Light_16k.mp3
new file mode 100644 (file)
index 0000000..b2d16ef
Binary files /dev/null and b/code/sdk/zhihui/websocket/websockets_py/src/mp3/01 - Out Of The Cavern And Into The Light_16k.mp3 differ
diff --git a/code/sdk/zhihui/websocket/websockets_py/src/mp3/02 - Catch Me!_16k.mp3 b/code/sdk/zhihui/websocket/websockets_py/src/mp3/02 - Catch Me!_16k.mp3
new file mode 100644 (file)
index 0000000..9fb9381
Binary files /dev/null and b/code/sdk/zhihui/websocket/websockets_py/src/mp3/02 - Catch Me!_16k.mp3 differ
diff --git a/code/sdk/zhihui/websocket/websockets_py/src/mp3/03 - Forgotten Bonds_16k.mp3 b/code/sdk/zhihui/websocket/websockets_py/src/mp3/03 - Forgotten Bonds_16k.mp3
new file mode 100644 (file)
index 0000000..4351dc2
Binary files /dev/null and b/code/sdk/zhihui/websocket/websockets_py/src/mp3/03 - Forgotten Bonds_16k.mp3 differ
diff --git a/code/sdk/zhihui/websocket/websockets_py/src/mp3/04 - Infiltration_16k.mp3 b/code/sdk/zhihui/websocket/websockets_py/src/mp3/04 - Infiltration_16k.mp3
new file mode 100644 (file)
index 0000000..b7d9b17
Binary files /dev/null and b/code/sdk/zhihui/websocket/websockets_py/src/mp3/04 - Infiltration_16k.mp3 differ
diff --git a/code/sdk/zhihui/websocket/websockets_py/src/mp3/05 - The Call_16k.mp3 b/code/sdk/zhihui/websocket/websockets_py/src/mp3/05 - The Call_16k.mp3
new file mode 100644 (file)
index 0000000..01b33af
Binary files /dev/null and b/code/sdk/zhihui/websocket/websockets_py/src/mp3/05 - The Call_16k.mp3 differ
diff --git a/code/sdk/zhihui/websocket/websockets_py/src/mp3/06 - Envers_16k.mp3 b/code/sdk/zhihui/websocket/websockets_py/src/mp3/06 - Envers_16k.mp3
new file mode 100644 (file)
index 0000000..5a6dd67
Binary files /dev/null and b/code/sdk/zhihui/websocket/websockets_py/src/mp3/06 - Envers_16k.mp3 differ
diff --git a/code/sdk/zhihui/websocket/websockets_py/src/mp3/07 - Gardens of Plenty_16k.mp3 b/code/sdk/zhihui/websocket/websockets_py/src/mp3/07 - Gardens of Plenty_16k.mp3
new file mode 100644 (file)
index 0000000..defb0fb
Binary files /dev/null and b/code/sdk/zhihui/websocket/websockets_py/src/mp3/07 - Gardens of Plenty_16k.mp3 differ
diff --git a/code/sdk/zhihui/websocket/websockets_py/src/mp3/09 - Temple Of Knowledge_16k.mp3 b/code/sdk/zhihui/websocket/websockets_py/src/mp3/09 - Temple Of Knowledge_16k.mp3
new file mode 100644 (file)
index 0000000..c33e140
Binary files /dev/null and b/code/sdk/zhihui/websocket/websockets_py/src/mp3/09 - Temple Of Knowledge_16k.mp3 differ
diff --git a/code/sdk/zhihui/websocket/websockets_py/src/mp3/10 - The Cogs Of Science_16k.mp3 b/code/sdk/zhihui/websocket/websockets_py/src/mp3/10 - The Cogs Of Science_16k.mp3
new file mode 100644 (file)
index 0000000..1dfae9d
Binary files /dev/null and b/code/sdk/zhihui/websocket/websockets_py/src/mp3/10 - The Cogs Of Science_16k.mp3 differ
diff --git a/code/sdk/zhihui/websocket/websockets_py/src/mp3/11 - Exil_16k.mp3 b/code/sdk/zhihui/websocket/websockets_py/src/mp3/11 - Exil_16k.mp3
new file mode 100644 (file)
index 0000000..b7fa6da
Binary files /dev/null and b/code/sdk/zhihui/websocket/websockets_py/src/mp3/11 - Exil_16k.mp3 differ
diff --git a/code/sdk/zhihui/websocket/websockets_py/src/mp3/13 - The Chase_16k.mp3 b/code/sdk/zhihui/websocket/websockets_py/src/mp3/13 - The Chase_16k.mp3
new file mode 100644 (file)
index 0000000..715ad2e
Binary files /dev/null and b/code/sdk/zhihui/websocket/websockets_py/src/mp3/13 - The Chase_16k.mp3 differ
diff --git a/code/sdk/zhihui/websocket/websockets_py/src/mp3/14 - Smell Of Rain_16k.mp3 b/code/sdk/zhihui/websocket/websockets_py/src/mp3/14 - Smell Of Rain_16k.mp3
new file mode 100644 (file)
index 0000000..c786526
Binary files /dev/null and b/code/sdk/zhihui/websocket/websockets_py/src/mp3/14 - Smell Of Rain_16k.mp3 differ
diff --git a/code/sdk/zhihui/websocket/websockets_py/src/mp3/15 - Bonds Reforged_16k.mp3 b/code/sdk/zhihui/websocket/websockets_py/src/mp3/15 - Bonds Reforged_16k.mp3
new file mode 100644 (file)
index 0000000..3669a0b
Binary files /dev/null and b/code/sdk/zhihui/websocket/websockets_py/src/mp3/15 - Bonds Reforged_16k.mp3 differ
diff --git a/code/sdk/zhihui/websocket/websockets_py/src/mp3/INN 1K_16k.mp3 b/code/sdk/zhihui/websocket/websockets_py/src/mp3/INN 1K_16k.mp3
new file mode 100644 (file)
index 0000000..2a24aeb
Binary files /dev/null and "b/code/sdk/zhihui/websocket/websockets_py/src/mp3/INN\302\2401K_16k.mp3" differ
diff --git a/code/sdk/zhihui/websocket/websockets_py/src/mp3/Zaho de Sagazan - Sous le ciel de Paris (巴黎天空下)(2024巴黎奥运会闭幕式现场)_16k.mp3 b/code/sdk/zhihui/websocket/websockets_py/src/mp3/Zaho de Sagazan - Sous le ciel de Paris (巴黎天空下)(2024巴黎奥运会闭幕式现场)_16k.mp3
new file mode 100644 (file)
index 0000000..658d5b5
Binary files /dev/null and "b/code/sdk/zhihui/websocket/websockets_py/src/mp3/Zaho de Sagazan - Sous le ciel de Paris (\345\267\264\351\273\216\345\244\251\347\251\272\344\270\213)(2024\345\267\264\351\273\216\345\245\245\350\277\220\344\274\232\351\227\255\345\271\225\345\274\217\347\216\260\345\234\272)_16k.mp3" differ
diff --git a/code/sdk/zhihui/websocket/websockets_py/src/mp3/better-day-186374_16k.mp3 b/code/sdk/zhihui/websocket/websockets_py/src/mp3/better-day-186374_16k.mp3
new file mode 100644 (file)
index 0000000..a1fb89b
Binary files /dev/null and b/code/sdk/zhihui/websocket/websockets_py/src/mp3/better-day-186374_16k.mp3 differ
diff --git a/code/sdk/zhihui/websocket/websockets_py/src/mp3/night-detective-226857_16k.mp3 b/code/sdk/zhihui/websocket/websockets_py/src/mp3/night-detective-226857_16k.mp3
new file mode 100644 (file)
index 0000000..863a61d
Binary files /dev/null and b/code/sdk/zhihui/websocket/websockets_py/src/mp3/night-detective-226857_16k.mp3 differ
diff --git a/code/sdk/zhihui/websocket/websockets_py/src/mp3/哎呦我的姑娘 (cover, 野哥)-妖妹_16k.mp3 b/code/sdk/zhihui/websocket/websockets_py/src/mp3/哎呦我的姑娘 (cover, 野哥)-妖妹_16k.mp3
new file mode 100644 (file)
index 0000000..0a73d92
Binary files /dev/null and "b/code/sdk/zhihui/websocket/websockets_py/src/mp3/\345\223\216\345\221\246\346\210\221\347\232\204\345\247\221\345\250\230 (cover, \351\207\216\345\223\245)-\345\246\226\345\246\271_16k.mp3" differ
diff --git a/code/sdk/zhihui/websocket/websockets_py/src/mp3/唱支山歌给党听-宋祖英_16k.mp3 b/code/sdk/zhihui/websocket/websockets_py/src/mp3/唱支山歌给党听-宋祖英_16k.mp3
new file mode 100644 (file)
index 0000000..b6f1b0d
Binary files /dev/null and "b/code/sdk/zhihui/websocket/websockets_py/src/mp3/\345\224\261\346\224\257\345\261\261\346\255\214\347\273\231\345\205\232\345\220\254-\345\256\213\347\245\226\350\213\261_16k.mp3" differ
diff --git a/code/sdk/zhihui/websocket/websockets_py/src/mp3/好大一棵树-那英_16k.mp3 b/code/sdk/zhihui/websocket/websockets_py/src/mp3/好大一棵树-那英_16k.mp3
new file mode 100644 (file)
index 0000000..d8aed12
Binary files /dev/null and "b/code/sdk/zhihui/websocket/websockets_py/src/mp3/\345\245\275\345\244\247\344\270\200\346\243\265\346\240\221-\351\202\243\350\213\261_16k.mp3" differ
diff --git a/code/sdk/zhihui/websocket/websockets_py/src/mp3/少游纪 - 海中央的山_16k.mp3 b/code/sdk/zhihui/websocket/websockets_py/src/mp3/少游纪 - 海中央的山_16k.mp3
new file mode 100644 (file)
index 0000000..5d7ff69
Binary files /dev/null and "b/code/sdk/zhihui/websocket/websockets_py/src/mp3/\345\260\221\346\270\270\347\272\252 - \346\265\267\344\270\255\345\244\256\347\232\204\345\261\261_16k.mp3" differ
diff --git a/code/sdk/zhihui/websocket/websockets_py/src/mp3/祈祷文-快颂10遍_16k.mp3 b/code/sdk/zhihui/websocket/websockets_py/src/mp3/祈祷文-快颂10遍_16k.mp3
new file mode 100644 (file)
index 0000000..9fb061e
Binary files /dev/null and "b/code/sdk/zhihui/websocket/websockets_py/src/mp3/\347\245\210\347\245\267\346\226\207-\345\277\253\351\242\20210\351\201\215_16k.mp3" differ
diff --git a/code/sdk/zhihui/websocket/websockets_py/src/mp3_stream_client.py b/code/sdk/zhihui/websocket/websockets_py/src/mp3_stream_client.py
new file mode 100644 (file)
index 0000000..07933ef
--- /dev/null
@@ -0,0 +1,350 @@
+#!/usr/bin/env python3
+"""
+WebSocket MP3流播放客户端
+接收MP3二进制流并实时播放
+"""
+
+import asyncio
+import logging
+import argparse
+import sys
+import io
+import tempfile
+from pathlib import Path
+from datetime import datetime
+
+try:
+    import websockets
+except ImportError:
+    print("错误: 无法导入 websockets 模块")
+    print("请安装: pip install websockets")
+    sys.exit(1)
+
+try:
+    import pygame
+    PYGAME_AVAILABLE = True
+except ImportError:
+    PYGAME_AVAILABLE = False
+    print("警告: pygame 未安装,将保存到文件而不播放")
+    print("安装播放功能: pip install pygame")
+
+# 配置日志
+logging.basicConfig(
+    level=logging.INFO,
+    format='%(asctime)s - %(levelname)s - %(message)s'
+)
+logger = logging.getLogger(__name__)
+
+
+class MP3StreamClient:
+    """WebSocket MP3流播放客户端"""
+    
+    def __init__(self, server_url: str, play_mode: str = 'stream', 
+                 save_dir: str = None, buffer_size: int = 262144):
+        """
+        初始化客户端
+        
+        Args:
+            server_url: WebSocket服务器地址 (如: ws://localhost:8910)
+            play_mode: 播放模式 ('stream'=流式播放, 'buffer'=缓冲播放, 'save'=仅保存)
+            save_dir: 保存目录 (save模式必需)
+            buffer_size: 缓冲区大小(字节,默认256KB,避免播放卡顿)
+        """
+        self.server_url = server_url
+        self.play_mode = play_mode
+        self.save_dir = Path(save_dir) if save_dir else None
+        self.buffer_size = buffer_size
+        
+        # 统计信息
+        self.received_bytes = 0
+        self.received_packets = 0
+        self.start_time = None
+        
+        # 播放相关
+        self.audio_buffer = bytearray()
+        self.temp_file = None
+        self.is_playing = False
+        self.play_queue = []  # 播放队列
+        self.min_buffer_size = max(buffer_size, 131072)  # 最小128KB
+        
+        # 验证保存目录
+        if self.play_mode == 'save':
+            if not self.save_dir:
+                raise ValueError("save模式必须指定保存目录")
+            self.save_dir.mkdir(parents=True, exist_ok=True)
+            logger.info(f"保存目录: {self.save_dir.absolute()}")
+        
+        # 初始化pygame(增大缓冲区避免卡顿)
+        if PYGAME_AVAILABLE and self.play_mode != 'save':
+            pygame.mixer.init(frequency=44100, size=-16, channels=2, buffer=8192)
+            logger.info("音频播放器已初始化")
+            logger.info(f"建议:为避免卡顿,建议缓冲区≥256KB")
+        
+        logger.info(f"服务器地址: {server_url}")
+        logger.info(f"播放模式: {play_mode}")
+        logger.info(f"缓冲大小: {self.format_bytes(buffer_size)}")
+        if play_mode != 'save':
+            logger.info(f"最小缓冲: {self.format_bytes(self.min_buffer_size)}")
+    
+    def format_bytes(self, bytes_val):
+        """格式化字节大小"""
+        if bytes_val < 1024:
+            return f"{bytes_val} B"
+        elif bytes_val < 1024 * 1024:
+            return f"{bytes_val / 1024:.2f} KB"
+        else:
+            return f"{bytes_val / (1024 * 1024):.2f} MB"
+    
+    def print_stats(self):
+        """打印统计信息"""
+        if self.start_time:
+            elapsed = (datetime.now() - self.start_time).total_seconds()
+            speed = self.received_bytes / elapsed if elapsed > 0 else 0
+            logger.info(f"统计: 接收 {self.format_bytes(self.received_bytes)} "
+                       f"({self.received_packets} 包) "
+                       f"平均速度 {self.format_bytes(speed)}/s")
+    
+    async def play_stream_mode(self, data):
+        """
+        流式播放模式:累积较大数据块后播放,减少卡顿
+        
+        关键改进:
+        1. 累积足够大的数据块(默认256KB)再播放
+        2. 使用队列机制平滑衔接
+        3. 避免频繁创建小文件导致的停顿
+        """
+        self.audio_buffer.extend(data)
+        
+        # 当缓冲区达到足够大时才播放(避免小片段导致卡顿)
+        if len(self.audio_buffer) >= self.min_buffer_size:
+            try:
+                # 创建临时MP3文件
+                with tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) as f:
+                    f.write(bytes(self.audio_buffer))
+                    temp_path = f.name
+                
+                self.play_queue.append(temp_path)
+                
+                # 播放队列中的音频
+                if not pygame.mixer.music.get_busy():
+                    # 没有音频在播放,立即播放
+                    pygame.mixer.music.load(temp_path)
+                    pygame.mixer.music.play()
+                    logger.info(f"▶ 开始播放: {self.format_bytes(len(self.audio_buffer))}")
+                    self.play_queue.pop(0)
+                else:
+                    # 正在播放,排队下一个
+                    if len(self.play_queue) == 1:
+                        pygame.mixer.music.queue(temp_path)
+                        logger.debug(f"📋 音频排队: {self.format_bytes(len(self.audio_buffer))}")
+                        self.play_queue.pop(0)
+                    else:
+                        logger.debug(f"⏸ 队列等待: {len(self.play_queue)} 个")
+                
+                # 清空缓冲区
+                self.audio_buffer.clear()
+                
+            except Exception as e:
+                logger.error(f"播放出错: {e}")
+    
+    async def play_buffer_mode(self, data):
+        """
+        缓冲播放模式:累积更大的数据后再播放
+        适合网络不稳定的情况,提供最流畅的体验
+        """
+        self.audio_buffer.extend(data)
+        
+        # 缓冲更大的数据量(512KB或更多)
+        min_buffer = max(self.buffer_size * 2, 524288)  # 至少512KB
+        
+        if len(self.audio_buffer) >= min_buffer:
+            try:
+                with tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) as f:
+                    f.write(bytes(self.audio_buffer))
+                    temp_path = f.name
+                
+                # 等待当前播放完成(避免重叠)
+                while pygame.mixer.music.get_busy():
+                    await asyncio.sleep(0.1)
+                
+                pygame.mixer.music.load(temp_path)
+                pygame.mixer.music.play()
+                logger.info(f"▶ 播放音频块: {self.format_bytes(len(self.audio_buffer))}")
+                
+                self.audio_buffer.clear()
+                
+            except Exception as e:
+                logger.error(f"播放出错: {e}")
+    
+    async def save_mode(self, data):
+        """
+        仅保存模式:将接收的数据保存到文件
+        """
+        if not self.temp_file:
+            # 创建新文件
+            timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
+            filename = f"stream_{timestamp}.mp3"
+            self.temp_file = self.save_dir / filename
+            logger.info(f"开始保存到: {self.temp_file}")
+        
+        # 追加数据
+        with open(self.temp_file, 'ab') as f:
+            f.write(data)
+    
+    async def receive_and_play(self):
+        """接收并播放MP3流"""
+        try:
+            logger.info(f"连接到服务器: {self.server_url}")
+            
+            async with websockets.connect(self.server_url) as websocket:
+                logger.info("连接成功,开始接收MP3流...")
+                self.start_time = datetime.now()
+                
+                # 定期打印统计信息
+                last_stats_time = datetime.now()
+                
+                async for message in websocket:
+                    # 接收二进制数据
+                    if isinstance(message, bytes):
+                        self.received_bytes += len(message)
+                        self.received_packets += 1
+                        
+                        logger.debug(f"接收包 #{self.received_packets}: {len(message)} 字节")
+                        
+                        # 根据模式处理数据
+                        if self.play_mode == 'stream' and PYGAME_AVAILABLE:
+                            await self.play_stream_mode(message)
+                        elif self.play_mode == 'buffer' and PYGAME_AVAILABLE:
+                            await self.play_buffer_mode(message)
+                        elif self.play_mode == 'save':
+                            await self.save_mode(message)
+                        else:
+                            # 没有pygame,自动切换到保存模式
+                            if not hasattr(self, '_warned_no_pygame'):
+                                logger.warning("pygame不可用,切换到保存模式")
+                                self._warned_no_pygame = True
+                                if not self.save_dir:
+                                    self.save_dir = Path('./received_mp3')
+                                    self.save_dir.mkdir(exist_ok=True)
+                            await self.save_mode(message)
+                        
+                        # 定期打印统计信息(每5秒)
+                        now = datetime.now()
+                        if (now - last_stats_time).total_seconds() >= 5:
+                            self.print_stats()
+                            last_stats_time = now
+                    
+                    else:
+                        logger.warning(f"接收到非二进制消息: {message}")
+                
+        except websockets.exceptions.ConnectionClosedOK:
+            logger.info("服务器关闭连接")
+        except websockets.exceptions.ConnectionClosedError as e:
+            logger.error(f"连接异常关闭: {e}")
+        except Exception as e:
+            logger.error(f"接收数据出错: {e}", exc_info=True)
+        finally:
+            # 播放剩余缓冲区数据
+            if len(self.audio_buffer) > 0:
+                if self.play_mode in ['stream', 'buffer'] and PYGAME_AVAILABLE:
+                    try:
+                        with tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) as f:
+                            f.write(bytes(self.audio_buffer))
+                            temp_path = f.name
+                        pygame.mixer.music.load(temp_path)
+                        pygame.mixer.music.play()
+                        # 等待播放完成
+                        while pygame.mixer.music.get_busy():
+                            await asyncio.sleep(0.1)
+                    except Exception as e:
+                        logger.error(f"播放剩余数据出错: {e}")
+                elif self.play_mode == 'save':
+                    await self.save_mode(bytes(self.audio_buffer))
+            
+            # 打印最终统计
+            self.print_stats()
+            
+            if self.play_mode == 'save' and self.temp_file:
+                logger.info(f"文件已保存: {self.temp_file} ({self.format_bytes(self.temp_file.stat().st_size)})")
+    
+    async def run(self):
+        """运行客户端"""
+        try:
+            await self.receive_and_play()
+        except KeyboardInterrupt:
+            logger.info("用户中断")
+        finally:
+            # 清理
+            if PYGAME_AVAILABLE:
+                pygame.mixer.quit()
+            logger.info("客户端已停止")
+
+
+def main():
+    """主函数"""
+    parser = argparse.ArgumentParser(
+        description='WebSocket MP3流播放客户端',
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+        epilog="""
+播放模式说明:
+  stream  - 流式播放(实时,延迟最低)
+  buffer  - 缓冲播放(累积更多数据后播放,更稳定)
+  save    - 仅保存到文件,不播放
+
+示例:
+  # 流式播放
+  python3 mp3_stream_client.py ws://localhost:8910
+  
+  # 缓冲播放
+  python3 mp3_stream_client.py ws://localhost:8910 --mode buffer
+  
+  # 仅保存
+  python3 mp3_stream_client.py ws://localhost:8910 --mode save --save-dir ./downloads
+        """
+    )
+    
+    parser.add_argument('server', help='WebSocket服务器地址 (如: ws://snicker.com.cn:8910)')
+    parser.add_argument('--mode', choices=['stream', 'buffer', 'save'], 
+                       default='buffer', help='播放模式 (默认: buffer,最流畅)')
+    parser.add_argument('--save-dir', help='保存目录 (save模式必需)')
+    parser.add_argument('--buffer', type=int, default=262144, 
+                       help='缓冲区大小(字节)(默认: 262144=256KB,避免卡顿)')
+    parser.add_argument('--debug', action='store_true', help='启用调试日志')
+    
+    args = parser.parse_args()
+    
+    # 设置日志级别
+    if args.debug:
+        logging.getLogger().setLevel(logging.DEBUG)
+    
+    # 验证参数
+    if args.mode == 'save' and not args.save_dir:
+        parser.error("save模式必须指定 --save-dir")
+    
+    # 检查pygame
+    if args.mode in ['stream', 'buffer'] and not PYGAME_AVAILABLE:
+        logger.warning("pygame未安装,将自动切换到save模式")
+        if not args.save_dir:
+            args.save_dir = './received_mp3'
+            logger.info(f"使用默认保存目录: {args.save_dir}")
+    
+    # 创建并运行客户端
+    try:
+        client = MP3StreamClient(
+            server_url=args.server,
+            play_mode=args.mode,
+            save_dir=args.save_dir,
+            buffer_size=args.buffer
+        )
+        
+        asyncio.run(client.run())
+        
+    except Exception as e:
+        logger.error(f"客户端错误: {e}", exc_info=True)
+        sys.exit(1)
+
+
+if __name__ == '__main__':
+    main()
+
+
diff --git a/code/sdk/zhihui/websocket/websockets_py/src/mp3_stream_server.py b/code/sdk/zhihui/websocket/websockets_py/src/mp3_stream_server.py
new file mode 100644 (file)
index 0000000..d73f333
--- /dev/null
@@ -0,0 +1,342 @@
+#!/usr/bin/env python3
+"""
+WebSocket MP3流传输服务器
+直接发送MP3二进制流,客户端可直接播放,无需保存
+"""
+
+import asyncio
+import logging
+import argparse
+import os
+import time
+import struct
+from pathlib import Path
+from typing import Set
+
+try:
+    import websockets
+    from websockets.asyncio.server import serve
+    from websockets.exceptions import ConnectionClosedOK, ConnectionClosedError
+except ImportError as e:
+    print(f"错误: 无法导入 websockets 模块: {e}")
+    print("请安装 websockets: pip install websockets")
+    exit(1)
+
+try:
+    from mutagen.mp3 import MP3
+    HAS_MUTAGEN = True
+except ImportError:
+    HAS_MUTAGEN = False
+    logger = logging.getLogger(__name__)
+    logger.warning("mutagen未安装,将使用比特率估算时长。安装: pip install mutagen")
+
+# 配置日志
+logging.basicConfig(
+    level=logging.INFO,
+    format='%(asctime)s - %(levelname)s - %(message)s'
+)
+logger = logging.getLogger(__name__)
+
+
+class MP3StreamServer:
+    """WebSocket MP3流传输服务器类"""
+    
+    def __init__(self, host: str, port: int, file_dir: str, 
+                 realtime: bool = True, speed_factor: float = 1.0,
+                 chunk_size: int = 4096, send_buffer_size: int = 16384):
+        """
+        初始化服务器
+        
+        Args:
+            host: 服务器地址
+            port: 服务器端口
+            file_dir: MP3文件目录
+            realtime: 是否按实时速度发送(根据MP3时长控制)
+            speed_factor: 播放速度倍数(1.0=正常,2.0=2倍速)
+            chunk_size: 每次读取的数据块大小(字节)
+            send_buffer_size: 发送缓存大小(字节)
+        """
+        self.host = host
+        self.port = port
+        self.file_dir = Path(file_dir)
+        self.realtime = realtime
+        self.speed_factor = speed_factor
+        self.chunk_size = chunk_size
+        self.send_buffer_size = send_buffer_size
+        self.clients = set()
+        
+        # 验证目录
+        if not self.file_dir.exists():
+            raise ValueError(f"目录不存在: {file_dir}")
+        if not self.file_dir.is_dir():
+            raise ValueError(f"不是目录: {file_dir}")
+        
+        logger.info(f"MP3目录: {self.file_dir.absolute()}")
+        logger.info(f"实时播放: {'是' if realtime else '否(最快速度)'}")
+        if realtime:
+            logger.info(f"播放速度: {speed_factor}x")
+        logger.info(f"读取块: {chunk_size}字节")
+        logger.info(f"发送缓存: {send_buffer_size}字节")
+    
+    def get_mp3_list(self):
+        """获取目录下所有MP3文件列表"""
+        files = []
+        for item in sorted(self.file_dir.iterdir()):
+            if item.is_file() and item.suffix.lower() in ['.mp3', '.MP3']:
+                files.append(item)
+        return files
+    
+    def get_mp3_duration(self, file_path: Path) -> float:
+        """
+        获取MP3文件时长(秒)
+        
+        Args:
+            file_path: MP3文件路径
+            
+        Returns:
+            float: 时长(秒),如果无法获取则返回0
+        """
+        try:
+            if HAS_MUTAGEN:
+                # 使用mutagen获取精确时长
+                audio = MP3(str(file_path))
+                duration = audio.info.length
+                logger.debug(f"MP3时长 (mutagen): {duration:.2f}秒")
+                return duration
+            else:
+                # 使用比特率估算(假设64kbps)
+                file_size = file_path.stat().st_size
+                estimated_bitrate = 64000  # 64 kbps
+                duration = (file_size * 8) / estimated_bitrate
+                logger.debug(f"MP3时长 (估算): {duration:.2f}秒")
+                return duration
+        except Exception as e:
+            logger.error(f"无法获取MP3时长 {file_path.name}: {e}")
+            return 0
+    
+    async def send_mp3_stream(self, websocket, file_path: Path, send_buffer: bytearray):
+        """
+        发送MP3文件流(纯二进制,无文件头)
+        根据MP3实际播放时长控制发送速度
+        
+        Args:
+            websocket: WebSocket连接
+            file_path: MP3文件路径
+            send_buffer: 发送缓存(bytearray)
+        """
+        try:
+            file_size = file_path.stat().st_size
+            file_name = file_path.name
+            
+            # 获取MP3时长
+            duration = self.get_mp3_duration(file_path) if self.realtime else 0
+            
+            if self.realtime and duration > 0:
+                # 计算实时发送速率 (bytes/秒)
+                bytes_per_second = file_size / (duration / self.speed_factor)
+                # 计算每个buffer发送后的等待时间(秒)
+                sleep_per_buffer = self.send_buffer_size / bytes_per_second
+                
+                logger.info(f"开始发送MP3流: {file_name}")
+                logger.info(f"  文件大小: {file_size:,} 字节 ({file_size/1024:.1f} KB)")
+                logger.info(f"  播放时长: {duration:.1f} 秒 ({duration/60:.1f} 分钟)")
+                logger.info(f"  发送速率: {bytes_per_second:.0f} bytes/s ({bytes_per_second/1024:.1f} KB/s)")
+                logger.info(f"  预计完成: {duration/self.speed_factor:.1f} 秒")
+            else:
+                sleep_per_buffer = 0
+                logger.info(f"开始发送MP3流: {file_name} ({file_size:,} 字节) - 最快速度")
+            
+            # 发送前清空缓存
+            send_buffer.clear()
+            
+            # 记录开始时间
+            start_time = time.time()
+            sent_bytes = 0
+            total_sent_packets = 0
+            
+            with open(file_path, 'rb') as f:
+                while True:
+                    # 读取数据块
+                    chunk = f.read(self.chunk_size)
+                    if not chunk:
+                        break
+                    
+                    # 添加到发送缓存
+                    send_buffer.extend(chunk)
+                    
+                    # 当缓存满时发送
+                    if len(send_buffer) >= self.send_buffer_size:
+                        packet_start = time.time()
+                        
+                        await websocket.send(bytes(send_buffer))
+                        sent_bytes += len(send_buffer)
+                        total_sent_packets += 1
+                        
+                        logger.debug(f"发送包 #{total_sent_packets}: {len(send_buffer)} 字节 "
+                                   f"({sent_bytes}/{file_size} = {sent_bytes/file_size*100:.1f}%)")
+                        
+                        # 清空缓存
+                        send_buffer.clear()
+                        
+                        # 实时播放:按计算的速率等待
+                        if self.realtime and sleep_per_buffer > 0:
+                            # 计算实际发送耗时
+                            send_elapsed = time.time() - packet_start
+                            # 等待剩余时间以匹配播放速率
+                            sleep_time = max(0, sleep_per_buffer - send_elapsed)
+                            if sleep_time > 0:
+                                await asyncio.sleep(sleep_time)
+            
+            # 发送缓存中的剩余数据
+            if len(send_buffer) > 0:
+                await websocket.send(bytes(send_buffer))
+                sent_bytes += len(send_buffer)
+                total_sent_packets += 1
+                logger.debug(f"发送最后一包: {len(send_buffer)} 字节")
+                send_buffer.clear()
+            
+            # 计算实际耗时
+            elapsed_time = time.time() - start_time
+            actual_speed = sent_bytes / elapsed_time if elapsed_time > 0 else 0
+            
+            logger.info(f"MP3流发送完成: {file_name}")
+            logger.info(f"  总计: {sent_bytes:,} 字节, {total_sent_packets} 包")
+            logger.info(f"  耗时: {elapsed_time:.1f} 秒")
+            logger.info(f"  实际速率: {actual_speed:.0f} bytes/s ({actual_speed/1024:.1f} KB/s)")
+            
+        except Exception as e:
+            logger.error(f"发送MP3流出错 {file_path.name}: {e}")
+            raise
+    
+    async def mp3_sender_loop(self, websocket):
+        """
+        MP3流发送循环
+        持续循环发送目录下的所有MP3文件
+        """
+        logger.info(f"客户端连接: {websocket.remote_address}")
+        
+        # 创建发送缓存(每个客户端独立的缓存)
+        send_buffer = bytearray()
+        logger.info(f"创建发送缓存: {self.send_buffer_size} 字节")
+        
+        try:
+            # 循环发送MP3文件
+            loop_count = 0
+            while True:
+                loop_count += 1
+                logger.info(f"===== 第 {loop_count} 轮播放 =====")
+                
+                # 获取MP3文件列表
+                files = self.get_mp3_list()
+                
+                if not files:
+                    logger.warning(f"目录为空或无MP3文件: {self.file_dir}")
+                    await asyncio.sleep(5)
+                    continue
+                
+                logger.info(f"找到 {len(files)} 个MP3文件")
+                
+                # 逐个发送MP3文件流
+                for idx, file_path in enumerate(files, 1):
+                    logger.info(f"[{idx}/{len(files)}] 播放: {file_path.name}")
+                    await self.send_mp3_stream(websocket, file_path, send_buffer)
+                
+                logger.info(f"第 {loop_count} 轮播放完成,准备下一轮...")
+                
+        except (ConnectionClosedOK, ConnectionClosedError) as e:
+            logger.info(f"客户端断开: {websocket.remote_address} - {e}")
+        except Exception as e:
+            logger.error(f"发送循环出错: {e}", exc_info=True)
+        finally:
+            self.clients.discard(websocket)
+            logger.info(f"客户端已移除,当前连接数: {len(self.clients)}")
+    
+    async def handle_client(self, websocket):
+        """
+        处理客户端连接
+        """
+        self.clients.add(websocket)
+        logger.info(f"新客户端连接: {websocket.remote_address}, 当前连接数: {len(self.clients)}")
+        
+        try:
+            # 启动MP3流发送循环
+            await self.mp3_sender_loop(websocket)
+        except Exception as e:
+            logger.error(f"处理客户端出错: {e}")
+        finally:
+            self.clients.discard(websocket)
+    
+    async def start(self):
+        """启动服务器"""
+        logger.info(f"启动 WebSocket MP3流服务器: ws://{self.host}:{self.port}")
+        
+        async with serve(self.handle_client, self.host, self.port):
+            logger.info("服务器已启动,等待客户端连接...")
+            logger.info("客户端连接后将持续接收MP3二进制流")
+            await asyncio.Future()  # 永久运行
+
+
+def main():
+    """主函数"""
+    parser = argparse.ArgumentParser(
+        description='WebSocket MP3流传输服务器(按播放时长实时发送)',
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+        epilog="""
+示例:
+  # 实时播放(根据MP3时长自动控制速度)
+  %(prog)s --dir ./mp3
+  
+  # 最快速度发送
+  %(prog)s --dir ./mp3 --no-realtime
+  
+  # 2倍速播放
+  %(prog)s --dir ./mp3 --speed 2.0
+  
+  # 0.5倍速(慢速播放)
+  %(prog)s --dir ./mp3 --speed 0.5
+        """
+    )
+    parser.add_argument('--host', default='0.0.0.0', help='服务器地址 (默认: 0.0.0.0)')
+    parser.add_argument('--port', type=int, default=8910, help='服务器端口 (默认: 8910)')
+    parser.add_argument('--dir', required=True, help='MP3文件目录')
+    parser.add_argument('--no-realtime', action='store_true', 
+                       help='禁用实时播放,以最快速度发送')
+    parser.add_argument('--speed', type=float, default=1.0, 
+                       help='播放速度倍数 (默认: 1.0, 实时播放)')
+    parser.add_argument('--chunk', type=int, default=4096, 
+                       help='读取块大小(字节)(默认: 4096)')
+    parser.add_argument('--buffer', type=int, default=16384, 
+                       help='发送缓存大小(字节)(默认: 16384)')
+    parser.add_argument('--debug', action='store_true', help='启用调试日志')
+    
+    args = parser.parse_args()
+    
+    # 设置日志级别
+    if args.debug:
+        logging.getLogger().setLevel(logging.DEBUG)
+    
+    # 创建并启动服务器
+    try:
+        server = MP3StreamServer(
+            host=args.host,
+            port=args.port,
+            file_dir=args.dir,
+            realtime=not args.no_realtime,
+            speed_factor=args.speed,
+            chunk_size=args.chunk,
+            send_buffer_size=args.buffer
+        )
+        
+        asyncio.run(server.start())
+        
+    except KeyboardInterrupt:
+        logger.info("服务器已停止")
+    except Exception as e:
+        logger.error(f"服务器错误: {e}", exc_info=True)
+        exit(1)
+
+
+if __name__ == '__main__':
+    main()
+
+
index 8e75ff3..74e1cc4 100644 (file)
@@ -24,69 +24,6 @@ tag = version = commit = "15.1"
 
 
 if not released:  # pragma: no cover
-    import pathlib
-    import re
-    import subprocess
-
-    def get_version(tag: str) -> str:
-        # Since setup.py executes the contents of src/websockets/version.py,
-        # __file__ can point to either of these two files.
-        file_path = pathlib.Path(__file__)
-        root_dir = file_path.parents[0 if file_path.name == "setup.py" else 2]
-
-        # Read version from package metadata if it is installed.
-        try:
-            version = importlib.metadata.version("websockets")
-        except ImportError:
-            pass
-        else:
-            # Check that this file belongs to the installed package.
-            files = importlib.metadata.files("websockets")
-            if files:
-                version_files = [f for f in files if f.name == file_path.name]
-                if version_files:
-                    version_file = version_files[0]
-                    if version_file.locate() == file_path:
-                        return version
-
-        # Read version from git if available.
-        try:
-            description = subprocess.run(
-                ["git", "describe", "--dirty", "--tags", "--long"],
-                capture_output=True,
-                cwd=root_dir,
-                timeout=1,
-                check=True,
-                text=True,
-            ).stdout.strip()
-        # subprocess.run raises FileNotFoundError if git isn't on $PATH.
-        except (
-            FileNotFoundError,
-            subprocess.CalledProcessError,
-            subprocess.TimeoutExpired,
-        ):
-            pass
-        else:
-            description_re = r"[0-9.]+-([0-9]+)-(g[0-9a-f]{7,}(?:-dirty)?)"
-            match = re.fullmatch(description_re, description)
-            if match is None:
-                raise ValueError(f"Unexpected git description: {description}")
-            distance, remainder = match.groups()
-            remainder = remainder.replace("-", ".")  # required by PEP 440
-            return f"{tag}.dev{distance}+{remainder}"
-
-        # Avoid crashing if the development version cannot be determined.
-        return f"{tag}.dev0+gunknown"
-
-    version = get_version(tag)
-
-    def get_commit(tag: str, version: str) -> str:
-        # Extract commit from version, falling back to tag if not available.
-        version_re = r"[0-9.]+\.dev[0-9]+\+g([0-9a-f]{7,}|unknown)(?:\.dirty)?"
-        match = re.fullmatch(version_re, version)
-        if match is None:
-            raise ValueError(f"Unexpected version: {version}")
-        (commit,) = match.groups()
-        return tag if commit == "unknown" else commit
-
-    commit = get_commit(tag, version)
+    # 开发版本:不使用 git 信息,直接使用固定版本
+    version = f"{tag}.dev0"
+    commit = tag