Initial commit: RecorderStudio:PyQt5 高保真录音软件,无损 WAV / WASAPI 独占、BS.1770 响度与 ffmpeg 交叉验证

This commit is contained in:
WpyQwq
2026-09-19 11:56:01 +08:00
commit b7f9098ccd
62 changed files with 10796 additions and 0 deletions
+370
View File
@@ -0,0 +1,370 @@
# RecorderStudio · 高清录音机
一个面向 **高保真、高清晰度** 的 Windows 录音软件:无损 WAV 母版、WASAPI 独占采集、
线性相位滤波、TPDF 抖动、ITU-R BS.1770 标准响度计量,并带完整的录音体检报告、
后期处理与多格式导出。界面用 PyQt5 手绘,深色专业风格。
自检套件 **97 项检查全部通过**,其中响度算法与 ffmpeg 的 `ebur128` 滤波器做过交叉验证,
WAV 编码用 Python 标准库与 ffmpeg 双向复核。
---
## 目录
1. [30 秒上手](#30-秒上手)
2. [为什么它"清晰"](#为什么它清晰)
3. [界面说明](#界面说明)
4. [参数怎么选](#参数怎么选)
5. [命令行用法](#命令行用法)
6. [录音体检报告](#录音体检报告)
7. [后期处理与导出](#后期处理与导出)
8. [自检与验证证据](#自检与验证证据)
9. [项目结构](#项目结构)
10. [常见问题](#常见问题)
11. [已知限制](#已知限制)
---
## 30 秒上手
```bat
:: 1) 安装依赖(装进项目内的 _vendor,不污染系统 Python)
python install_deps.py --with-pyqt5
:: 2) 检查依赖与设备
python install_deps.py --check
python -m recorder.cli --list-devices
:: 3) 启动
双击 启动录音机.bat :: 或 python -m recorder.gui
```
按下 **空格** 开始录音,再按一次停止;停止后自动弹出体检报告。
> 第一次用,建议先做两件事:
> 1. 点左侧「**检测能力**」,确认所选采样率带 ✓(避免设备不支持导致开流失败);
> 2. 点「**校准本底噪声**」,安静 3 秒,程序会告出房间本底噪声并给出低切/噪声门建议。
---
## 为什么它"清晰"
"清晰度"在数字录音里由几个环节共同决定,这个软件在每一环都做了明确取舍:
| 环节 | 做法 | 为什么重要 |
|---|---|---|
| **采集通道** | WASAPI **独占模式**(可关闭) | 绕开 Windows 混音器,不重采样、不被其它程序改音量;这是 Windows 上最接近原始信号的方式 |
| **内部精度** | 全程 float32 浮点运算 | float32 的 24 位尾数正好无损容纳 24 位 PCM,运算不产生额外量化 |
| **写盘位深** | 16 / 24 / 32 位 PCM / 32 位浮点 | 24 位动态范围 144 dB,远超人耳与话筒本底;24 位用真正的 3 字节打包,不做 16 位截断 |
| **抖动** | 16/24 位可选 **TPDF 抖动**(1 LSB 峰峰值) | 把量化失真从"与信号相关的非线性失真"变成无关宽带白噪。实测误差与信号相关系数从 0.224 降到 0.0008 |
| **低切滤波** | 5000+ 抽头**线性相位 FIR**,并补偿群延迟 | 不引入相位失真;实测直流抑制 -240 dB、1 kHz 通带 +0.0000 dB、样本数严格守恒(不丢头掉尾) |
| **实时链路** | 音频回调**只做内存拷贝** | 滤波/FFT/写盘全在写入线程,从根上避免 xrun(爆音、掉采样)。实测长时间录音 0 xrun、0 溢出块 |
| **计量标准** | 真峰值 4× 过采样(BS.1770 附录 2)+ K 加权响度(EBU R128) | 能看到"采样点之间"的过冲,避免转码后突然过载;响度按国际标准,不是"看着差不多" |
| **崩溃安全** | 每秒回写文件头 | 断电/崩溃后已落盘的数据仍能被播放器正常识别 |
实测的实测数据(自检输出,非估算):
```
K 加权滤波器系数 vs ITU 原文 最大偏差 8.9e-16 (即逐位一致)
响度 vs ffmpeg ebur128 -13.856 LUFS vs -13.8 LUFS(白噪声)
满量程立体声 997 Hz 正弦 0.0007 LUFS(ffmpeg: -0.0)
16/24/32/float32 往返最大误差 4.5e-5 / 1.7e-7 / 6.9e-10 / 0
ffmpeg 独立解码本程序的 24 位文件 最大偏差 4.6e-5
低切:直流 / 20 Hz / 1 kHz -240 dB / -98.6 dB / +0.0000 dB
真峰值捕捉采样点间过冲 采样峰值 0.3536 → 真峰值 0.5050(理论 0.5)
```
---
## 界面说明
```
┌───────────────┬──────────────────────────────────────────────────────┐
│ ① 输入设备 │ ● 00:12.3 [暂停][标记][分段][停止] │
│ 设备 / 检测能力│ 待机 │
│ ② 录音格式 ├──────────────────────────────────────────────────────┤
│ 采样率/位深/声道│ 电平表 RMS 渐变条 + 峰值保持 + 削波锁存 + dB 刻度 │
│ 独占/缓冲/延迟 ├──────────────────────────────────────────────────────┤
│ ③ 信号处理 │ 实时波形(最近 1 秒,min/max 包络) │
│ 低切/增益/抖动 ├──────────────────────────────────────────────────────┤
│ ④ 文件与分段 │ 响度条 瞬时 / 短时 / 整体 + 目标参考线 │
│ 目录/命名/分段 ├──────────────────────────────────────────────────────┤
│ 校准本底噪声 │ 整段总览 + 标记轨道 │
│ ├──────────────────────────────────────────────────────┤
│ │ 文件名 · 磁盘剩余 · 缓冲队列 · 驱动 xrun │
└───────────────┴──────────────────────────────────────────────────────┘
```
**快捷键**
| 键 | 功能 |
|---|---|
| `空格` | 开始 / 停止录音 |
| `P` | 暂停 / 继续(暂停段不写入文件) |
| `M` | 添加标记(写入元数据与标记轨道) |
| `Ctrl+S` | 立即分段(切到新文件继续录) |
| `Ctrl+O` | 打开输出目录 |
| `Ctrl+Q` | 退出 |
**录音中可以实时调整**:软件增益、响度参考线、暂停/标记/分段。
其余参数在录音期间锁定,避免中途改变格式造成文件不一致。
**状态灯含义**
- 「剩余空间」> 2 GB 绿、> 512 MB 黄、再低红(低于 512 MB 时启动也会警告)
- 「缓冲队列」积压 < 8 块绿、< 32 块黄、再多红。持续红灯说明磁盘跟不上,会自动记录溢出块数
- 「驱动 xrun」非 0 表示 PortAudio 层出现溢出,报告里会统计
---
## 参数怎么选
| 参数 | 推荐值 | 理由 |
|---|---|---|
| 采样率 | **48000 Hz** | 设备原生多为 48 kHz,选它可完全避免重采样;语音/音乐/视频后期通吃 |
| 位深 | **24 位 PCM** | 动态范围 144 dB,兼容性极好;要发出去就录 24 位、导出 16 位(带抖动) |
| 声道 | 话筒 **1**;立体声素材 **2** | 单声道话筒录成双声道只是浪费一倍体积 |
| 独占模式 | **开**(WASAPI) | 保真度最高;若打不开设备再关掉换共享模式 |
| 缓冲区 | **自动 / 4096 + 高延迟策略** | 录音不需要低延迟,稳定不出 xrun 才是第一优先级 |
| 低切 | 人声 **80 Hz**,男声 60 Hz,播客 180 Hz | 去掉空调/桌面震动/近讲喷麦的低频堆积 |
| 软件增益 | **0 dB 起** | 优先调系统麦克风音量(作用在更靠前的增益级);软件增益只在不够用时补 |
| 抖动 | **开** | 写 16/24 位时把量化失真变成白噪;写 32 位浮点时自动跳过 |
| 响度参考 | 播客 -16 / 流媒体 -14 LUFS | 只是在响度条上画一条目标线,不影响录音 |
**电平目标:正常说话时峰值落在 -12 ~ -6 dBFS。** 数字削波不可逆,而且比模拟过载难听得多。
---
## 命令行用法
适合自动化、定时任务、无界面环境。
```bat
:: 设备与能力
python -m recorder.cli --list-devices
python -m recorder.cli --probe 3
:: 录 60 秒到指定目录,24 位/48 kHz、80 Hz 低切、顺带导出 16 位 WAV
python -m recorder.cli -d 3 -t 60 -b 24 --lowcut 80 -o D:\rec ^
--name "会议_{datetime}" --report --export wav_16
:: 不定时长,回车停止(Ctrl+C 也可)
python -m recorder.cli -d 3 -b 24 --lowcut 80 -o D:\rec
:: 静音 10 秒自动停止;每 5 分钟自动分段
python -m recorder.cli -t 3600 --stop-after-silence 10 --split-seconds 300
:: 分析已有录音
python -m recorder.cli --analyze D:\rec\会议.wav
:: 后期处理:裁剪静音 + 响度归一化到 -16 LUFS + 混单声道,并导出 FLAC
python -m recorder.cli --process D:\rec\会议.wav --trim ^
--normalize lufs --target -16 --mono --export flac
```
主要参数:`-d 设备` `-r 采样率` `-c 声道` `-b 位深` `--gain dB` `--lowcut Hz`
`--no-exclusive` `--no-dither` `--rf64` `-t 秒` `-o 目录` `--name 模板`
`--split-seconds` `--split-mb` `--stop-after-silence` `--silence-threshold`
`--report` `--export <预设>`;`--gui` 直接启动图形界面。
命名模板变量:`{datetime} {date} {time} {device} {sr} {bits} {ch} {seq}`
---
## 录音体检报告
每次录音结束都会弹出报告,并可生成同名 `.json`(机器可读)与 `.txt`(人读)、
`_waveform.png`(波形预览图,纯 numpy 生成,不依赖 PIL)。
```
RecorderStudio 录音报告
==============================================
开始时间 : 2026-09-12T22:02:15
输入设备 : 麦克风 (F20) [Windows WASAPI]
录制格式 : 48000 Hz / 24-bit PCM / 2 声道
独占模式 : 是
软件增益 : +0.0 dB
低切滤波 : 80 Hz
抖动 : 开启 (TPDF)
总时长 : 00:02.7 总采样帧 : 129840 数据量 : 742.5 KB
音质体检
----------------------------------------------
采样峰值 : -32.60, -32.60 dBFS
真峰值 : -32.48, -32.48 dBTP
RMS 电平 : -68.10, -68.10 dBFS
整体响度 : -55.200 LUFS
动态范围 : 0.0 LU
直流偏移 : [0.0, 0.0]
本底噪声 : -52.3 dBFS
削波样本 : 0 丢弃块/溢出: 0 驱动层 xrun: 0
标记
----------------------------------------------
1.234 s 标记 1 (20260912_220215_麦克风 (F20).wav)
```
说明:
- **真峰值(dBTP)** 比采样峰值更能反映真实过载风险;若超过 -0.1 dBTP 报告会提示降低增益
- **本底噪声** 只在录音里确实存在"安静段"时才给出;全程持续发声时明确显示"无法测定",
而不是编造一个数字
- **动态范围** 是短时响度的 10%~95% 分位差(EBU Tech 3342 的工程近似值)
---
## 后期处理与导出
原则:**原始 WAV 母版永不被覆盖**。所有处理另存为 `*_processed.wav`,
导出时即使目标路径与源文件同名也会自动改名。
**后期处理链**(顺序可自由组合,全部离线、可复现)
1. 去除直流偏移
2. 线性相位低切(40 ~ 180 Hz)
3. 噪声门(带保持/攻放包络,避免"抽气"感)
4. 裁剪首尾静音(只有长于设定时长才裁,且保留呼吸空间)
5. 混合单声道
6. 峰值归一化(-1 dBFS,按真峰值保护)或 **响度归一化**(目标 LUFS,并保证真峰值上限)
7. 淡入 / 淡出
**导出格式**
| 预设 | 说明 |
|---|---|
| WAV 16 / 24 / 32位浮点 | 内置编码器,不需要 ffmpeg |
| FLAC | 无损压缩,体积约减半 |
| MP3 320 kbps / MP3 V0 | 需要 ffmpeg |
| Opus 128 kbps | 语音/播客首选 |
| AAC / M4A 256 kbps | 苹果生态 |
程序会自动在 PATH 与常见安装位置寻找 ffmpeg;找不到时只有 WAV 导出可用,
界面上会明确提示而不是静默失败。
---
## 自检与验证证据
```bat
python -m recorder.selftest :: 全部(含真实硬件录音,约 11 秒)
python -m recorder.selftest --no-hw :: 只跑离线数学/格式测试(无需麦克风)
python dev/gui_check.py --shots :: 界面回归检查(离屏渲染 + 像素校验 + 截图)
python dev/dialog_check.py :: 对话框后台任务链路(QThread + 信号)
python dev/lint_scan.py :: 静态检查(未使用导入 / 裸 except / 过长函数)
```
自检覆盖 6 个方面共 **97 项**(含硬件时):
| 分组 | 内容 |
|---|---|
| ITU-R BS.1770 符合性 | 滤波器系数与标准原文逐位比对;响度标定(满量程立体声正弦 = 0 LUFS、单声道 -3.01 LUFS);与 ffmpeg `ebur128` 交叉验证 |
| 线性相位滤波器 | 直流/通带/阻带响应、系数对称性、群延迟补偿 |
| 真峰值 | 采样点对齐与相移两种情况,验证能捕捉 inter-sample peak |
| WAV 编解码 | 四种载荷往返误差在量化极限内;标准库 `wave` 与 ffmpeg 独立复核;RF64;非法文件与零长度边界 |
| TPDF 抖动 | 误差-信号相关性、方差分解(1/6 抖动 + 1/12 量化 = 1/4 LSB²) |
| 引擎与硬件 | 低切样本数守恒、命名模板、自动分段(1.0 秒分段精确得到 48000 帧)、手动分段、**静音自动停止后音频流确实被关闭**、真实设备录音 xrun 统计 |
界面回归检查(`dev/gui_check.py`)用两种可判定的方式验证渲染,
因为纯截图无法被自动判定:
- **几何**:164 个控件无零尺寸、无越界、无横向裁切;大号计时器与状态栏用字体度量
确认最长文本不被截断
- **像素**:注入已知电平验证 dB→像素映射(-3 dB → 90% 表宽、-30 dB → 49%、-60 dB → 0%),
削波锁存显示红色,示波器/总览图有波形,录音按钮在空闲/录音态颜色正确
---
## 项目结构
```
RecorderStudio/
├── 启动录音机.bat / run.bat 启动器(自动寻找带 PyQt5 的解释器)
├── install_deps.py 依赖安装器(pip + 直接下载 wheel 双通道)
├── requirements.txt
├── recorder/ 主程序包
│ ├── dsp.py DSP:滤波器、电平、真峰值、BS.1770 响度、离线处理链
│ ├── wavfile.py 无损 WAV 读写:16/24/32/float32、TPDF、RF64、崩溃安全头
│ ├── engine.py 录音引擎:设备枚举、独占模式、落盘线程、自动分段
│ ├── post.py 后期处理、导出、元数据、纯 numpy 的 PNG 波形图
│ ├── widgets.py 自绘控件:电平表、示波器、响度条、录音按钮
│ ├── window.py 主窗口、报告/处理/导出对话框
│ ├── gui.py 图形界面入口(High-DPI + 深色主题)
│ ├── cli.py 命令行录音
│ └── selftest.py 自检套件
├── dev/gui_check.py 界面回归检查(开发/验收用)
├── _vendor/ 项目内依赖(sounddevice 及其内置 PortAudio)
└── recordings/ 默认输出目录
```
架构要点:`dsp` / `wavfile` 不依赖 GUI 也不依赖音频后端,可以单独 import 做批量处理;
`engine` 是三线程模型(PortAudio 回调线程 / 写入线程 / 界面线程),
只有写入线程接触磁盘,界面只读快照。因此 CLI 与 GUI 共用同一套录音核心,
不存在"两套行为不一致"的问题。
---
## 常见问题
**打不开设备 / device unavailable**
多半被其它程序独占(浏览器、会议软件、直播工具、DAW)。关掉它们,或在左侧关掉
「WASAPI 独占模式」改用共享模式。
**提示不支持某个采样率**
点「检测能力」看哪些采样率带 ✓。设备原生采样率(通常是 48000)保真度最高;
不要为了"看起来更高"强行选 96 kHz——那只会让驱动做重采样。
**录出来是静音**
1) Windows 设置 → 隐私和安全性 → 麦克风,确认"允许桌面应用访问麦克风"已开;
2) 系统声音设置里确认默认输入设备选对;
3) 用「校准本底噪声」测一下,如果本底是 -inf 说明没有信号进来。
**想录电脑内部声音(内录)**
在设备列表里选 **「立体声混音 / Stereo Mix」** 或 **「主声音捕获驱动程序」**
(通常在 WASAPI / WDM-KS 分组下)。若列表中没有,需要在
「声音设置 → 录制 → 显示禁用的设备」里启用"立体声混音"。
**有爆音 / 掉采样**
看状态栏「缓冲队列」和「驱动 xrun」。把缓冲区改成 4096、延迟策略改成"高",
并关闭其它占用磁盘与 CPU 的程序。报告里的「丢弃块/溢出」与「xrun」会给出确切数字。
**提示超过 4 GB**
RIFF 容器上限就是 4 GB。开启「RF64 大文件容器」用 RF64 单文件继续录,
或设置「按体积分段」自动切成多个文件。
**FFmpeg 找不到**
只影响 FLAC/MP3/Opus/AAC 导出,WAV 全部功能不受影响。安装 ffmpeg 并加入 PATH 即可。
---
## 已知限制
诚实说明,避免误用:
1. **平台**:开发与验证都在 Windows 上完成。`sounddevice` 在 macOS/Linux 也可用,
但本项目的设备排序、独占模式说明、批处理启动器都针对 Windows 优化。
2. **ASIO**:`_vendor` 里带了 ASIO 版 PortAudio DLL,但 sounddevice 默认加载非 ASIO 版本;
需要 ASIO 时要把 `libportaudio64bit-asio.dll` 覆盖为 `libportaudio64bit.dll` 后再启动。
本项目未对 ASIO 路径做测试。
3. **内录**:依赖系统的"立体声混音"设备。PortAudio 19.7(sounddevice 0.5.6 内置)
没有暴露 WASAPI loopback,因此不提供"直接抓系统输出"的开关。
4. **响度计量的实现方式**:K 加权在频域按 |H(f)|² 施加。对"能量"计量而言与
时域滤波等价(自检已与 ffmpeg 交叉验证,误差 < 0.1 LU),
但与逐样本时域实现相比,块边界处存在理论上的细微差异。
5. **动态范围(LRA)** 是 EBU Tech 3342 的工程近似值,不用于合规认证。
6. **暂停是"跳切"**:暂停期间的数据直接丢弃,文件里会有一次不连续;
需要保留时间轴连续性请用分段而不是暂停。
---
## 依赖
| 组件 | 用途 | 许可 |
|---|---|---|
| Python 3.9+ | 运行环境 | PSF |
| numpy | 数值运算 | BSD-3 |
| sounddevice | PortAudio 绑定(内置 PortAudio 二进制) | MIT / PortAudio MIT |
| PyQt5 | 图形界面(仅 GUI 需要) | GPL v3 / 商业双许可 |
| ffmpeg(可选,外部) | FLAC/MP3/Opus/AAC 导出 | LGPL/GPL |
> PyQt5 采用 GPL v3 与商业双许可。若要把本软件用于闭源商业分发,
> 请改用 PySide6(LGPL)或购买 PyQt 商业许可。
+1
View File
@@ -0,0 +1 @@
not a wav file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+73
View File
@@ -0,0 +1,73 @@
{
"app": "RecorderStudio",
"version": "1.0",
"recorded_at": "2024-01-01T00:00:00",
"finished_at": "2024-01-01T00:00:01",
"device": "测试设备 [WASAPI]",
"format": "48000 Hz / 24-bit PCM / 2 声道",
"sample_rate": 48000,
"bit_depth": "24",
"channels": 2,
"gain_db": 0.0,
"lowcut_hz": 0.0,
"exclusive_mode": true,
"dither": true,
"duration_seconds": 1.0,
"frames": 48000,
"bytes": 288068,
"peak_dbfs": -10.4,
"clipped_samples": 0,
"xruns": 0,
"queue_overflows": 0,
"files": [
"meta_test.wav"
],
"markers": [
{
"label": "测试标记",
"file": "meta_test.wav",
"seconds": 0.5,
"timestamp": "2024-01-01T00:00:00"
}
],
"analysis": {
"path": "E:\\deepseek\\RecorderStudio\\_build\\selftest\\meta_test.wav",
"samplerate": 48000,
"channels": 2,
"encoding": "pcm",
"bits": 24,
"frames": 48000,
"duration": 1.0,
"data_bytes": 288000,
"rf64": false,
"format": "48000 Hz / 24-bit PCM / 2 声道",
"analyzed_frames": 48000,
"analyzed_duration": 1.0,
"peak_dbfs": [
-10.458,
-10.458
],
"true_peak_dbtp": [
-10.457,
-10.457
],
"rms_dbfs": [
-13.468,
-13.468
],
"integrated_lufs": -11.14,
"loudness_range_lu": 0.0,
"dc_offset": [
0.0,
-0.0
],
"noise_floor_dbfs": [],
"noise_floor_available": false,
"clipped_samples": [
0,
0
],
"clipped_total": 0
},
"notes": []
}
+33
View File
@@ -0,0 +1,33 @@
RecorderStudio 录音报告
==============================================
开始时间 : 2024-01-01T00:00:00
结束时间 : 2024-01-01T00:00:01
输入设备 : 测试设备 [WASAPI]
录制格式 : 48000 Hz / 24-bit PCM / 2 声道
独占模式 : 是
软件增益 : +0.0 dB
低切滤波 : 关闭
抖动 : 开启 (TPDF)
总时长 : 00:01.0
总采样帧 : 48000
数据量 : 281.3 KB
文件数 : 1
· meta_test.wav (281.3 KB)
音质体检
----------------------------------------------
采样峰值 : -10.46, -10.46 dBFS
真峰值 : -10.46, -10.46 dBTP
RMS 电平 : -13.47, -13.47 dBFS
整体响度 : -11.14 LUFS
动态范围 : 0.0 LU
直流偏移 : [0.0, -0.0]
本底噪声 : dBFS(录音中未检测到静音段,无法测定)
削波样本 : 0
丢弃块/溢出: 0
驱动层 xrun: 0
标记
----------------------------------------------
0.500 s 测试标记 (meta_test.wav)
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because one or more lines are too long
@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
@@ -0,0 +1,131 @@
name: Build PortAudio lib
on:
push:
branches-ignore:
- master
permissions:
# This is needed for pushing a new commit to the repo:
contents: write
env:
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
jobs:
macos:
runs-on: macos-latest
env:
CMAKE_OSX_ARCHITECTURES: "arm64;x86_64"
MACOSX_DEPLOYMENT_TARGET: "10.9"
steps:
- name: checkout portaudio
uses: actions/checkout@v5
with:
repository: PortAudio/portaudio
ref: v19.7.0
path: portaudio
- name: cmake configure
run: |
cmake -S portaudio -B build -D CMAKE_BUILD_TYPE=Release
- name: cmake build
run: |
cmake --build build
- name: move dylib
run: |
mv build/libportaudio.dylib .
- name: show some information about dylib
run: |
file libportaudio.dylib
otool -L libportaudio.dylib
- name: upload dylib
uses: actions/upload-artifact@v5
with:
name: macos-dylib
path: libportaudio.dylib
windows:
runs-on: windows-2022
strategy:
matrix:
platform: [x64, Win32, ARM64]
asio: ["", "-asio"]
include:
- platform: x64
platform-suffix: 64bit
- platform: Win32
platform-suffix: 32bit
- platform: ARM64
platform-suffix: arm64
env:
# Reproducible build by avoiding time stamp
LDFLAGS: "/Brepro"
steps:
- name: checkout portaudio
uses: actions/checkout@v5
with:
repository: PortAudio/portaudio
ref: v19.7.0
path: portaudio
# The next portaudio release will have an auto-download feature:
- name: download and extract ASIO SDK
if: matrix.asio
run: |
curl -L -o asiosdk.zip https://www.steinberg.net/asiosdk
7z x asiosdk.zip
- name: cmake configure
run: |
cmake -S portaudio -B build -A ${{ matrix.platform }} -D PA_USE_ASIO=${{ matrix.asio && 'ON' || 'OFF' }}
- name: cmake build
run: |
cmake --build build --config Release
- name: rename DLL
run: |
mv build/Release/portaudio_*.dll libportaudio${{ matrix.platform-suffix }}${{ matrix.asio }}.dll
- name: show some information about DLL
run: |
ldd libportaudio${{ matrix.platform-suffix }}${{ matrix.asio }}.dll
- name: upload DLL
uses: actions/upload-artifact@v5
with:
name: windows-${{ matrix.platform }}-dll${{ matrix.asio }}
path: libportaudio${{ matrix.platform-suffix }}${{ matrix.asio }}.dll
push:
runs-on: ubuntu-latest
needs: [macos, windows]
steps:
- name: Clone Git repository
uses: actions/checkout@v5
- name: Retrieve dylib
uses: actions/download-artifact@v6
with:
name: macos-dylib
- name: Retrieve x64 DLL
uses: actions/download-artifact@v6
with:
name: windows-x64-dll
- name: Retrieve x64 DLL with ASIO support
uses: actions/download-artifact@v6
with:
name: windows-x64-dll-asio
- name: Retrieve Win32 DLL
uses: actions/download-artifact@v6
with:
name: windows-Win32-dll
- name: Retrieve Win32 DLL with ASIO support
uses: actions/download-artifact@v6
with:
name: windows-Win32-dll-asio
- name: Retrieve ARM64 DLL
uses: actions/download-artifact@v6
with:
name: windows-ARM64-dll
- name: Retrieve ARM64 DLL with ASIO support
uses: actions/download-artifact@v6
with:
name: windows-ARM64-dll-asio
- name: Commit and push binaries (if there are changes)
run: |
git config --global user.name 'github-actions[bot]'
git config --global user.email 'github-actions[bot]@users.noreply.github.com'
git commit -am "Update binaries" && git push || true
@@ -0,0 +1,34 @@
PortAudio binaries
==================
This repository provides pre-compiled dynamic libraries for
[PortAudio](http://www.portaudio.com/).
DLLs for Windows (32-bit and 64-bit)
------------------------------------
There are two sets of DLL files,
one including the default host APIs,
namely MME, DirectSound, WDM/KS and WASAPI,
and another one (`*-asio.dll`) where ASIO is enabled as well.
For more information about the ASIO SDK see
http://www.steinberg.net/en/company/developers.html.
dylib for macOS (64-bit, "universal")
----------------------------------------
The file `libportaudio.dylib` is compatible both
with Intel (`x86_64`) and with Apple Silicon (`arm64`) CPUs.
Details
-------
All files were auto-created with Github Actions.
For all the details, see [the config file](.github/workflows/build-libs.yml).
Copyright
---------
* PortAudio by Ross Bencina and Phil Burk, MIT License.
* Steinberg Audio Stream I/O API by Steinberg Media Technologies GmbH.
@@ -0,0 +1,42 @@
Metadata-Version: 2.4
Name: sounddevice
Version: 0.5.6
Summary: Play and Record Sound with Python
Author-email: Matthias Geier <[email protected]>
License-Expression: MIT
Project-URL: Documentation, https://python-sounddevice.readthedocs.io/
Project-URL: Repository, https://github.com/spatialaudio/python-sounddevice/
Project-URL: Issues, https://github.com/spatialaudio/python-sounddevice/issues
Keywords: sound,audio,PortAudio,play,record,playrec
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Multimedia :: Sound/Audio
Requires-Python: >=3.7
Description-Content-Type: text/x-rst
License-File: LICENSE
Requires-Dist: cffi
Provides-Extra: numpy
Requires-Dist: numpy; extra == "numpy"
Dynamic: license-file
Play and Record Sound with Python
=================================
This Python_ module provides bindings for the PortAudio_ library and a few
convenience functions to play and record NumPy_ arrays containing audio signals.
The ``sounddevice`` module is available for Linux, macOS and Windows.
Documentation:
https://python-sounddevice.readthedocs.io/
Source code repository and issue tracker:
https://github.com/spatialaudio/python-sounddevice/
License:
MIT -- see the file ``LICENSE`` for details.
.. _Python: https://www.python.org/
.. _PortAudio: http://www.portaudio.com/
.. _NumPy: https://numpy.org/
@@ -0,0 +1,18 @@
_sounddevice.py,sha256=2bWMz3S9scJIfu083UejZ_JSkpzFB5CQ69NDt3lINTE,13837
sounddevice.py,sha256=13v0C6lFSRU43WqTtfuPRdBRwQaJ8OEMC5KO-J8XKJE,111712
_sounddevice_data/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
_sounddevice_data/portaudio-binaries/README.md,sha256=s8sFNWa284w4vfSCLlP4-KKjd-BcUD_dacUuXRxsEEw,974
_sounddevice_data/portaudio-binaries/libportaudio.dylib,sha256=9Ivtt_T3nAygUHdq_pQ0WFVdtzQeDa4AUR0QnM3pB8A,236064
_sounddevice_data/portaudio-binaries/libportaudio32bit-asio.dll,sha256=uW2BfevhdH2Mo6XltX0QIb8CB-u9kHJCMxNXuXxl5tc,280576
_sounddevice_data/portaudio-binaries/libportaudio32bit.dll,sha256=PSc4OgA5fa5ksmYYl5GsIPhKGYIHeHJOBXBcL2AS39k,260608
_sounddevice_data/portaudio-binaries/libportaudio64bit-asio.dll,sha256=44YYoMuDoCh77juL1opxCuDTQXghfvT6Ie1W92URGUM,343040
_sounddevice_data/portaudio-binaries/libportaudio64bit.dll,sha256=7AgBlPAeQJXH-0Pb1-0Fr5IsWzQpUFap_1Z4J0HWVIE,307712
_sounddevice_data/portaudio-binaries/libportaudioarm64-asio.dll,sha256=V7eCb9OIPaq7uJUYFGVaTsJTrLgiwP9s5AAtqUYwZPU,316416
_sounddevice_data/portaudio-binaries/libportaudioarm64.dll,sha256=456V6VwMsmLHDKTmduRAzzS-VkP-WjHN_iPoq28UIss,293888
_sounddevice_data/portaudio-binaries/.github/dependabot.yml,sha256=iDuV475VT7rdkrMV5KjG6mymiLZJJOIt1hIisH9oNQc,118
_sounddevice_data/portaudio-binaries/.github/workflows/build-libs.yml,sha256=qC2El-ijMJGxdom0STTDMLnIJ7sf_vXuetSSztLDVNs,4035
sounddevice-0.5.6.dist-info/licenses/LICENSE,sha256=tu6iG7r7r3ohd-SS8b5rXvxvTnCLGYB3PeUc2qUvghE,1063
sounddevice-0.5.6.dist-info/METADATA,sha256=DpViAIPEbIktPHj1yuXbAdY4FNs5XBsXDSwBPLngtN4,1450
sounddevice-0.5.6.dist-info/WHEEL,sha256=3I5VVWZdsFlU417aCS2bMRuerS8wsfvBq99MyatBV3A,97
sounddevice-0.5.6.dist-info/top_level.txt,sha256=4x_9IJSWMb6StbHBe-jbHWC1cJf_unhQwxaMBghH_L4,43
sounddevice-0.5.6.dist-info/RECORD,,
@@ -0,0 +1,5 @@
Wheel-Version: 1.0
Generator: setuptools (84.0.0)
Root-Is-Purelib: true
Tag: py3-none-win_amd64
@@ -0,0 +1,19 @@
Copyright (c) 2015-2025 Matthias Geier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,3 @@
_sounddevice
_sounddevice_data
sounddevice
File diff suppressed because it is too large Load Diff
+131
View File
@@ -0,0 +1,131 @@
"""界面回归检查之二:对话框的后台任务链路(QThread + 信号)真跑一遍。
自检覆盖了 process_file / export_audio 这些纯函数,但没有覆盖
"Qt 工作线程 → 信号 → 界面回填"这段管线。这里直接构造对话框、
触发 _run()、泵事件循环直到完成,再检查产物是否真的生成。
"""
import os
import sys
import time
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
import numpy as np
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QApplication
QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)
app = QApplication([])
from recorder.gui import QSS # noqa: E402
app.setStyleSheet(QSS)
from recorder import dsp # noqa: E402
from recorder.wavfile import WavReader, write_wav # noqa: E402
from recorder.window import ExportDialog, ProcessDialog # noqa: E402
fails = []
def check(name, ok, detail=""):
print((" ✓ " if ok else " ✗ ") + name + ((" " + detail) if detail else ""))
if not ok:
fails.append(name)
def pump(seconds=1.0, until=None):
"""泵事件循环,直到 until() 为真或超时。"""
t0 = time.time()
while time.time() - t0 < seconds:
app.processEvents()
time.sleep(0.02)
if until is not None and until():
return True
return until() if until else True
work = os.path.join(ROOT, "dev", "shots", "dlg")
os.makedirs(work, exist_ok=True)
# 造一段"两头静音 + 中间正弦 + 轻微直流偏置"的测试素材
sr = 48000
rng = np.random.default_rng(3)
sil = rng.standard_normal((sr // 2, 2)) * dsp.db_to_lin(-85) + 0.001
tone = np.stack([np.sin(2 * np.pi * 440 * np.arange(sr) / sr) * 0.25] * 2, axis=1)
src = os.path.join(work, "dialog_src.wav")
write_wav(src, np.concatenate([sil, tone, sil]).astype(np.float32), sr, bit_depth="24")
print("── 后期处理对话框 " + "─" * 48)
pd = ProcessDialog(src, None)
pd.show()
app.processEvents()
pd.trim.setChecked(True)
pd.norm.setCurrentIndex(1) # 峰值归一化
pd.depth.setCurrentIndex(pd.depth.findData("16")) # 16 位输出
pd._run()
ok = pump(60, until=lambda: pd.progress.value() == 100 and "完成" in pd.log.toPlainText())
out = os.path.splitext(src)[0] + "_processed.wav"
check("后期处理任务完成并回填界面", ok and pd.run_btn.isEnabled(),
pd.log.toPlainText().strip().splitlines()[-1] if pd.log.toPlainText() else "")
check("生成了处理后的文件", os.path.exists(out),
f"{os.path.getsize(out) if os.path.exists(out) else 0} 字节")
check("处理完成后「打开输出目录」按钮可用", pd.open_btn.isEnabled())
if os.path.exists(out):
with WavReader(out) as r:
info = r.info()
data = r.read()
check("输出为 16 位且用时变短(首尾静音已裁)",
info["bits"] == 16 and 1.1 < info["duration"] < 1.5,
f"{info['format']},{info['duration']:.3f}s(原始 2.000s)")
check("峰值被归一化到 -1 dBFS 附近",
abs(float(dsp.dbfs(dsp.peak(data, axis=0))[0]) + 1.0) < 0.3,
f"{float(dsp.dbfs(dsp.peak(data, axis=0))[0]):+.2f} dBFS")
check("直流偏移已去除", abs(float(np.mean(data))) < 1e-3,
f"{float(np.mean(data)):.2e}")
pd.close()
print("\n── 导出对话框 " + "─" * 50)
ed = ExportDialog(out, None)
ed.show()
app.processEvents()
check("导出预设列表完整", ed.preset.count() >= 8,
"、".join(ed.preset.itemData(i) for i in range(ed.preset.count())))
ed.preset.setCurrentIndex(1) # WAV 24 位
ed._run()
done = pump(60, until=lambda: "成功" in ed.log.toPlainText()
or "失败" in ed.log.toPlainText())
check("导出任务完成并回填界面", done and "成功" in ed.log.toPlainText(),
ed.log.toPlainText().strip())
exported = None
for i in range(ed.preset.count()):
from recorder import post
p = post.default_export_path(out, ed.preset.itemData(i))
if os.path.exists(p):
exported = p
check("导出文件已生成且不与源文件同名",
exported is not None and os.path.abspath(exported) != os.path.abspath(out),
os.path.basename(exported) if exported else "无")
# 找不到 ffmpeg 的场景:必须有清晰提示而不是崩溃
from recorder import post as _post
if _post.find_ffmpeg() is None:
ed.preset.setCurrentIndex(ed.preset.findData("flac"))
ed._run()
pump(20, until=lambda: "失败" in ed.log.toPlainText())
check("缺少 ffmpeg 时导出失败并给出提示",
"ffmpeg" in ed.log.toPlainText(), ed.log.toPlainText().strip()[-60:])
else:
ed.preset.setCurrentIndex(ed.preset.findData("flac"))
ed._run()
flac_ok = pump(120, until=lambda: "成功" in ed.log.toPlainText()
or "失败" in ed.log.toPlainText())
check("导出 FLAC 成功", flac_ok and "成功" in ed.log.toPlainText(),
ed.log.toPlainText().strip()[-60:])
ed.close()
print("\n" + "=" * 62)
if fails:
print(f"失败 {len(fails)} 项:" + "、".join(fails))
sys.exit(1)
print("对话框后台任务链路校验全部通过")
+363
View File
@@ -0,0 +1,363 @@
"""界面回归检查(开发/验收用):布局几何 + 像素颜色区域校验 + 截图。
AI 无法直接查看截图,所以这里用两种可判定的方式验证界面确实渲染正确:
1. 遍历控件树,检查是否有零尺寸 / 越界 / 被横向裁掉的控件,
并用字体度量核对关键文本(大号计时器、状态栏)不会被截断;
2. 读取渲染结果的像素,按区域统计颜色,确认电平条、录音按钮、
示波器、整段总览等自绘控件确实画出了预期颜色。
还会真实录音 2.6 秒,验证「开始录音 → 界面刷新 → 停止 → 仪表冻结」链路。
用法::
python dev/gui_check.py # 离屏运行,不需要显示器
python dev/gui_check.py --shots # 额外把截图存到 dev/shots/
"""
import os, sys, time, collections
SHOT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "shots")
SAVE_SHOTS = "--shots" in sys.argv
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from PyQt5.QtCore import Qt, QRect
from PyQt5.QtWidgets import (QApplication, QWidget, QComboBox, QLabel, QPushButton,
QGroupBox, QScrollArea, QSpinBox, QDoubleSpinBox,
QLineEdit, QCheckBox, QSlider)
from PyQt5.QtGui import QFont, QImage
QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)
app = QApplication([])
from recorder.gui import QSS
app.setStyleSheet(QSS)
app.setFont(QFont("Microsoft YaHei UI", 9))
from recorder.window import MainWindow
from recorder import engine
from recorder.widgets import ACCENT, REC, OK, WARN
fails = []
def check(name, ok, detail=""):
print((" ✓ " if ok else " ✗ ") + name + ((" " + detail) if detail else ""))
if not ok:
fails.append(name)
def walk(w, depth=0, out=None):
out = [] if out is None else out
out.append((depth, w))
for c in w.findChildren(QWidget, options=Qt.FindDirectChildrenOnly):
walk(c, depth + 1, out)
return out
print("── 布局几何检查 " + "─" * 50)
win = MainWindow()
win.resize(1280, 900)
out_dir = SHOT_DIR
os.makedirs(out_dir, exist_ok=True)
win.dir_edit.setText(os.path.join(out_dir, "rec"))
win.auto_report_check.setChecked(False)
win.show()
for _ in range(40):
app.processEvents()
time.sleep(0.01)
if SAVE_SHOTS:
win.grab().save(os.path.join(SHOT_DIR, "01_idle.png"))
nodes = walk(win)
print(f"控件总数:{len(nodes)}")
zero = [w for _d, w in nodes
if w.isVisible() and (w.width() <= 1 or w.height() <= 1)
and not isinstance(w, (QScrollArea,))]
check("没有零尺寸的可见控件", not zero,
"、".join(f"{type(w).__name__}" for w in zero[:6]))
# 关键控件必须可见且有合理尺寸
for name, w, min_w, min_h in (
("录音按钮", win.rec_button, 60, 60),
("电平表", win.meter, 300, 60),
("示波器", win.scope, 300, 120),
("响度条", win.loudness, 300, 40),
("总览图", win.history, 300, 60),
("定时显示", win.time_label, 120, 30),
):
check(f"{name}尺寸合理", w.isVisible() and w.width() >= min_w and w.height() >= min_h,
f"{w.width()}×{w.height()}")
# 控件不得超出窗口(滚动区内的控件按内容坐标滚动,单独检查其水平适配)
over = []
for _d, w in nodes:
if not w.isVisible() or w is win:
continue
anc, in_scroll = w.parent(), False
while anc is not None:
if isinstance(anc, QScrollArea):
in_scroll = True
break
anc = anc.parent()
if in_scroll:
continue
tl = w.mapTo(win, w.rect().topLeft() - w.rect().topLeft())
if tl.x() + w.width() > win.width() + 2 or tl.y() + w.height() > win.height() + 2 \
or tl.x() < -2 or tl.y() < -2:
over.append(f"{type(w).__name__}@{tl.x()},{tl.y()} {w.width()}×{w.height()}")
check("没有控件越出窗口边界", not over, ";".join(over[:5]))
# 关键文本不得被截断(用字体度量判断)
for name, w, sample in (("定时显示", win.time_label, "10:59:59"),
("状态栏左侧", win.status_left, "正在录音:D:\\Recordings")):
need = w.fontMetrics().horizontalAdvance(sample)
check(f"{name}能完整显示最长文本", w.width() >= need,
f"需要 {need}px,实得 {w.width()}px")
# 左面板不该被横向裁掉(滚动区内容宽度 <= 视口宽度 + 容差)
scroll = win.findChild(QScrollArea)
if scroll is not None:
inner = scroll.widget()
check("设置面板内容宽度适配视口(无横向裁切)",
inner.minimumSizeHint().width() <= scroll.viewport().width() + 2,
f"内容最小 {inner.minimumSizeHint().width()} / 视口 {scroll.viewport().width()}")
def region_colors(pixmap, rect, targets, tol=40):
"""统计 rect 区域内与各目标颜色接近的像素比例。"""
img = pixmap.toImage().convertToFormat(QImage.Format_RGB32)
counts = collections.Counter()
total = 0
for y in range(rect.top(), min(rect.bottom(), img.height()), 2):
for x in range(rect.left(), min(rect.right(), img.width()), 2):
c = img.pixel(x, y)
r, g, b = (c >> 16) & 255, (c >> 8) & 255, c & 255
total += 1
for name, (tr, tg, tb) in targets.items():
if abs(r - tr) < tol and abs(g - tg) < tol and abs(b - tb) < tol:
counts[name] += 1
break
return {k: v / max(1, total) for k, v in counts.items()}, total
def to_rgb(hexstr):
hexstr = hexstr.lstrip("#")
return tuple(int(hexstr[i:i + 2], 16) for i in (0, 2, 4))
print("\n── 空闲状态像素校验 " + "─" * 46)
pm = win.grab()
targets = {"面板": to_rgb("#161A22"), "背景": to_rgb("#0F1218"),
"强调蓝": to_rgb(ACCENT), "录音红": to_rgb(REC)}
win_rect = QRect(0, 0, win.width(), win.height())
ratios, total = region_colors(pm, win_rect, targets)
print(f" 采样 {total} 点:面板 {ratios.get('面板', 0):.1%},"
f"背景 {ratios.get('背景', 0):.1%},录音红 {ratios.get('录音红', 0):.1%}")
check("界面不是一片黑(面板/背景色占比正常)",
ratios.get("面板", 0) + ratios.get("背景", 0) > 0.4)
br, br_total = region_colors(
pm, QRect(win.rec_button.mapTo(win, win.rec_button.rect().topLeft()),
win.rec_button.size()), {"红": to_rgb(REC)})
check("空闲时录音按钮显示红色圆点", br.get("红", 0) > 0.05,
f"按钮区域内红色占比 {br.get('红', 0):.1%}")
print("\n── 录音状态像素校验 " + "─" * 46)
for i in range(win.device_combo.count()):
d = win.device_combo.itemData(i)
if d is not None:
dev = [x for x in win._devices if x.index == d]
if dev and dev[0].quality_rank == 0:
win.device_combo.setCurrentIndex(i)
break
win.gain_slider.setValue(150)
win.start_record()
t0 = time.time()
while time.time() - t0 < 2.2:
app.processEvents()
time.sleep(0.02)
win.add_marker()
while time.time() - t0 < 2.6:
app.processEvents()
time.sleep(0.02)
pm2 = win.grab()
if SAVE_SHOTS:
pm2.save(os.path.join(SHOT_DIR, "02_recording.png"))
def _is_meter_fill(r, g, b) -> bool:
"""电平条填充色:饱和度高的绿/黄/红/深蓝;排除灰白文字与白色峰值线。"""
if max(r, g, b) < 110 or min(r, g, b) > 150:
return False
return (max(r, g, b) - min(r, g, b)) > 45
def meter_fill_extent(lane: int = 0) -> float:
"""返回某个通道电平条"填充到最右边的位置"占表宽的百分比。"""
img = win.meter.grab().toImage().convertToFormat(QImage.Format_RGB32)
w, h = img.width(), img.height()
channels = win.meter.channels
top, footer = 4, 20
per = (h - top - footer) / channels
y0 = max(0, int(top + lane * per))
y1 = min(h - 1, int(top + (lane + 1) * per) - 1)
best = 0
for y in range(y0, y1):
for x in range(w - 1, -1, -1):
c = img.pixel(x, y)
if _is_meter_fill((c >> 16) & 255, (c >> 8) & 255, c & 255):
best = max(best, x)
break
return best / w
# 确定性校验 dB -> 像素映射:注入已知电平,比较填充长度
# (现场麦克风可能接近静音,直接看真实输入会让这个检查随环境漂移。
# 同时必须暂停界面刷新定时器,否则注入的值会被真实电平覆盖。)
from recorder import dsp as _dsp # noqa: E402
win.timer.stop()
def inject(peak_db, rms_db=None, clipped=False):
rms_db = peak_db if rms_db is None else rms_db
win.meter.update_levels(_dsp.MeterSnapshot(
rms_db=list(rms_db), peak_db=list(peak_db), hold_db=list(peak_db),
true_peak_db=list(peak_db), clipped=[clipped] * len(peak_db)))
app.processEvents()
inject([-3.0] * 2)
loud = meter_fill_extent(0)
inject([-30.0] * 2)
quiet = meter_fill_extent(0)
inject([-60.0] * 2)
floor = meter_fill_extent(0)
check("电平表按 dB 线性映射为像素长度",
loud > quiet + 0.15 and quiet > floor + 0.15 and loud > 0.85,
f"-3 dB → {loud:.0%},-30 dB → {quiet:.0%},-60 dB → {floor:.0%} 表宽")
inject([0.0, 0.0], clipped=True)
img = win.meter.grab().toImage().convertToFormat(QImage.Format_RGB32)
red = 0
tot = 0
for y in range(0, img.height(), 2):
for x in range(0, img.width(), 2):
c = img.pixel(x, y)
r, g, b = (c >> 16) & 255, (c >> 8) & 255, c & 255
tot += 1
if r > 200 and g < 130 and b < 140:
red += 1
check("削波锁存时电平表显示红色警示", red / max(1, tot) > 0.002,
f"红色占比 {red / max(1, tot):.2%}")
inject([-14.0, -14.0], rms_db=[-20.0, -20.0]) # 恢复成接近真实输入的样子
win.timer.start()
app.processEvents()
pix = pm2.toImage().convertToFormat(QImage.Format_RGB32)
scope_rect = QRect(win.scope.mapTo(win, win.scope.rect().topLeft()), win.scope.size())
sc_lit = 0
sc_total = 0
for y in range(scope_rect.top(), scope_rect.bottom(), 2):
for x in range(scope_rect.left(), scope_rect.right(), 2):
c = pix.pixel(x, y)
r, g, b = (c >> 16) & 255, (c >> 8) & 255, c & 255
sc_total += 1
if b > 150 and b > r + 40:
sc_lit += 1
check("示波器画出了波形(蓝色像素)", sc_lit / max(1, sc_total) > 0.001,
f"{sc_lit}/{sc_total} = {sc_lit/max(1,sc_total):.2%}")
hist_rect = QRect(win.history.mapTo(win, win.history.rect().topLeft()),
win.history.size())
h_lit = 0
h_total = 0
for y in range(hist_rect.top(), hist_rect.bottom(), 2):
for x in range(hist_rect.left(), hist_rect.right(), 2):
c = pix.pixel(x, y)
r, g, b = (c >> 16) & 255, (c >> 8) & 255, c & 255
h_total += 1
if b > 150:
h_lit += 1
check("整段总览图有波形", h_lit > 0, f"{h_lit} 个采样点")
snap = win.recorder.live()
check("录音统计在更新", snap.elapsed > 1.5 and snap.bytes_written > 0,
f"{snap.elapsed:.2f}s,{snap.bytes_written} 字节,"
f"峰值 {snap.peak_dbfs:+.1f} dBFS")
check("定时器显示已刷新", win.time_label.text() != "00:00.0", win.time_label.text())
check("状态标签显示文件名", "wav" in win.pill_file.text, win.pill_file.text)
check("录音按钮切换为录音态", win.rec_button.state == "recording", win.rec_button.state)
check("参数控件在录音中被禁用", not win.device_combo.isEnabled()
and not win.rate_combo.isEnabled())
check("传输按钮在录音中可用", win.stop_btn.isEnabled() and win.marker_btn.isEnabled()
and win.pause_btn.isEnabled())
win.stop_record()
for _ in range(20):
app.processEvents()
time.sleep(0.01)
check("停止后回到空闲态", win.recorder is None and win.rec_button.state == "idle")
check("停止后有录音结果", win.result is not None and bool(win.result.files),
f"{len(win.result.files) if win.result else 0} 个文件")
check("停止后电平表冻结显示最后一次读数(便于回看峰值)",
win.meter._peak[0] > -60, f"{win.meter._peak[0]:.1f} dBFS")
check("停止后定时显示保留最终时长", win.time_label.text() != "00:00.0",
win.time_label.text())
check("暂停/标记/分段按钮停止后禁用", not win.stop_btn.isEnabled())
# 重新开始录音时仪表盘必须清零
win.start_record()
for _ in range(10):
app.processEvents()
time.sleep(0.01)
check("重新开始时电平表已复位", win.meter._clip == [False] * win.meter.channels,
f"削波锁存 {win.meter._clip}")
win.stop_record()
for _ in range(10):
app.processEvents()
time.sleep(0.01)
if SAVE_SHOTS:
win.grab().save(os.path.join(SHOT_DIR, "03_after.png"))
from recorder.window import ReportDialog
if win.result:
dlg = ReportDialog(win.result, win)
dlg.resize(760, 620)
dlg.show()
for _ in range(10):
app.processEvents()
dlg.grab().save(os.path.join(SHOT_DIR, "04_report.png"))
dlg.close()
print(f"截图已保存到 {SHOT_DIR}")
print("\n── 静音自动停止 → 界面自动收尾 " + "─" * 33)
win.auto_stop.setValue(0.6)
win.silence_thr.setValue(-20.0) # 阈值故意设高,保证当前环境一定触发
win.start_record()
check("自动停止测试已开始录音", win.recorder is not None)
t0 = time.time()
while time.time() - t0 < 8:
app.processEvents()
time.sleep(0.02)
if win.recorder is None:
break
check("引擎自动停止后界面自动收尾(不再停在「录音中」)",
win.recorder is None and win.rec_button.state == "idle",
f"用时 {time.time() - t0:.2f} 秒")
check("自动停止也产生了可用的录音结果",
win.result is not None and bool(win.result.files),
f"{win.result.duration:.2f}s,{len(win.result.files)} 个文件"
if win.result else "无结果")
check("自动停止后传输按钮已禁用", not win.stop_btn.isEnabled())
win.auto_stop.setValue(0.0)
win.silence_thr.setValue(-50.0)
print("\n" + "=" * 62)
if fails:
print(f"失败 {len(fails)} 项:" + "、".join(fails))
sys.exit(1)
print("界面程序化校验全部通过")
+55
View File
@@ -0,0 +1,55 @@
"""静态检查(无第三方 linter 时的替代):未使用导入、裸 except、过长函数。
用法:``python dev/lint_scan.py``
`from __future__ import annotations` 会被误判(它确实没有显式引用),已忽略。
"""
import ast
import os
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TARGETS = [os.path.join(ROOT, "recorder"), ROOT]
IGNORE = {"annotations"}
MAX_FUNC_LINES = 130
issues: list[str] = []
for target in TARGETS:
files = ([os.path.join(target, f) for f in sorted(os.listdir(target))
if f.endswith(".py")]
if os.path.isdir(target) else [target])
for path in files:
rel = os.path.relpath(path, ROOT)
with open(path, encoding="utf-8") as fh:
src = fh.read()
lines = src.splitlines()
tree = ast.parse(src, path)
def has_noqa(lineno: int) -> bool:
return 0 < lineno <= len(lines) and "noqa" in lines[lineno - 1]
imported: dict[str, int] = {}
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for a in node.names:
imported[(a.asname or a.name).split(".")[0]] = node.lineno
elif isinstance(node, ast.ImportFrom):
for a in node.names:
if a.name != "*":
imported[a.asname or a.name] = node.lineno
used = {n.id for n in ast.walk(tree) if isinstance(n, ast.Name)}
for name, line in sorted(imported.items(), key=lambda kv: kv[1]):
if name in used or name in IGNORE or has_noqa(line):
continue
if f'"{name}"' in src or f"'{name}'" in src:
continue
issues.append(f"{rel}:{line} 未使用的导入 {name}")
for node in ast.walk(tree):
if isinstance(node, ast.ExceptHandler) and node.type is None \
and not has_noqa(node.lineno):
issues.append(f"{rel}:{node.lineno} 裸 except")
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
length = (node.end_lineno or node.lineno) - node.lineno
if length > MAX_FUNC_LINES and not has_noqa(node.lineno):
issues.append(f"{rel}:{node.lineno} 函数 {node.name} 过长({length} 行)")
print("\n".join(issues) if issues else "未发现明显问题")
print(f"\n共 {len(issues)} 条提示")
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+271
View File
@@ -0,0 +1,271 @@
"""依赖安装器:把 sounddevice(及可选 PyQt5)装进项目内的 _vendor 目录。
为什么不用 ``pip install sounddevice`` 就完事?
--------------------------------------------------
* 项目内的 ``_vendor`` 目录让整个软件"自包含":换台电脑、拷个 U 盘就能跑,
不污染系统 Python 环境,也不需要管理员权限;
* 某些受限环境(企业代理、受管控的临时目录、沙箱)里 pip 会因为无法写入
自己的临时目录而失败,本脚本提供了 **直接下载 wheel 并解压** 的兜底路径,
完全不依赖 pip 的临时目录机制。
用法::
python install_deps.py # 安装必需依赖(sounddevice)
python install_deps.py --with-pyqt5 # 同时安装图形界面依赖 PyQt5(较大)
python install_deps.py --check # 只检查当前依赖状态
"""
from __future__ import annotations
import argparse
import io
import json
import os
import platform
import ssl
import subprocess
import sys
import sysconfig
import zipfile
HERE = os.path.dirname(os.path.abspath(__file__))
VENDOR = os.path.join(HERE, "_vendor")
# 每一组都会被装进 _vendor;顺序即安装顺序
PACKAGE_SETS = {
"sounddevice": ["sounddevice"],
"pyqt5": ["PyQt5", "PyQt5-Qt5", "PyQt5-sip"],
}
# --------------------------------------------------------------- 平台标签
def platform_tag() -> str | None:
"""返回当前平台在 wheel 文件名里的标签(如 win_amd64)。"""
system = platform.system().lower()
machine = platform.machine().lower()
if system == "windows":
if machine in ("amd64", "x86_64"):
return "win_amd64"
if machine in ("arm64", "aarch64"):
return "win_arm64"
return "win32"
if system == "darwin":
return "macosx"
if system == "linux":
return "manylinux"
return None
def python_tags() -> list[str]:
"""当前解释器可接受的 ABI 标签,用于挑选正确的 wheel。"""
ver = f"cp{sys.version_info.major}{sys.version_info.minor}"
return [ver, "abi3", "py3", "py2.py3", ver.replace("cp", "cp3")]
def _ssl_context() -> ssl.SSLContext:
try:
import certifi # type: ignore
return ssl.create_default_context(cafile=certifi.where())
except Exception:
return ssl.create_default_context()
def _http_get_json(url: str, timeout: int = 40) -> dict:
import urllib.request
req = urllib.request.Request(url, headers={"User-Agent": "RecorderStudio-installer"})
with urllib.request.urlopen(req, context=_ssl_context(), timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
def _http_get_bytes(url: str, timeout: int = 120) -> bytes:
import urllib.request
req = urllib.request.Request(url, headers={"User-Agent": "RecorderStudio-installer"})
with urllib.request.urlopen(req, context=_ssl_context(), timeout=timeout) as resp:
return resp.read()
def pick_wheel(files: list[dict], plat: str | None) -> dict | None:
"""从 PyPI 的文件列表里挑出最适合当前平台的 wheel。"""
wheels = [f for f in files if f["filename"].endswith(".whl")]
if not wheels:
return None
def score(name: str) -> int:
base = name[:-4]
parts = base.split("-")
if len(parts) < 5:
return -1
py_tag, abi_tag, plat_tag = parts[-3], parts[-2], parts[-1]
s = 0
# 平台匹配
tags = plat_tag.split(".")
if plat and any(plat in t for t in tags):
s += 100
elif "any" in plat_tag:
s += 40
else:
return -1
# Python/ABI 匹配
want = python_tags()
if any(t == abi_tag for t in want):
s += 30
elif "abi3" in abi_tag:
s += 20
elif "any" in abi_tag or abi_tag == "none":
s += 5
else:
return -1
if any(py_tag == t or py_tag.startswith("py3") for t in want):
s += 10
# 64 位优先
if "64" in plat_tag or "amd64" in plat_tag or "arm64" in plat_tag:
s += 2
return s
best, best_score = None, 0
for w in wheels:
s = score(w["filename"])
if s > best_score:
best, best_score = w, s
return best
def install_via_wheel_download(pkg: str) -> tuple[bool, str]:
"""直接下载 wheel 并解压到 _vendor(不依赖 pip 的临时目录)。"""
plat = platform_tag()
try:
data = _http_get_json(f"https://pypi.org/pypi/{pkg}/json")
except Exception as exc:
return False, f"无法访问 PyPI:{exc}"
wheel = pick_wheel(data.get("urls") or [], plat)
if wheel is None:
return False, (f"PyPI 上没有适配当前平台({plat} / Python "
f"{sys.version_info.major}.{sys.version_info.minor})的 wheel")
try:
blob = _http_get_bytes(wheel["url"])
except Exception as exc:
return False, f"下载失败:{exc}"
try:
os.makedirs(VENDOR, exist_ok=True)
with zipfile.ZipFile(io.BytesIO(blob)) as z:
z.extractall(VENDOR)
except Exception as exc:
return False, f"解压失败:{exc}"
return True, f"{wheel['filename']}({wheel['size'] / 1024:.0f} KB)"
def install_via_pip(pkg: str) -> tuple[bool, str]:
"""优先尝试 pip(把临时目录也指到项目内,避免受管控目录写入失败)。"""
tmp = os.path.join(HERE, "_build", "tmp")
os.makedirs(tmp, exist_ok=True)
env = dict(os.environ, TEMP=tmp, TMP=tmp, TMPDIR=tmp, PIP_DISABLE_PIP_VERSION_CHECK="1")
cmd = [sys.executable, "-m", "pip", "install", "--no-input", "--no-cache-dir",
"--target", VENDOR, pkg]
try:
proc = subprocess.run(cmd, capture_output=True, text=True, env=env, timeout=900)
except Exception as exc:
return False, f"pip 调用失败:{exc}"
if proc.returncode == 0:
return True, "pip 安装成功"
tail = (proc.stderr or proc.stdout or "").strip().splitlines()
return False, tail[-1] if tail else "pip 返回非零退出码"
def ensure(pkg: str, *, verbose: bool = True) -> bool:
ok, msg = install_via_pip(pkg)
if ok:
print(f" ✓ {pkg}:{msg}")
return True
if verbose:
print(f" · {pkg}:pip 方式失败({msg}),改用直接下载 wheel…")
ok, msg = install_via_wheel_download(pkg)
if ok:
print(f" ✓ {pkg}:{msg}")
return True
print(f" ✗ {pkg}:安装失败 —— {msg}")
return False
# ------------------------------------------------------------------ 检查
def check(verbose: bool = True) -> dict:
"""检查依赖是否就绪(会临时把 _vendor 加入搜索路径)。"""
if VENDOR not in sys.path:
sys.path.insert(0, VENDOR)
status = {}
for mod, label in (("numpy", "numpy(数值运算,必需)"),
("sounddevice", "sounddevice(音频后端,必需)"),
("PyQt5", "PyQt5(图形界面,必需)")):
try:
m = __import__(mod)
ver = getattr(m, "__version__", "")
if mod == "PyQt5":
from PyQt5.QtCore import QT_VERSION_STR # noqa: F401
ver = QT_VERSION_STR
status[mod] = (True, str(ver))
if verbose:
print(f" ✓ {label}:{ver}")
except Exception as exc:
status[mod] = (False, str(exc))
if verbose:
print(f" ✗ {label}:未安装({exc})")
if status.get("sounddevice", (False,))[0]:
try:
import sounddevice as sd
n = len([d for d in sd.query_devices() if d["max_input_channels"] > 0])
if verbose:
print(f" ✓ PortAudio:{sd.get_portaudio_version()[1]},"
f"检测到 {n} 个输入设备")
status["devices"] = (n > 0, str(n))
except Exception as exc:
status["devices"] = (False, str(exc))
if verbose:
print(f" ✗ 音频设备枚举失败:{exc}")
return status
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description="RecorderStudio 依赖安装器")
p.add_argument("--with-pyqt5", action="store_true",
help="同时安装 PyQt5(图形界面所需,约 60 MB)")
p.add_argument("--check", action="store_true", help="只检查依赖状态")
args = p.parse_args(argv)
print(f"Python {sys.version.split()[0]} {sysconfig.get_platform()} "
f"目标目录 {VENDOR}\n")
if args.check:
st = check()
needed = [k for k in ("numpy", "sounddevice", "PyQt5") if not st.get(k, (False,))[0]]
if needed:
print(f"\n缺少:{', '.join(needed)} 请运行 python install_deps.py")
return 1
print("\n依赖齐全,可以启动:python -m recorder.gui")
return 0
print("正在安装依赖:")
ok = True
for pkg in PACKAGE_SETS["sounddevice"]:
ok &= ensure(pkg)
if args.with_pyqt5:
for pkg in PACKAGE_SETS["pyqt5"]:
ok &= ensure(pkg)
print("\n安装后检查:")
st = check()
missing = [k for k in ("numpy", "sounddevice", "PyQt5") if not st.get(k, (False,))[0]]
if not ok or missing:
print("\n以下依赖仍不可用:" + "、".join(missing))
if "PyQt5" in missing and not args.with_pyqt5:
print("提示:图形界面需要 PyQt5,请加参数重新运行:"
"python install_deps.py --with-pyqt5")
print("也可以手动安装:pip install --target _vendor sounddevice PyQt5")
return 1
print("\n全部就绪!启动方式:")
print(" · 图形界面:python -m recorder.gui (或双击 启动录音机.bat)")
print(" · 命令行: python -m recorder.cli --list-devices")
print(" · 自检: python -m recorder.selftest")
return 0
if __name__ == "__main__":
sys.exit(main())
+15
View File
@@ -0,0 +1,15 @@
"""RecorderStudio —— 高清晰度无损录音机。
模块一览:
dsp DSP 核心(滤波器、电平、真峰值、BS.1770 响度、离线处理)
wavfile 无损 WAV 读写(16/24/32/float32、TPDF 抖动、RF64、崩溃安全头)
engine 录音引擎(设备枚举、独占模式、落盘线程、自动分段)
post 后期处理与导出(归一化、裁剪、门限、转码、元数据、波形图)
widgets 自绘界面控件(电平表、示波器、响度条)
window 主窗口
cli 命令行录音
selftest 自检(数学正确性 + 硬件端到端)
"""
__version__ = "1.0.0"
__all__ = ["__version__"]
+263
View File
@@ -0,0 +1,263 @@
"""命令行录音:适合自动化、定时任务、无界面服务器。
示例::
python -m recorder.cli --list-devices
python -m recorder.cli -d 3 -t 60 -o D:\\rec --name 会议
python -m recorder.cli --probe 3
python -m recorder.cli --analyze D:\\rec\\会议.wav
python -m recorder.cli --process D:\\rec\\会议.wav --normalize lufs --target -16
"""
from __future__ import annotations
import argparse
import os
import signal
import sys
import time
from . import __version__, engine, post
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="recorder",
description="RecorderStudio · 高清晰度无损录音机(命令行模式)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="提示:加 --gui 可启动图形界面(等价于 python -m recorder.gui)。")
p.add_argument("--version", action="version", version=f"RecorderStudio {__version__}")
g = p.add_argument_group("设备")
g.add_argument("--list-devices", action="store_true", help="列出所有输入设备并退出")
g.add_argument("--probe", type=int, metavar="INDEX",
help="检测指定设备支持哪些采样率")
g.add_argument("-d", "--device", type=int, help="输入设备索引(默认使用系统默认设备)")
g.add_argument("--no-exclusive", action="store_true",
help="关闭 WASAPI 独占模式(共享模式,兼容性更好)")
g.add_argument("--blocksize", type=int, default=0, help="缓冲区采样数,0 = 自动")
g = p.add_argument_group("格式")
g.add_argument("-r", "--rate", type=int, default=48000, help="采样率(默认 48000)")
g.add_argument("-c", "--channels", type=int, default=2, help="声道数(默认 2)")
g.add_argument("-b", "--bit-depth", default="24",
choices=["16", "24", "32", "float32"],
help="位深(默认 24)")
g.add_argument("--no-dither", action="store_true", help="关闭 TPDF 抖动")
g.add_argument("--rf64", action="store_true", help="使用 RF64 容器(>4 GB 单文件)")
g.add_argument("--gain", type=float, default=0.0, help="软件增益 dB(默认 0)")
g.add_argument("--lowcut", type=float, default=0.0,
help="线性相位低切频率 Hz(0 = 关闭)")
g = p.add_argument_group("录制")
g.add_argument("-t", "--seconds", type=float, help="录制时长(秒);不填则按回车停止")
g.add_argument("-o", "--outdir", default="", help="输出目录(默认 ./recordings)")
g.add_argument("--name", default="{datetime}_{device}", help="文件命名模板")
g.add_argument("--split-seconds", type=float, default=0.0, help="按时长自动分段(秒)")
g.add_argument("--split-mb", type=float, default=0.0, help="按体积自动分段(MB)")
g.add_argument("--stop-after-silence", type=float, default=0.0,
help="静音多少秒后自动停止(0 = 不自动停止)")
g.add_argument("--silence-threshold", type=float, default=-50.0,
help="静音判定阈值 dBFS")
g = p.add_argument_group("后处理")
g.add_argument("--analyze", metavar="WAV", help="分析已有录音并退出")
g.add_argument("--process", metavar="WAV", help="对已有录音做后期处理")
g.add_argument("--normalize", choices=["none", "peak", "lufs"], default="none",
help="归一化方式(默认 none)")
g.add_argument("--target", type=float, default=-16.0, help="响度归一化目标 LUFS")
g.add_argument("--target-peak", type=float, default=-1.0,
help="峰值归一化目标 dBFS")
g.add_argument("--trim", action="store_true", help="裁剪首尾静音")
g.add_argument("--mono", action="store_true", help="混合为单声道")
g.add_argument("--export", default="", help="导出格式预设(flac/mp3_320/opus/m4a/wav_16…)")
g.add_argument("--report", action="store_true", help="额外生成报告与波形预览图")
g.add_argument("--gui", action="store_true", help="启动图形界面")
return p
def cmd_list_devices() -> int:
print(engine.device_summary())
return 0
def cmd_probe(index: int, channels: int) -> int:
info = engine.find_device(index) if hasattr(engine, "find_device") else None
if info is None:
devs = [d for d in engine.list_input_devices() if d.index == index]
info = devs[0] if devs else None
if info is None:
print(f"找不到设备 {index}", file=sys.stderr)
return 1
print(f"设备 [{info.index}] {info.name} 宿主 API:{info.hostapi}")
res = engine.probe_capabilities(index, channels=min(channels,
info.max_input_channels))
print(f"检测通道数:{res['channels']}\n")
for rate, entry in res["rates"].items():
if entry["supported"]:
mode = "独占" if entry.get("exclusive") else "共享"
print(f" ✓ {rate:>6} Hz {mode} {', '.join(entry['dtypes'])}")
else:
print(f" ✗ {rate:>6} Hz 不支持({entry.get('error', '')[:60]})")
return 0
def cmd_record(args: argparse.Namespace) -> int:
cfg = engine.RecordConfig(
device=args.device,
samplerate=args.rate,
channels=args.channels,
bit_depth=args.bit_depth,
gain_db=args.gain,
exclusive=not args.no_exclusive,
blocksize=args.blocksize,
lowcut_hz=args.lowcut,
dither=not args.no_dither,
rf64=args.rf64,
output_dir=args.outdir or os.path.join(os.getcwd(), "recordings"),
name_template=args.name,
split_seconds=args.split_seconds,
split_megabytes=args.split_mb,
silence_threshold_dbfs=args.silence_threshold,
auto_stop_silence_seconds=args.stop_after_silence,
)
rec = engine.Recorder(cfg)
try:
rec.start()
except Exception as exc:
print(f"启动失败:{exc}", file=sys.stderr)
return 2
print(f"正在录音 → {cfg.output_dir}")
print(f"格式:{cfg.wav_format().describe()} "
f"{'独占模式' if cfg.exclusive else '共享模式'}"
f"{' 低切 %.0f Hz' % cfg.lowcut_hz if cfg.lowcut_hz else ''}")
for n in rec.notes():
print(" · " + n)
stop_by_signal = {"flag": False}
def _handler(signum, frame): # noqa: ANN001
stop_by_signal["flag"] = True
for sig in (signal.SIGINT, signal.SIGTERM):
try:
signal.signal(sig, _handler)
except Exception:
pass
try:
if args.seconds:
deadline = time.time() + args.seconds
while time.time() < deadline and not stop_by_signal["flag"]:
time.sleep(0.2)
snap = rec.live()
sys.stdout.write(
f"\r 已录 {engine.format_duration(snap.elapsed)} "
f"{engine.format_bytes(snap.bytes_written)} "
f"峰值 {snap.peak_dbfs:+.1f} dBFS "
f"响度 {snap.meter.momentary_lufs:+.1f} LUFS "
f"xrun {snap.xruns} 溢出 {snap.overflow_blocks} ")
sys.stdout.flush()
if snap.state == engine.RecorderState.IDLE.value:
break # 静音自动停止
else:
print("按回车停止录音…")
input()
except KeyboardInterrupt:
pass
print()
result = rec.stop()
print(post.render_report(result))
if args.report and result.primary_file:
files = post.write_metadata(result)
png = post.make_preview_for(result)
if png:
files.append(png)
print("\n已生成:" + "、".join(os.path.basename(f) for f in files))
if args.export and result.primary_file:
meta = post.EXPORT_PRESETS.get(args.export)
if meta is None:
print(f"未知导出格式:{args.export};可选:{', '.join(post.EXPORT_PRESETS)}",
file=sys.stderr)
return 1
dst = post.default_export_path(result.primary_file, args.export)
ok, msg = post.export_audio(result.primary_file, dst, args.export)
print(("导出成功:" if ok else "导出失败:") + msg)
return 0 if ok else 1
return 0
def cmd_analyze(path: str) -> int:
if not os.path.exists(path):
print(f"文件不存在:{path}", file=sys.stderr)
return 1
print(post.regenerate_report(path))
return 0
def cmd_process(args: argparse.Namespace) -> int:
if not os.path.exists(args.process):
print(f"文件不存在:{args.process}", file=sys.stderr)
return 1
opts = post.ProcessOptions(
trim_silence=args.trim,
remove_dc=True,
lowcut_hz=args.lowcut,
normalize=args.normalize,
normalize_target_lufs=args.target,
normalize_target_dbfs=args.target_peak,
mono=args.mono,
bit_depth=args.bit_depth,
dither=not args.no_dither,
)
report = post.process_file(args.process, opts,
progress=lambda m: print(" " + m))
print("\n处理完成:" + report["output"])
for s in report.get("steps", []):
print(" · " + s)
an = report.get("analysis") or {}
if an:
print(f" 输出体检:峰值 {an.get('peak_dbfs')} dBFS "
f"真峰值 {an.get('true_peak_dbtp')} dBTP "
f"响度 {an.get('integrated_lufs')} LUFS")
if args.report:
png = post.write_waveform_png(
os.path.splitext(report["output"])[0] + "_waveform.png",
*post._read_whole(report["output"]))
print(" 波形预览:" + png)
if args.export:
meta = post.EXPORT_PRESETS.get(args.export)
if meta is None:
print(f"未知导出格式:{args.export}", file=sys.stderr)
return 1
dst = post.default_export_path(report["output"], args.export)
ok, msg = post.export_audio(report["output"], dst, args.export)
print((" 导出成功:" if ok else " 导出失败:") + msg)
return 0 if ok else 1
return 0
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
if args.gui:
from .gui import run
return run([sys.argv[0]])
if args.list_devices:
return cmd_list_devices()
if args.probe is not None:
return cmd_probe(args.probe, args.channels)
if args.analyze:
return cmd_analyze(args.analyze)
if args.process:
return cmd_process(args)
if engine.sd is None:
print(f"音频后端不可用:{engine.SD_IMPORT_ERROR}", file=sys.stderr)
return 2
return cmd_record(args)
if __name__ == "__main__":
sys.exit(main())
+897
View File
@@ -0,0 +1,897 @@
"""DSP 核心:线性相位滤波、电平计量、真峰值、ITU-R BS.1770 响度、离线处理。
设计原则是"录音链路尽量透明":
* 除用户显式开启的功能(低切、门限、归一化)外,不做任何隐式处理;
* 实时链路上的滤波器一律使用**线性相位 FIR**,不引入相位失真;
* 所有电平/响度计量按国际标准实现(真峰值 4 倍过采样、BS.1770 K 加权);
* 不依赖 scipy,只用 numpy,便于打包分发。
"""
from __future__ import annotations
import math
from collections import deque
from dataclasses import dataclass, field
import numpy as np
__all__ = [
"db_to_lin", "lin_to_db", "rms", "peak",
"design_highpass_fir", "design_lowpass_fir", "FIRFilter",
"true_peak", "true_peak_dbfs", "dbfs",
"kweighting_coeffs", "kweighting_response_sq",
"LoudnessMeter", "LevelMeter", "analyze_data", "analyze_file",
"remove_dc", "trim_silence", "normalize_peak", "normalize_loudness",
"fade_edges", "noise_gate", "mixdown_mono", "highpass_offline",
"SILENCE_DBFS_FLOOR",
]
# 低于该电平即可视为数字静音(16 位理论本底约 -96 dBFS)
SILENCE_DBFS_FLOOR = -120.0
# --------------------------------------------------------------------- 基础
def db_to_lin(db: float) -> float:
return float(10.0 ** (float(db) / 20.0))
def lin_to_db(x: float, floor: float = SILENCE_DBFS_FLOOR) -> float:
x = float(x)
if x <= 0 or not math.isfinite(x):
return float(floor)
v = 20.0 * math.log10(x)
return v if v > floor else float(floor)
def dbfs(x: np.ndarray | float, floor: float = SILENCE_DBFS_FLOOR) -> np.ndarray | float:
"""线性幅度 -> dBFS(数组安全)。"""
arr = np.asarray(x, dtype=np.float64)
with np.errstate(divide="ignore", invalid="ignore"):
out = 20.0 * np.log10(np.abs(arr))
out = np.where(np.isfinite(out) & (out > floor), out, floor)
return out if isinstance(x, np.ndarray) else float(out)
def rms(x: np.ndarray, axis=None) -> np.ndarray | float:
arr = np.asarray(x, dtype=np.float64)
if arr.size == 0:
return 0.0
return np.sqrt(np.mean(np.square(arr), axis=axis))
def peak(x: np.ndarray, axis=None) -> np.ndarray | float:
arr = np.asarray(x, dtype=np.float64)
if arr.size == 0:
return 0.0
return np.max(np.abs(arr), axis=axis)
# ------------------------------------------------------------------- 滤波器
def _windowed_sinc(numtaps: int, cutoff: float, *, highpass: bool,
beta: float = 8.6) -> np.ndarray:
"""窗函数法设计线性相位 FIR。``cutoff`` 为归一化频率(cycles/sample)。"""
if numtaps % 2 == 0:
numtaps += 1 # 奇数长度 -> 整数群延迟,且可表示真正的直流零点
n = np.arange(numtaps) - (numtaps - 1) / 2.0
h = 2.0 * cutoff * np.sinc(2.0 * cutoff * n)
if highpass:
h = -h
h[(numtaps - 1) // 2] += 1.0
h *= np.kaiser(numtaps, beta)
if highpass:
# 强制直流增益为严格 0:彻底消除直流漂移,同时保持线性相位(对称加权)
h -= h.mean()
return h
def design_highpass_fir(cutoff_hz: float, samplerate: int, *,
numtaps: int | None = None,
beta: float = 8.0,
max_taps: int = 8193) -> np.ndarray:
"""设计低切(高通)线性相位 FIR,通带增益归一到 0 dB。
抽头数按 Kaiser 公式反推,保证阻带衰减约 80 dB 且过渡带足够窄
(过渡带宽度取截止频率的一半),这样 100 Hz 以上的语音基频几乎不受影响。
"""
if cutoff_hz <= 0:
raise ValueError("截至频率必须为正")
nyq = samplerate / 2.0
if cutoff_hz >= nyq * 0.9:
raise ValueError("截至频率过高")
if numtaps is None:
atten = max(40.0, 9.0 * beta) # beta 与阻带衰减的经验对应
trans = max(cutoff_hz * 0.5, 4.0) # 过渡带宽度 (Hz)
d_omega = 2.0 * math.pi * trans / samplerate
numtaps = int(math.ceil((atten - 7.95) / (2.285 * d_omega)))
numtaps = int(np.clip(numtaps, 129, max_taps))
h = _windowed_sinc(int(numtaps), cutoff_hz / samplerate,
highpass=True, beta=beta)
# 用 1 kHz(或奈奎斯特的 10%)处的响应把通带增益归一到 1.0
ref = min(1000.0, nyq * 0.1)
w = 2.0 * math.pi * ref / samplerate
k = np.arange(h.size)
gain = np.abs(np.sum(h * np.exp(-1j * w * k)))
if gain > 1e-12:
h = h / gain
return h
def design_lowpass_fir(cutoff_hz: float, samplerate: int,
numtaps: int = 129, beta: float = 8.6) -> np.ndarray:
h = _windowed_sinc(int(numtaps), cutoff_hz / samplerate,
highpass=False, beta=beta)
s = h.sum()
return h / s if abs(s) > 1e-12 else h
class FIRFilter:
"""多通道流式 FIR(重叠相加),保持跨块状态。"""
def __init__(self, coeffs: np.ndarray, channels: int):
self.h = np.asarray(coeffs, dtype=np.float64)
self.channels = int(channels)
self._tail = np.zeros((self.h.size - 1, self.channels), dtype=np.float64)
@property
def latency_samples(self) -> int:
return (self.h.size - 1) // 2
def reset(self) -> None:
self._tail[:] = 0.0
def process(self, block: np.ndarray) -> np.ndarray:
x = np.asarray(block, dtype=np.float64)
if x.ndim == 1:
x = x[:, None]
n = x.shape[0]
out = np.empty((n, self.channels), dtype=np.float64)
for c in range(self.channels):
y = np.convolve(x[:, c], self.h)
y[:self.h.size - 1] += self._tail[:, c]
out[:, c] = y[:n]
self._tail[:, c] = y[n:n + self.h.size - 1]
return out
# ------------------------------------------------------------------ 真峰值
_OVERSAMPLE_CACHE: dict[tuple[int, int], list[np.ndarray]] = {}
def _polyphase_bank(factor: int = 4, taps_per_phase: int = 12) -> list[np.ndarray]:
"""L 倍过采样的多相插值滤波器组。
``h_p[j] = g[p + jL]``,其中 ``g`` 是以 1/(2L) 为截止频率的窗函数 sinc
(在过采样后的采样率下)。零相位支路 ``h_0`` 即近似恒等,
因此插值结果绝不会低于原始采样峰值。
"""
key = (factor, taps_per_phase)
if key in _OVERSAMPLE_CACHE:
return _OVERSAMPLE_CACHE[key]
numtaps = factor * taps_per_phase + 1
n = np.arange(numtaps) - (numtaps - 1) / 2.0
# 关键:插值核峰值必须为 1(不能用 sum 归一化),这样零相位支路恰好是
# 恒等滤波,插值结果的峰值才不会低于原始采样峰值。
g = np.sinc(n / factor) * np.kaiser(numtaps, 9.0)
bank = [g[p::factor].copy() for p in range(factor)]
_OVERSAMPLE_CACHE[key] = bank
return bank
def true_peak(x: np.ndarray, *, factor: int = 4) -> np.ndarray:
"""按 ITU-R BS.1770 附录 2 计算真峰值(返回每声道线性幅度)。
做法是多相插值到 ``factor`` 倍采样率后取最大绝对值,
可以捕捉到采样点之间的过冲(inter-sample peak)。
"""
arr = np.asarray(x, dtype=np.float64)
if arr.ndim == 1:
arr = arr[:, None]
if arr.size == 0:
return np.zeros(arr.shape[1])
bank = _polyphase_bank(factor)
out = np.zeros(arr.shape[1])
for c in range(arr.shape[1]):
col = arr[:, c]
best = 0.0
for h in bank:
y = np.convolve(col, h)
m = float(np.max(np.abs(y))) if y.size else 0.0
if m > best:
best = m
out[c] = best
return out
def true_peak_dbfs(x: np.ndarray, *, factor: int = 4) -> np.ndarray:
return dbfs(true_peak(x, factor=factor))
# ------------------------------------------------------- BS.1770 K 加权响度
# ITU-R BS.1770-4 在 48 kHz 下给出的参考系数(用于自检比对)
_KW_48K_SHELF = ([1.53512485958697, -2.69169618940638, 1.19839281085285],
[1.0, -1.69065929318241, 0.73248077421585])
_KW_48K_HPF = ([1.0, -2.0, 1.0],
[1.0, -1.99004745483398, 0.99007225036621])
# 等效设计参数(与 ITU 系数在 48 kHz 下逐位吻合,见自检用例)
_SHELF_GAIN_DB = 3.999843853973347
_SHELF_FC = 1681.974450955533
_SHELF_Q = 0.7071752369554196
_SHELF_VB_EXP = 0.4996667741545416
_HPF_FC = 38.13547087602444
_HPF_Q = 0.5003270373238773
def _itu_highshelf(samplerate: int):
"""BS.1770 第一级:高频搁架(+4 dB @ 高频段)。
采用 ITU 参考实现所用的参数化公式,在 48 kHz 下与标准给定系数
完全一致(误差 < 1e-15),其它采样率下即为标准的双线性变换推广。
"""
k = math.tan(math.pi * _SHELF_FC / samplerate)
vh = 10.0 ** (_SHELF_GAIN_DB / 20.0)
vb = vh ** _SHELF_VB_EXP
a0 = 1.0 + k / _SHELF_Q + k * k
b = np.array([(vh + vb * k / _SHELF_Q + k * k) / a0,
2.0 * (k * k - vh) / a0,
(vh - vb * k / _SHELF_Q + k * k) / a0])
a = np.array([1.0,
2.0 * (k * k - 1.0) / a0,
(1.0 - k / _SHELF_Q + k * k) / a0])
return b, a
def _itu_highpass(samplerate: int):
"""BS.1770 第二级:约 38 Hz 的二阶高通。"""
k = math.tan(math.pi * _HPF_FC / samplerate)
a0 = 1.0 + k / _HPF_Q + k * k
b = np.array([1.0, -2.0, 1.0])
a = np.array([1.0,
2.0 * (k * k - 1.0) / a0,
(1.0 - k / _HPF_Q + k * k) / a0])
return b, a
def kweighting_coeffs(samplerate: int):
"""返回 K 加权的两级 biquad 系数 ``((b1,a1),(b2,a2))``。"""
if int(samplerate) == 48000:
# 48 kHz 直接使用标准原文系数,保证逐位一致
b1, a1 = _KW_48K_SHELF
b2, a2 = _KW_48K_HPF
return (np.array(b1, dtype=np.float64), np.array(a1, dtype=np.float64)), \
(np.array(b2, dtype=np.float64), np.array(a2, dtype=np.float64))
return _itu_highshelf(int(samplerate)), _itu_highpass(int(samplerate))
def biquad_response_sq(b: np.ndarray, a: np.ndarray,
w: np.ndarray) -> np.ndarray:
"""|H(e^{jw})|^2,``w`` 为归一化角频率数组。"""
z1 = np.exp(-1j * w)
z2 = z1 * z1
num = b[0] + b[1] * z1 + b[2] * z2
den = a[0] + a[1] * z1 + a[2] * z2
return np.abs(num / den) ** 2
def kweighting_response_sq(samplerate: int, fft_size: int) -> np.ndarray:
"""K 加权滤波器在 rfft 各 bin 上的功率响应(长度 ``fft_size//2+1``)。"""
(b1, a1), (b2, a2) = kweighting_coeffs(samplerate)
w = 2.0 * math.pi * np.arange(fft_size // 2 + 1) / fft_size
return biquad_response_sq(b1, a1, w) * biquad_response_sq(b2, a2, w)
# BS.1770 的绝对门限与相对门限
_LUFS_ABS_GATE = -70.0
_LUFS_REL_GATE = -10.0
_LUFS_OFFSET = -0.691
def _lufs_from_ms(mean_square: float) -> float:
if mean_square <= 0:
return float("-inf")
return _LUFS_OFFSET + 10.0 * math.log10(mean_square)
class LoudnessMeter:
"""流式响度计(ITU-R BS.1770-4 门限算法)。
以 100 ms 为 hop、400 ms 为瞬时块(即 75% 重叠)统计能量,
再按绝对门限 -70 LUFS、相对门限 -10 LU 计算整体响度。
为实现高效,K 加权在频域按 |H(f)|² 施加——对"能量"计量而言与
时域滤波完全等价(K 加权滤波器冲激响应仅数毫秒,块边界残留可忽略)。
"""
HOP_SECONDS = 0.1
MOMENTARY_HOPS = 4 # 400 ms
SHORT_TERM_HOPS = 30 # 3 s
def __init__(self, samplerate: int, channels: int, *,
keep_hops: bool = True):
self.samplerate = int(samplerate)
self.channels = int(channels)
self.hop = max(1, int(round(self.HOP_SECONDS * self.samplerate)))
self._resp = kweighting_response_sq(self.samplerate, self.hop)
# rfft 的单边能量补正(直流与奈奎斯特 bin 不翻倍)
self._fold = np.full(self._resp.size, 2.0)
self._fold[0] = 1.0
if self.hop % 2 == 0:
self._fold[-1] = 1.0
self._weight = self._resp * self._fold / float(self.hop * self.hop)
self._acc = np.zeros((0, self.channels), dtype=np.float64)
self._hops: deque[float] = deque(maxlen=self.SHORT_TERM_HOPS)
self._all_hops: list[float] = [] if keep_hops else []
self._keep_hops = keep_hops
self.processed_frames = 0
# ------------------------------------------------------------------ push
def push(self, block: np.ndarray) -> None:
"""送入一块音频(float,(n, channels))。"""
x = np.asarray(block, dtype=np.float64)
if x.ndim == 1:
x = x[:, None]
if x.shape[1] != self.channels:
raise ValueError("通道数与响度计配置不符")
self.processed_frames += x.shape[0]
if self._acc.size:
x = np.concatenate((self._acc, x), axis=0)
while x.shape[0] >= self.hop:
self._hop_energy(x[:self.hop])
x = x[self.hop:]
self._acc = x
def _hop_energy(self, hop_data: np.ndarray) -> None:
"""计算一个 hop 的加权均方(所有声道求和,声道权重取 1.0)。"""
spec = np.fft.rfft(hop_data, axis=0)
power = np.abs(spec) ** 2 # (bins, channels)
weighted = np.einsum("kc,k->", power, self._weight)
ms = float(weighted)
self._hops.append(ms)
if self._keep_hops:
self._all_hops.append(ms)
# ------------------------------------------------------------- readings
def _window_ms(self, hops: int) -> float:
n = min(hops, len(self._hops))
if n == 0:
return 0.0
vals = list(self._hops)[-n:]
return float(np.mean(vals))
@property
def momentary(self) -> float:
"""瞬时响度(400 ms 窗),LUFS。"""
return _lufs_from_ms(self._window_ms(self.MOMENTARY_HOPS))
@property
def short_term(self) -> float:
"""短时响度(3 s 窗),LUFS。"""
return _lufs_from_ms(self._window_ms(self.SHORT_TERM_HOPS))
@property
def integrated(self) -> float:
"""整体响度(带门限),LUFS。"""
if not self._keep_hops or len(self._all_hops) < self.MOMENTARY_HOPS:
return float("-inf")
# 400 ms 块(100 ms 步进 = 75% 重叠)的块能量序列
arr = np.asarray(self._all_hops, dtype=np.float64)
if arr.size < self.MOMENTARY_HOPS:
return float("-inf")
kernel = np.ones(self.MOMENTARY_HOPS) / self.MOMENTARY_HOPS
blocks = np.convolve(arr, kernel, mode="valid")
abs_gate = 10.0 ** ((_LUFS_ABS_GATE - _LUFS_OFFSET) / 10.0)
keep = blocks > abs_gate
if not np.any(keep):
return float("-inf")
mean1 = float(np.mean(blocks[keep]))
rel_gate = mean1 * (10.0 ** (_LUFS_REL_GATE / 10.0))
keep2 = blocks > max(rel_gate, abs_gate)
if not np.any(keep2):
return _lufs_from_ms(mean1)
return _lufs_from_ms(float(np.mean(blocks[keep2])))
def loudness_range_estimate(self) -> float:
"""LRA 粗估(10%~95% 分位的短时响度差)。
真正符合 EBU Tech 3342 的 LRA 需要 3 s 窗 + 10 s 分片,这里给出
一个足够稳定的工程近似值,用于提示动态范围。
"""
if not self._keep_hops:
return 0.0
arr = np.asarray(self._all_hops, dtype=np.float64)
if arr.size < self.SHORT_TERM_HOPS:
return 0.0
kernel = np.ones(self.SHORT_TERM_HOPS) / self.SHORT_TERM_HOPS
st = np.convolve(arr, kernel, mode="valid")
st = st[st > 10.0 ** ((_LUFS_ABS_GATE - _LUFS_OFFSET) / 10.0)]
if st.size < 2:
return 0.0
loud = _LUFS_OFFSET + 10.0 * np.log10(st)
p10, p95 = np.percentile(loud, 10.0), np.percentile(loud, 95.0)
return float(p95 - p10)
def reset(self) -> None:
self._acc = np.zeros((0, self.channels), dtype=np.float64)
self._hops.clear()
self._all_hops.clear()
self.processed_frames = 0
def flush(self) -> None:
"""把不足一个 hop 的尾部补零送算,保证短录音也有响度读数。"""
if self._acc.size:
tail = self._acc
pad = np.zeros((self.hop - tail.shape[0], self.channels))
self._hop_energy(np.concatenate((tail, pad), axis=0))
self._acc = np.zeros((0, self.channels))
# --------------------------------------------------------------- 实时电平表
@dataclass
class MeterSnapshot:
rms_db: list[float] = field(default_factory=list)
peak_db: list[float] = field(default_factory=list)
hold_db: list[float] = field(default_factory=list)
true_peak_db: list[float] = field(default_factory=list)
clipped: list[bool] = field(default_factory=list)
clip_count: int = 0
momentary_lufs: float = float("-inf")
short_term_lufs: float = float("-inf")
integrated_lufs: float = float("-inf")
class LevelMeter:
"""实时电平表:每块 RMS/峰值 + 峰值保持 + 削波锁存 + 真峰值 + 响度。"""
def __init__(self, samplerate: int, channels: int, *,
hold_decay_db_per_s: float = 12.0,
true_peak_enabled: bool = True,
loudness_enabled: bool = True):
self.samplerate = int(samplerate)
self.channels = int(channels)
self.hold_decay = float(hold_decay_db_per_s)
self.true_peak_enabled = bool(true_peak_enabled)
self.loudness_enabled = bool(loudness_enabled)
self._hold = np.full(self.channels, SILENCE_DBFS_FLOOR)
self._hold_timer = np.zeros(self.channels)
self._tp = np.zeros(self.channels)
self._clip = np.zeros(self.channels, dtype=bool)
self.clip_count = 0
self._last_block_time: float | None = None
self._tp_hop = max(1, int(round(0.1 * self.samplerate)))
self._acc: list[np.ndarray] = []
self._acc_frames = 0
self._preroll = np.zeros((0, self.channels))
self.loudness = LoudnessMeter(self.samplerate, self.channels) \
if self.loudness_enabled else None
self._rms_db = [SILENCE_DBFS_FLOOR] * self.channels
self._peak_db = [SILENCE_DBFS_FLOOR] * self.channels
# ------------------------------------------------------------------ push
def process(self, block: np.ndarray, *, now: float | None = None) -> None:
x = np.asarray(block, dtype=np.float64)
if x.ndim == 1:
x = x[:, None]
if x.size == 0:
return
# 1) 逐块 RMS / 峰值(更新快,界面刷新率高)
self._rms_db = [lin_to_db(v) for v in rms(x, axis=0)]
self._peak_db = [lin_to_db(v) for v in peak(x, axis=0)]
# 2) 峰值保持(带自动回落,避免"钉住"不动)
if now is None:
import time
now = time.monotonic()
dt = 0.0 if self._last_block_time is None else max(0.0, now - self._last_block_time)
self._last_block_time = now
self._hold = np.maximum(self._hold - self.hold_decay * dt,
np.asarray(self._peak_db, dtype=np.float64))
# 3) 削波锁存
over = np.any(np.abs(x) >= 0.99999, axis=0)
self._clip |= over
self.clip_count += int(np.count_nonzero(np.abs(x) >= 0.99999))
# 4) 响度
if self.loudness is not None:
self.loudness.push(x)
# 5) 真峰值:按 100 ms 累积(含 12 样本前导以避免块边界低估)
if self.true_peak_enabled:
self._acc.append(x)
self._acc_frames += x.shape[0]
while self._acc_frames >= self._tp_hop:
merged = np.concatenate(self._acc, axis=0)
window = merged[:self._tp_hop]
rest = merged[self._tp_hop:]
seg = np.concatenate((self._preroll, window), axis=0) \
if self._preroll.size else window
tp = true_peak(seg)
# 只统计新增部分的真峰值(近似:整段最大值)
self._tp = np.maximum(self._tp, tp)
self._preroll = window[-12:] if window.shape[0] >= 12 else window
self._acc = [rest] if rest.size else []
self._acc_frames = rest.shape[0]
# ------------------------------------------------------------- readings
def snapshot(self) -> MeterSnapshot:
return MeterSnapshot(
rms_db=[float(v) for v in self._rms_db],
peak_db=list(self._peak_db),
hold_db=[float(v) for v in self._hold],
true_peak_db=[lin_to_db(v) for v in self._tp],
clipped=[bool(v) for v in self._clip],
clip_count=self.clip_count,
momentary_lufs=self.loudness.momentary if self.loudness else float("-inf"),
short_term_lufs=self.loudness.short_term if self.loudness else float("-inf"),
integrated_lufs=self.loudness.integrated if self.loudness else float("-inf"),
)
def reset_clip(self) -> None:
self._clip[:] = False
def reset(self) -> None:
self._hold[:] = SILENCE_DBFS_FLOOR
self._tp[:] = 0.0
self._clip[:] = False
self.clip_count = 0
self._acc = []
self._acc_frames = 0
self._preroll = np.zeros((0, self.channels))
self._last_block_time = None
if self.loudness is not None:
self.loudness.reset()
# --------------------------------------------------------------- 离线分析
def _noise_floor(block_rms: np.ndarray) -> tuple[list[float], bool]:
"""从 50 ms 块 RMS 序列估计本底噪声。
取安静段的 10% 分位作为本底,但要求整体动态范围(90% 分位 / 10% 分位)
至少 10 dB——否则说明录音里根本没有"安静段"(例如全程持续发声的
测试信号),此时返回不可测,而不是编造一个数字。
"""
if block_rms.size < 4:
return [], False
p10 = np.percentile(block_rms, 10.0, axis=0)
p90 = np.percentile(block_rms, 90.0, axis=0)
if np.any(p10 <= 0) or np.any(p90 <= 0):
return [], False
if np.any(20.0 * np.log10(p90 / p10) < 10.0):
return [], False
return [round(float(v), 2) for v in np.atleast_1d(dbfs(p10))], True
def analyze_data(data: np.ndarray, samplerate: int, *,
true_peak_factor: int = 4) -> dict:
"""对一段音频做全面体检,返回可直接写入元数据的字典。"""
x = np.asarray(data, dtype=np.float64)
if x.ndim == 1:
x = x[:, None]
n, ch = x.shape
duration = n / float(samplerate) if samplerate else 0.0
pk = peak(x, axis=0)
pk_db = dbfs(pk)
tp = true_peak(x, factor=true_peak_factor) if n else np.zeros(ch)
tp_db = dbfs(tp)
rms_db = dbfs(rms(x, axis=0))
# 直流偏移
dc = np.mean(x, axis=0) if n else np.zeros(ch)
# 噪声本底:只在检测到安静段时才给出
block = max(1, int(0.05 * samplerate))
usable = (n // block) * block
if usable >= block:
blocks = x[:usable].reshape(-1, block, ch)
blk_rms = np.sqrt(np.mean(np.square(blocks), axis=1)) # (nblocks, ch)
floor_db, floor_ok = _noise_floor(blk_rms)
else:
floor_db, floor_ok = [], False
# 削波统计
clip_counts = np.count_nonzero(np.abs(x) >= 0.99999, axis=0)
# 静音占比(低于 -60 dBFS 的采样比例)
quiet = float(np.mean(np.abs(x) < db_to_lin(-60.0))) if n else 1.0
# 响度
lm = LoudnessMeter(samplerate, ch)
step = 1 << 16
for i in range(0, n, step):
lm.push(x[i:i + step])
lm.flush()
return {
"samplerate": int(samplerate),
"channels": int(ch),
"frames": int(n),
"duration": duration,
"peak_dbfs": [round(float(v), 3) for v in np.atleast_1d(pk_db)],
"true_peak_dbtp": [round(float(v), 3) for v in np.atleast_1d(tp_db)],
"rms_dbfs": [round(float(v), 3) for v in np.atleast_1d(rms_db)],
"integrated_lufs": round(float(lm.integrated), 3)
if math.isfinite(lm.integrated) else None,
"short_term_max_lufs": None,
"loudness_range_lu": round(float(lm.loudness_range_estimate()), 3),
"dc_offset": [round(float(v), 8) for v in np.atleast_1d(dc)],
"noise_floor_dbfs": floor_db,
"noise_floor_available": floor_ok,
"clipped_samples": [int(v) for v in np.atleast_1d(clip_counts)],
"clipped_total": int(np.sum(clip_counts)),
"silence_ratio": round(quiet, 4),
}
def analyze_file(path: str, *, max_seconds: float | None = None) -> dict:
"""对 WAV 文件做体检(分块读取,适合超长录音)。"""
from .wavfile import WavReader
with WavReader(path) as r:
info = r.info()
limit = r.frames if max_seconds is None else min(
r.frames, int(max_seconds * r.samplerate))
lm = LoudnessMeter(r.samplerate, r.channels)
pk = np.zeros(r.channels)
tp = np.zeros(r.channels)
rms_acc = np.zeros(r.channels, dtype=np.float64)
dc_acc = np.zeros(r.channels, dtype=np.float64)
clips = np.zeros(r.channels, dtype=np.int64)
count = 0
blk_rms: list[np.ndarray] = []
preroll = np.zeros((0, r.channels), dtype=np.float64)
while count < limit:
block = r.read(count, min(1 << 16, limit - count))
if block.size == 0:
break
x64 = block # 已是 float64
pk = np.maximum(pk, np.max(np.abs(x64), axis=0))
# 真峰值:带 32 样本前导,避免块边界漏掉采样点之间的过冲
seg = np.concatenate((preroll, x64), axis=0) if preroll.size else x64
if seg.shape[0] > 128:
tp = np.maximum(tp, true_peak(seg))
else:
tp = np.maximum(tp, np.max(np.abs(seg), axis=0))
preroll = x64[-32:] if x64.shape[0] >= 32 else x64
rms_acc += np.sum(np.square(x64), axis=0)
dc_acc += np.sum(x64, axis=0)
clips += np.count_nonzero(np.abs(x64) >= 0.99999, axis=0)
lm.push(x64)
sub = max(1, int(0.05 * r.samplerate))
usable = (x64.shape[0] // sub) * sub
if usable:
blk_rms.append(np.sqrt(np.mean(
np.square(x64[:usable].reshape(-1, sub, r.channels)), axis=1)))
count += block.shape[0]
lm.flush()
duration = count / r.samplerate if r.samplerate else 0.0
floor_db, floor_ok = _noise_floor(np.concatenate(blk_rms, axis=0)) \
if blk_rms else ([], False)
out = dict(info)
out.update({
"analyzed_frames": count,
"analyzed_duration": duration,
"peak_dbfs": [round(float(v), 3) for v in np.atleast_1d(dbfs(pk))],
"true_peak_dbtp": [round(float(v), 3) for v in np.atleast_1d(dbfs(tp))],
"rms_dbfs": [round(float(v), 3) for v in np.atleast_1d(
dbfs(np.sqrt(rms_acc / max(1, count))))],
"integrated_lufs": round(float(lm.integrated), 3)
if math.isfinite(lm.integrated) else None,
"loudness_range_lu": round(float(lm.loudness_range_estimate()), 3),
"dc_offset": [round(float(v), 8) for v in dc_acc / max(1, count)],
"noise_floor_dbfs": floor_db,
"noise_floor_available": floor_ok,
"clipped_samples": [int(v) for v in clips],
"clipped_total": int(clips.sum()),
})
return out
# ------------------------------------------------------------- 离线处理链
def remove_dc(x: np.ndarray, samplerate: int | None = None,
window_seconds: float = 1.0) -> np.ndarray:
"""去除直流与极低频漂移。
给出 ``samplerate`` 时减去**滑动平均**(默认 1 秒窗)而不是全局均值:
录音中途如果直流电平发生漂移(设备热漂移、某些 USB 声卡的固有偏移),
全局均值法只会把漂移原样留成低频噪声,滑动平均才能真正跟掉它。
对 1 秒窗而言,2 Hz 以上的频响基本平坦,不会动到有用信号。
"""
arr = np.asarray(x, dtype=np.float64)
if arr.size == 0:
return arr
if arr.ndim == 1:
arr = arr[:, None]
n, ch = arr.shape
w = int(window_seconds * samplerate) if samplerate else 0
if w < 3 or w >= n:
return arr - np.mean(arr, axis=0, keepdims=True)
cum = np.cumsum(np.concatenate([np.zeros((1, ch)), arr], axis=0), axis=0)
pad = w // 2
idx = np.arange(n)
lo = np.clip(idx - pad, 0, n)
hi = np.clip(idx - pad + w, 0, n)
count = np.maximum(1, hi - lo)
avg = (cum[hi] - cum[lo]) / count[:, None]
return arr - avg
def highpass_offline(x: np.ndarray, samplerate: int, cutoff_hz: float) -> np.ndarray:
h = design_highpass_fir(cutoff_hz, samplerate)
f = FIRFilter(h, x.shape[1] if x.ndim > 1 else 1)
y = f.process(x)
# 补偿滤波群延迟,保持时间轴对齐
d = f.latency_samples
return y[d:] if d else y
def trim_silence(x: np.ndarray, samplerate: int, *,
threshold_dbfs: float = -50.0,
min_silence: float = 0.35,
pad: float = 0.15) -> tuple[np.ndarray, dict]:
"""裁掉首尾静音,返回 ``(新数据, 裁剪信息)``。
只有当首/尾静音长于 ``min_silence`` 时才裁剪,避免把短促的气口误伤;
裁剪后仍保留 ``pad`` 秒的呼吸空间,防止削掉起音与尾音的自然衰减。
"""
if x.size == 0:
return x, {"trimmed_seconds": 0.0}
thr = db_to_lin(threshold_dbfs)
win = max(1, int(0.01 * samplerate))
usable = (x.shape[0] // win) * win
if usable < win:
return x, {"trimmed_seconds": 0.0}
env = np.max(np.abs(x[:usable]), axis=1).reshape(-1, win).max(axis=1)
idx = np.flatnonzero(env >= thr)
if idx.size == 0:
return x, {"trimmed_seconds": 0.0, "all_silent": True}
pad_blocks = int(pad * samplerate / win)
min_blocks = max(1, int(min_silence * samplerate / win))
start_block = int(idx[0])
end_block = int(idx[-1]) + 1
# 首部静音不够长就不动它
if start_block < min_blocks:
start_block = 0
else:
start_block = max(0, start_block - pad_blocks)
# 尾部同理
if env.size - end_block < min_blocks:
end_block = env.size
else:
end_block = min(env.size, end_block + pad_blocks)
start = start_block * win
end = min(x.shape[0], end_block * win)
trimmed = x[start:end]
if trimmed.size == 0:
return x, {"trimmed_seconds": 0.0}
return trimmed, {
"trimmed_seconds": round((x.shape[0] - trimmed.shape[0]) / samplerate, 3),
"lead_seconds": round(start / samplerate, 3),
"tail_seconds": round((x.shape[0] - end) / samplerate, 3),
"min_silence": min_silence,
"threshold_dbfs": threshold_dbfs,
}
def fade_edges(x: np.ndarray, samplerate: int, *,
fade_in: float = 0.005, fade_out: float = 0.02) -> np.ndarray:
y = np.array(x, copy=True)
n = y.shape[0]
for dur, head in ((fade_in, True), (fade_out, False)):
k = min(n, int(dur * samplerate))
if k <= 1:
continue
ramp = np.linspace(0.0, 1.0, k, dtype=np.float64)
if head:
y[:k] *= ramp[:, None]
else:
y[n - k:] *= ramp[::-1, None]
return y
def normalize_peak(x: np.ndarray, target_dbfs: float = -1.0,
*, true_peak_mode: bool = True,
samplerate: int | None = None) -> tuple[np.ndarray, float]:
"""按峰值归一化(默认按真峰值,避免转码后过载)。"""
if x.size == 0:
return x, 0.0
cur = float(np.max(true_peak(x))) if (true_peak_mode and x.shape[0] > 64) \
else float(peak(x))
if cur <= 1e-12:
return x, 0.0
gain = db_to_lin(target_dbfs) / cur
return x * gain, float(20.0 * math.log10(gain))
def normalize_loudness(x: np.ndarray, samplerate: int, target_lufs: float,
*, max_gain_db: float = 30.0,
true_peak_ceiling_dbfs: float = -1.0,
) -> tuple[np.ndarray, dict]:
"""按整体响度(BS.1770)归一化,并保证真峰值不超过上限。"""
if x.size == 0:
return x, {"gain_db": 0.0}
ch = x.shape[1] if x.ndim > 1 else 1
lm = LoudnessMeter(samplerate, ch)
step = 1 << 16
for i in range(0, x.shape[0], step):
lm.push(x[i:i + step])
lm.flush()
current = float(lm.integrated)
if not math.isfinite(current):
return x, {"gain_db": 0.0, "note": "无法测定响度,未做处理"}
gain_db = target_lufs - current
limited = False
if gain_db > max_gain_db:
gain_db, limited = max_gain_db, True
if gain_db < -max_gain_db:
gain_db, limited = -max_gain_db, True
y = x * db_to_lin(gain_db)
# 峰值保护:若真峰值越界则回退增益
tp = float(np.max(true_peak(y))) if y.shape[0] > 64 else float(peak(y))
ceiling = db_to_lin(true_peak_ceiling_dbfs)
if tp > ceiling and tp > 0:
extra = ceiling / tp
y = y * extra
gain_db += 20.0 * math.log10(extra)
return y, {
"gain_db": round(float(gain_db), 3),
"measured_lufs": round(current, 3),
"target_lufs": float(target_lufs),
"gain_limited": limited,
}
def noise_gate(x: np.ndarray, samplerate: int, *, threshold_dbfs: float = -60.0,
attack: float = 0.005, release: float = 0.12,
hold: float = 0.1) -> tuple[np.ndarray, dict]:
"""带包络跟随的软门限(仅在用户显式开启时使用)。"""
if x.size == 0:
return x, {"gated_ratio": 0.0}
thr = db_to_lin(threshold_dbfs)
win = max(1, int(0.005 * samplerate))
usable = (x.shape[0] // win) * win
env = np.max(np.abs(x[:usable]), axis=1).reshape(-1, win).max(axis=1)
open_mask = env >= thr
# 保持(hold)+ 攻放(attack/release)平滑,避免产生"抽气"感
hop = win / samplerate
hold_frames = max(1, int(hold / hop))
atk = max(1, int(attack / hop))
rel = max(1, int(release / hop))
gain = np.zeros(env.size)
g = 0.0
counter = 0
for i in range(env.size):
if open_mask[i]:
counter = hold_frames
target = 1.0
elif counter > 0:
counter -= 1
target = 1.0
else:
target = 0.0
step = 1.0 / (atk if target > g else rel)
g += np.clip(target - g, -step, step)
gain[i] = g
# 上采样到采样级并做线性插值,避免阶梯噪声
up = np.interp(np.arange(x.shape[0]),
np.arange(env.size) * win, gain)
y = x * up[:, None]
return y, {
"threshold_dbfs": threshold_dbfs,
"gated_ratio": round(float(np.mean(up < 0.5)), 4),
}
def mixdown_mono(x: np.ndarray) -> np.ndarray:
if x.ndim == 1 or x.shape[1] == 1:
return x
return np.mean(x, axis=1, keepdims=True)
+1082
View File
File diff suppressed because it is too large Load Diff
+173
View File
@@ -0,0 +1,173 @@
"""图形界面入口:``python -m recorder.gui``(或双击 启动录音机.bat)。
负责三件事:High-DPI 适配、深色主题样式表、以及缺依赖时的友好提示。
"""
from __future__ import annotations
import sys
QSS = """
* { font-family: "Microsoft YaHei UI", "Microsoft YaHei", "Segoe UI", sans-serif; }
QWidget { background: #0F1218; color: #E8ECF4; font-size: 12px; }
QMainWindow, QDialog { background: #0F1218; }
QMenuBar { background: #12161D; border-bottom: 1px solid #2A313D; }
QMenuBar::item { padding: 5px 12px; background: transparent; }
QMenuBar::item:selected { background: #1D222C; border-radius: 4px; }
QMenu { background: #161A22; border: 1px solid #2A313D; padding: 4px; }
QMenu::item { padding: 6px 22px; border-radius: 4px; }
QMenu::item:selected { background: #23303F; }
QGroupBox {
background: #161A22;
border: 1px solid #2A313D;
border-radius: 8px;
margin-top: 12px;
padding-top: 8px;
font-weight: 600;
}
QGroupBox::title {
subcontrol-origin: margin;
left: 12px;
padding: 0 6px;
color: #9FB0CB;
}
QFrame#card {
background: #161A22;
border: 1px solid #2A313D;
border-radius: 10px;
}
QLabel { background: transparent; }
QPushButton {
background: #232A36;
border: 1px solid #323A48;
border-radius: 6px;
padding: 6px 12px;
color: #E8ECF4;
}
QPushButton:hover { background: #2A3341; border-color: #3E4A5C; }
QPushButton:pressed { background: #1B2129; }
QPushButton:disabled { color: #5C6577; background: #1A1F27; border-color: #262D38; }
QComboBox, QLineEdit, QSpinBox, QDoubleSpinBox, QPlainTextEdit {
background: #1B2029;
border: 1px solid #2E3644;
border-radius: 6px;
padding: 5px 8px;
selection-background-color: #2C5F9E;
}
QComboBox:hover, QLineEdit:hover, QSpinBox:hover, QDoubleSpinBox:hover {
border-color: #3E4A5C;
}
QComboBox::drop-down { border: none; width: 18px; }
QComboBox QAbstractItemView {
background: #1B2029;
border: 1px solid #2E3644;
selection-background-color: #23303F;
outline: none;
}
QComboBox::down-arrow {
image: none;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 5px solid #8B94A7;
margin-right: 6px;
}
QCheckBox { spacing: 7px; }
QCheckBox::indicator {
width: 15px; height: 15px;
border: 1px solid #3A4352; border-radius: 4px;
background: #1B2029;
}
QCheckBox::indicator:checked {
background: #4EA1FF; border-color: #4EA1FF;
}
QSlider::groove:horizontal {
height: 4px; background: #2A313D; border-radius: 2px;
}
QSlider::sub-page:horizontal { background: #4EA1FF; border-radius: 2px; }
QSlider::handle:horizontal {
background: #E8ECF4; width: 14px; margin: -6px 0; border-radius: 7px;
}
QScrollArea { background: transparent; border: none; }
QScrollBar:vertical { background: #12161D; width: 10px; border-radius: 5px; }
QScrollBar::handle:vertical { background: #333B49; border-radius: 5px; min-height: 30px; }
QScrollBar::handle:vertical:hover { background: #414B5C; }
QScrollBar::add-line, QScrollBar::sub-line { height: 0; }
QStatusBar { background: #12161D; border-top: 1px solid #2A313D; color: #8B94A7; }
QStatusBar QLabel { color: #8B94A7; }
QSplitter::handle { background: transparent; width: 6px; }
QProgressBar {
background: #1B2029; border: 1px solid #2E3644; border-radius: 6px;
text-align: center; color: #E8ECF4; height: 16px;
}
QProgressBar::chunk { background: #4EA1FF; border-radius: 5px; }
QToolTip {
background: #1B2029; color: #E8ECF4; border: 1px solid #3E4A5C;
padding: 4px 6px; border-radius: 4px;
}
QTabBar::tab {
background: #1B2029; padding: 6px 14px; border: 1px solid #2E3644;
border-bottom: none; border-top-left-radius: 6px; border-top-right-radius: 6px;
}
QTabBar::tab:selected { background: #232A36; }
"""
def _check_dependencies() -> str | None:
"""返回缺失依赖的说明,全部就绪时返回 None。"""
missing = []
try:
import numpy # noqa: F401 (仅用于探测依赖是否就绪)
except Exception:
missing.append("numpy(数值运算)")
try:
import PyQt5 # noqa: F401 (仅用于探测依赖是否就绪)
except Exception:
missing.append("PyQt5(图形界面)")
from . import engine
if engine.sd is None:
missing.append("sounddevice(PortAudio 音频后端)")
if not missing:
return None
return ("缺少以下依赖,无法启动图形界面:\n\n · " + "\n · ".join(missing)
+ "\n\n请运行:python install_deps.py\n"
"(在项目目录下执行,会把依赖装到项目内的 _vendor 目录)")
def run(argv: list[str] | None = None) -> int:
argv = list(sys.argv if argv is None else argv)
problem = _check_dependencies()
if problem:
print(problem, file=sys.stderr)
try:
from PyQt5.QtWidgets import QApplication as _QA, QMessageBox
except Exception:
return 2
_app = _QA(argv)
QMessageBox.critical(None, "缺少依赖", problem)
return 2
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QFont
from PyQt5.QtWidgets import QApplication
# High-DPI 相关属性必须在 QApplication 实例化之前设置
QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)
QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True)
from .window import MainWindow
app = QApplication(argv)
app.setApplicationName("RecorderStudio")
app.setOrganizationName("RecorderStudio")
app.setStyleSheet(QSS)
app.setFont(QFont("Microsoft YaHei UI", 9))
win = MainWindow()
win.show()
return app.exec_()
if __name__ == "__main__":
sys.exit(run())
+469
View File
@@ -0,0 +1,469 @@
"""后期处理与导出:归一化、裁剪、门限、淡入淡出、转码、元数据、波形预览。
设计取向:**原始 WAV 母版永远保留且不被覆盖**。所有后期处理默认写出新文件
(``*_processed.wav``),源文件只读。
"""
from __future__ import annotations
import json
import os
import shutil
import struct
import subprocess
import threading
import zlib
from dataclasses import asdict, dataclass
from datetime import datetime
import numpy as np
from . import dsp
from .engine import TakeResult, format_bytes, format_duration
from .wavfile import WavReader, write_wav
# ---------------------------------------------------------------- ffmpeg
_FFMPEG_CACHE: list[str | None] = []
_FFMPEG_LOCK = threading.Lock()
def find_ffmpeg() -> str | None:
"""定位 ffmpeg(PATH 或常见安装位置)。"""
with _FFMPEG_LOCK:
if _FFMPEG_CACHE:
return _FFMPEG_CACHE[0]
candidates: list[str | None] = []
exe = shutil.which("ffmpeg")
if exe:
candidates.append(exe)
for p in (
r"C:\ffmpeg\bin\ffmpeg.exe",
r"C:\Program Files\ffmpeg\bin\ffmpeg.exe",
os.path.expanduser(r"~\scoop\shims\ffmpeg.exe"),
os.path.expanduser(r"~\AppData\Local\Microsoft\WinGet\Links\ffmpeg.exe"),
):
if os.path.exists(p):
candidates.append(p)
_FFMPEG_CACHE.append(candidates[0] if candidates else None)
return _FFMPEG_CACHE[0]
EXPORT_PRESETS: dict[str, dict] = {
"wav_16": {"label": "WAV 16-bit(兼容性最好)", "ext": ".wav", "kind": "wav"},
"wav_24": {"label": "WAV 24-bit(无损母版)", "ext": ".wav", "kind": "wav"},
"wav_f32": {"label": "WAV 32-bit float(后期制作)", "ext": ".wav", "kind": "wav"},
"flac": {"label": "FLAC(无损压缩,约 50% 体积)", "ext": ".flac", "kind": "ffmpeg"},
"mp3_320": {"label": "MP3 320 kbps(高码率有损)", "ext": ".mp3", "kind": "ffmpeg"},
"mp3_v0": {"label": "MP3 V0(VBR 约 245 kbps)", "ext": ".mp3", "kind": "ffmpeg"},
"opus": {"label": "Opus 128 kbps(语音/播客首选)", "ext": ".opus", "kind": "ffmpeg"},
"m4a": {"label": "AAC/M4A 256 kbps(苹果生态)", "ext": ".m4a", "kind": "ffmpeg"},
}
def _ffmpeg_args(preset: str, src: str, dst: str) -> list[str]:
ff = find_ffmpeg() or "ffmpeg"
base = [ff, "-hide_banner", "-loglevel", "error", "-y", "-i", src]
if preset == "flac":
return base + ["-c:a", "flac", "-compression_level", "8", dst]
if preset == "mp3_320":
return base + ["-c:a", "libmp3lame", "-b:a", "320k", dst]
if preset == "mp3_v0":
return base + ["-c:a", "libmp3lame", "-q:a", "0", dst]
if preset == "opus":
return base + ["-c:a", "libopus", "-b:a", "128k", dst]
if preset == "m4a":
return base + ["-c:a", "aac", "-b:a", "256k", dst]
return base + [dst]
def default_export_path(src: str, preset: str) -> str:
"""给出导出的默认目标路径;**绝不允许覆盖源文件**。"""
meta = EXPORT_PRESETS.get(preset) or {"ext": ".wav"}
root, _ext = os.path.splitext(src)
dst = f"{root}{meta['ext']}"
if os.path.abspath(dst) == os.path.abspath(src):
dst = f"{root}_{preset}{meta['ext']}"
return dst
def export_audio(src: str, dst: str, preset: str) -> tuple[bool, str]:
"""把 WAV 转成目标格式。返回 ``(是否成功, 说明)``。
安全约束:如果 ``dst`` 指向源文件本身,会自动改名,避免把母版覆盖掉。
"""
info = EXPORT_PRESETS.get(preset)
if info is None:
return False, f"未知的导出预设:{preset}"
if os.path.abspath(dst) == os.path.abspath(src):
dst = default_export_path(src, preset)
if info["kind"] == "wav":
try:
data, sr = _read_whole(src)
bits = {"wav_16": "16", "wav_24": "24", "wav_f32": "float32"}[preset]
write_wav(dst, data, sr, bit_depth=bits,
dither=(bits in ("16", "24")))
return True, f"{os.path.basename(dst)}({format_bytes(os.path.getsize(dst))})"
except Exception as exc:
return False, f"写入失败:{exc}"
ff = find_ffmpeg()
if ff is None:
return False, "未找到 ffmpeg:请安装 ffmpeg 并加入 PATH,或改用 WAV 导出"
try:
proc = subprocess.run(_ffmpeg_args(preset, src, dst),
capture_output=True, text=True, timeout=1800)
except Exception as exc:
return False, f"调用 ffmpeg 失败:{exc}"
if proc.returncode != 0:
return False, f"ffmpeg 出错:{(proc.stderr or '').strip()[:300]}"
return True, f"{os.path.basename(dst)}({format_bytes(os.path.getsize(dst))})"
# ------------------------------------------------------------ 处理选项
@dataclass
class ProcessOptions:
trim_silence: bool = False
trim_threshold_dbfs: float = -50.0
trim_min_silence: float = 0.4
remove_dc: bool = False
lowcut_hz: float = 0.0
noise_gate: bool = False
gate_threshold_dbfs: float = -60.0
normalize: str = "none" # 'none' | 'peak' | 'lufs'
normalize_target_dbfs: float = -1.0
normalize_target_lufs: float = -16.0
fade_in: float = 0.0
fade_out: float = 0.0
mono: bool = False
bit_depth: str = "24"
dither: bool = True
@property
def is_identity(self) -> bool:
return (not self.trim_silence and not self.remove_dc and self.lowcut_hz <= 0
and not self.noise_gate and self.normalize == "none"
and self.fade_in <= 0 and self.fade_out <= 0 and not self.mono)
def process_array(data: np.ndarray, samplerate: int, opts: ProcessOptions
) -> tuple[np.ndarray, dict]:
"""在内存中执行后期处理链,返回 ``(处理后的数据, 处理报告)``。"""
x = np.asarray(data, dtype=np.float64)
report: dict = {"steps": []}
if opts.remove_dc:
x = dsp.remove_dc(x, samplerate)
report["steps"].append("去除直流偏移(1 秒滑动平均,兼顾漂移)")
if opts.lowcut_hz > 0:
x = dsp.highpass_offline(x, samplerate, opts.lowcut_hz)
report["steps"].append(f"{opts.lowcut_hz:.0f} Hz 线性相位低切")
if opts.noise_gate:
x, gate_info = dsp.noise_gate(x, samplerate,
threshold_dbfs=opts.gate_threshold_dbfs)
report["noise_gate"] = gate_info
report["steps"].append(f"噪声门({opts.gate_threshold_dbfs:.0f} dBFS)")
if opts.trim_silence:
x, trim_info = dsp.trim_silence(x, samplerate,
threshold_dbfs=opts.trim_threshold_dbfs,
min_silence=opts.trim_min_silence)
report["trim"] = trim_info
report["steps"].append(f"裁剪首尾静音({trim_info.get('trimmed_seconds', 0)} s)")
if opts.mono and x.ndim > 1 and x.shape[1] > 1:
x = dsp.mixdown_mono(x)
report["steps"].append("混合为单声道")
if opts.normalize == "peak":
x, gain = dsp.normalize_peak(x, opts.normalize_target_dbfs)
report["normalize"] = {"mode": "peak", "gain_db": round(gain, 3),
"target_dbfs": opts.normalize_target_dbfs}
report["steps"].append(f"峰值归一化到 {opts.normalize_target_dbfs} dBFS")
elif opts.normalize == "lufs":
x, lufs_info = dsp.normalize_loudness(x, samplerate,
opts.normalize_target_lufs)
report["normalize"] = {"mode": "lufs", **lufs_info}
report["steps"].append(f"响度归一化到 {opts.normalize_target_lufs} LUFS")
if opts.fade_in > 0 or opts.fade_out > 0:
x = dsp.fade_edges(x, samplerate, fade_in=opts.fade_in, fade_out=opts.fade_out)
report["steps"].append(f"淡入 {opts.fade_in}s / 淡出 {opts.fade_out}s")
report["output_peak_dbfs"] = [round(float(v), 3) for v in
np.atleast_1d(dsp.dbfs(dsp.peak(x, axis=0)))]
return x, report
def _read_whole(path: str) -> tuple[np.ndarray, int]:
"""读取整个 WAV;超大文件自动降级为 float32 以节省内存。"""
with WavReader(path) as r:
frames = r.frames
need = frames * r.channels * 8
dtype = np.float64 if need < (1 << 31) else np.float32
chunks = []
for blk in r.iter_blocks(1 << 20):
chunks.append(blk.astype(dtype))
sr = r.samplerate
if not chunks:
return np.zeros((0, 1), dtype=dtype), sr
return np.concatenate(chunks, axis=0), sr
def process_file(src: str, opts: ProcessOptions, *,
dst: str | None = None,
progress=None) -> dict:
"""对录音文件执行后期处理并写出新文件(源文件保持不变)。"""
if dst is None:
root, ext = os.path.splitext(src)
dst = f"{root}_processed{ext or '.wav'}"
if progress:
progress("读取音频…")
data, sr = _read_whole(src)
if progress:
progress(f"处理 {len(data) / max(1, sr):.1f} 秒音频…")
out, report = process_array(data, sr, opts)
if progress:
progress("写出文件…")
stats = write_wav(dst, out.astype(np.float32), sr,
bit_depth=opts.bit_depth, dither=opts.dither)
try:
report["analysis"] = dsp.analyze_file(dst)
except Exception:
report["analysis"] = None
report.update({"source": src, "output": dst, "format": stats.get("format", "")})
return report
# --------------------------------------------------------------- 元数据
def write_metadata(result: TakeResult, *, extra: dict | None = None) -> list[str]:
"""写出 JSON 元数据 + 人类可读文本日志,返回生成的文件列表。"""
written: list[str] = []
cfg = result.config
meta = {
"app": "RecorderStudio",
"version": "1.0",
"recorded_at": result.started_at,
"finished_at": result.ended_at,
"device": result.device_label,
"format": result.format_label,
"sample_rate": cfg.samplerate if cfg else None,
"bit_depth": cfg.bit_depth if cfg else None,
"channels": cfg.channels if cfg else None,
"gain_db": cfg.gain_db if cfg else 0.0,
"lowcut_hz": cfg.lowcut_hz if cfg else 0.0,
"exclusive_mode": cfg.exclusive if cfg else None,
"dither": cfg.dither if cfg else None,
"duration_seconds": round(result.duration, 3),
"frames": result.frames,
"bytes": result.bytes_written,
"peak_dbfs": round(result.peak_dbfs, 3)
if np.isfinite(result.peak_dbfs) else None,
"clipped_samples": result.clipped_samples,
"xruns": result.xruns,
"queue_overflows": result.overflow_blocks,
"files": [os.path.basename(f) for f in result.files],
"markers": [asdict(m) for m in result.markers],
"analysis": result.analysis,
"notes": result.notes,
}
if extra:
meta.update(extra)
base = os.path.splitext(result.primary_file)[0] if result.primary_file else None
if not base:
return written
json_path = f"{base}.json"
with open(json_path, "w", encoding="utf-8") as fh:
json.dump(meta, fh, ensure_ascii=False, indent=2)
written.append(json_path)
txt_path = f"{base}.txt"
with open(txt_path, "w", encoding="utf-8") as fh:
fh.write(render_report(result))
written.append(txt_path)
return written
def render_report(result: TakeResult) -> str:
"""生成人类可读的录音报告(也用于界面上的"体检"面板)。"""
cfg = result.config
an = result.analysis or {}
lines = [
"RecorderStudio 录音报告",
"=" * 46,
f"开始时间 : {result.started_at}",
f"结束时间 : {result.ended_at}",
f"输入设备 : {result.device_label or '—'}",
f"录制格式 : {result.format_label or '—'}",
f"独占模式 : {'是' if (cfg and cfg.exclusive) else '否'}",
f"软件增益 : {cfg.gain_db:+.1f} dB" if cfg else "",
f"低切滤波 : {cfg.lowcut_hz:.0f} Hz" if cfg and cfg.lowcut_hz > 0 else "低切滤波 : 关闭",
f"抖动 : {'开启 (TPDF)' if (cfg and cfg.dither) else '关闭'}",
"",
f"总时长 : {format_duration(result.duration)}",
f"总采样帧 : {result.frames}",
f"数据量 : {format_bytes(result.bytes_written)}",
f"文件数 : {len(result.files)}",
]
for f in result.files:
try:
sz = os.path.getsize(f)
except OSError:
sz = 0
lines.append(f" · {os.path.basename(f)} ({format_bytes(sz)})")
lines += [
"",
"音质体检",
"-" * 46,
f"采样峰值 : {_fmt_list(an.get('peak_dbfs'))} dBFS",
f"真峰值 : {_fmt_list(an.get('true_peak_dbtp'))} dBTP"
+ ("(已达上限,建议降低增益)"
if _maxf(an.get("true_peak_dbtp")) is not None
and _maxf(an.get("true_peak_dbtp")) > -0.1 else ""),
f"RMS 电平 : {_fmt_list(an.get('rms_dbfs'))} dBFS",
f"整体响度 : {an.get('integrated_lufs')} LUFS",
f"动态范围 : {an.get('loudness_range_lu')} LU",
f"直流偏移 : {an.get('dc_offset')}",
f"本底噪声 : {_fmt_list(an.get('noise_floor_dbfs'))} dBFS"
+ ("" if an.get("noise_floor_available") else "(录音中未检测到静音段,无法测定)"),
f"削波样本 : {an.get('clipped_total', result.clipped_samples)}",
f"丢弃块/溢出: {result.overflow_blocks}",
f"驱动层 xrun: {result.xruns}",
]
if result.markers:
lines += ["", "标记", "-" * 46]
for m in result.markers:
lines.append(f" {m.seconds:8.3f} s {m.label} ({m.file})")
if result.notes:
lines += ["", "运行日志", "-" * 46] + [f" · {n}" for n in result.notes]
if an.get("segment_count"):
lines += ["", f"注:本次录音共 {an['segment_count']} 个分段,以上为合并统计。"]
return "\n".join(l for l in lines if l is not None)
def _fmt_list(v) -> str:
if v is None:
return "—"
if isinstance(v, list):
return ", ".join("—" if x is None else f"{x:+.2f}" for x in v)
return f"{v:+.2f}"
def _maxf(v) -> float | None:
if v is None:
return None
if isinstance(v, list):
vals = [x for x in v if x is not None]
return max(vals) if vals else None
return float(v)
# ------------------------------------------------------- 波形预览 (PNG)
def _png_chunk(tag: bytes, data: bytes) -> bytes:
return (struct.pack(">I", len(data)) + tag + data
+ struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF))
def write_waveform_png(path: str, data: np.ndarray, samplerate: int,
*, width: int = 1600, height: int = 320,
bg=(18, 20, 26), wave=(90, 200, 255), mid=(70, 78, 96),
rms_color=(255, 190, 80)) -> str:
"""用纯 numpy + zlib 画一张波形预览图(不依赖 PIL)。"""
x = np.asarray(data, dtype=np.float32)
if x.ndim == 1:
x = x[:, None]
n, ch = x.shape
if n == 0:
x = np.zeros((1, 1), np.float32)
n, ch = 1, 1
canvas = np.zeros((height, width, 3), dtype=np.uint8)
canvas[:, :] = bg
lanes = ch if ch <= 2 else 2
lane_h = height // lanes
per = max(1, n // width)
usable = (n // per) * per
block = x[:usable].reshape(-1, per, ch)
mn = block.min(axis=1)
mx = block.max(axis=1)
rms = np.sqrt(np.mean(block.astype(np.float64) ** 2, axis=1))
cols = mn.shape[0]
for lane in range(lanes):
y0 = lane * lane_h
yc = y0 + lane_h // 2
canvas[max(0, yc - 1):yc + 1, :] = mid
src = lane if ch <= 2 else 0
for c in range(cols):
xx = int(c * width / max(1, cols))
if xx >= width:
continue
top = int(yc - mx[c, src] * (lane_h / 2 - 4))
bot = int(yc - mn[c, src] * (lane_h / 2 - 4))
top = max(y0, min(y0 + lane_h - 1, top))
bot = max(y0, min(y0 + lane_h - 1, bot))
if bot < top:
top, bot = bot, top
canvas[top:bot + 1, xx] = wave
r = float(rms[c, src]) * (lane_h / 2 - 4)
canvas[max(y0, int(yc - r)):min(y0 + lane_h, int(yc + r) + 1), xx] = rms_color
raw = b"".join(b"\x00" + canvas[y].tobytes() for y in range(height))
png = (b"\x89PNG\r\n\x1a\n"
+ _png_chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0))
+ _png_chunk(b"IDAT", zlib.compress(raw, 6))
+ _png_chunk(b"IEND", b""))
with open(path, "wb") as fh:
fh.write(png)
return path
def make_preview_for(result: TakeResult, *, seconds: float | None = None) -> str | None:
"""为录音结果生成波形预览图 + 报告文本。"""
if not result.primary_file or not os.path.exists(result.primary_file):
return None
try:
data, sr = _read_whole(result.primary_file)
if seconds is not None and len(data) > seconds * sr:
data = data[:int(seconds * sr)]
base = os.path.splitext(result.primary_file)[0]
return write_waveform_png(f"{base}_waveform.png", data, sr)
except Exception:
return None
def write_readme_for_session(result: TakeResult) -> list[str]:
"""额外的"每次录音都留一份说明"的兜底函数(供 CLI 使用)。"""
out: list[str] = []
if not result.primary_file:
return out
base = os.path.splitext(result.primary_file)[0]
p = f"{base}_info.txt"
with open(p, "w", encoding="utf-8") as fh:
fh.write(render_report(result) + "\n")
out.append(p)
return out
def regenerate_report(path: str) -> str:
"""对已有录音重新生成报告(界面上的"重新体检")。"""
an = dsp.analyze_file(path)
lines = [
"RecorderStudio 文件体检",
"=" * 46,
f"文件 : {os.path.basename(path)}",
f"生成时间 : {datetime.now().isoformat(timespec='seconds')}",
f"格式 : {an.get('format')}",
f"时长 : {format_duration(an.get('duration', 0.0))}",
f"数据量 : {format_bytes(an.get('data_bytes', 0))}",
"",
f"采样峰值 : {_fmt_list(an.get('peak_dbfs'))} dBFS",
f"真峰值 : {_fmt_list(an.get('true_peak_dbtp'))} dBTP",
f"RMS 电平 : {_fmt_list(an.get('rms_dbfs'))} dBFS",
f"整体响度 : {an.get('integrated_lufs')} LUFS",
f"动态范围 : {an.get('loudness_range_lu')} LU",
f"直流偏移 : {an.get('dc_offset')}",
f"本底噪声 : {_fmt_list(an.get('noise_floor_dbfs'))} dBFS",
f"削波样本 : {an.get('clipped_total')}",
]
return "\n".join(lines)
+661
View File
@@ -0,0 +1,661 @@
"""自检测试:数学正确性 + 格式往返 + 硬件端到端。
用法::
python -m recorder.selftest # 全部(含真实硬件录音,约 20 秒)
python -m recorder.selftest --no-hw # 只跑离线数学/格式测试(无需麦克风)
python -m recorder.selftest --quick # 硬件测试缩短到 1 秒
"""
from __future__ import annotations
import argparse
import math
import os
import sys
import time
import numpy as np
from . import dsp, engine, post
from .wavfile import WavReader, _TpdfDither, float_to_pcm, pcm_to_float, write_wav
class Runner:
def __init__(self, quiet: bool = False):
self.passed = 0
self.failed: list[tuple[str, str]] = []
self.quiet = quiet
self.current = ""
def section(self, name: str) -> None:
self.current = name
if not self.quiet:
print(f"\n── {name} " + "─" * max(0, 56 - len(name)))
def check(self, name: str, ok: bool, detail: str = "") -> bool:
if ok:
self.passed += 1
if not self.quiet:
print(f" ✓ {name}" + (f" {detail}" if detail else ""))
else:
self.failed.append((f"{self.current} / {name}", detail))
print(f" ✗ {name} {detail}", file=sys.stderr)
return bool(ok)
def near(self, name: str, got: float, expect: float, tol: float,
unit: str = "") -> bool:
ok = abs(float(got) - float(expect)) <= tol
return self.check(name, ok,
f"实测 {got:.4f}{unit},期望 {expect:.4f}±{tol:g}{unit}")
@property
def ok(self) -> bool:
return not self.failed
# ------------------------------------------------------------- 1. 响度与滤波
def test_loudness(r: Runner) -> None:
r.section("ITU-R BS.1770 响度标准符合性")
fb1, fa1 = dsp._itu_highshelf(48000)
fb2, fa2 = dsp._itu_highpass(48000)
itu_b1 = np.array([1.53512485958697, -2.69169618940638, 1.19839281085285])
itu_a1 = np.array([1.0, -1.69065929318241, 0.73248077421585])
itu_b2 = np.array([1.0, -2.0, 1.0])
itu_a2 = np.array([1.0, -1.99004745483398, 0.99007225036621])
r.check("高频搁架设计系数与 ITU 原文一致",
np.max(np.abs(fb1 - itu_b1)) < 1e-12
and np.max(np.abs(fa1 - itu_a1)) < 1e-12,
f"最大偏差 {max(np.max(np.abs(fb1 - itu_b1)), np.max(np.abs(fa1 - itu_a1))):.2e}")
r.check("二阶高通设计系数与 ITU 原文一致",
np.max(np.abs(fb2 - itu_b2)) < 1e-12
and np.max(np.abs(fa2 - itu_a2)) < 1e-12,
f"最大偏差 {max(np.max(np.abs(fb2 - itu_b2)), np.max(np.abs(fa2 - itu_a2))):.2e}")
for sr in (22050, 44100, 96000):
for b, a in dsp.kweighting_coeffs(sr):
r.check(f"{sr} Hz K 加权滤波器稳定",
float(np.max(np.abs(np.roots(a)))) < 1.0)
wref = 2 * math.pi * 997.0 / 48000
gain = 10 * math.log10(float((
dsp.biquad_response_sq(fb1, fa1, np.array([wref]))
* dsp.biquad_response_sq(fb2, fa2, np.array([wref])))[0]))
r.near("K 加权在 997 Hz 的增益 = +0.691 dB(标准偏移量的来源)",
gain, 0.691, 0.01, " dB")
sr = 48000
t = np.arange(sr * 5) / sr
sine = 0.999 * np.sin(2 * np.pi * 997 * t)
st = np.stack([sine, sine], axis=1)
def loud(data, ch):
m = dsp.LoudnessMeter(sr, ch)
for i in range(0, data.shape[0], 4096):
m.push(data[i:i + 4096])
m.flush()
return m.integrated
# 期望值已用 ffmpeg 的 ebur128 滤波器独立验证过
r.near("满量程立体声 997 Hz 正弦 = 0.0 LUFS(ffmpeg 实测 -0.0)",
loud(st, 2), 0.0, 0.1, " LUFS")
r.near("满量程单声道 997 Hz 正弦 = -3.01 LUFS(ffmpeg 实测 -3.0)",
loud(sine[:, None], 1), -3.010, 0.1, " LUFS")
r.near("电平 -20 dB 时响度同步下降 20 LU(ffmpeg 实测 -20.0)",
loud(st * 0.1, 2), -20.0, 0.1, " LUFS")
rng = np.random.default_rng(7)
noise = rng.standard_normal((sr * 6, 2)) * 0.1
got = loud(noise, 2)
r.near("宽带白噪声响度(ffmpeg 实测 -13.8 LUFS)", got, -13.84, 0.15, " LUFS")
def test_filters(r: Runner) -> None:
r.section("线性相位滤波器")
h = dsp.design_highpass_fir(80.0, 48000)
k = np.arange(h.size)
def resp(f):
w = 2 * np.pi * f / 48000
return float(np.abs(np.sum(h * np.exp(-1j * w * k))))
r.check("直流增益 < -60 dB", 20 * math.log10(max(resp(0.0), 1e-12)) < -60,
f"{20 * math.log10(max(resp(0.0), 1e-12)):.1f} dB")
r.check("1 kHz 通带增益 ≈ 0 dB", abs(20 * math.log10(resp(1000.0))) < 0.02,
f"{20 * math.log10(resp(1000.0)):+.4f} dB")
r.check("100 Hz 通带起伏 < 0.35 dB", abs(20 * math.log10(resp(100.0))) < 0.35,
f"{20 * math.log10(resp(100.0)):+.3f} dB")
r.check("20 Hz 抑制 > 40 dB", 20 * math.log10(resp(20.0)) < -40,
f"{20 * math.log10(resp(20.0)):.1f} dB,{h.size} 抽头")
r.check("系数严格对称(线性相位)", np.allclose(h, h[::-1], atol=1e-15))
# 群延迟对齐:首尾各补一段直流,滤波后不应丢样本
x = np.concatenate([np.zeros((5000, 1)), np.ones((5000, 1)) * 0.5,
np.zeros((5000, 1))])
y = dsp.highpass_offline(x, 48000, 80.0)
r.check("离线低切保持样本数(群延迟已补偿)", y.shape[0] == x.shape[0] - (h.size - 1) // 2,
f"{x.shape[0]} → {y.shape[0]}")
def test_true_peak(r: Runner) -> None:
r.section("真峰值(4 倍过采样)")
sr = 48000
t = np.arange(sr) / sr
aligned = np.sin(2 * np.pi * 12000 * t) * 0.5
r.near("采样点对齐时真峰值 = 采样峰值 0.5",
float(dsp.true_peak(aligned[:, None])[0]), 0.5, 0.005)
shifted = np.sin(2 * np.pi * 12000 * t + np.pi / 4) * 0.5
sp = float(np.max(np.abs(shifted)))
tp = float(dsp.true_peak(shifted[:, None])[0])
r.check("能捕捉采样点之间的过冲(这是 dBTP 的意义)",
tp > sp * 1.2 and abs(tp - 0.5) < 0.01,
f"采样峰值 {sp:.4f} → 真峰值 {tp:.4f}(理论 0.5)")
mono = np.zeros((0, 1))
r.check("空输入不崩溃", dsp.true_peak(mono).shape == (1,))
def test_meters(r: Runner) -> None:
r.section("电平表与响度计(流式)")
sr = 48000
m = dsp.LevelMeter(sr, 2)
x = np.stack([np.ones(1024) * 0.5, np.ones(1024) * 0.25], axis=1).astype(np.float32)
for _ in range(20):
m.process(x)
snap = m.snapshot()
r.near("RMS 计算正确(0.5 → -6.02 dBFS)", snap.rms_db[0], -6.02, 0.02, " dB")
r.near("峰值计算正确(0.25 → -12.04 dBFS)", snap.peak_db[1], -12.04, 0.02, " dB")
m.process(np.ones((256, 2), dtype=np.float32))
r.check("削波锁存生效", m.snapshot().clipped[0] and m.snapshot().clip_count > 0)
m.reset_clip()
r.check("削波锁存可复位", not m.snapshot().clipped[0])
lm = dsp.LoudnessMeter(sr, 2, keep_hops=False)
t2 = np.arange(sr) / sr
sine = np.sin(2 * np.pi * 997 * t2) * 0.1
for i in range(0, sr, 4800):
lm.push(np.stack([sine[i:i + 4800]] * 2, axis=1))
r.check("不保留历史时整体响度返回 -inf 而不是乱码",
not math.isfinite(lm.integrated))
r.near("瞬时响度读数正确(-20 dBFS 立体声正弦 ≈ -20 LUFS)",
lm.momentary, -20.0, 0.3, " LUFS")
dc_meter = dsp.LoudnessMeter(sr, 2)
dc_meter.push(np.ones((sr, 2)) * 0.1)
dc_meter.flush()
r.check("纯直流被 K 加权高通滤除(响度极低)",
dc_meter.momentary < -60, f"{dc_meter.momentary:.1f} LUFS")
# ------------------------------------------------------- 2. WAV 格式与量化
def test_wav(r: Runner, tmp: str) -> None:
r.section("WAV 编解码与量化")
rng = np.random.default_rng(11)
sig = ((rng.random((48000, 2)) * 2 - 1) * 0.5).astype(np.float32)
cases = [("16", 1.6 / 32768), ("24", 2.1 / 8388608), ("32", 1e-7),
("float32", 1e-7)]
for depth, tol in cases:
p = os.path.join(tmp, f"rt_{depth}.wav")
stats = write_wav(p, sig, 48000, bit_depth=depth, dither=(depth != "float32"))
with WavReader(p) as rd:
back = rd.read()
info = rd.info()
err = float(np.max(np.abs(back - sig)))
r.check(f"{depth} 位往返误差在量化极限内",
back.shape == sig.shape and err <= tol,
f"最大误差 {err:.2e}(容差 {tol:.1e}),{info['format']}")
# 头部正确性:用 Python 标准库 wave 模块独立复核 16 位文件
import wave as pywave
p16 = os.path.join(tmp, "rt_16.wav")
with pywave.open(p16, "rb") as w:
r.check("标准库 wave 能读我们的 16 位文件",
w.getnchannels() == 2 and w.getframerate() == 48000
and w.getsampwidth() == 2 and w.getnframes() == 48000,
f"{w.getnchannels()}ch {w.getframerate()}Hz {w.getsampwidth()*8}bit "
f"{w.getnframes()}帧")
# 24 位文件用 ffmpeg 独立解码核对
if post.find_ffmpeg():
import subprocess
p24 = os.path.join(tmp, "rt_24.wav")
proc = subprocess.run(
[post.find_ffmpeg(), "-hide_banner", "-v", "error", "-i", p24,
"-f", "s16le", "-ac", "2", "-ar", "48000", "-"],
capture_output=True)
decoded = np.frombuffer(proc.stdout, dtype="<i2").reshape(-1, 2)
ref = np.clip(sig, -1, 1) * 32767
err = float(np.max(np.abs(decoded.astype(np.float64) - ref))) / 32767
r.check("ffmpeg 独立解码 24 位文件内容正确", decoded.shape[0] == 48000 and err < 1e-4,
f"最大偏差 {err:.2e},{decoded.shape[0]} 帧")
else:
r.check("ffmpeg 可用(跳过交叉解码验证)", True, "未检测到 ffmpeg,已跳过")
# RF64
pr = os.path.join(tmp, "rt_rf64.wav")
write_wav(pr, sig, 48000, bit_depth="24", rf64=True)
with WavReader(pr) as rd:
back = rd.read()
r.check("RF64 容器读写往返", rd.rf64 and back.shape == sig.shape,
f"rf64={rd.rf64},{back.shape[0]} 帧")
# 损坏/边界输入
pbad = os.path.join(tmp, "bad.wav")
with open(pbad, "wb") as fh:
fh.write(b"not a wav file")
try:
WavReader(pbad)
r.check("非法文件被拒绝", False, "竟然没有报错")
except ValueError:
r.check("非法文件被拒绝", True)
# 空录音
pe = os.path.join(tmp, "empty.wav")
write_wav(pe, np.zeros((0, 2), np.float32), 48000)
with WavReader(pe) as rd:
r.check("零长度录音可被正常写出与读取",
rd.frames == 0 and rd.duration == 0.0)
def test_dither(r: Runner) -> None:
r.section("TPDF 抖动")
sr = 48000
t = np.arange(sr * 2) / sr
lo = (np.sin(2 * np.pi * 300 * t) * dsp.db_to_lin(-80)).astype(np.float64)
def qerr(x, use_dither):
d = _TpdfDither(4242) if use_dither else None
raw = float_to_pcm(x[:, None], "pcm", 16, d)
return pcm_to_float(raw, "pcm", 16) - x
e_plain = qerr(lo, False)
e_dith = qerr(lo, True)
c_plain = abs(float(np.corrcoef(e_plain, lo)[0, 1]))
c_dith = abs(float(np.corrcoef(e_dith, lo)[0, 1]))
r.check("未抖动时量化误差与信号强相关(会产生非线性失真)",
c_plain > 0.05, f"相关系数 {c_plain:.4f}")
r.check("加抖动后误差与信号去相关", c_dith < 0.05 and c_dith < c_plain,
f"相关系数 {c_dith:.4f}")
lsb = 1.0 / 32767
r.near("抖动总误差方差 = 1/4 LSB²(1/6 抖动 + 1/12 量化)",
float(np.var(e_dith)) / lsb ** 2, 0.25, 0.02)
r.near("未抖动误差方差 = 1/12 LSB²",
float(np.var(e_plain)) / lsb ** 2, 1 / 12, 0.02)
def test_bitdepth(r: Runner) -> None:
r.section("位深与满量程处理")
edge = np.array([[1.0], [-1.0], [0.0], [1.5], [-1.5], [0.99999]],
dtype=np.float64)
for depth in ("16", "24", "32"):
raw = float_to_pcm(edge, "pcm", int(depth), None)
back = pcm_to_float(raw, "pcm", int(depth))
r.check(f"{depth} 位满量程对称且不越界",
abs(back[0] - 1.0) < 1e-6 and abs(back[1] + 1.0) < 1e-6
and abs(back[2]) < 1e-9,
f"+1→{back[0]:.6f} −1→{back[1]:.6f} 0→{back[2]:.1e}")
f = float_to_pcm(edge, "float", 32, None)
r.check("32 位浮点保留超量程信息(不做削波,便于后期)",
len(f) == edge.size * 4)
# ------------------------------------------------------------ 3. 离线处理
def test_processing(r: Runner, tmp: str) -> None:
r.section("离线处理与导出")
sr = 48000
rng = np.random.default_rng(5)
silence = rng.standard_normal((sr, 2)) * dsp.db_to_lin(-80)
tone = np.stack([np.sin(2 * np.pi * 440 * np.arange(sr * 2) / sr) * 0.2] * 2,
axis=1)
x = np.concatenate([silence, tone, silence], axis=0)
x += 0.01 # 人为加入直流偏移
opts = post.ProcessOptions(trim_silence=True, remove_dc=True, lowcut_hz=60.0,
normalize="peak", normalize_target_dbfs=-1.0,
fade_in=0.01, fade_out=0.05, bit_depth="24")
y, report = post.process_array(x, sr, opts)
r.check("后期处理链执行成功", y.shape[0] > 0 and len(report["steps"]) >= 4,
"、".join(report["steps"]))
r.check("裁剪掉了首尾静音", y.shape[0] < x.shape[0] - sr,
f"{x.shape[0]} → {y.shape[0]} 帧")
r.near("直流偏移被消除", float(np.mean(y)), 0.0, 1e-6)
r.near("峰值归一化到目标 -1 dBFS", float(report["output_peak_dbfs"][0]),
-1.0, 0.15, " dBFS")
r.near("淡入淡出生效(首样本接近 0)", float(abs(y[0, 0])), 0.0, 0.02)
src = os.path.join(tmp, "proc_src.wav")
write_wav(src, x.astype(np.float32), sr, bit_depth="24")
rep = post.process_file(src, opts)
r.check("处理结果写到新文件,原文件保持不变",
os.path.exists(rep["output"]) and os.path.exists(src)
and rep["output"] != src, os.path.basename(rep["output"]))
ok, msg = post.export_audio(rep["output"], os.path.join(tmp, "out16.wav"), "wav_16")
r.check("导出 16 位 WAV", ok, msg)
# 关键安全约束:导出目标与源文件同名时绝不覆盖母版
same = post.default_export_path(rep["output"], "wav_16")
r.check("导出目标与源同名时自动改名,母版不会被覆盖",
os.path.abspath(same) != os.path.abspath(rep["output"]),
os.path.basename(same))
with WavReader(rep["output"]) as before:
frames_before = before.frames
bits_before = before.bits
ok, _msg = post.export_audio(rep["output"], rep["output"], "wav_16")
with WavReader(rep["output"]) as after:
r.check("即使显式传入源路径,源文件位深/长度也不被改动",
ok and after.frames == frames_before and after.bits == bits_before,
f"{after.bits} 位,{after.frames} 帧")
if post.find_ffmpeg():
ok, msg = post.export_audio(rep["output"], os.path.join(tmp, "out.flac"), "flac")
r.check("导出 FLAC", ok, msg)
else:
r.check("缺少 ffmpeg 时给出清晰提示",
not post.export_audio(rep["output"],
os.path.join(tmp, "x.mp3"), "mp3_320")[0])
png = post.write_waveform_png(os.path.join(tmp, "wave.png"), y, sr)
with open(png, "rb") as fh:
head = fh.read(8)
r.check("纯 numpy 生成的 PNG 波形图有效",
head == b"\x89PNG\r\n\x1a\n" and os.path.getsize(png) > 1000,
f"{os.path.getsize(png)} 字节")
# 分析 & 报告
an = dsp.analyze_file(rep["output"])
r.check("文件体检字段齐全",
all(k in an for k in ("peak_dbfs", "true_peak_dbtp", "rms_dbfs",
"integrated_lufs", "loudness_range_lu",
"dc_offset", "noise_floor_dbfs",
"clipped_total")),
f"峰值 {an['peak_dbfs']},真峰值 {an['true_peak_dbtp']},"
f"响度 {an['integrated_lufs']} LUFS")
r.check("体检报告可渲染", "RecorderStudio 文件体检" in post.regenerate_report(rep["output"]))
# 多分段合并统计
a = {"duration": 10.0, "peak_dbfs": [-3.0], "true_peak_dbtp": [-2.8],
"integrated_lufs": -20.0, "clipped_total": 0}
b = {"duration": 30.0, "peak_dbfs": [-1.0], "true_peak_dbtp": [-0.9],
"integrated_lufs": -14.0, "clipped_total": 3}
m = engine._merge_analysis([a, b])
r.check("分段合并取最严值", m["peak_dbfs"] == [-1.0] and m["true_peak_dbtp"] == [-0.9]
and m["clipped_total"] == 3 and abs(m["duration"] - 40.0) < 1e-6,
f"峰值 {m['peak_dbfs']},削波 {m['clipped_total']},时长 {m['duration']}")
r.check("响度按能量加权合并(应介于两段之间)",
-20.0 < m["integrated_lufs"] < -14.0, f"{m['integrated_lufs']} LUFS")
def test_metadata(r: Runner, tmp: str) -> None:
r.section("元数据与报告")
sr = 48000
tone = np.sin(2 * np.pi * 440 * np.arange(sr) / sr) * 0.3
path = os.path.join(tmp, "meta_test.wav")
write_wav(path, np.stack([tone, tone], axis=1).astype(np.float32), sr,
bit_depth="24")
result = engine.TakeResult(
files=[path], duration=1.0, frames=sr, bytes_written=os.path.getsize(path),
peak_dbfs=-10.4, markers=[engine.Marker("测试标记", os.path.basename(path),
0.5, "2024-01-01T00:00:00")],
config=engine.RecordConfig(samplerate=sr, bit_depth="24", channels=2),
started_at="2024-01-01T00:00:00", ended_at="2024-01-01T00:00:01",
device_label="测试设备 [WASAPI]", format_label="48000 Hz / 24-bit PCM / 2 声道",
analysis=dsp.analyze_file(path))
files = post.write_metadata(result)
import json
with open(files[0], encoding="utf-8") as fh:
meta = json.load(fh)
r.check("元数据 JSON 含关键字段",
meta["bit_depth"] == "24" and meta["sample_rate"] == sr
and meta["markers"][0]["label"] == "测试标记"
and meta["analysis"]["peak_dbfs"] is not None,
"、".join(k for k in ("format", "markers", "analysis")))
r.check("文本报告含标记与体检", "测试标记" in post.render_report(result)
and "整体响度" in post.render_report(result))
# ---------------------------------------------------------- 4. 引擎(离线)
def test_engine_offline(r: Runner, tmp: str) -> None:
r.section("引擎(离线部分)")
cfg = engine.RecordConfig(samplerate=48000, channels=2, bit_depth="24")
r.check("配置清洗:非法采样率/声道被夹紧",
engine.RecordConfig(samplerate=1, channels=99, gain_db=999,
bit_depth="24").sanitized().channels <= 32)
r.near("24 位 48 kHz 立体声码率 = 288 kB/s",
cfg.wav_format().bytes_per_second, 288000.0, 1.0, " B/s")
r.check("小时体积估算正确",
abs(cfg.estimate_bytes_per_hour() - 288000 * 3600) < 1,
engine.format_bytes(cfg.estimate_bytes_per_hour()))
r.check("时长格式化", engine.format_duration(3725.4) == "1:02:05.4",
engine.format_duration(3725.4))
r.check("体积格式化", engine.format_bytes(1024 * 1024 * 3) == "3.0 MB",
engine.format_bytes(1024 * 1024 * 3))
r.check("磁盘空间可查", engine.free_space_bytes(tmp) > 0,
engine.format_bytes(engine.free_space_bytes(tmp)))
lc = engine.AlignedLowCut(80.0, 48000, 2)
r.check("低切滤波器抽头数与群延迟合理",
lc.taps > 1000 and lc.latency > 0,
f"{lc.taps} 抽头,群延迟 {lc.latency / 48.0:.1f} ms")
total_in = 0
total_out = 0
rng = np.random.default_rng(3)
for _ in range(20):
blk = rng.standard_normal((1024, 2)).astype(np.float32) * 0.1
total_in += blk.shape[0]
total_out += lc.process(blk).shape[0]
total_out += lc.flush().shape[0]
r.check("低切流式处理后样本数严格守恒(不丢头不掉尾)",
total_in == total_out, f"输入 {total_in} 帧 → 输出 {total_out} 帧")
# 文件名模板
rec = engine.Recorder(cfg)
rec._out_dir = tmp # noqa: SLF001
rec.config.name_template = "{date}_{time}_{sr}_{bits}_{ch}_{seq}"
p = rec._next_path() # noqa: SLF001
r.check("命名模板变量全部替换且无非法字符",
"{" not in p and "}" not in p and p.endswith(".wav")
and "48000" in p and "24" in p and "2ch" in p,
os.path.basename(p))
# 设备枚举(不要求存在设备)
devs = engine.list_input_devices()
r.check("设备枚举可调用", isinstance(devs, list),
f"发现 {len(devs)} 个输入设备" if devs else "当前机器无输入设备")
if devs:
r.check("设备按宿主 API 保真度排序",
all(devs[i].quality_rank <= devs[i + 1].quality_rank
for i in range(len(devs) - 1)),
" → ".join(dict.fromkeys(d.hostapi for d in devs)))
# ---------------------------------------------------------- 5. 硬件端到端
def test_hardware(r: Runner, tmp: str, seconds: float = 1.5) -> None:
r.section("硬件端到端录音")
if engine.sd is None:
r.check("音频后端可用", False, f"sounddevice 不可用:{engine.SD_IMPORT_ERROR}")
return
devs = [d for d in engine.list_input_devices() if d.max_input_channels > 0]
if not devs:
r.check("存在可用输入设备", False, "本机没有录音输入设备,已跳过硬件测试")
return
# 优先选 WASAPI 设备
dev = next((d for d in devs if d.quality_rank == 0), devs[0])
r.check("选中设备", True, f"[{dev.index}] {dev.name} [{dev.hostapi}]")
rate = int(dev.default_samplerate)
ch = min(2, dev.max_input_channels)
is_wasapi = "wasapi" in dev.hostapi.lower()
for depth, lowcut in (("24", 0.0), ("float32", 80.0)):
cfg = engine.RecordConfig(device=dev.index, samplerate=rate, channels=ch,
bit_depth=depth, exclusive=is_wasapi,
lowcut_hz=lowcut, output_dir=tmp,
name_template=f"hw_{depth}_{int(lowcut)}",
split_seconds=0.0)
rec = engine.Recorder(cfg)
try:
rec.start()
except Exception as exc:
r.check(f"{depth} 位开流", False, str(exc).splitlines()[0])
continue
time.sleep(seconds)
marker = rec.add_marker("自检测试标记")
snap = rec.live()
time.sleep(0.2)
res = rec.stop()
expect = res.duration
r.check(f"{depth} 位录音时长接近请求值",
abs(expect - (seconds + 0.2)) < 0.6,
f"实得 {expect:.3f}s(请求 {seconds + 0.2:.1f}s)")
r.check(f"{depth} 位文件存在且可解析",
bool(res.files) and all(os.path.exists(f) for f in res.files),
"、".join(os.path.basename(f) for f in res.files))
if res.files:
with WavReader(res.files[0]) as rd:
info = rd.info()
frames_ok = abs(info["duration"] - res.duration) < 0.05
r.check(f"{depth} 位头部元数据自洽", frames_ok and info["samplerate"] == rate,
f"{info['format']},{info['frames']} 帧")
r.check(f"{depth} 位格式符合请求",
(info["bits"] == int(depth)) if depth != "float32"
else info["encoding"] == "float",
info["format"])
r.check(f"{depth} 位无驱动溢出/xrun",
res.xruns == 0 and res.overflow_blocks == 0,
f"xrun {res.xruns},溢出块 {res.overflow_blocks}")
r.check(f"{depth} 位标记已记录", marker is not None and len(res.markers) == 1,
f"{len(res.markers)} 个标记")
r.check(f"{depth} 位体检报告生成", bool(res.analysis),
f"峰值 {res.peak_dbfs:+.1f} dBFS,"
f"响度 {(res.analysis or {}).get('integrated_lufs')} LUFS")
if lowcut > 0 and res.files:
# 低切路径同样要做到样本数守恒(对齐正确)
expected_frames = int(round(res.duration * rate))
with WavReader(res.files[0]) as rd:
r.check("启用低切后样本数与时长一致(对齐正确)",
abs(rd.frames - expected_frames) <= 2,
f"{rd.frames} 帧 vs 期望约 {expected_frames} 帧")
# 分段能力
cfg = engine.RecordConfig(device=dev.index, samplerate=rate, channels=ch,
bit_depth="24", exclusive=is_wasapi, output_dir=tmp,
name_template="hw_split", split_seconds=0.5)
rec = engine.Recorder(cfg)
rec.start()
time.sleep(1.6)
res = rec.stop()
r.check("按时长自动分段生效", len(res.files) >= 3,
f"{len(res.files)} 个文件:" +
"、".join(f"{WavReader(f).duration:.2f}s" for f in res.files
if os.path.exists(f)))
if res.files:
total = 0.0
for f in res.files:
with WavReader(f) as rd:
total += rd.duration
r.check("分段后总时长不丢数据", abs(total - res.duration) < 0.05,
f"文件合计 {total:.3f}s vs 统计 {res.duration:.3f}s")
test_hardware_auto_stop(r, tmp, dev, rate, ch, is_wasapi)
def test_hardware_auto_stop(r: Runner, tmp: str, dev, rate: int, ch: int,
is_wasapi: bool) -> None:
"""手动分段与静音自动停止(这两条路径最容易出"文件没收尾/流没关"的问题)。"""
# 手动分段
cfg = engine.RecordConfig(device=dev.index, samplerate=rate, channels=ch,
bit_depth="24", exclusive=is_wasapi, output_dir=tmp,
name_template="hw_manual_split")
rec = engine.Recorder(cfg)
rec.start()
time.sleep(0.6)
rec.split_now()
time.sleep(0.7)
res = rec.stop()
r.check("手动分段立即切换新文件", len(res.files) == 2,
"、".join(f"{WavReader(f).duration:.2f}s" for f in res.files
if os.path.exists(f)))
# 静音自动停止(阈值故意设得很高,保证当前环境一定能触发)
cfg = engine.RecordConfig(device=dev.index, samplerate=rate, channels=ch,
bit_depth="24", exclusive=is_wasapi, output_dir=tmp,
name_template="hw_autostop",
silence_threshold_dbfs=-20.0,
auto_stop_silence_seconds=0.6)
rec = engine.Recorder(cfg)
rec.start()
t0 = time.time()
stopped_at = None
while time.time() - t0 < 6.0:
if rec.live().state == engine.RecorderState.IDLE.value:
stopped_at = time.time() - t0
break
time.sleep(0.05)
r.check("静音自动停止被触发", stopped_at is not None,
f"{stopped_at:.2f} 秒后停止" if stopped_at else "6 秒内未触发")
# 关键:引擎自己停止时必须同时关闭 PortAudio 流,否则回调会持续灌满队列
time.sleep(1.2)
backlog_after = rec.live().queue_backlog
overflow_after = rec.live().overflow_blocks
r.check("自动停止后音频流已关闭(队列不再增长、无溢出)",
backlog_after <= 1 and overflow_after == 0,
f"静置 1.2 秒后队列 {backlog_after},溢出块 {overflow_after}")
res = rec.stop()
r.check("自动停止后文件已正确收尾",
bool(res.files) and res.duration > 0.3
and all(os.path.exists(f) for f in res.files),
f"{res.duration:.2f}s,{len(res.files)} 个文件")
r.check("自动停止的原因写进了运行日志",
any("自动停止" in n for n in res.notes),
res.notes[-1] if res.notes else "无日志")
# ---------------------------------------------------------------- 入口
def run_all(*, include_hardware: bool = True, quick: bool = False,
quiet: bool = False) -> Runner:
r = Runner(quiet=quiet)
tmp = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"_build", "selftest")
os.makedirs(tmp, exist_ok=True)
print(f"RecorderStudio 自检 Python {sys.version.split()[0]} "
f"numpy {np.__version__} ffmpeg "
f"{'已找到' if post.find_ffmpeg() else '未找到'}")
print(f"临时目录:{tmp}")
t0 = time.time()
test_loudness(r)
test_filters(r)
test_true_peak(r)
test_meters(r)
test_wav(r, tmp)
test_dither(r)
test_bitdepth(r)
test_processing(r, tmp)
test_metadata(r, tmp)
test_engine_offline(r, tmp)
if include_hardware:
test_hardware(r, tmp, seconds=0.8 if quick else 1.5)
print("\n" + "=" * 62)
elapsed = time.time() - t0
if r.ok:
print(f"全部通过:{r.passed} 项检查,用时 {elapsed:.1f} 秒。")
else:
print(f"通过 {r.passed} 项,失败 {len(r.failed)} 项,用时 {elapsed:.1f} 秒:")
for name, detail in r.failed:
print(f" ✗ {name} {detail}")
return r
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description="RecorderStudio 自检")
p.add_argument("--no-hw", action="store_true", help="跳过需要真实硬件的测试")
p.add_argument("--quick", action="store_true", help="硬件测试缩短到 1 秒")
p.add_argument("--quiet", action="store_true", help="只输出失败项")
args = p.parse_args(argv)
r = run_all(include_hardware=not args.no_hw, quick=args.quick, quiet=args.quiet)
return 0 if r.ok else 1
if __name__ == "__main__":
sys.exit(main())
+579
View File
@@ -0,0 +1,579 @@
"""无损 WAV 读写核心。
设计要点(面向"高质量、高清晰度"):
* 支持 16 / 24 / 32 位整数 PCM 与 32 位浮点(IEEE float)四种载荷;
* 24 位采用真正的 3 字节打包,不做 16 位截断;
* 多声道、24 位、浮点载荷统一使用 ``WAVE_FORMAT_EXTENSIBLE``(含通道掩码),
避免播放器把 24 位误判为 16 位、把浮点误判为整数;
* 高位深写入时可选 **TPDF(三角概率密度)抖动**,把量化失真从
"与信号相关的非线性失真" 变成 "与信号无关的宽带白噪",这是后期降位深的
标准做法,听感明显更干净;
* 录音过程中定期回写文件头(``checkpoint``),即使程序崩溃/断电,
已落盘的数据也能被播放器正常识别;
* 超过 4 GiB 时可选 RF64(EBU Tech 3306)容器,避免 RIFF 32 位长度溢出。
本模块不依赖 numpy 以外的任何第三方库。
"""
from __future__ import annotations
import os
import struct
from dataclasses import dataclass
from typing import BinaryIO, Iterator
import numpy as np
WAVE_FORMAT_PCM = 0x0001
WAVE_FORMAT_IEEE_FLOAT = 0x0003
WAVE_FORMAT_EXTENSIBLE = 0xFFFE
# KSDATAFORMAT_SUBTYPE_*
_SUBTYPE_PCM = bytes.fromhex("0100000000001000800000aa00389b71")
_SUBTYPE_FLOAT = bytes.fromhex("0300000000001000800000aa00389b71")
# 声道掩码(dwChannelMask),用于 EXTENSIBLE 头部
_CHANNEL_MASKS = {
1: 0x4, # SPEAKER_FRONT_CENTER -> 单声道习惯上用 CENTER,但很多软件写 FRONT_LEFT
2: 0x3, # FRONT_LEFT | FRONT_RIGHT
4: 0x33, # FL FR BL BR
6: 0x3F,
8: 0x63F,
}
# 1 GiB 保留量:RIFF 数据块上限 4 GiB-1,留出余量给头部与安全边界
RIFF_DATA_LIMIT = 0xFFF00000 - 64
__all__ = [
"WavFormat",
"WavWriter",
"WavReader",
"wav_info",
"read_wav",
"write_wav",
"RIFF_DATA_LIMIT",
"SUPPORTED_BIT_DEPTHS",
]
# (名称, 位深, 采样格式) —— 直接对应界面上的"位深"下拉框
SUPPORTED_BIT_DEPTHS = ("16", "24", "32", "float32")
@dataclass(frozen=True)
class WavFormat:
"""描述一个 WAV 载荷格式。"""
samplerate: int
channels: int
encoding: str # 'pcm' | 'float'
bits: int # 每个采样的有效位宽(float 为 32)
@property
def bytes_per_sample(self) -> int:
return self.bits // 8
@property
def frame_bytes(self) -> int:
return self.bytes_per_sample * self.channels
@property
def bytes_per_second(self) -> float:
return float(self.frame_bytes) * self.samplerate
@property
def block_align(self) -> int:
return self.frame_bytes
@property
def label(self) -> str:
if self.encoding == "float":
return "32-bit float"
return f"{self.bits}-bit PCM"
def describe(self) -> str:
return (
f"{self.samplerate} Hz / {self.label} / "
f"{'单声道' if self.channels == 1 else f'{self.channels} 声道'}"
)
def _fmt_for_bitdepth(bit_depth: int | str) -> tuple[str, int]:
"""把界面上的位深选项翻译成 (encoding, bits)。"""
if isinstance(bit_depth, str):
s = bit_depth.strip().lower()
if s in ("float", "float32", "f32", "32f"):
return "float", 32
s = s.rstrip("bit").rstrip("-").strip()
bit_depth = int(s)
bit_depth = int(bit_depth)
if bit_depth == 16:
return "pcm", 16
if bit_depth == 24:
return "pcm", 24
if bit_depth == 32:
return "pcm", 32
if bit_depth == 64:
raise ValueError("不支持 64 位整数 PCM;如需更高精度请使用 32-bit float")
raise ValueError(f"不支持的位深: {bit_depth}")
def _safe_scale(bits: int) -> float:
"""整数满量程对应的浮点值。
采用 ``2**(bits-1) - 1``(对称满量程),这样 +1.0 与 -1.0 都能被精确表示,
不会出现 "正半周先削顶、负半周仍有余量" 的非对称削波。
"""
return float((1 << (bits - 1)) - 1)
class _TpdfDither:
"""TPDF 抖动噪声源。
三角分布 = 两个独立均匀分布之和,峰峰值恰好 1 LSB,
这是业界公认的"无调制噪声"抖动,优于矩形(RPDF)抖动。
"""
def __init__(self, seed: int | None = None):
self._rng = np.random.Generator(np.random.PCG64(seed))
def generate(self, size: int | tuple[int, ...], lsb: float = 1.0) -> np.ndarray:
u1 = self._rng.random(size)
u2 = self._rng.random(size)
return (u1 - u2) * lsb
def reseed(self, seed: int) -> None:
self._rng = np.random.Generator(np.random.PCG64(seed))
def float_to_pcm(data: np.ndarray, encoding: str, bits: int,
dither: _TpdfDither | None = None) -> bytes:
"""把 float32/float64 的 [-1, 1] 数据编码为 WAV 载荷字节。"""
x = np.asarray(data)
if encoding == "float":
return np.ascontiguousarray(x, dtype="<f4").tobytes()
# 抖动与量化都必须在"整数域"内进行:抖动幅度 = 1 LSB(峰峰值)
scale = _safe_scale(bits)
y = x.astype(np.float64, copy=True) * scale
if dither is not None:
y += dither.generate(y.shape, lsb=1.0)
ints = np.rint(y)
lo, hi = -(1 << (bits - 1)), (1 << (bits - 1)) - 1
np.clip(ints, lo, hi, out=ints)
ints = ints.astype(np.int64)
if bits == 16:
return ints.astype("<i2").tobytes()
if bits == 32:
return ints.astype("<i4").tobytes()
if bits == 24:
buf = np.ascontiguousarray(ints, dtype="<i4")
# 小端序下低 3 字节即为 24 位补码,直接切片比逐样本运算快两个数量级
return buf.view(np.uint8).reshape(-1, 4)[:, :3].tobytes()
raise ValueError(f"不支持的位深: {bits}")
def pcm_to_float(raw: bytes, encoding: str, bits: int) -> np.ndarray:
"""WAV 载荷字节 -> float64 数组([-1, 1] 标度)。"""
if encoding == "float":
return np.frombuffer(raw, dtype="<f4").astype(np.float64)
if bits == 16:
return np.frombuffer(raw, dtype="<i2").astype(np.float64) / _safe_scale(16)
if bits == 32:
return np.frombuffer(raw, dtype="<i4").astype(np.float64) / _safe_scale(32)
if bits == 24:
b = np.frombuffer(raw, dtype=np.uint8)
usable = (b.size // 3) * 3
b = b[:usable].reshape(-1, 3).astype(np.int32)
v = b[:, 0] | (b[:, 1] << 8) | (b[:, 2] << 16)
# 手动符号扩展
v = np.where(v & 0x800000, v - 0x1000000, v)
return v.astype(np.float64) / _safe_scale(24)
raise ValueError(f"不支持的位深: {bits}")
class WavWriter:
"""流式(边录边写)WAV 写入器。
典型用法::
w = WavWriter("a.wav", WavFormat(48000, 2, "pcm", 24), dither=True)
w.write(block) # block: float32 (n, channels)
w.checkpoint() # 每秒调用一次,保证崩溃安全
info = w.close() # 回写文件头,返回统计信息
"""
def __init__(self, path: str, fmt: WavFormat, *, dither: bool = True,
rf64: bool = False, seed: int | None = None,
checkpoint_interval: float = 1.0):
self.path = os.fspath(path)
self.fmt = fmt
self.rf64 = bool(rf64)
self._dither = _TpdfDither(seed) if (dither and fmt.encoding == "pcm") else None
self._checkpoint_interval = float(checkpoint_interval)
self._last_checkpoint = 0.0
self.frames_written = 0
self.bytes_written = 0
self.clipped_samples = 0
self.peak = 0.0
self.closed = False
self._extensible = (
fmt.encoding == "float" or fmt.bits > 16 or fmt.channels > 2
)
self._fh: BinaryIO = open(self.path, "wb", buffering=1024 * 1024)
hdr = self._build_header()
self._header = hdr
self._data_size_offset = hdr.data_offset - 4
self._fh.write(hdr.bytes)
self._fh.flush()
# ---------------------------------------------------------------- header
def _build_header(self) -> "_Header":
f = self.fmt
if f.encoding == "float":
fmt_tag = WAVE_FORMAT_IEEE_FLOAT if not self._extensible else WAVE_FORMAT_EXTENSIBLE
subtype = _SUBTYPE_FLOAT
else:
fmt_tag = WAVE_FORMAT_PCM if not self._extensible else WAVE_FORMAT_EXTENSIBLE
subtype = _SUBTYPE_PCM
if self._extensible:
mask = _CHANNEL_MASKS.get(f.channels, 0)
fmt_body = struct.pack(
"<HHIIHHHHI16s",
WAVE_FORMAT_EXTENSIBLE, f.channels, f.samplerate,
int(f.bytes_per_second), f.block_align, f.bits,
22, f.bits, mask, subtype,
)
else:
fmt_body = struct.pack(
"<HHIIHH",
fmt_tag, f.channels, f.samplerate,
int(f.bytes_per_second), f.block_align, f.bits,
)
chunks: list[bytes] = []
# fmt
chunks.append(b"fmt " + struct.pack("<I", len(fmt_body)) + fmt_body)
# fact(非 PCM 载荷必须声明采样帧数)
if f.encoding == "float":
chunks.append(b"fact" + struct.pack("<II", 4, 0))
self._fact_offset = 12 + len(chunks[0]) + 8
else:
self._fact_offset = None
riff_overhead = 12 + sum(len(c) for c in chunks) + 8
if self.rf64:
# RF64: ds64 紧跟 WAVE 之后,RIFF size 写 0xFFFFFFFF
ds64_body = struct.pack("<QQQI", 0, 0, 0, 0)
ds64 = b"ds64" + struct.pack("<I", len(ds64_body)) + ds64_body
body = ds64 + b"".join(chunks)
header = (b"RF64" + struct.pack("<I", 0xFFFFFFFF) + b"WAVE"
+ body + b"data" + struct.pack("<I", 0))
return _Header(header, len(header), riff_overhead, ds64_offset=12)
body = b"".join(chunks)
size = len(body) + 8 + 8 # 占位,收尾时回写
header = (b"RIFF" + struct.pack("<I", size) + b"WAVE" + body
+ b"data" + struct.pack("<I", 0))
return _Header(header, len(header), riff_overhead, ds64_offset=None)
# ----------------------------------------------------------------- write
def write(self, data: np.ndarray) -> int:
"""写入一块浮点音频(形状 (n,) 或 (n, channels)),返回写入的采样帧数。"""
if self.closed:
raise ValueError("写入器已关闭")
x = np.asarray(data, dtype=np.float32)
if x.ndim == 1:
x = x.reshape(-1, 1)
if x.shape[1] != self.fmt.channels:
raise ValueError(
f"通道数不匹配:收到 {x.shape[1]},期望 {self.fmt.channels}")
n = int(x.shape[0])
if n == 0:
return 0
# 统计(在抖动/削波之前,反映真实输入)
blk_peak = float(np.max(np.abs(x))) if n else 0.0
if blk_peak > self.peak:
self.peak = blk_peak
if blk_peak > 1.0:
self.clipped_samples += int(np.count_nonzero(np.abs(x) > 1.0))
payload = float_to_pcm(x, self.fmt.encoding, self.fmt.bits, self._dither)
# 先判断是否越界,再落盘:这样调用方可以无损地切到下一个文件
if not self.rf64 and self.bytes_written + len(payload) > RIFF_DATA_LIMIT:
raise _RiffOverflow(
f"RIFF 容器已达 4 GiB 上限(已写 {self.bytes_written} 字节),"
"请启用自动分段或 RF64 模式")
self._fh.write(payload)
self.frames_written += n
self.bytes_written += len(payload)
return n
def checkpoint(self, now: float | None = None) -> None:
"""定期回写长度字段,保证异常退出后文件仍可播放。"""
if self.closed:
return
now = _monotonic() if now is None else now
if now - self._last_checkpoint < self._checkpoint_interval:
return
self._last_checkpoint = now
self._patch_sizes(final=False)
def _patch_sizes(self, *, final: bool) -> None:
f = self.fmt
data_bytes = self.frames_written * f.frame_bytes
pos = self._fh.tell()
if self.rf64:
body_len = len(self._header.bytes) - 8 + data_bytes
if final:
self._fh.seek(4)
self._fh.write(struct.pack("<I", 0xFFFFFFFF))
self._fh.seek(self._header.ds64_offset + 8) # ds64 body 起点
self._fh.write(struct.pack(
"<QQQI", body_len, data_bytes, self.frames_written, 0))
self._fh.seek(pos)
return
# RIFF
riff_size = (len(self._header.bytes) - 8) + data_bytes
self._fh.seek(4)
self._fh.write(struct.pack("<I", riff_size & 0xFFFFFFFF))
self._fh.seek(self._data_size_offset)
self._fh.write(struct.pack("<I", data_bytes & 0xFFFFFFFF))
if self._fact_offset is not None:
self._fh.seek(self._fact_offset)
self._fh.write(struct.pack("<I", self.frames_written & 0xFFFFFFFF))
self._fh.seek(pos)
# ----------------------------------------------------------------- close
def close(self) -> dict:
if self.closed:
return self.stats()
self._patch_sizes(final=True)
self._fh.flush()
try:
os.fsync(self._fh.fileno())
except OSError:
pass
self._fh.close()
self.closed = True
return self.stats()
def stats(self) -> dict:
f = self.fmt
duration = self.frames_written / f.samplerate if f.samplerate else 0.0
return {
"path": self.path,
"frames": self.frames_written,
"duration": duration,
"bytes": self.bytes_written,
"peak": self.peak,
"peak_dbfs": 20 * np.log10(self.peak) if self.peak > 0 else float("-inf"),
"clipped_samples": self.clipped_samples,
"format": f.describe(),
"dither": self._dither is not None,
"rf64": self.rf64,
}
def abort(self) -> None:
"""放弃写入:关闭句柄但不回写头部(用于创建后立即失败的场景)。"""
if not self.closed:
try:
self._fh.close()
finally:
self.closed = True
def __enter__(self) -> "WavWriter":
return self
def __exit__(self, *exc) -> None:
if exc and exc[0] is not None:
self.abort()
else:
self.close()
@dataclass
class _Header:
bytes: bytes
data_offset: int
riff_overhead: int
ds64_offset: int | None = None
class _RiffOverflow(Exception):
"""RIFF 4 GiB 上限。引擎捕获后自动分段。"""
def _monotonic() -> float:
import time
return time.monotonic()
class WavReader:
"""轻量 WAV 读取器,支持 PCM 16/24/32、float32、EXTENSIBLE、RF64。"""
def __init__(self, path: str):
self.path = os.fspath(path)
self.fh = open(self.path, "rb")
self._parse()
def _parse(self) -> None:
fh = self.fh
hdr = fh.read(12)
if len(hdr) < 12:
raise ValueError("不是有效的 WAV 文件(文件过短)")
riff, _size, wave = hdr[:4], hdr[4:8], hdr[8:12]
if riff == b"RF64":
self.rf64 = True
elif riff == b"RIFF":
self.rf64 = False
else:
raise ValueError("不是有效的 WAV 文件(缺少 RIFF/RF64 标记)")
if wave != b"WAVE":
raise ValueError("不是有效的 WAV 文件(缺少 WAVE 标记)")
self.fmt_tag = None
self.channels = self.samplerate = self.bits = 0
self.encoding = "pcm"
self.data_offset = self.data_size = 0
self.frames = 0
self._ds64_data_size = None
self._ds64_frames = None
self._fact_frames = None
while True:
cid = fh.read(4)
if len(cid) < 4:
break
(csize,) = struct.unpack("<I", fh.read(4))
body_start = fh.tell()
if cid == b"fmt ":
body = fh.read(csize)
(tag, ch, sr, _bps, _align, bits) = struct.unpack("<HHIIHH", body[:16])
if tag == WAVE_FORMAT_EXTENSIBLE and csize >= 40:
(bits,) = struct.unpack("<H", body[18:20])
if body[24:40] == _SUBTYPE_FLOAT:
tag = WAVE_FORMAT_IEEE_FLOAT
elif body[24:40] == _SUBTYPE_PCM:
tag = WAVE_FORMAT_PCM
self.fmt_tag, self.channels, self.samplerate = tag, ch, sr
self.bits = bits
self.encoding = "float" if tag == WAVE_FORMAT_IEEE_FLOAT else "pcm"
elif cid == b"data":
self.data_offset = body_start
self.data_size = csize
elif cid == b"fact" and csize >= 4:
(self._fact_frames,) = struct.unpack("<I", fh.read(4))
elif cid == b"ds64":
body = fh.read(csize)
_riff64, data64, frames64, _table = struct.unpack("<QQQI", body[:28])
self._ds64_data_size = data64
self._ds64_frames = frames64
fh.seek(body_start + csize + (csize & 1))
if self.fmt_tag is None:
raise ValueError("WAV 文件缺少 fmt 块")
if self.encoding == "pcm" and self.bits not in (16, 24, 32):
raise ValueError(f"暂不支持的 PCM 位深: {self.bits}")
if self.rf64 and self._ds64_data_size is not None:
self.data_size = int(self._ds64_data_size)
if self.channels <= 0 or self.samplerate <= 0:
raise ValueError("WAV 头部信息不完整")
self.frames = self.data_size // (self.channels * (self.bits // 8))
@property
def fmt(self) -> WavFormat:
return WavFormat(self.samplerate, self.channels, self.encoding, self.bits)
@property
def duration(self) -> float:
return self.frames / self.samplerate if self.samplerate else 0.0
def read(self, start_frame: int = 0, num_frames: int | None = None) -> np.ndarray:
"""读取为 float64 数组,形状 (frames, channels),范围 [-1, 1]。"""
bps = self.bits // 8
if num_frames is None:
num_frames = max(0, self.frames - start_frame)
num_frames = min(num_frames, max(0, self.frames - start_frame))
if num_frames <= 0:
return np.zeros((0, self.channels), dtype=np.float64)
self.fh.seek(self.data_offset + start_frame * self.channels * bps)
raw = self.fh.read(num_frames * self.channels * bps)
flat = pcm_to_float(raw, self.encoding, self.bits)
usable = (flat.size // self.channels) * self.channels
return flat[:usable].reshape(-1, self.channels)
def iter_blocks(self, block_frames: int = 1 << 16) -> Iterator[np.ndarray]:
"""分块迭代读取,便于处理超长录音而不占内存。"""
pos = 0
while pos < self.frames:
block = self.read(pos, block_frames)
if block.size == 0:
break
yield block
pos += block.shape[0]
def close(self) -> None:
if not self.fh.closed:
self.fh.close()
def __enter__(self) -> "WavReader":
return self
def __exit__(self, *exc) -> None:
self.close()
def info(self) -> dict:
return {
"path": self.path,
"samplerate": self.samplerate,
"channels": self.channels,
"encoding": self.encoding,
"bits": self.bits,
"frames": self.frames,
"duration": self.duration,
"data_bytes": self.data_size,
"rf64": self.rf64,
"format": self.fmt.describe(),
}
def wav_info(path: str) -> dict:
with WavReader(path) as r:
return r.info()
def read_wav(path: str, *, max_seconds: float | None = None) -> tuple[np.ndarray, int]:
"""读取整个 WAV,返回 ``(data, samplerate)``;``max_seconds`` 可只读前段。"""
with WavReader(path) as r:
n = None if max_seconds is None else int(max_seconds * r.samplerate)
return r.read(0, n), r.samplerate
def write_wav(path: str, data: np.ndarray, samplerate: int, *,
bit_depth: int | str = 24, dither: bool = True,
rf64: bool = False) -> dict:
"""一次性写出 WAV(离线后期处理用)。"""
x = np.asarray(data, dtype=np.float32)
if x.ndim == 1:
x = x[:, None]
encoding, bits = _fmt_for_bitdepth(bit_depth)
fmt = WavFormat(int(samplerate), int(x.shape[1]), encoding, bits)
w = WavWriter(path, fmt, dither=dither, rf64=rf64)
try:
# 分块写,避免超大数组一次性编码造成内存峰值
step = 1 << 18
for i in range(0, x.shape[0], step):
w.write(x[i:i + step])
finally:
stats = w.close()
stats["format"] = fmt.describe()
return stats
+564
View File
@@ -0,0 +1,564 @@
"""自绘界面控件:专业电平表、实时示波器、响度条、录音按钮、状态标签。
全部用 QPainter 手绘,好处是刷新率与刻度都能精确控制,
并且不受样式表在自定义绘制上的限制。
"""
from __future__ import annotations
import math
import numpy as np
from PyQt5.QtCore import QPointF, QRect, QRectF, Qt, pyqtSignal
from PyQt5.QtGui import (QBrush, QColor, QFontMetrics, QLinearGradient,
QPainter, QPen, QPolygonF)
from PyQt5.QtWidgets import QSizePolicy, QWidget
from . import dsp
# ------------------------------------------------------------- 设计变量
BG = "#0F1218"
PANEL = "#161A22"
PANEL_2 = "#1D222C"
BORDER = "#2A313D"
TEXT = "#E8ECF4"
TEXT_DIM = "#8B94A7"
ACCENT = "#4EA1FF"
REC = "#FF4757"
OK = "#37D67A"
WARN = "#FFB020"
CRIT = "#FF5C5C"
DB_MIN = -60.0
DB_MAX = 6.0
def db_x(db: float, width: float, db_min: float = DB_MIN,
db_max: float = 0.0) -> float:
"""dB -> 像素(线性刻度,0 dBFS 贴右端)。"""
v = (float(db) - db_min) / (db_max - db_min)
return max(0.0, min(1.0, v)) * width
class LevelMeter(QWidget):
"""多通道电平表:RMS 条 + 峰值保持 + 削波锁存 + dB 刻度。"""
SCALE = [0, -3, -6, -12, -18, -24, -36, -48, -60]
def __init__(self, parent=None, *, channels: int = 2, compact: bool = False):
super().__init__(parent)
self.channels = channels
self.compact = compact
self._rms: list[float] = [DB_MIN] * channels
self._peak: list[float] = [DB_MIN] * channels
self._hold: list[float] = [DB_MIN] * channels
self._tp: list[float] = [DB_MIN] * channels
self._clip: list[bool] = [False] * channels
self._interfaces: float = float("-inf")
self._labels = ["L", "R", "3", "4", "5", "6", "7", "8"]
self.setMinimumHeight(self._preferred_height())
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
def _preferred_height(self) -> int:
per = 20 if self.compact else 30
return per * max(1, self.channels) + 26
def set_channels(self, channels: int) -> None:
self.channels = max(1, int(channels))
self._rms = [DB_MIN] * self.channels
self._peak = [DB_MIN] * self.channels
self._hold = [DB_MIN] * self.channels
self._tp = [DB_MIN] * self.channels
self._clip = [False] * self.channels
self.setMinimumHeight(self._preferred_height())
self.update()
def update_levels(self, snap: dsp.MeterSnapshot) -> None:
n = self.channels
self._rms = (list(snap.rms_db) + [DB_MIN] * n)[:n]
self._peak = (list(snap.peak_db) + [DB_MIN] * n)[:n]
self._hold = (list(snap.hold_db) + [DB_MIN] * n)[:n]
self._tp = ([v if v > -200 else DB_MIN for v in snap.true_peak_db]
+ [DB_MIN] * n)[:n]
self._clip = (list(snap.clipped) + [False] * n)[:n]
self._interfaces = snap.integrated_lufs
self.update()
def reset(self) -> None:
self._rms = [DB_MIN] * self.channels
self._peak = [DB_MIN] * self.channels
self._hold = [DB_MIN] * self.channels
self._tp = [DB_MIN] * self.channels
self._clip = [False] * self.channels
self.update()
# ------------------------------------------------------------ painting
def paintEvent(self, _event) -> None: # noqa: N802
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing, True)
w, h = self.width(), self.height()
fm = QFontMetrics(self.font())
scale_w = 34
bar_right = w - scale_w - 6
label_w = 26 if self.channels > 2 else 22
bar_left = label_w + 4
bar_w = max(10.0, bar_right - bar_left)
top = 4
footer = 20
per = (h - top - footer) / self.channels
bar_h = max(8.0, per - (6 if not self.compact else 3))
# 底部统一说明
p.setPen(QColor(TEXT_DIM))
p.setFont(self.font())
tp_max = max(self._tp) if self._tp else DB_MIN
intf = ("—" if not math.isfinite(self._interfaces)
else f"{self._interfaces:+.1f}")
p.drawText(QRect(2, h - footer + 1, w - 4, footer - 2),
Qt.AlignLeft | Qt.AlignVCenter,
f"真峰值 {tp_max:+.1f} dBTP 整体响度 {intf} LUFS")
for ch in range(self.channels):
y = top + ch * per
bar = QRectF(bar_left, y, bar_w, bar_h)
# 轨道
p.setPen(Qt.NoPen)
p.setBrush(QColor(PANEL_2))
p.drawRoundedRect(bar, 3, 3)
rms_x = db_x(self._rms[ch], bar_w)
peak_x = db_x(self._peak[ch], bar_w)
hold_x = db_x(self._hold[ch], bar_w)
# 峰值填充(暗一些)+ RMS 填充(亮)
if peak_x > 0.5:
p.setBrush(QColor(ACCENT).darker(190))
p.drawRoundedRect(QRectF(bar_left, y, peak_x, bar_h), 2, 2)
if rms_x > 0.5:
grad = QLinearGradient(bar_left, 0, bar_left + bar_w, 0)
grad.setColorAt(0.0, QColor("#1FA97B"))
grad.setColorAt(max(0.001, db_x(-18, 1.0) - 0.001), QColor("#37D67A"))
grad.setColorAt(db_x(-6, 1.0), QColor("#FFD24A"))
grad.setColorAt(db_x(-1, 1.0), QColor("#FF6B5A"))
grad.setColorAt(1.0, QColor("#FF4757"))
p.setBrush(QBrush(grad))
p.drawRoundedRect(QRectF(bar_left, y, rms_x, bar_h), 2, 2)
# 刻度线
p.setPen(QPen(QColor(255, 255, 255, 26), 1))
for mark in self.SCALE:
mx = bar_left + db_x(mark, bar_w)
p.drawLine(QPointF(mx, y), QPointF(mx, y + bar_h))
# 峰值保持
if hold_x > 1:
p.setPen(QPen(QColor("#F2F5FA"), 2))
p.drawLine(QPointF(bar_left + hold_x, y - 1),
QPointF(bar_left + hold_x, y + bar_h + 1))
# 削波锁存
if self._clip[ch]:
p.setPen(Qt.NoPen)
p.setBrush(QColor(REC))
p.drawRoundedRect(QRectF(bar_left + bar_w - 6, y, 6, bar_h), 2, 2)
# 通道标记
p.setPen(QColor(TEXT if self._peak[ch] > DB_MIN + 1 else TEXT_DIM))
p.drawText(QRect(2, int(y), label_w, int(bar_h)),
Qt.AlignLeft | Qt.AlignVCenter,
self._labels[ch] if ch < len(self._labels) else str(ch + 1))
# 刻度文字(仅每档首行画一次)
p.setPen(QColor(TEXT_DIM))
p.setFont(self.font())
if ch == 0:
for mark in (0, -6, -18, -36, -60):
mx = bar_left + db_x(mark, bar_w)
txt = "0" if mark == 0 else str(mark)
tw = fm.horizontalAdvance(txt)
p.drawText(QRectF(mx - tw / 2, top - 2, tw + 2, 12),
Qt.AlignCenter, txt)
# 当前峰值数字
val = self._peak[ch]
p.setPen(QColor(CRIT if self._clip[ch]
else (WARN if val > -2 else TEXT)))
p.drawText(QRect(int(bar_right) + 4, int(y), scale_w,
int(bar_h)),
Qt.AlignRight | Qt.AlignVCenter,
"−∞" if val <= DB_MIN + 0.5 else f"{val:.1f}")
p.end()
class Scope(QWidget):
"""实时波形示波器:绘制最近一段的 min/max 包络。"""
def __init__(self, parent=None, *, seconds: float = 1.0):
super().__init__(parent)
self.seconds = seconds
self.samplerate = 48000
self._data = np.zeros((0, 2), dtype=np.float32)
self._recording = False
self.setMinimumHeight(150)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
def set_format(self, samplerate: int, channels: int) -> None:
self.samplerate = int(samplerate)
if self._data.shape[1] != channels:
self._data = np.zeros((0, channels), dtype=np.float32)
self.update()
def update_data(self, data: np.ndarray, recording: bool) -> None:
self._data = data
self._recording = recording
self.update()
def paintEvent(self, _event) -> None: # noqa: N802
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing, False)
w, h = self.width(), self.height()
p.fillRect(0, 0, w, h, QColor(PANEL))
p.setPen(QPen(QColor(BORDER), 1))
p.drawRect(0, 0, w - 1, h - 1)
ch = max(1, self._data.shape[1] if self._data.size else 2)
lanes = min(2, ch)
lane_h = h / lanes
cols = max(64, min(2048, w))
# 网格
p.setPen(QPen(QColor(255, 255, 255, 18), 1))
step = max(1, int(self.seconds * 10)) # 100 ms 一条
px_per_div = w / max(1.0, self.seconds * 10)
for i in range(step + 1):
x = w - i * px_per_div
if x < 0:
break
p.drawLine(QPointF(x, 0), QPointF(x, h))
for lane in range(lanes):
y0 = lane * lane_h
yc = y0 + lane_h / 2
p.setPen(QPen(QColor(255, 255, 255, 40), 1))
p.drawLine(QPointF(0, yc), QPointF(w, yc))
# ±0.5 参考线
p.setPen(QPen(QColor(255, 255, 255, 16), 1))
for amp in (0.5, -0.5):
y = yc - amp * (lane_h / 2 - 3)
p.drawLine(QPointF(0, y), QPointF(w, y))
if self._data.size == 0:
p.setPen(QColor(TEXT_DIM))
p.drawText(self.rect(), Qt.AlignCenter,
"等待录音…" if not self._recording else "正在采集…")
p.end()
return
n = self._data.shape[0]
src_ch = [0] if ch == 1 else [0, 1]
per = max(1, n // cols)
usable = (n // per) * per
for lane in range(lanes):
c = src_ch[min(lane, len(src_ch) - 1)]
col = self._data[:usable, c] if usable else self._data[:, c]
if usable:
blk = col.reshape(-1, per)
mn = blk.min(axis=1)
mx = blk.max(axis=1)
else:
mn = mx = col
y0 = lane * lane_h
yc = y0 + lane_h / 2
scale = (lane_h / 2 - 3)
grid = np.arange(mn.size) * (w / max(1, mn.size))
p.setPen(QPen(QColor(ACCENT) if lane == 0 else QColor("#5AD1A8"), 1))
for i in range(mn.size):
x = float(grid[i])
top = yc - float(mx[i]) * scale
bot = yc - float(mn[i]) * scale
if bot - top < 1.0:
top, bot = top - 0.5, bot + 0.5
p.drawLine(QPointF(x, top), QPointF(x, bot))
p.setPen(QColor(TEXT_DIM))
p.drawText(QRect(6, 2, w - 12, 14), Qt.AlignLeft,
f"{self.seconds:.1f} 秒窗口 · {self.samplerate} Hz")
p.end()
class LoudnessBar(QWidget):
"""BS.1770 响度条:瞬时 / 短时 / 整体 + 目标线。"""
def __init__(self, parent=None, *, minimum: float = -40.0, maximum: float = 0.0):
super().__init__(parent)
self.minimum = minimum
self.maximum = maximum
self.target = -16.0
self._momentary = float("-inf")
self._short_term = float("-inf")
self._integrated = float("-inf")
self.setMinimumHeight(54)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
def set_target(self, target: float) -> None:
self.target = float(target)
self.update()
def update_levels(self, momentary: float, short_term: float,
integrated: float) -> None:
self._momentary, self._short_term, self._integrated = \
momentary, short_term, integrated
self.update()
def _x(self, lufs: float, w: float) -> float:
if not math.isfinite(lufs):
return 0.0
v = (lufs - self.minimum) / (self.maximum - self.minimum)
return max(0.0, min(1.0, v)) * w
def paintEvent(self, _event) -> None: # noqa: N802
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing, True)
w, h = self.width(), self.height()
bar = QRectF(0, 22, w, 14)
p.setPen(Qt.NoPen)
p.setBrush(QColor(PANEL_2))
p.drawRoundedRect(bar, 3, 3)
# 瞬时响度填充
mx = self._x(self._momentary, w)
if mx > 0.5:
grad = QLinearGradient(0, 0, w, 0)
grad.setColorAt(0.0, QColor("#1D5FA8"))
grad.setColorAt(0.65, QColor(ACCENT))
grad.setColorAt(0.9, QColor("#7BD3A0"))
grad.setColorAt(1.0, QColor("#FFD24A"))
p.setBrush(QBrush(grad))
p.drawRoundedRect(QRectF(0, 22, mx, 14), 3, 3)
# 短时(亮度更高的一条细线)
sx = self._x(self._short_term, w)
if sx > 0.5:
p.setBrush(QColor(255, 255, 255, 190))
p.drawRect(QRectF(0, 25, sx, 2))
# 整体响度三角标
ix = self._x(self._integrated, w)
if ix > 0.0:
tri = QPolygonF([QPointF(ix, 19), QPointF(ix - 5, 11), QPointF(ix + 5, 11)])
p.setBrush(QColor("#FFD24A"))
p.drawPolygon(tri)
# 目标线
tx = self._x(self.target, w)
p.setPen(QPen(QColor(OK), 1, Qt.DashLine))
p.drawLine(QPointF(tx, 8), QPointF(tx, 44))
# 刻度
p.setPen(QColor(TEXT_DIM))
p.setFont(self.font())
for val in range(int(self.minimum), int(self.maximum) + 1, 5):
x = self._x(val, w)
p.drawLine(QPointF(x, 37), QPointF(x, 41))
tw = QFontMetrics(self.font()).horizontalAdvance(str(val))
p.drawText(QRectF(x - tw / 2, 40, tw + 2, 12), Qt.AlignCenter, str(val))
def shown(v: float) -> str:
return "—" if not math.isfinite(v) else f"{v:.1f}"
p.setPen(QColor(TEXT))
p.drawText(QRect(0, 2, w, 14), Qt.AlignLeft,
f"瞬时 {shown(self._momentary)} LUFS "
f"短时 {shown(self._short_term)} "
f"整体 {shown(self._integrated)}")
p.setPen(QColor(TEXT_DIM))
p.drawText(QRect(0, 2, w, 14), Qt.AlignRight,
f"目标 {self.target:.0f} LUFS")
p.end()
class RecordButton(QWidget):
"""圆形录音按钮(带脉冲光晕)。"""
clicked = pyqtSignal()
def __init__(self, parent=None, *, diameter: int = 74):
super().__init__(parent)
self.d = diameter
self.setFixedSize(diameter, diameter)
self.state = "idle" # idle | recording | paused
self._pulse = 0.0
self.setCursor(Qt.PointingHandCursor)
self.setToolTip("开始 / 停止录音(空格)")
def set_state(self, state: str) -> None:
self.state = state
self.update()
def set_pulse(self, phase: float) -> None:
self._pulse = phase
if self.state != "idle":
self.update()
def mousePressEvent(self, event) -> None: # noqa: N802
if event.button() == Qt.LeftButton:
self.clicked.emit()
def paintEvent(self, _event) -> None: # noqa: N802
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing, True)
c = self.d / 2.0
r = c - 5
if self.state == "recording":
halo = 4 + 3 * (0.5 + 0.5 * math.sin(self._pulse * 2 * math.pi))
p.setPen(Qt.NoPen)
p.setBrush(QColor(255, 71, 87, 60))
p.drawEllipse(QPointF(c, c), r + halo, r + halo)
base = {"idle": QColor("#242A36"), "recording": QColor(REC),
"paused": QColor(WARN)}[self.state]
p.setPen(QPen(QColor("#0B0E13"), 2))
p.setBrush(base)
p.drawEllipse(QPointF(c, c), r, r)
inner = {"idle": QColor(REC), "recording": QColor("#FFFFFF"),
"paused": QColor("#1A1E27")}[self.state]
p.setPen(Qt.NoPen)
p.setBrush(inner)
if self.state == "recording":
p.drawRoundedRect(QRectF(c - 11, c - 11, 22, 22), 3, 3)
elif self.state == "paused":
p.drawRoundedRect(QRectF(c - 11, c - 11, 8, 22), 2, 2)
p.drawRoundedRect(QRectF(c + 3, c - 11, 8, 22), 2, 2)
else:
p.drawEllipse(QPointF(c, c), r * 0.52, r * 0.52)
p.end()
class StatusPill(QWidget):
"""小圆角状态标签(正常/警告/错误)。"""
def __init__(self, text: str = "", level: str = "ok", parent=None):
super().__init__(parent)
self.text = text
self.level = level
self.setMinimumHeight(22)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
def set(self, text: str, level: str = "ok") -> None:
if text == self.text and level == self.level:
return
self.text = text
self.level = level
self.update()
def paintEvent(self, _event) -> None: # noqa: N802
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing, True)
color = {"ok": OK, "warn": WARN, "err": CRIT, "info": ACCENT,
"dim": TEXT_DIM}.get(self.level, TEXT_DIM)
p.setPen(Qt.NoPen)
p.setBrush(QColor(color))
p.setOpacity(0.16)
p.drawRoundedRect(QRectF(0, 1, self.width(), self.height() - 2), 5, 5)
p.setOpacity(1.0)
p.setPen(QColor(color))
p.drawText(self.rect().adjusted(8, 0, -8, 0),
Qt.AlignLeft | Qt.AlignVCenter, self.text)
p.end()
class HistoryPlot(QWidget):
"""整段录音的峰值包络总览(类似 DAW 的概览条)。"""
def __init__(self, parent=None):
super().__init__(parent)
self.setMinimumHeight(72)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self._mins = np.zeros((0, 2), np.float32)
self._maxs = np.zeros((0, 2), np.float32)
self._cursor = 0.0
self._buckets_per_second = 48000 / 256
def update_envelope(self, mins: np.ndarray, maxs: np.ndarray,
bucket: int, samplerate: int, cursor_seconds: float) -> None:
self._mins, self._maxs = mins, maxs
self._buckets_per_second = samplerate / max(1, bucket)
self._cursor = cursor_seconds
self.update()
def paintEvent(self, _event) -> None: # noqa: N802
p = QPainter(self)
w, h = self.width(), self.height()
p.fillRect(0, 0, w, h, QColor(PANEL))
p.setPen(QPen(QColor(BORDER), 1))
p.drawRect(0, 0, w - 1, h - 1)
yc = h / 2
p.setPen(QPen(QColor(255, 255, 255, 36), 1))
p.drawLine(QPointF(0, yc), QPointF(w, yc))
if self._mins.size == 0:
p.setPen(QColor(TEXT_DIM))
p.drawText(self.rect(), Qt.AlignCenter, "录音总览(开始录音后显示)")
p.end()
return
n = self._mins.shape[0]
cols = max(1, min(w, 4000))
per = max(1, n // cols)
usable = (n // per) * per
mn = self._mins[:usable].reshape(-1, per, self._mins.shape[1]).min(axis=1)
mx = self._maxs[:usable].reshape(-1, per, self._maxs.shape[1]).max(axis=1)
scale = (h / 2 - 4)
p.setPen(QPen(QColor(ACCENT), 1))
for i in range(mn.shape[0]):
x = i * (w / max(1, mn.shape[0]))
top = yc - float(mx[i, 0]) * scale
bot = yc - float(mn[i, 0]) * scale
if bot - top < 1:
top, bot = top - 0.5, bot + 0.5
p.drawLine(QPointF(x, top), QPointF(x, bot))
# 光标
total_seconds = n / self._buckets_per_second
if total_seconds > 0:
cx = min(w - 1, self._cursor / total_seconds * w)
p.setPen(QPen(QColor(REC), 1))
p.drawLine(QPointF(cx, 0), QPointF(cx, h))
p.end()
class MarkerRail(QWidget):
"""标记轨道:把标记点画在整段录音的时间轴上。"""
def __init__(self, parent=None):
super().__init__(parent)
self.setFixedHeight(18)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self._marks: list[tuple[float, str]] = []
self._total = 0.0
def set_marks(self, marks: list[tuple[float, str]], total: float) -> None:
self._marks = marks
self._total = max(1e-6, total)
self.update()
def paintEvent(self, _event) -> None: # noqa: N802
p = QPainter(self)
w, h = self.width(), self.height()
p.fillRect(0, 0, w, h, QColor(PANEL))
p.setPen(QPen(QColor(BORDER), 1))
p.drawLine(0, h - 1, w, h - 1)
p.setPen(QPen(QColor("#FFD24A"), 1))
for sec, _label in self._marks:
x = min(w - 1.0, max(0.0, sec / self._total * w))
p.drawLine(QPointF(x, 2), QPointF(x, h - 2))
if not self._marks:
p.setPen(QColor(TEXT_DIM))
p.drawText(self.rect(), Qt.AlignLeft | Qt.AlignVCenter,
" 标记轨道(录音中按 M 打点)")
p.end()
+1471
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
# RecorderStudio 运行依赖
#
# 推荐用项目自带的安装器(会装进项目内的 _vendor 目录,不污染系统环境):
# python install_deps.py --with-pyqt5
#
# 也可以手动安装:
# pip install --target _vendor sounddevice numpy PyQt5
numpy>=1.20 # 数值运算(DSP / 计量 / 离线处理)
sounddevice>=0.4.6 # 音频后端:PortAudio 绑定,wheel 内含 PortAudio 二进制
PyQt5>=5.15 # 图形界面(仅 GUI 需要;CLI 与自检不需要)
# 可选:FLAC / MP3 / Opus / AAC 导出需要系统安装 ffmpeg 并加入 PATH。
# 找不到 ffmpeg 时仅影响这些导出格式,WAV 全功能不受影响。
+60
View File
@@ -0,0 +1,60 @@
@echo off
rem ============================================================
rem RecorderStudio launcher (ASCII only, safe in any code page)
rem Picks a Python that has PyQt5, adds the project-local
rem _vendor directory to the module path, then starts the GUI.
rem ============================================================
setlocal
cd /d "%~dp0"
title RecorderStudio
set "PYEXE="
call :try python
if not defined PYEXE call :try "py -3.12"
if not defined PYEXE call :try "py -3.13"
if not defined PYEXE call :try "py -3.11"
if not defined PYEXE call :try "py -3"
if not defined PYEXE call :try "python3"
if not defined PYEXE goto :nodeps
set "PYTHONPATH=%~dp0_vendor;%PYTHONPATH%"
set "PYTHONUTF8=1"
echo interpreter : %PYEXE%
%PYEXE% -c "import sys; print(' python :', sys.version.split()[0])"
echo starting RecorderStudio GUI ...
echo.
%PYEXE% -m recorder.gui %*
if errorlevel 1 goto :failed
endlocal
exit /b 0
:try
%~1 -c "import PyQt5" >nul 2>&1
if %errorlevel%==0 set "PYEXE=%~1"
goto :eof
:nodeps
echo.
echo No Python with PyQt5 was found.
echo.
echo Install the dependencies first:
echo python install_deps.py --with-pyqt5
echo.
echo Or install them manually:
echo pip install --target _vendor sounddevice PyQt5
echo.
pause
endlocal
exit /b 2
:failed
echo.
echo Startup failed. Diagnostics:
echo %PYEXE% -m recorder.selftest --no-hw
echo %PYEXE% install_deps.py --check
echo.
pause
endlocal
exit /b 1
+6
View File
@@ -0,0 +1,6 @@
@echo off
rem ============================================================
rem 中文名启动器(内容保持纯 ASCII,避免任何编码问题)
rem ============================================================
cd /d "%~dp0"
call "%~dp0run.bat" %*