commit b7f9098ccda8dac3b051d3abdde52f2bcac633b8 Author: WpyQwq <3911625973@qq.com> Date: Sat Sep 19 11:56:01 2026 +0800 Initial commit: RecorderStudio:PyQt5 高保真录音软件,无损 WAV / WASAPI 独占、BS.1770 响度与 ffmpeg 交叉验证 diff --git a/README.md b/README.md new file mode 100644 index 0000000..4c3bb9d --- /dev/null +++ b/README.md @@ -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 商业许可。 diff --git a/_build/selftest/bad.wav b/_build/selftest/bad.wav new file mode 100644 index 0000000..d561d78 --- /dev/null +++ b/_build/selftest/bad.wav @@ -0,0 +1 @@ +not a wav file \ No newline at end of file diff --git a/_build/selftest/empty.wav b/_build/selftest/empty.wav new file mode 100644 index 0000000..027a6d5 Binary files /dev/null and b/_build/selftest/empty.wav differ diff --git a/_build/selftest/hw_24_0.wav b/_build/selftest/hw_24_0.wav new file mode 100644 index 0000000..08e1dfc Binary files /dev/null and b/_build/selftest/hw_24_0.wav differ diff --git a/_build/selftest/hw_autostop.wav b/_build/selftest/hw_autostop.wav new file mode 100644 index 0000000..c3e93c5 Binary files /dev/null and b/_build/selftest/hw_autostop.wav differ diff --git a/_build/selftest/hw_float32_80.wav b/_build/selftest/hw_float32_80.wav new file mode 100644 index 0000000..597c3b4 Binary files /dev/null and b/_build/selftest/hw_float32_80.wav differ diff --git a/_build/selftest/hw_manual_split.wav b/_build/selftest/hw_manual_split.wav new file mode 100644 index 0000000..3f1caa2 Binary files /dev/null and b/_build/selftest/hw_manual_split.wav differ diff --git a/_build/selftest/hw_manual_split_2.wav b/_build/selftest/hw_manual_split_2.wav new file mode 100644 index 0000000..fa8bc98 Binary files /dev/null and b/_build/selftest/hw_manual_split_2.wav differ diff --git a/_build/selftest/hw_split.wav b/_build/selftest/hw_split.wav new file mode 100644 index 0000000..f2796e7 Binary files /dev/null and b/_build/selftest/hw_split.wav differ diff --git a/_build/selftest/hw_split_2.wav b/_build/selftest/hw_split_2.wav new file mode 100644 index 0000000..c6ea1a1 Binary files /dev/null and b/_build/selftest/hw_split_2.wav differ diff --git a/_build/selftest/hw_split_3.wav b/_build/selftest/hw_split_3.wav new file mode 100644 index 0000000..b35c5cf Binary files /dev/null and b/_build/selftest/hw_split_3.wav differ diff --git a/_build/selftest/hw_split_4.wav b/_build/selftest/hw_split_4.wav new file mode 100644 index 0000000..9806b6b Binary files /dev/null and b/_build/selftest/hw_split_4.wav differ diff --git a/_build/selftest/meta_test.json b/_build/selftest/meta_test.json new file mode 100644 index 0000000..89f2822 --- /dev/null +++ b/_build/selftest/meta_test.json @@ -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": [] +} \ No newline at end of file diff --git a/_build/selftest/meta_test.txt b/_build/selftest/meta_test.txt new file mode 100644 index 0000000..84a0358 --- /dev/null +++ b/_build/selftest/meta_test.txt @@ -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) \ No newline at end of file diff --git a/_build/selftest/meta_test.wav b/_build/selftest/meta_test.wav new file mode 100644 index 0000000..c01918c Binary files /dev/null and b/_build/selftest/meta_test.wav differ diff --git a/_build/selftest/out.flac b/_build/selftest/out.flac new file mode 100644 index 0000000..c41e3a3 Binary files /dev/null and b/_build/selftest/out.flac differ diff --git a/_build/selftest/out16.wav b/_build/selftest/out16.wav new file mode 100644 index 0000000..a7d19c7 Binary files /dev/null and b/_build/selftest/out16.wav differ diff --git a/_build/selftest/proc_src.wav b/_build/selftest/proc_src.wav new file mode 100644 index 0000000..997f8cb Binary files /dev/null and b/_build/selftest/proc_src.wav differ diff --git a/_build/selftest/proc_src_processed.wav b/_build/selftest/proc_src_processed.wav new file mode 100644 index 0000000..10eaee6 Binary files /dev/null and b/_build/selftest/proc_src_processed.wav differ diff --git a/_build/selftest/proc_src_processed_wav_16.wav b/_build/selftest/proc_src_processed_wav_16.wav new file mode 100644 index 0000000..6a0831d Binary files /dev/null and b/_build/selftest/proc_src_processed_wav_16.wav differ diff --git a/_build/selftest/rt_16.wav b/_build/selftest/rt_16.wav new file mode 100644 index 0000000..7233673 Binary files /dev/null and b/_build/selftest/rt_16.wav differ diff --git a/_build/selftest/rt_24.wav b/_build/selftest/rt_24.wav new file mode 100644 index 0000000..d212c60 Binary files /dev/null and b/_build/selftest/rt_24.wav differ diff --git a/_build/selftest/rt_32.wav b/_build/selftest/rt_32.wav new file mode 100644 index 0000000..c4b4c54 Binary files /dev/null and b/_build/selftest/rt_32.wav differ diff --git a/_build/selftest/rt_float32.wav b/_build/selftest/rt_float32.wav new file mode 100644 index 0000000..0057809 Binary files /dev/null and b/_build/selftest/rt_float32.wav differ diff --git a/_build/selftest/rt_rf64.wav b/_build/selftest/rt_rf64.wav new file mode 100644 index 0000000..53a7274 Binary files /dev/null and b/_build/selftest/rt_rf64.wav differ diff --git a/_build/selftest/wave.png b/_build/selftest/wave.png new file mode 100644 index 0000000..0f086c0 Binary files /dev/null and b/_build/selftest/wave.png differ diff --git a/_vendor/_sounddevice.py b/_vendor/_sounddevice.py new file mode 100644 index 0000000..6b48b7a --- /dev/null +++ b/_vendor/_sounddevice.py @@ -0,0 +1,11 @@ +# auto-generated file +import _cffi_backend + +ffi = _cffi_backend.FFI('_sounddevice', + _version = 0x2601, + _types = b'\x00\x00\x76\x0D\x00\x00\x07\x01\x00\x00\x00\x0F\x00\x00\x79\x0D\x00\x00\x07\x01\x00\x00\x00\x0F\x00\x00\x1C\x0D\x00\x00\x8D\x03\x00\x00\x00\x0F\x00\x00\x7B\x0D\x00\x00\x00\x0F\x00\x00\x80\x0D\x00\x00\x07\x11\x00\x00\x00\x0F\x00\x00\x88\x0D\x00\x00\x07\x01\x00\x00\x00\x0F\x00\x00\x88\x0D\x00\x00\x07\x01\x00\x00\x07\x01\x00\x00\x01\x01\x00\x00\x00\x0F\x00\x00\x88\x0D\x00\x00\x00\x0F\x00\x00\x21\x0D\x00\x00\x07\x11\x00\x00\x00\x0F\x00\x00\x01\x0D\x00\x00\x01\x0B\x00\x00\x00\x0F\x00\x00\x01\x0D\x00\x00\x82\x03\x00\x00\x1F\x11\x00\x00\x0E\x01\x00\x00\x00\x0F\x00\x00\x01\x0D\x00\x00\x07\x01\x00\x00\x00\x0F\x00\x00\x01\x0D\x00\x00\x07\x01\x00\x00\x07\x01\x00\x00\x00\x0F\x00\x00\x01\x0D\x00\x00\x0A\x01\x00\x00\x00\x0F\x00\x00\x01\x0D\x00\x00\x07\x03\x00\x00\x1F\x11\x00\x00\x1F\x11\x00\x00\x0E\x01\x00\x00\x0A\x01\x00\x00\x0A\x01\x00\x00\x52\x03\x00\x00\x07\x11\x00\x00\x00\x0F\x00\x00\x01\x0D\x00\x00\x2E\x11\x00\x00\x07\x01\x00\x00\x07\x01\x00\x00\x0A\x01\x00\x00\x0E\x01\x00\x00\x0A\x01\x00\x00\x34\x11\x00\x00\x07\x11\x00\x00\x00\x0F\x00\x00\x01\x0D\x00\x00\x07\x11\x00\x00\x00\x0F\x00\x00\x01\x0D\x00\x00\x07\x11\x00\x00\x07\x11\x00\x00\x0A\x01\x00\x00\x00\x0F\x00\x00\x01\x0D\x00\x00\x07\x11\x00\x00\x8D\x03\x00\x00\x0A\x01\x00\x00\x00\x0F\x00\x00\x01\x0D\x00\x00\x07\x11\x00\x00\x6B\x03\x00\x00\x00\x0F\x00\x00\x01\x0D\x00\x00\x4B\x11\x00\x00\x07\x11\x00\x00\x0A\x01\x00\x00\x7F\x03\x00\x00\x0A\x01\x00\x00\x07\x11\x00\x00\x00\x0F\x00\x00\x01\x0D\x00\x00\x00\x0F\x00\x00\x69\x0D\x00\x00\x07\x11\x00\x00\x00\x0F\x00\x00\x8D\x0D\x00\x00\x7D\x03\x00\x00\x8B\x03\x00\x00\x0A\x01\x00\x00\x00\x0F\x00\x00\x8D\x0D\x00\x00\x60\x11\x00\x00\x0A\x01\x00\x00\x00\x0F\x00\x00\x8D\x0D\x00\x00\x09\x01\x00\x00\x00\x0F\x00\x00\x8D\x0D\x00\x00\x07\x11\x00\x00\x00\x0F\x00\x00\x8D\x0D\x00\x00\x07\x11\x00\x00\x09\x01\x00\x00\x07\x11\x00\x00\x09\x01\x00\x00\x07\x11\x00\x00\x00\x0F\x00\x00\x01\x09\x00\x00\x77\x03\x00\x00\x02\x09\x00\x00\x00\x0B\x00\x00\x7A\x03\x00\x00\x03\x09\x00\x00\x7C\x03\x00\x00\x04\x09\x00\x00\x00\x09\x00\x00\x02\x0B\x00\x00\x05\x09\x00\x00\x81\x03\x00\x00\x06\x09\x00\x00\x07\x09\x00\x00\x03\x0B\x00\x00\x04\x0B\x00\x00\x08\x09\x00\x00\x05\x0B\x00\x00\x06\x0B\x00\x00\x89\x03\x00\x00\x02\x01\x00\x00\x01\x03\x00\x00\x15\x01\x00\x00\x6E\x03\x00\x00\x00\x01', + _globals = (b'\x00\x00\x11\x23PaMacCore_GetChannelName',0,b'\x00\x00\x5F\x23PaMacCore_SetupChannelMap',0,b'\x00\x00\x64\x23PaMacCore_SetupStreamInfo',0,b'\x00\x00\x23\x23PaWasapi_IsLoopback',0,b'\x00\x00\x5A\x23PaWasapi_UpdateDeviceList',0,b'\x00\x00\x41\x23Pa_AbortStream',0,b'\x00\x00\x41\x23Pa_CloseStream',0,b'\x00\x00\x5A\x23Pa_GetDefaultHostApi',0,b'\x00\x00\x5A\x23Pa_GetDefaultInputDevice',0,b'\x00\x00\x5A\x23Pa_GetDefaultOutputDevice',0,b'\x00\x00\x5A\x23Pa_GetDeviceCount',0,b'\x00\x00\x00\x23Pa_GetDeviceInfo',0,b'\x00\x00\x0E\x23Pa_GetErrorText',0,b'\x00\x00\x5A\x23Pa_GetHostApiCount',0,b'\x00\x00\x03\x23Pa_GetHostApiInfo',0,b'\x00\x00\x09\x23Pa_GetLastHostErrorInfo',0,b'\x00\x00\x2A\x23Pa_GetSampleSize',0,b'\x00\x00\x18\x23Pa_GetStreamCpuLoad',0,b'\x00\x00\x06\x23Pa_GetStreamHostApiType',0,b'\x00\x00\x0B\x23Pa_GetStreamInfo',0,b'\x00\x00\x5C\x23Pa_GetStreamReadAvailable',0,b'\x00\x00\x18\x23Pa_GetStreamTime',0,b'\x00\x00\x5C\x23Pa_GetStreamWriteAvailable',0,b'\x00\x00\x5A\x23Pa_GetVersion',0,b'\x00\x00\x16\x23Pa_GetVersionText',0,b'\x00\x00\x26\x23Pa_HostApiDeviceIndexToDeviceIndex',0,b'\x00\x00\x1B\x23Pa_HostApiTypeIdToHostApiIndex',0,b'\x00\x00\x5A\x23Pa_Initialize',0,b'\x00\x00\x1E\x23Pa_IsFormatSupported',0,b'\x00\x00\x41\x23Pa_IsStreamActive',0,b'\x00\x00\x41\x23Pa_IsStreamStopped',0,b'\x00\x00\x37\x23Pa_OpenDefaultStream',0,b'\x00\x00\x2D\x23Pa_OpenStream',0,b'\x00\x00\x44\x23Pa_ReadStream',0,b'\x00\x00\x4E\x23Pa_SetStreamFinishedCallback',0,b'\x00\x00\x68\x23Pa_Sleep',0,b'\x00\x00\x41\x23Pa_StartStream',0,b'\x00\x00\x41\x23Pa_StopStream',0,b'\x00\x00\x5A\x23Pa_Terminate',0,b'\x00\x00\x49\x23Pa_WriteStream',0,b'\xFF\xFF\xFF\x0BeAudioCategoryAlerts',4,b'\xFF\xFF\xFF\x0BeAudioCategoryCommunications',3,b'\xFF\xFF\xFF\x0BeAudioCategoryGameChat',8,b'\xFF\xFF\xFF\x0BeAudioCategoryGameEffects',6,b'\xFF\xFF\xFF\x0BeAudioCategoryGameMedia',7,b'\xFF\xFF\xFF\x0BeAudioCategoryMedia',11,b'\xFF\xFF\xFF\x0BeAudioCategoryMovie',10,b'\xFF\xFF\xFF\x0BeAudioCategoryOther',0,b'\xFF\xFF\xFF\x0BeAudioCategorySoundEffects',5,b'\xFF\xFF\xFF\x0BeAudioCategorySpeech',9,b'\xFF\xFF\xFF\x0BeStreamOptionMatchFormat',2,b'\xFF\xFF\xFF\x0BeStreamOptionNone',0,b'\xFF\xFF\xFF\x0BeStreamOptionRaw',1,b'\xFF\xFF\xFF\x0BeThreadPriorityAudio',1,b'\xFF\xFF\xFF\x0BeThreadPriorityCapture',2,b'\xFF\xFF\xFF\x0BeThreadPriorityDistribution',3,b'\xFF\xFF\xFF\x0BeThreadPriorityGames',4,b'\xFF\xFF\xFF\x0BeThreadPriorityNone',0,b'\xFF\xFF\xFF\x0BeThreadPriorityPlayback',5,b'\xFF\xFF\xFF\x0BeThreadPriorityProAudio',6,b'\xFF\xFF\xFF\x0BeThreadPriorityWindowManager',7,b'\xFF\xFF\xFF\x0BpaAL',9,b'\xFF\xFF\xFF\x0BpaALSA',8,b'\xFF\xFF\xFF\x0BpaASIO',3,b'\xFF\xFF\xFF\x0BpaAbort',2,b'\xFF\xFF\xFF\x1FpaAsioUseChannelSelectors',1,b'\xFF\xFF\xFF\x0BpaAudioScienceHPI',14,b'\xFF\xFF\xFF\x0BpaBadBufferPtr',-9972,b'\xFF\xFF\xFF\x0BpaBadIODeviceCombination',-9993,b'\xFF\xFF\xFF\x0BpaBadStreamPtr',-9988,b'\xFF\xFF\xFF\x0BpaBeOS',10,b'\xFF\xFF\xFF\x0BpaBufferTooBig',-9991,b'\xFF\xFF\xFF\x0BpaBufferTooSmall',-9990,b'\xFF\xFF\xFF\x0BpaCanNotReadFromACallbackStream',-9977,b'\xFF\xFF\xFF\x0BpaCanNotReadFromAnOutputOnlyStream',-9975,b'\xFF\xFF\xFF\x0BpaCanNotWriteToACallbackStream',-9976,b'\xFF\xFF\xFF\x0BpaCanNotWriteToAnInputOnlyStream',-9974,b'\xFF\xFF\xFF\x1FpaClipOff',1,b'\xFF\xFF\xFF\x0BpaComplete',1,b'\xFF\xFF\xFF\x0BpaContinue',0,b'\xFF\xFF\xFF\x0BpaCoreAudio',5,b'\xFF\xFF\xFF\x1FpaCustomFormat',65536,b'\xFF\xFF\xFF\x0BpaDeviceUnavailable',-9985,b'\xFF\xFF\xFF\x0BpaDirectSound',1,b'\xFF\xFF\xFF\x1FpaDitherOff',2,b'\xFF\xFF\xFF\x1FpaFloat32',1,b'\xFF\xFF\xFF\x1FpaFormatIsSupported',0,b'\xFF\xFF\xFF\x1FpaFramesPerBufferUnspecified',0,b'\xFF\xFF\xFF\x0BpaHostApiNotFound',-9979,b'\xFF\xFF\xFF\x0BpaInDevelopment',0,b'\xFF\xFF\xFF\x0BpaIncompatibleHostApiSpecificStreamInfo',-9984,b'\xFF\xFF\xFF\x0BpaIncompatibleStreamHostApi',-9973,b'\xFF\xFF\xFF\x1FpaInputOverflow',2,b'\xFF\xFF\xFF\x0BpaInputOverflowed',-9981,b'\xFF\xFF\xFF\x1FpaInputUnderflow',1,b'\xFF\xFF\xFF\x0BpaInsufficientMemory',-9992,b'\xFF\xFF\xFF\x1FpaInt16',8,b'\xFF\xFF\xFF\x1FpaInt24',4,b'\xFF\xFF\xFF\x1FpaInt32',2,b'\xFF\xFF\xFF\x1FpaInt8',16,b'\xFF\xFF\xFF\x0BpaInternalError',-9986,b'\xFF\xFF\xFF\x0BpaInvalidChannelCount',-9998,b'\xFF\xFF\xFF\x0BpaInvalidDevice',-9996,b'\xFF\xFF\xFF\x0BpaInvalidFlag',-9995,b'\xFF\xFF\xFF\x0BpaInvalidHostApi',-9978,b'\xFF\xFF\xFF\x0BpaInvalidSampleRate',-9997,b'\xFF\xFF\xFF\x0BpaJACK',12,b'\xFF\xFF\xFF\x0BpaMME',2,b'\xFF\xFF\xFF\x1FpaMacCoreChangeDeviceParameters',1,b'\xFF\xFF\xFF\x1FpaMacCoreConversionQualityHigh',1024,b'\xFF\xFF\xFF\x1FpaMacCoreConversionQualityLow',768,b'\xFF\xFF\xFF\x1FpaMacCoreConversionQualityMax',0,b'\xFF\xFF\xFF\x1FpaMacCoreConversionQualityMedium',512,b'\xFF\xFF\xFF\x1FpaMacCoreConversionQualityMin',256,b'\xFF\xFF\xFF\x1FpaMacCoreFailIfConversionRequired',2,b'\xFF\xFF\xFF\x1FpaMacCoreMinimizeCPU',257,b'\xFF\xFF\xFF\x1FpaMacCoreMinimizeCPUButPlayNice',256,b'\xFF\xFF\xFF\x1FpaMacCorePlayNice',0,b'\xFF\xFF\xFF\x1FpaMacCorePro',1,b'\xFF\xFF\xFF\x1FpaNeverDropInput',4,b'\xFF\xFF\xFF\x1FpaNoDevice',-1,b'\xFF\xFF\xFF\x0BpaNoError',0,b'\xFF\xFF\xFF\x1FpaNoFlag',0,b'\xFF\xFF\xFF\x1FpaNonInterleaved',2147483648,b'\xFF\xFF\xFF\x0BpaNotInitialized',-10000,b'\xFF\xFF\xFF\x0BpaNullCallback',-9989,b'\xFF\xFF\xFF\x0BpaOSS',7,b'\xFF\xFF\xFF\x1FpaOutputOverflow',8,b'\xFF\xFF\xFF\x1FpaOutputUnderflow',4,b'\xFF\xFF\xFF\x0BpaOutputUnderflowed',-9980,b'\xFF\xFF\xFF\x1FpaPlatformSpecificFlags',4294901760,b'\xFF\xFF\xFF\x1FpaPrimeOutputBuffersUsingStreamCallback',8,b'\xFF\xFF\xFF\x1FpaPrimingOutput',16,b'\xFF\xFF\xFF\x0BpaSampleFormatNotSupported',-9994,b'\xFF\xFF\xFF\x0BpaSoundManager',4,b'\xFF\xFF\xFF\x0BpaStreamIsNotStopped',-9982,b'\xFF\xFF\xFF\x0BpaStreamIsStopped',-9983,b'\xFF\xFF\xFF\x0BpaTimedOut',-9987,b'\xFF\xFF\xFF\x1FpaUInt8',32,b'\xFF\xFF\xFF\x0BpaUnanticipatedHostError',-9999,b'\xFF\xFF\xFF\x1FpaUseHostApiSpecificDeviceSpecification',-2,b'\xFF\xFF\xFF\x0BpaWASAPI',13,b'\xFF\xFF\xFF\x0BpaWDMKS',11,b'\xFF\xFF\xFF\x0BpaWinWasapiAutoConvert',64,b'\xFF\xFF\xFF\x0BpaWinWasapiExclusive',1,b'\xFF\xFF\xFF\x0BpaWinWasapiExplicitSampleFormat',32,b'\xFF\xFF\xFF\x0BpaWinWasapiPolling',8,b'\xFF\xFF\xFF\x0BpaWinWasapiRedirectHostProcessor',2,b'\xFF\xFF\xFF\x0BpaWinWasapiThreadPriority',16,b'\xFF\xFF\xFF\x0BpaWinWasapiUseChannelMask',4), + _struct_unions = ((b'\x00\x00\x00\x7D\x00\x00\x00\x02$PaMacCoreStreamInfo',b'\x00\x00\x2B\x11size',b'\x00\x00\x1C\x11hostApiType',b'\x00\x00\x2B\x11version',b'\x00\x00\x2B\x11flags',b'\x00\x00\x61\x11channelMap',b'\x00\x00\x2B\x11channelMapSize'),(b'\x00\x00\x00\x75\x00\x00\x00\x02PaAsioStreamInfo',b'\x00\x00\x2B\x11size',b'\x00\x00\x1C\x11hostApiType',b'\x00\x00\x2B\x11version',b'\x00\x00\x2B\x11flags',b'\x00\x00\x8A\x11channelSelectors'),(b'\x00\x00\x00\x77\x00\x00\x00\x02PaDeviceInfo',b'\x00\x00\x01\x11structVersion',b'\x00\x00\x88\x11name',b'\x00\x00\x01\x11hostApi',b'\x00\x00\x01\x11maxInputChannels',b'\x00\x00\x01\x11maxOutputChannels',b'\x00\x00\x21\x11defaultLowInputLatency',b'\x00\x00\x21\x11defaultLowOutputLatency',b'\x00\x00\x21\x11defaultHighInputLatency',b'\x00\x00\x21\x11defaultHighOutputLatency',b'\x00\x00\x21\x11defaultSampleRate'),(b'\x00\x00\x00\x7A\x00\x00\x00\x02PaHostApiInfo',b'\x00\x00\x01\x11structVersion',b'\x00\x00\x1C\x11type',b'\x00\x00\x88\x11name',b'\x00\x00\x01\x11deviceCount',b'\x00\x00\x01\x11defaultInputDevice',b'\x00\x00\x01\x11defaultOutputDevice'),(b'\x00\x00\x00\x7C\x00\x00\x00\x02PaHostErrorInfo',b'\x00\x00\x1C\x11hostApiType',b'\x00\x00\x69\x11errorCode',b'\x00\x00\x88\x11errorText'),(b'\x00\x00\x00\x7F\x00\x00\x00\x02PaStreamCallbackTimeInfo',b'\x00\x00\x21\x11inputBufferAdcTime',b'\x00\x00\x21\x11currentTime',b'\x00\x00\x21\x11outputBufferDacTime'),(b'\x00\x00\x00\x81\x00\x00\x00\x02PaStreamInfo',b'\x00\x00\x01\x11structVersion',b'\x00\x00\x21\x11inputLatency',b'\x00\x00\x21\x11outputLatency',b'\x00\x00\x21\x11sampleRate'),(b'\x00\x00\x00\x82\x00\x00\x00\x02PaStreamParameters',b'\x00\x00\x01\x11device',b'\x00\x00\x01\x11channelCount',b'\x00\x00\x2B\x11sampleFormat',b'\x00\x00\x21\x11suggestedLatency',b'\x00\x00\x07\x11hostApiSpecificStreamInfo'),(b'\x00\x00\x00\x85\x00\x00\x00\x02PaWasapiStreamInfo',b'\x00\x00\x2B\x11size',b'\x00\x00\x1C\x11hostApiType',b'\x00\x00\x2B\x11version',b'\x00\x00\x2B\x11flags',b'\x00\x00\x2B\x11channelMask',b'\x00\x00\x8C\x11hostProcessorOutput',b'\x00\x00\x8C\x11hostProcessorInput',b'\x00\x00\x87\x11threadPriority',b'\x00\x00\x84\x11streamCategory',b'\x00\x00\x86\x11streamOption')), + _enums = (b'\x00\x00\x00\x78\x00\x00\x00\x15PaErrorCode\x00paNoError,paNotInitialized,paUnanticipatedHostError,paInvalidChannelCount,paInvalidSampleRate,paInvalidDevice,paInvalidFlag,paSampleFormatNotSupported,paBadIODeviceCombination,paInsufficientMemory,paBufferTooBig,paBufferTooSmall,paNullCallback,paBadStreamPtr,paTimedOut,paInternalError,paDeviceUnavailable,paIncompatibleHostApiSpecificStreamInfo,paStreamIsStopped,paStreamIsNotStopped,paInputOverflowed,paOutputUnderflowed,paHostApiNotFound,paInvalidHostApi,paCanNotReadFromACallbackStream,paCanNotWriteToACallbackStream,paCanNotReadFromAnOutputOnlyStream,paCanNotWriteToAnInputOnlyStream,paIncompatibleStreamHostApi,paBadBufferPtr',b'\x00\x00\x00\x1C\x00\x00\x00\x16PaHostApiTypeId\x00paInDevelopment,paDirectSound,paMME,paASIO,paSoundManager,paCoreAudio,paOSS,paALSA,paAL,paBeOS,paWDMKS,paJACK,paWASAPI,paAudioScienceHPI',b'\x00\x00\x00\x7E\x00\x00\x00\x16PaStreamCallbackResult\x00paContinue,paComplete,paAbort',b'\x00\x00\x00\x83\x00\x00\x00\x16PaWasapiFlags\x00paWinWasapiExclusive,paWinWasapiRedirectHostProcessor,paWinWasapiUseChannelMask,paWinWasapiPolling,paWinWasapiThreadPriority,paWinWasapiExplicitSampleFormat,paWinWasapiAutoConvert',b'\x00\x00\x00\x84\x00\x00\x00\x16PaWasapiStreamCategory\x00eAudioCategoryOther,eAudioCategoryCommunications,eAudioCategoryAlerts,eAudioCategorySoundEffects,eAudioCategoryGameEffects,eAudioCategoryGameMedia,eAudioCategoryGameChat,eAudioCategorySpeech,eAudioCategoryMovie,eAudioCategoryMedia',b'\x00\x00\x00\x86\x00\x00\x00\x16PaWasapiStreamOption\x00eStreamOptionNone,eStreamOptionRaw,eStreamOptionMatchFormat',b'\x00\x00\x00\x87\x00\x00\x00\x16PaWasapiThreadPriority\x00eThreadPriorityNone,eThreadPriorityAudio,eThreadPriorityCapture,eThreadPriorityDistribution,eThreadPriorityGames,eThreadPriorityPlayback,eThreadPriorityProAudio,eThreadPriorityWindowManager'), + _typenames = (b'\x00\x00\x00\x75PaAsioStreamInfo',b'\x00\x00\x00\x01PaDeviceIndex',b'\x00\x00\x00\x77PaDeviceInfo',b'\x00\x00\x00\x01PaError',b'\x00\x00\x00\x78PaErrorCode',b'\x00\x00\x00\x01PaHostApiIndex',b'\x00\x00\x00\x7APaHostApiInfo',b'\x00\x00\x00\x1CPaHostApiTypeId',b'\x00\x00\x00\x7CPaHostErrorInfo',b'\x00\x00\x00\x7DPaMacCoreStreamInfo',b'\x00\x00\x00\x2BPaSampleFormat',b'\x00\x00\x00\x8DPaStream',b'\x00\x00\x00\x52PaStreamCallback',b'\x00\x00\x00\x2BPaStreamCallbackFlags',b'\x00\x00\x00\x7EPaStreamCallbackResult',b'\x00\x00\x00\x7FPaStreamCallbackTimeInfo',b'\x00\x00\x00\x6BPaStreamFinishedCallback',b'\x00\x00\x00\x2BPaStreamFlags',b'\x00\x00\x00\x81PaStreamInfo',b'\x00\x00\x00\x82PaStreamParameters',b'\x00\x00\x00\x21PaTime',b'\x00\x00\x00\x83PaWasapiFlags',b'\x00\x00\x00\x8CPaWasapiHostProcessorCallback',b'\x00\x00\x00\x84PaWasapiStreamCategory',b'\x00\x00\x00\x85PaWasapiStreamInfo',b'\x00\x00\x00\x86PaWasapiStreamOption',b'\x00\x00\x00\x87PaWasapiThreadPriority',b'\x00\x00\x00\x2BPaWinWaveFormatChannelMask',b'\x00\x00\x00\x8BSInt32'), +) diff --git a/_vendor/_sounddevice_data/__init__.py b/_vendor/_sounddevice_data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/_vendor/_sounddevice_data/portaudio-binaries/.github/dependabot.yml b/_vendor/_sounddevice_data/portaudio-binaries/.github/dependabot.yml new file mode 100644 index 0000000..5ace460 --- /dev/null +++ b/_vendor/_sounddevice_data/portaudio-binaries/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/_vendor/_sounddevice_data/portaudio-binaries/.github/workflows/build-libs.yml b/_vendor/_sounddevice_data/portaudio-binaries/.github/workflows/build-libs.yml new file mode 100644 index 0000000..a2b670e --- /dev/null +++ b/_vendor/_sounddevice_data/portaudio-binaries/.github/workflows/build-libs.yml @@ -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 diff --git a/_vendor/_sounddevice_data/portaudio-binaries/README.md b/_vendor/_sounddevice_data/portaudio-binaries/README.md new file mode 100644 index 0000000..acbe5f5 --- /dev/null +++ b/_vendor/_sounddevice_data/portaudio-binaries/README.md @@ -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. diff --git a/_vendor/sounddevice-0.5.6.dist-info/METADATA b/_vendor/sounddevice-0.5.6.dist-info/METADATA new file mode 100644 index 0000000..430eb32 --- /dev/null +++ b/_vendor/sounddevice-0.5.6.dist-info/METADATA @@ -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 +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/ diff --git a/_vendor/sounddevice-0.5.6.dist-info/RECORD b/_vendor/sounddevice-0.5.6.dist-info/RECORD new file mode 100644 index 0000000..589c6fb --- /dev/null +++ b/_vendor/sounddevice-0.5.6.dist-info/RECORD @@ -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,, diff --git a/_vendor/sounddevice-0.5.6.dist-info/WHEEL b/_vendor/sounddevice-0.5.6.dist-info/WHEEL new file mode 100644 index 0000000..9c3e481 --- /dev/null +++ b/_vendor/sounddevice-0.5.6.dist-info/WHEEL @@ -0,0 +1,5 @@ +Wheel-Version: 1.0 +Generator: setuptools (84.0.0) +Root-Is-Purelib: true +Tag: py3-none-win_amd64 + diff --git a/_vendor/sounddevice-0.5.6.dist-info/licenses/LICENSE b/_vendor/sounddevice-0.5.6.dist-info/licenses/LICENSE new file mode 100644 index 0000000..4afca06 --- /dev/null +++ b/_vendor/sounddevice-0.5.6.dist-info/licenses/LICENSE @@ -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. diff --git a/_vendor/sounddevice-0.5.6.dist-info/top_level.txt b/_vendor/sounddevice-0.5.6.dist-info/top_level.txt new file mode 100644 index 0000000..d5147a5 --- /dev/null +++ b/_vendor/sounddevice-0.5.6.dist-info/top_level.txt @@ -0,0 +1,3 @@ +_sounddevice +_sounddevice_data +sounddevice diff --git a/_vendor/sounddevice.py b/_vendor/sounddevice.py new file mode 100644 index 0000000..a47e1e3 --- /dev/null +++ b/_vendor/sounddevice.py @@ -0,0 +1,2976 @@ +# Copyright (c) 2015-2026 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. + +"""Play and Record Sound with Python. + +API overview: + * Convenience functions to play and record NumPy arrays: + `play()`, `rec()`, `playrec()` and the related functions + `wait()`, `stop()`, `get_status()`, `get_stream()` + + * Functions to get information about the available hardware: + `query_devices()`, `query_hostapis()`, + `check_input_settings()`, `check_output_settings()` + + * Module-wide default settings: `default` + + * Platform-specific settings: + `AsioSettings`, `CoreAudioSettings`, `WasapiSettings` + + * PortAudio streams, using NumPy arrays: + `Stream`, `InputStream`, `OutputStream` + + * PortAudio streams, using Python buffer objects (NumPy not needed): + `RawStream`, `RawInputStream`, `RawOutputStream` + + * Miscellaneous functions and classes: + `sleep()`, `get_portaudio_version()`, `CallbackFlags`, + `CallbackStop`, `CallbackAbort` + +Online documentation: + https://python-sounddevice.readthedocs.io/ + +""" +__version__ = '0.5.6' + +import atexit as _atexit +import contextlib as _contextlib +import os as _os +import platform as _platform +import sys as _sys +import sysconfig as _sysconfig +from ctypes.util import find_library as _find_library +from _sounddevice import ffi as _ffi + + +try: + for _libname in ( + 'portaudio', # Default name on POSIX systems + 'bin\\libportaudio-2.dll', # DLL from conda-forge + 'lib/libportaudio.dylib', # dylib from anaconda + ): + _libname = _find_library(_libname) + if _libname is not None: + break + else: + raise OSError('PortAudio library not found') + _lib: ... = _ffi.dlopen(_libname) +except OSError: + if _platform.system() == 'Darwin': + _libname = 'libportaudio.dylib' + elif _platform.system() == 'Windows': + if 'arm64' in _sysconfig.get_platform() or 'aarch64' in _sysconfig.get_platform(): + _platform_suffix = 'arm64' + else: + _platform_suffix = _platform.architecture()[0] + if 'SD_ENABLE_ASIO' in _os.environ: + _libname = 'libportaudio' + _platform_suffix + '-asio.dll' + else: + _libname = 'libportaudio' + _platform_suffix + '.dll' + else: + raise + import _sounddevice_data + _libname = _os.path.join( + next(iter(_sounddevice_data.__path__)), 'portaudio-binaries', _libname) + _lib: ... = _ffi.dlopen(_libname) + +_sampleformats: ... = { + 'float32': _lib.paFloat32, + 'int32': _lib.paInt32, + 'int24': _lib.paInt24, + 'int16': _lib.paInt16, + 'int8': _lib.paInt8, + 'uint8': _lib.paUInt8, +} + +_initialized = 0 +_last_callback = None + + +def play(data, samplerate=None, mapping=None, blocking=False, loop=False, + **kwargs): + """Play back a NumPy array containing audio data. + + This is a convenience function for interactive use and for small + scripts. It cannot be used for multiple overlapping playbacks. + + This function does the following steps internally: + + * Call `stop()` to terminate any currently running invocation + of `play()`, `rec()` and `playrec()`. + + * Create an `OutputStream` and a callback function for taking care + of the actual playback. + + * Start the stream. + + * If ``blocking=True`` was given, wait until playback is done. + If not, return immediately + (to start waiting at a later point, `wait()` can be used). + + If you need more control (e.g. block-wise gapless playback, multiple + overlapping playbacks, ...), you should explicitly create an + `OutputStream` yourself. + If NumPy is not available, you can use a `RawOutputStream`. + + Parameters + ---------- + data : array_like + Audio data to be played back. The columns of a two-dimensional + array are interpreted as channels, one-dimensional arrays are + treated as mono data. + The data types *float64*, *float32*, *int32*, *int16*, *int8* + and *uint8* can be used. + *float64* data is simply converted to *float32* before passing + it to PortAudio, because it's not supported natively. + mapping : array_like, optional + List of channel numbers (starting with 1) where the columns of + *data* shall be played back on. Must have the same length as + number of channels in *data* (except if *data* is mono, in which + case the signal is played back on all given output channels). + Each channel number may only appear once in *mapping*. + blocking : bool, optional + If ``False`` (the default), return immediately (but playback + continues in the background), if ``True``, wait until playback + is finished. A non-blocking invocation can be stopped with + `stop()` or turned into a blocking one with `wait()`. + loop : bool, optional + Play *data* in a loop. + + Other Parameters + ---------------- + samplerate, **kwargs + All parameters of `OutputStream` -- except *channels*, *dtype*, + *callback* and *finished_callback* -- can be used. + + Notes + ----- + If you don't specify the correct sampling rate + (either with the *samplerate* argument or by assigning a value to + `default.samplerate`), the audio data will be played back, + but it might be too slow or too fast! + + See Also + -------- + rec, playrec + + """ + ctx = _CallbackContext(loop=loop) + ctx.frames = ctx.check_data(data, mapping, kwargs.get('device')) + + def callback(outdata, frames, time, status): + assert len(outdata) == frames + ctx.callback_enter(status, outdata) + ctx.write_outdata(outdata) + ctx.callback_exit() + + ctx.start_stream(OutputStream, samplerate, ctx.output_channels, + ctx.output_dtype, callback, blocking, + prime_output_buffers_using_stream_callback=False, + **kwargs) + + +def rec(frames=None, samplerate=None, channels=None, dtype=None, + out=None, mapping=None, blocking=False, **kwargs): + """Record audio data into a NumPy array. + + This is a convenience function for interactive use and for small + scripts. + + This function does the following steps internally: + + * Call `stop()` to terminate any currently running invocation + of `play()`, `rec()` and `playrec()`. + + * Create an `InputStream` and a callback function for taking care + of the actual recording. + + * Start the stream. + + * If ``blocking=True`` was given, wait until recording is done. + If not, return immediately + (to start waiting at a later point, `wait()` can be used). + + If you need more control (e.g. block-wise gapless recording, + overlapping recordings, ...), you should explicitly create an + `InputStream` yourself. + If NumPy is not available, you can use a `RawInputStream`. + + Parameters + ---------- + frames : int, sometimes optional + Number of frames to record. Not needed if *out* is given. + channels : int, optional + Number of channels to record. Not needed if *mapping* or *out* + is given. The default value can be changed with + `default.channels`. + dtype : str or numpy.dtype, optional + Data type of the recording. Not needed if *out* is given. + The data types *float64*, *float32*, *int32*, *int16*, *int8* + and *uint8* can be used. For ``dtype='float64'``, audio data is + recorded in *float32* format and converted afterwards, because + it's not natively supported by PortAudio. The default value can + be changed with `default.dtype`. + mapping : array_like, optional + List of channel numbers (starting with 1) to record. + If *mapping* is given, *channels* is silently ignored. + blocking : bool, optional + If ``False`` (the default), return immediately (but recording + continues in the background), if ``True``, wait until recording + is finished. + A non-blocking invocation can be stopped with `stop()` or turned + into a blocking one with `wait()`. + + Returns + ------- + numpy.ndarray or type(out) + The recorded data. + + .. note:: By default (``blocking=False``), an array of data is + returned which is still being written to while recording! + The returned data is only valid once recording has stopped. + Use `wait()` to make sure the recording is finished. + + Other Parameters + ---------------- + out : numpy.ndarray or subclass, optional + If *out* is specified, the recorded data is written into the + given array instead of creating a new array. + In this case, the arguments *frames*, *channels* and *dtype* are + silently ignored! + If *mapping* is given, its length must match the number of + channels in *out*. + samplerate, **kwargs + All parameters of `InputStream` -- except *callback* and + *finished_callback* -- can be used. + + Notes + ----- + If you don't specify a sampling rate (either with the *samplerate* + argument or by assigning a value to `default.samplerate`), + the default sampling rate of the sound device will be used + (see `query_devices()`). + + See Also + -------- + play, playrec + + """ + ctx = _CallbackContext() + out, ctx.frames = ctx.check_out(out, frames, channels, dtype, mapping) + + def callback(indata, frames, time, status): + assert len(indata) == frames + ctx.callback_enter(status, indata) + ctx.read_indata(indata) + ctx.callback_exit() + + ctx.start_stream(InputStream, samplerate, ctx.input_channels, + ctx.input_dtype, callback, blocking, **kwargs) + return out + + +def playrec(data, samplerate=None, channels=None, dtype=None, + out=None, input_mapping=None, output_mapping=None, blocking=False, + **kwargs): + """Simultaneous playback and recording of NumPy arrays. + + This function does the following steps internally: + + * Call `stop()` to terminate any currently running invocation + of `play()`, `rec()` and `playrec()`. + + * Create a `Stream` and a callback function for taking care of the + actual playback and recording. + + * Start the stream. + + * If ``blocking=True`` was given, wait until playback/recording is + done. If not, return immediately + (to start waiting at a later point, `wait()` can be used). + + If you need more control (e.g. block-wise gapless playback and + recording, realtime processing, ...), + you should explicitly create a `Stream` yourself. + If NumPy is not available, you can use a `RawStream`. + + Parameters + ---------- + data : array_like + Audio data to be played back. See `play()`. + channels : int, sometimes optional + Number of input channels, see `rec()`. + The number of output channels is obtained from *data.shape*. + dtype : str or numpy.dtype, optional + Input data type, see `rec()`. + If *dtype* is not specified, it is taken from *data.dtype* + (i.e. `default.dtype` is ignored). + The output data type is obtained from *data.dtype* anyway. + input_mapping, output_mapping : array_like, optional + See the parameter *mapping* of `rec()` and `play()`, + respectively. + blocking : bool, optional + If ``False`` (the default), return immediately (but continue + playback/recording in the background), if ``True``, wait until + playback/recording is finished. + A non-blocking invocation can be stopped with `stop()` or turned + into a blocking one with `wait()`. + + Returns + ------- + numpy.ndarray or type(out) + The recorded data. See `rec()`. + + Other Parameters + ---------------- + out : numpy.ndarray or subclass, optional + See `rec()`. + samplerate, **kwargs + All parameters of `Stream` -- except *channels*, *dtype*, + *callback* and *finished_callback* -- can be used. + + Notes + ----- + If you don't specify the correct sampling rate + (either with the *samplerate* argument or by assigning a value to + `default.samplerate`), the audio data will be played back, + but it might be too slow or too fast! + + See Also + -------- + play, rec + + """ + ctx = _CallbackContext() + output_frames = ctx.check_data(data, output_mapping, kwargs.get('device')) + if dtype is None: + dtype = ctx.data.dtype # ignore module defaults + out, input_frames = ctx.check_out(out, output_frames, channels, dtype, + input_mapping) + if input_frames != output_frames: + raise ValueError('len(data) != len(out)') + ctx.frames = input_frames + + def callback(indata, outdata, frames, time, status): + assert len(indata) == len(outdata) == frames + ctx.callback_enter(status, indata) + ctx.read_indata(indata) + ctx.write_outdata(outdata) + ctx.callback_exit() + + ctx.start_stream(Stream, samplerate, + (ctx.input_channels, ctx.output_channels), + (ctx.input_dtype, ctx.output_dtype), + callback, blocking, + prime_output_buffers_using_stream_callback=False, + **kwargs) + return out + + +def wait(ignore_errors=True): + """Wait for `play()`/`rec()`/`playrec()` to be finished. + + Playback/recording can be stopped with a `KeyboardInterrupt`. + + Returns + ------- + CallbackFlags or None + If at least one buffer over-/underrun happened during the last + playback/recording, a `CallbackFlags` object is returned. + + See Also + -------- + get_status + + """ + if _last_callback: + return _last_callback.wait(ignore_errors) + + +def stop(ignore_errors=True): + """Stop playback/recording. + + This only stops `play()`, `rec()` and `playrec()`, but has no + influence on streams created with `Stream`, `InputStream`, + `OutputStream`, `RawStream`, `RawInputStream`, `RawOutputStream`. + + """ + if _last_callback: + # Calling stop() before close() is necessary for older PortAudio + # versions, see issue #87: + _last_callback.stream.stop(ignore_errors) + _last_callback.stream.close(ignore_errors) + + +def get_status(): + """Get info about over-/underflows in `play()`/`rec()`/`playrec()`. + + Returns + ------- + CallbackFlags + A `CallbackFlags` object that holds information about the last + invocation of `play()`, `rec()` or `playrec()`. + + See Also + -------- + wait + + """ + if _last_callback: + return _last_callback.status + else: + raise RuntimeError('play()/rec()/playrec() was not called yet') + + +def get_stream(): + """Get a reference to the current stream. + + This applies only to streams created by calls to `play()`, `rec()` + or `playrec()`. + + Returns + ------- + Stream + An `OutputStream`, `InputStream` or `Stream` associated with + the last invocation of `play()`, `rec()` or `playrec()`, + respectively. + + """ + if _last_callback: + return _last_callback.stream + else: + raise RuntimeError('play()/rec()/playrec() was not called yet') + + +def query_devices(device=None, kind=None) -> ...: + """Return information about available devices. + + Information and capabilities of PortAudio devices. + Devices may support input, output or both input and output. + + To find the default input/output device(s), use `default.device`. + + Parameters + ---------- + device : int or str, optional + Numeric device ID or device name substring(s). + If specified, information about only the given *device* is + returned in a single dictionary. + kind : {'input', 'output'}, optional + If *device* is not specified and *kind* is ``'input'`` or + ``'output'``, a single dictionary is returned with information + about the default input or output device, respectively. + + Returns + ------- + dict or DeviceList + A dictionary with information about the given *device* or -- if + no arguments were specified -- a `DeviceList` containing one + dictionary for each available device. + The dictionaries have the following keys: + + ``'name'`` + The name of the device. + ``'index'`` + The device index. + ``'hostapi'`` + The ID of the corresponding host API. Use + `query_hostapis()` to get information about a host API. + ``'max_input_channels'``, ``'max_output_channels'`` + The maximum number of input/output channels supported by the + device. See `default.channels`. + ``'default_low_input_latency'``, ``'default_low_output_latency'`` + Default latency values for interactive performance. + This is used if `default.latency` (or the *latency* argument + of `playrec()`, `Stream` etc.) is set to ``'low'``. + ``'default_high_input_latency'``, ``'default_high_output_latency'`` + Default latency values for robust non-interactive + applications (e.g. playing sound files). + This is used if `default.latency` (or the *latency* argument + of `playrec()`, `Stream` etc.) is set to ``'high'``. + ``'default_samplerate'`` + The default sampling frequency of the device. + This is used if `default.samplerate` is not set. + + Notes + ----- + The list of devices can also be displayed in a terminal: + + .. code-block:: sh + + python3 -m sounddevice + + Examples + -------- + The returned `DeviceList` can be indexed and iterated over like any + sequence type (yielding the abovementioned dictionaries), but it + also has a special string representation which is shown when used in + an interactive Python session. + + Each available device is listed on one line together with the + corresponding device ID, which can be assigned to `default.device` + or used as *device* argument in `play()`, `Stream` etc. + + The first character of a line is ``>`` for the default input device, + ``<`` for the default output device and ``*`` for the default + input/output device. After the device ID and the device name, the + corresponding host API name is displayed. In the end of each line, + the maximum number of input and output channels is shown. + + On a GNU/Linux computer it might look somewhat like this: + + >>> import sounddevice as sd + >>> sd.query_devices() + 0 HDA Intel: ALC662 rev1 Analog (hw:0,0), ALSA (2 in, 2 out) + 1 HDA Intel: ALC662 rev1 Digital (hw:0,1), ALSA (0 in, 2 out) + 2 HDA Intel: HDMI 0 (hw:0,3), ALSA (0 in, 8 out) + 3 sysdefault, ALSA (128 in, 128 out) + 4 front, ALSA (0 in, 2 out) + 5 surround40, ALSA (0 in, 2 out) + 6 surround51, ALSA (0 in, 2 out) + 7 surround71, ALSA (0 in, 2 out) + 8 iec958, ALSA (0 in, 2 out) + 9 spdif, ALSA (0 in, 2 out) + 10 hdmi, ALSA (0 in, 8 out) + * 11 default, ALSA (128 in, 128 out) + 12 dmix, ALSA (0 in, 2 out) + 13 /dev/dsp, OSS (16 in, 16 out) + + Note that ALSA provides access to some "real" and some "virtual" + devices. The latter sometimes have a ridiculously high number of + (virtual) inputs and outputs. + + On macOS, you might get something similar to this: + + >>> sd.query_devices() + 0 Built-in Line Input, Core Audio (2 in, 0 out) + > 1 Built-in Digital Input, Core Audio (2 in, 0 out) + < 2 Built-in Output, Core Audio (0 in, 2 out) + 3 Built-in Line Output, Core Audio (0 in, 2 out) + 4 Built-in Digital Output, Core Audio (0 in, 2 out) + + """ + if kind not in ('input', 'output', None): + raise ValueError(f'Invalid kind: {kind!r}') + if device is None and kind is None: + return DeviceList(query_devices(i) + for i in range(_check(_lib.Pa_GetDeviceCount()))) + device = _get_device_id(device, kind, raise_on_error=True) + info = _lib.Pa_GetDeviceInfo(device) + if not info: + raise PortAudioError(f'Error querying device {device}') + assert info.structVersion == 2 + name_bytes = _ffi_string(info.name) + try: + # We don't know beforehand if DirectSound and MME device names use + # 'utf-8' or 'mbcs' encoding. Let's try 'utf-8' first, because it more + # likely raises an exception on 'mbcs' data than vice versa, see also + # https://github.com/spatialaudio/python-sounddevice/issues/72. + name = name_bytes.decode('utf-8') + except UnicodeDecodeError: + api_idx = _lib.Pa_HostApiTypeIdToHostApiIndex + if info.hostApi in (api_idx(_lib.paDirectSound), api_idx(_lib.paMME)): + name = name_bytes.decode('mbcs') + elif info.hostApi == api_idx(_lib.paASIO): + # See https://github.com/spatialaudio/python-sounddevice/issues/490 + import locale + name = name_bytes.decode(locale.getpreferredencoding()) + else: + raise + device_dict = { + 'name': name, + 'index': device, + 'hostapi': info.hostApi, + 'max_input_channels': info.maxInputChannels, + 'max_output_channels': info.maxOutputChannels, + 'default_low_input_latency': info.defaultLowInputLatency, + 'default_low_output_latency': info.defaultLowOutputLatency, + 'default_high_input_latency': info.defaultHighInputLatency, + 'default_high_output_latency': info.defaultHighOutputLatency, + 'default_samplerate': info.defaultSampleRate, + } + if kind and device_dict['max_' + kind + '_channels'] < 1: + raise ValueError( + 'Not an {} device: {!r}'.format(kind, device_dict['name'])) + return device_dict + + +def query_hostapis(index=None) -> ...: + """Return information about available host APIs. + + Parameters + ---------- + index : int, optional + If specified, information about only the given host API *index* + is returned in a single dictionary. + + Returns + ------- + dict or tuple of dict + A dictionary with information about the given host API *index* + or -- if no *index* was specified -- a tuple containing one + dictionary for each available host API. + The dictionaries have the following keys: + + ``'name'`` + The name of the host API. + ``'devices'`` + A list of device IDs belonging to the host API. + Use `query_devices()` to get information about a device. + ``'default_input_device'``, ``'default_output_device'`` + The device ID of the default input/output device of the host + API. If no default input/output device exists for the given + host API, this is -1. + + .. note:: The overall default device(s) -- which can be + overwritten by assigning to `default.device` -- take(s) + precedence over `default.hostapi` and the information in + the abovementioned dictionaries. + + See Also + -------- + query_devices + + """ + if index is None: + return tuple(query_hostapis(i) + for i in range(_check(_lib.Pa_GetHostApiCount()))) + info = _lib.Pa_GetHostApiInfo(index) + if not info: + raise PortAudioError(f'Error querying host API {index}') + assert info.structVersion == 1 + return { + 'name': _ffi_string(info.name).decode(), + 'devices': [_lib.Pa_HostApiDeviceIndexToDeviceIndex(index, i) + for i in range(info.deviceCount)], + 'default_input_device': info.defaultInputDevice, + 'default_output_device': info.defaultOutputDevice, + } + + +def check_input_settings(device=None, channels=None, dtype=None, + extra_settings=None, samplerate=None): + """Check if given input device settings are supported. + + All parameters are optional, `default` settings are used for any + unspecified parameters. If the settings are supported, the function + does nothing; if not, an exception is raised. + + Parameters + ---------- + device : int or str, optional + Device ID or device name substring(s), see `default.device`. + channels : int, optional + Number of input channels, see `default.channels`. + dtype : str or numpy.dtype, optional + Data type for input samples, see `default.dtype`. + extra_settings : settings object, optional + This can be used for host-API-specific input settings. + See `default.extra_settings`. + samplerate : float, optional + Sampling frequency, see `default.samplerate`. + + """ + parameters, dtype, samplesize, samplerate = _get_stream_parameters( + 'input', device=device, channels=channels, dtype=dtype, latency=None, + extra_settings=extra_settings, samplerate=samplerate) + _check(_lib.Pa_IsFormatSupported(parameters, _ffi.NULL, samplerate)) + + +def check_output_settings(device=None, channels=None, dtype=None, + extra_settings=None, samplerate=None): + """Check if given output device settings are supported. + + Same as `check_input_settings()`, just for output device + settings. + + """ + parameters, dtype, samplesize, samplerate = _get_stream_parameters( + 'output', device=device, channels=channels, dtype=dtype, latency=None, + extra_settings=extra_settings, samplerate=samplerate) + _check(_lib.Pa_IsFormatSupported(_ffi.NULL, parameters, samplerate)) + + +def sleep(msec): + """Put the caller to sleep for at least *msec* milliseconds. + + The function may sleep longer than requested so don't rely on this + for accurate musical timing. + + """ + _lib.Pa_Sleep(msec) + + +def get_portaudio_version(): + """Get version information for the PortAudio library. + + Returns the release number and a textual description of the current + PortAudio build, e.g. :: + + (1899, 'PortAudio V19-devel (built Feb 15 2014 23:28:00)') + + """ + return _lib.Pa_GetVersion(), _ffi_string(_lib.Pa_GetVersionText()).decode() + + +class _StreamBase: + """Direct or indirect base class for all stream classes.""" + + def __init__(self, kind, samplerate=None, blocksize=None, device=None, + channels=None, dtype=None, latency=None, extra_settings=None, + callback=None, finished_callback=None, clip_off=None, + dither_off=None, never_drop_input=None, + prime_output_buffers_using_stream_callback=None, + userdata=None, wrap_callback=None): + """Base class for PortAudio streams. + + This class should only be used by library authors who want to + create their own custom stream classes. + Most users should use the derived classes + `Stream`, `InputStream`, `OutputStream`, + `RawStream`, `RawInputStream` and `RawOutputStream` instead. + + This class has the same properties and methods as `Stream`, + except for `~Stream.read_available`/`~Stream.read()` and + `~Stream.write_available`/`~Stream.write()`. + + It can be created with the same parameters as `Stream`, + except that there are three additional parameters + and the *callback* parameter also accepts a C function pointer. + + Parameters + ---------- + kind : {'input', 'output', 'duplex'} + The desired type of stream: for recording, playback or both. + callback : Python callable or CData function pointer, optional + If *wrap_callback* is ``None`` this can be a function pointer + provided by CFFI. + Otherwise, it has to be a Python callable. + wrap_callback : {'array', 'buffer'}, optional + If *callback* is a Python callable, this selects whether + the audio data is provided as NumPy array (like in `Stream`) + or as Python buffer object (like in `RawStream`). + userdata : CData void pointer + This is passed to the underlying C callback function + on each call and can only be accessed from a *callback* + provided as ``CData`` function pointer. + + Examples + -------- + A usage example of this class can be seen at + https://github.com/spatialaudio/python-rtmixer. + + """ + assert kind in ('input', 'output', 'duplex') + assert wrap_callback in ('array', 'buffer', None) + if wrap_callback == 'array': + # Import NumPy as early as possible, see: + # https://github.com/spatialaudio/python-sounddevice/issues/487 + import numpy + assert numpy # avoid "imported but unused" message (W0611) + + if blocksize is None: + blocksize = default.blocksize + if clip_off is None: + clip_off = default.clip_off + if dither_off is None: + dither_off = default.dither_off + if never_drop_input is None: + never_drop_input = default.never_drop_input + if prime_output_buffers_using_stream_callback is None: + prime_output_buffers_using_stream_callback = \ + default.prime_output_buffers_using_stream_callback + + stream_flags = _lib.paNoFlag + if clip_off: + stream_flags |= _lib.paClipOff + if dither_off: + stream_flags |= _lib.paDitherOff + if never_drop_input: + stream_flags |= _lib.paNeverDropInput + if prime_output_buffers_using_stream_callback: + stream_flags |= _lib.paPrimeOutputBuffersUsingStreamCallback + + if kind == 'duplex': + idevice, odevice = _split(device) + ichannels, ochannels = _split(channels) + idtype, odtype = _split(dtype) + ilatency, olatency = _split(latency) + iextra, oextra = _split(extra_settings) + iparameters, idtype, isize, isamplerate = _get_stream_parameters( + 'input', idevice, ichannels, idtype, ilatency, iextra, + samplerate) + oparameters, odtype, osize, osamplerate = _get_stream_parameters( + 'output', odevice, ochannels, odtype, olatency, oextra, + samplerate) + self._dtype = idtype, odtype + self._device = iparameters.device, oparameters.device + self._channels = iparameters.channelCount, oparameters.channelCount + self._samplesize = isize, osize + if isamplerate != osamplerate: + raise ValueError( + 'Input and output device must have the same samplerate') + else: + samplerate = isamplerate + else: + parameters, self._dtype, self._samplesize, samplerate = \ + _get_stream_parameters(kind, device, channels, dtype, latency, + extra_settings, samplerate) + self._device = parameters.device + self._channels = parameters.channelCount + iparameters = _ffi.NULL + oparameters = _ffi.NULL + if kind == 'input': + iparameters = parameters + elif kind == 'output': + oparameters = parameters + + ffi_callback = _ffi.callback('PaStreamCallback', error=_lib.paAbort) + + if callback is None: + callback_ptr = _ffi.NULL + elif kind == 'input' and wrap_callback == 'buffer': + + @ffi_callback + def callback_ptr(iptr, optr, frames, time, status, _): + data = _buffer(iptr, frames, self._channels, self._samplesize) + return _wrap_callback(callback, data, frames, time, status) + + elif kind == 'input' and wrap_callback == 'array': + + @ffi_callback + def callback_ptr(iptr, optr, frames, time, status, _): + data = _array( + _buffer(iptr, frames, self._channels, self._samplesize), + self._channels, self._dtype) + return _wrap_callback(callback, data, frames, time, status) + + elif kind == 'output' and wrap_callback == 'buffer': + + @ffi_callback + def callback_ptr(iptr, optr, frames, time, status, _): + data = _buffer(optr, frames, self._channels, self._samplesize) + return _wrap_callback(callback, data, frames, time, status) + + elif kind == 'output' and wrap_callback == 'array': + + @ffi_callback + def callback_ptr(iptr, optr, frames, time, status, _): + data = _array( + _buffer(optr, frames, self._channels, self._samplesize), + self._channels, self._dtype) + return _wrap_callback(callback, data, frames, time, status) + + elif kind == 'duplex' and wrap_callback == 'buffer': + + @ffi_callback + def callback_ptr(iptr, optr, frames, time, status, _): + ichannels, ochannels = self._channels + isize, osize = self._samplesize + idata = _buffer(iptr, frames, ichannels, isize) + odata = _buffer(optr, frames, ochannels, osize) + return _wrap_callback( + callback, idata, odata, frames, time, status) + + elif kind == 'duplex' and wrap_callback == 'array': + + @ffi_callback + def callback_ptr(iptr, optr, frames, time, status, _): + ichannels, ochannels = self._channels + idtype, odtype = self._dtype + isize, osize = self._samplesize + idata = _array(_buffer(iptr, frames, ichannels, isize), + ichannels, idtype) + odata = _array(_buffer(optr, frames, ochannels, osize), + ochannels, odtype) + return _wrap_callback( + callback, idata, odata, frames, time, status) + + else: + # Use cast() to allow CData from different FFI instance: + callback_ptr = _ffi.cast('PaStreamCallback*', callback) + + # CFFI callback object must be kept alive during stream lifetime: + self._callback = callback_ptr + if userdata is None: + userdata = _ffi.NULL + self._ptr = _ffi.new('PaStream**') + _check(_lib.Pa_OpenStream(self._ptr, iparameters, oparameters, + samplerate, blocksize, stream_flags, + callback_ptr, userdata), + f'Error opening {self.__class__.__name__}') + + # dereference PaStream** --> PaStream* + self._ptr = self._ptr[0] + + self._blocksize = blocksize + info = _lib.Pa_GetStreamInfo(self._ptr) + if not info: + raise PortAudioError('Could not obtain stream info') + # TODO: assert info.structVersion == 1 + self._samplerate = info.sampleRate + if not oparameters: + self._latency = info.inputLatency + elif not iparameters: + self._latency = info.outputLatency + else: + self._latency = info.inputLatency, info.outputLatency + + if finished_callback: + if isinstance(finished_callback, _ffi.CData): + self._finished_callback = finished_callback + else: + + def finished_callback_wrapper(_): + return finished_callback() + + # CFFI callback object is kept alive during stream lifetime: + self._finished_callback = _ffi.callback( + 'PaStreamFinishedCallback', finished_callback_wrapper) + _check(_lib.Pa_SetStreamFinishedCallback(self._ptr, + self._finished_callback)) + + # Avoid confusion if something goes wrong before assigning self._ptr: + _ptr = _ffi.NULL + + @property + def samplerate(self): + """The sampling frequency in Hertz (= frames per second). + + In cases where the hardware sampling frequency is inaccurate and + PortAudio is aware of it, the value of this field may be + different from the *samplerate* parameter passed to `Stream()`. + If information about the actual hardware sampling frequency is + not available, this field will have the same value as the + *samplerate* parameter passed to `Stream()`. + + """ + return self._samplerate + + @property + def blocksize(self): + """Number of frames per block. + + The special value 0 means that the blocksize can change between + blocks. See the *blocksize* argument of `Stream`. + + """ + return self._blocksize + + @property + def device(self): + """IDs of the input/output device.""" + return self._device + + @property + def channels(self): + """The number of input/output channels.""" + return self._channels + + @property + def dtype(self): + """Data type of the audio samples. + + See Also + -------- + default.dtype, samplesize + + """ + return self._dtype + + @property + def samplesize(self): + """The size in bytes of a single sample. + + See Also + -------- + dtype + + """ + return self._samplesize + + @property + def latency(self): + """The input/output latency of the stream in seconds. + + This value provides the most accurate estimate of input/output + latency available to the implementation. + It may differ significantly from the *latency* value(s) passed + to `Stream()`. + + """ + return self._latency + + @property + def active(self): + """``True`` when the stream is active, ``False`` otherwise. + + A stream is active after a successful call to `start()`, until + it becomes inactive either as a result of a call to `stop()` or + `abort()`, or as a result of an exception raised in the stream + callback. In the latter case, the stream is considered inactive + after the last buffer has finished playing. + + See Also + -------- + stopped + + """ + if self.closed: + return False + return _check(_lib.Pa_IsStreamActive(self._ptr)) == 1 + + @property + def stopped(self): + """``True`` when the stream is stopped, ``False`` otherwise. + + A stream is considered to be stopped prior to a successful call + to `start()` and after a successful call to `stop()` or + `abort()`. If a stream callback is cancelled (by raising an + exception) the stream is *not* considered to be stopped. + + See Also + -------- + active + + """ + if self.closed: + return True + return _check(_lib.Pa_IsStreamStopped(self._ptr)) == 1 + + @property + def closed(self): + """``True`` after a call to `close()`, ``False`` otherwise.""" + return self._ptr == _ffi.NULL + + @property + def time(self): + """The current stream time in seconds. + + This is according to the same clock used to generate the + timestamps passed with the *time* argument to the stream + callback (see the *callback* argument of `Stream`). + The time values are monotonically increasing and have + unspecified origin. + + This provides valid time values for the entire life of the + stream, from when the stream is opened until it is closed. + Starting and stopping the stream does not affect the passage of + time as provided here. + + This time may be used for synchronizing other events to the + audio stream, for example synchronizing audio to MIDI. + + """ + time = _lib.Pa_GetStreamTime(self._ptr) + if not time: + raise PortAudioError('Error getting stream time') + return time + + @property + def cpu_load(self): + """CPU usage information for the stream. + + The "CPU Load" is a fraction of total CPU time consumed by a + callback stream's audio processing routines including, but not + limited to the client supplied stream callback. This function + does not work with blocking read/write streams. + + This may be used in the stream callback function or in the + application. + It provides a floating point value, typically between 0.0 and + 1.0, where 1.0 indicates that the stream callback is consuming + the maximum number of CPU cycles possible to maintain real-time + operation. A value of 0.5 would imply that PortAudio and the + stream callback was consuming roughly 50% of the available CPU + time. The value may exceed 1.0. A value of 0.0 will always be + returned for a blocking read/write stream, or if an error + occurs. + + """ + return _lib.Pa_GetStreamCpuLoad(self._ptr) + + def __enter__(self): + """Start the stream in the beginning of a "with" statement.""" + self.start() + return self + + def __exit__(self, *args): + """Stop and close the stream when exiting a "with" statement.""" + self.stop() + self.close() + + def start(self): + """Commence audio processing. + + See Also + -------- + stop, abort + + """ + err = _lib.Pa_StartStream(self._ptr) + if err != _lib.paStreamIsNotStopped: + _check(err, 'Error starting stream') + + def stop(self, ignore_errors=True): + """Terminate audio processing. + + This waits until all pending audio buffers have been played + before it returns. + + See Also + -------- + start, abort + + """ + err = _lib.Pa_StopStream(self._ptr) + if not ignore_errors: + _check(err, 'Error stopping stream') + + def abort(self, ignore_errors=True): + """Terminate audio processing immediately. + + This does not wait for pending buffers to complete. + + See Also + -------- + start, stop + + """ + err = _lib.Pa_AbortStream(self._ptr) + if not ignore_errors: + _check(err, 'Error aborting stream') + + def close(self, ignore_errors=True): + """Close the stream. + + If the audio stream is active any pending buffers are discarded + as if `abort()` had been called. + + """ + err = _lib.Pa_CloseStream(self._ptr) + self._ptr = _ffi.NULL + if not ignore_errors: + _check(err, 'Error closing stream') + + +class _InputStreamBase(_StreamBase): + """Base class for input stream classes.""" + + @property + def read_available(self): + """The number of frames that can be read without waiting. + + Returns a value representing the maximum number of frames that + can be read from the stream without blocking or busy waiting. + + """ + return _check(_lib.Pa_GetStreamReadAvailable(self._ptr)) + + def _raw_read(self, frames): + """Read samples from the stream into a buffer. + + This is the same as `Stream.read()`, except that it returns + a plain Python buffer object instead of a NumPy array. + NumPy is not necessary for using this. + + Parameters + ---------- + frames : int + The number of frames to be read. See `Stream.read()`. + + Returns + ------- + data : buffer + A buffer of interleaved samples. The buffer contains + samples in the format specified by the *dtype* parameter + used to open the stream, and the number of channels + specified by *channels*. + See also `~Stream.samplesize`. + overflowed : bool + See `Stream.read()`. + + """ + channels, _ = _split(self._channels) + samplesize, _ = _split(self._samplesize) + data = _ffi.new('signed char[]', channels * samplesize * frames) # type: ignore + err = _lib.Pa_ReadStream(self._ptr, data, frames) + if err == _lib.paInputOverflowed: + overflowed = True + else: + _check(err) + overflowed = False + return _ffi.buffer(data), overflowed + + +class RawInputStream(_InputStreamBase): + """Raw stream for recording only. See __init__() and RawStream.""" + + def __init__(self, samplerate=None, blocksize=None, + device=None, channels=None, dtype=None, latency=None, + extra_settings=None, callback=None, finished_callback=None, + clip_off=None, dither_off=None, never_drop_input=None, + prime_output_buffers_using_stream_callback=None): + """PortAudio input stream (using buffer objects). + + This is the same as `InputStream`, except that the *callback* + function and `~RawStream.read()` work on plain Python buffer + objects instead of on NumPy arrays. + NumPy is not necessary for using this. + + Parameters + ---------- + dtype : str + See `RawStream`. + callback : callable + User-supplied function to consume audio data in response to + requests from an active stream. + The callback must have this signature: + + .. code-block:: text + + callback(indata: buffer, frames: int, + time: CData, status: CallbackFlags) -> None + + The arguments are the same as in the *callback* parameter of + `RawStream`, except that *outdata* is missing. + + See Also + -------- + RawStream, Stream + + """ + _StreamBase.__init__(self, kind='input', wrap_callback='buffer', + **_remove_self(locals())) + + read = _InputStreamBase._raw_read + + +class _OutputStreamBase(_StreamBase): + """Base class for output stream classes.""" + + @property + def write_available(self): + """The number of frames that can be written without waiting. + + Returns a value representing the maximum number of frames that + can be written to the stream without blocking or busy waiting. + + """ + return _check(_lib.Pa_GetStreamWriteAvailable(self._ptr)) + + def _raw_write(self, data): + """Write samples to the stream. + + This is the same as `Stream.write()`, except that it expects + a plain Python buffer object instead of a NumPy array. + NumPy is not necessary for using this. + + Parameters + ---------- + data : buffer or bytes or iterable of int + A buffer of interleaved samples. The buffer contains + samples in the format specified by the *dtype* argument used + to open the stream, and the number of channels specified by + *channels*. The length of the buffer is not constrained to + a specific range, however high performance applications will + want to match this parameter to the *blocksize* parameter + used when opening the stream. See also `~Stream.samplesize`. + + Returns + ------- + underflowed : bool + See `Stream.write()`. + + """ + try: + data = _ffi.from_buffer(data) + except AttributeError: + pass # from_buffer() not supported + except TypeError: + pass # input is not a buffer + _, samplesize = _split(self._samplesize) + _, channels = _split(self._channels) + samples, remainder = divmod(len(data), samplesize) # type: ignore + if remainder: + raise ValueError('len(data) not divisible by samplesize') + frames, remainder = divmod(samples, channels) + if remainder: + raise ValueError('Number of samples not divisible by channels') + err = _lib.Pa_WriteStream(self._ptr, data, frames) + if err == _lib.paOutputUnderflowed: + underflowed = True + else: + _check(err) + underflowed = False + return underflowed + + +class RawOutputStream(_OutputStreamBase): + """Raw stream for playback only. See __init__() and RawStream.""" + + def __init__(self, samplerate=None, blocksize=None, + device=None, channels=None, dtype=None, latency=None, + extra_settings=None, callback=None, finished_callback=None, + clip_off=None, dither_off=None, never_drop_input=None, + prime_output_buffers_using_stream_callback=None): + """PortAudio output stream (using buffer objects). + + This is the same as `OutputStream`, except that the *callback* + function and `~RawStream.write()` work on plain Python + buffer objects instead of on NumPy arrays. + NumPy is not necessary for using this. + + Parameters + ---------- + dtype : str + See `RawStream`. + callback : callable + User-supplied function to generate audio data in response to + requests from an active stream. + The callback must have this signature: + + .. code-block:: text + + callback(outdata: buffer, frames: int, + time: CData, status: CallbackFlags) -> None + + The arguments are the same as in the *callback* parameter of + `RawStream`, except that *indata* is missing. + + See Also + -------- + RawStream, Stream + + """ + _StreamBase.__init__(self, kind='output', wrap_callback='buffer', + **_remove_self(locals())) + + write = _OutputStreamBase._raw_write + + +class RawStream(RawInputStream, RawOutputStream): + """Raw stream for playback and recording. See __init__().""" + + def __init__(self, samplerate=None, blocksize=None, + device=None, channels=None, dtype=None, latency=None, + extra_settings=None, callback=None, finished_callback=None, + clip_off=None, dither_off=None, never_drop_input=None, + prime_output_buffers_using_stream_callback=None): + """PortAudio input/output stream (using buffer objects). + + This is the same as `Stream`, except that the *callback* + function and `read()`/`write()` work on plain Python buffer + objects instead of on NumPy arrays. + NumPy is not necessary for using this. + + To open a "raw" input-only or output-only stream use + `RawInputStream` or `RawOutputStream`, respectively. + If you want to handle audio data as NumPy arrays instead of + buffer objects, use `Stream`, `InputStream` or `OutputStream`. + + Parameters + ---------- + dtype : str or pair of str + The sample format of the buffers provided to the stream + callback, `read()` or `write()`. + In addition to the formats supported by `Stream` + (``'float32'``, ``'int32'``, ``'int16'``, ``'int8'``, + ``'uint8'``), this also supports ``'int24'``, i.e. + packed 24 bit format. + The default value can be changed with `default.dtype`. + See also `~Stream.samplesize`. + callback : callable + User-supplied function to consume, process or generate audio + data in response to requests from an active stream. + The callback must have this signature: + + .. code-block:: text + + callback(indata: buffer, outdata: buffer, frames: int, + time: CData, status: CallbackFlags) -> None + + The arguments are the same as in the *callback* parameter of + `Stream`, except that *indata* and *outdata* are plain + Python buffer objects instead of NumPy arrays. + + See Also + -------- + RawInputStream, RawOutputStream, Stream + + """ + _StreamBase.__init__(self, kind='duplex', wrap_callback='buffer', + **_remove_self(locals())) + + +class InputStream(_InputStreamBase): + """Stream for input only. See __init__() and Stream.""" + + def __init__(self, samplerate=None, blocksize=None, + device=None, channels=None, dtype=None, latency=None, + extra_settings=None, callback=None, finished_callback=None, + clip_off=None, dither_off=None, never_drop_input=None, + prime_output_buffers_using_stream_callback=None): + """PortAudio input stream (using NumPy). + + This has the same methods and attributes as `Stream`, except + `~Stream.write()` and `~Stream.write_available`. + Furthermore, the stream callback is expected to have a different + signature (see below). + + Parameters + ---------- + callback : callable + User-supplied function to consume audio in response to + requests from an active stream. + The callback must have this signature: + + .. code-block:: text + + callback(indata: numpy.ndarray, frames: int, + time: CData, status: CallbackFlags) -> None + + The arguments are the same as in the *callback* parameter of + `Stream`, except that *outdata* is missing. + + See Also + -------- + Stream, RawInputStream + + """ + _StreamBase.__init__(self, kind='input', wrap_callback='array', + **_remove_self(locals())) + + def read(self, frames): + """Read samples from the stream into a NumPy array. + + The function doesn't return until all requested *frames* have + been read -- this may involve waiting for the operating system + to supply the data (except if no more than `read_available` + frames were requested). + + This is the same as `RawStream.read()`, except that it + returns a NumPy array instead of a plain Python buffer object. + + Parameters + ---------- + frames : int + The number of frames to be read. This parameter is not + constrained to a specific range, however high performance + applications will want to match this parameter to the + *blocksize* parameter used when opening the stream. + + Returns + ------- + data : numpy.ndarray + A two-dimensional `numpy.ndarray` with one column per + channel (i.e. with a shape of ``(frames, channels)``) and + with a data type specified by `dtype`. + overflowed : bool + ``True`` if input data was discarded by PortAudio after the + previous call and before this call. + + """ + dtype, _ = _split(self._dtype) + channels, _ = _split(self._channels) + data, overflowed = _InputStreamBase._raw_read(self, frames) + data = _array(data, channels, dtype) + return data, overflowed + + +class OutputStream(_OutputStreamBase): + """Stream for output only. See __init__() and Stream.""" + + def __init__(self, samplerate=None, blocksize=None, + device=None, channels=None, dtype=None, latency=None, + extra_settings=None, callback=None, finished_callback=None, + clip_off=None, dither_off=None, never_drop_input=None, + prime_output_buffers_using_stream_callback=None): + """PortAudio output stream (using NumPy). + + This has the same methods and attributes as `Stream`, except + `~Stream.read()` and `~Stream.read_available`. + Furthermore, the stream callback is expected to have a different + signature (see below). + + Parameters + ---------- + callback : callable + User-supplied function to generate audio data in response to + requests from an active stream. + The callback must have this signature: + + .. code-block:: text + + callback(outdata: numpy.ndarray, frames: int, + time: CData, status: CallbackFlags) -> None + + The arguments are the same as in the *callback* parameter of + `Stream`, except that *indata* is missing. + + See Also + -------- + Stream, RawOutputStream + + """ + _StreamBase.__init__(self, kind='output', wrap_callback='array', + **_remove_self(locals())) + + def write(self, data): + """Write samples to the stream. + + This function doesn't return until the entire buffer has been + consumed -- this may involve waiting for the operating system to + consume the data (except if *data* contains no more than + `write_available` frames). + + This is the same as `RawStream.write()`, except that it + expects a NumPy array instead of a plain Python buffer object. + + Parameters + ---------- + data : array_like + A two-dimensional array-like object with one column per + channel (i.e. with a shape of ``(frames, channels)``) and + with a data type specified by `dtype`. A one-dimensional + array can be used for mono data. The array layout must be + C-contiguous (see :func:`numpy.ascontiguousarray`). + + The length of the buffer is not constrained to a specific + range, however high performance applications will want to + match this parameter to the *blocksize* parameter used when + opening the stream. + + Returns + ------- + underflowed : bool + ``True`` if additional output data was inserted after the + previous call and before this call. + + """ + import numpy as np + data = np.asarray(data) + _, dtype = _split(self._dtype) + _, channels = _split(self._channels) + if data.ndim < 2: + data = data.reshape(-1, 1) + elif data.ndim > 2: + raise ValueError('data must be one- or two-dimensional') + if data.shape[1] != channels: + raise ValueError('number of channels must match') + if data.dtype != dtype: + raise TypeError('dtype mismatch: {!r} vs {!r}'.format( + data.dtype.name, dtype)) + if not data.flags.c_contiguous: + raise TypeError('data must be C-contiguous') + return _OutputStreamBase._raw_write(self, data) + + +class Stream(InputStream, OutputStream): + """Stream for input and output. See __init__().""" + + def __init__(self, samplerate=None, blocksize=None, + device=None, channels=None, dtype=None, latency=None, + extra_settings=None, callback=None, finished_callback=None, + clip_off=None, dither_off=None, never_drop_input=None, + prime_output_buffers_using_stream_callback=None): + """PortAudio stream for simultaneous input and output (using NumPy). + + To open an input-only or output-only stream use `InputStream` or + `OutputStream`, respectively. If you want to handle audio data + as plain buffer objects instead of NumPy arrays, use + `RawStream`, `RawInputStream` or `RawOutputStream`. + + A single stream can provide multiple channels of real-time + streaming audio input and output to a client application. A + stream provides access to audio hardware represented by one or + more devices. Depending on the underlying host API, it may be + possible to open multiple streams using the same device, however + this behavior is implementation defined. Portable applications + should assume that a device may be simultaneously used by at + most one stream. + + The arguments *device*, *channels*, *dtype* and *latency* can be + either single values (which will be used for both input and + output parameters) or pairs of values (where the first one is + the value for the input and the second one for the output). + + All arguments are optional, the values for unspecified + parameters are taken from the `default` object. + If one of the values of a parameter pair is ``None``, the + corresponding value from `default` will be used instead. + + The created stream is inactive (see `active`, `stopped`). + It can be started with `start()`. + + Every stream object is also a + :ref:`context manager `, i.e. it can be + used in a :ref:`with statement ` to automatically + call `start()` in the beginning of the statement and `stop()` + and `close()` on exit. + + Parameters + ---------- + samplerate : float, optional + The desired sampling frequency (for both input and output). + The default value can be changed with `default.samplerate`. + blocksize : int, optional + The number of frames passed to the stream callback function, + or the preferred block granularity for a blocking read/write + stream. + The special value ``blocksize=0`` (which is the default) may + be used to request that the stream callback will receive an + optimal (and possibly varying) number of frames based on + host requirements and the requested latency settings. + The default value can be changed with `default.blocksize`. + + .. note:: With some host APIs, the use of non-zero + *blocksize* for a callback stream may introduce an + additional layer of buffering which could introduce + additional latency. PortAudio guarantees that the + additional latency will be kept to the theoretical + minimum however, it is strongly recommended that a + non-zero *blocksize* value only be used when your + algorithm requires a fixed number of frames per stream + callback. + device : int or str or pair thereof, optional + Device index(es) or query string(s) specifying the device(s) + to be used. The default value(s) can be changed with + `default.device`. + If a string is given, the device is selected which contains + all space-separated parts in the right order. Each device + string contains the name of the corresponding host API in + the end. The string comparison is case-insensitive. + channels : int or pair of int, optional + The number of channels of sound to be delivered to the + stream callback or accessed by `read()` or `write()`. It + can range from 1 to the value of ``'max_input_channels'`` or + ``'max_output_channels'`` in the dict returned by + `query_devices()`. By default, the maximum possible number + of channels for the selected device is used (which may not + be what you want; see `query_devices()`). The default + value(s) can be changed with `default.channels`. + dtype : str or numpy.dtype or pair thereof, optional + The sample format of the `numpy.ndarray` provided to the + stream callback, `read()` or `write()`. + It may be any of *float32*, *int32*, *int16*, *int8*, + *uint8*. See `numpy.dtype`. + The *float64* data type is not supported, this is only + supported for convenience in `play()`/`rec()`/`playrec()`. + The packed 24 bit format ``'int24'`` is only supported in + the "raw" stream classes, see `RawStream`. The default + value(s) can be changed with `default.dtype`. + If NumPy is available, the corresponding `numpy.dtype` + objects can be used as well. The floating point + representations ``'float32'`` and ``'float64'`` use ``+1.0`` + and ``-1.0`` as the maximum and minimum values, + respectively. ``'uint8'`` is an unsigned 8 bit format where + ``128`` is considered "ground". + latency : float or {'low', 'high'} or pair thereof, optional + The desired latency in seconds. The special values + ``'low'`` and ``'high'`` (latter being the default) select + the device's default low and high latency, respectively (see + `query_devices()`). ``'high'`` is typically more robust + (i.e. buffer under-/overflows are less likely), + but the latency may be too large for interactive applications. + + .. note:: Specifying the desired latency as ``'high'`` does + not *guarantee* a stable audio stream. For reference, by + default Audacity_ specifies a desired latency of ``0.1`` + seconds and typically achieves robust performance. + + .. _Audacity: https://www.audacityteam.org/ + + The default value(s) can be changed with `default.latency`. + Actual latency values for an open stream can be retrieved + using the `latency` attribute. + extra_settings : settings object or pair thereof, optional + This can be used for host-API-specific input/output + settings. See `default.extra_settings`. + callback : callable, optional + User-supplied function to consume, process or generate audio + data in response to requests from an `active` stream. + When a stream is running, PortAudio calls the stream + callback periodically. The callback function is responsible + for processing and filling input and output buffers, + respectively. + + If no *callback* is given, the stream will be opened in + "blocking read/write" mode. In blocking mode, the client + can receive sample data using `read()` and write sample + data using `write()`, the number of frames that may be + read or written without blocking is returned by + `read_available` and `write_available`, respectively. + + The callback must have this signature: + + .. code-block:: text + + callback(indata: ndarray, outdata: ndarray, frames: int, + time: CData, status: CallbackFlags) -> None + + The first and second argument are the input and output + buffer, respectively, as two-dimensional `numpy.ndarray` + with one column per channel (i.e. with a shape of + ``(frames, channels)``) and with a data type specified by + `dtype`. + The output buffer contains uninitialized data and the + *callback* is supposed to fill it with proper audio data. + If no data is available, the buffer should be filled with + zeros (e.g. by using ``outdata.fill(0)``). + + .. note:: In Python, assigning to an identifier merely + re-binds the identifier to another object, so this *will + not work* as expected:: + + outdata = my_data # Don't do this! + + To actually assign data to the buffer itself, you can use + indexing, e.g.:: + + outdata[:] = my_data + + ... which fills the whole buffer, or:: + + outdata[:, 1] = my_channel_data + + ... which only fills one channel. + + The third argument holds the number of frames to be + processed by the stream callback. This is the same as the + length of the input and output buffers. + + The forth argument provides a CFFI structure with + timestamps indicating the ADC capture time of the first + sample in the input buffer (``time.inputBufferAdcTime``), + the DAC output time of the first sample in the output buffer + (``time.outputBufferDacTime``) and the time the callback was + invoked (``time.currentTime``). + These time values are expressed in seconds and are + synchronised with the time base used by `time` for the + associated stream. + + The fifth argument is a `CallbackFlags` instance indicating + whether input and/or output buffers have been inserted or + will be dropped to overcome underflow or overflow + conditions. + + If an exception is raised in the *callback*, it will not be + called again. If `CallbackAbort` is raised, the stream will + finish as soon as possible. If `CallbackStop` is raised, + the stream will continue until all buffers generated by the + callback have been played. This may be useful in + applications such as soundfile players where a specific + duration of output is required. If another exception is + raised, its traceback is printed to `sys.stderr`. + Exceptions are *not* propagated to the main thread, i.e. the + main Python program keeps running as if nothing had + happened. + + .. note:: The *callback* must always fill the entire output + buffer, no matter if or which exceptions are raised. + + If no exception is raised in the *callback*, it + automatically continues to be called until `stop()`, + `abort()` or `close()` are used to stop the stream. + + The PortAudio stream callback runs at very high or real-time + priority. It is required to consistently meet its time + deadlines. Do not allocate memory, access the file system, + call library functions or call other functions from the + stream callback that may block or take an unpredictable + amount of time to complete. With the exception of + `cpu_load` it is not permissible to call PortAudio API + functions from within the stream callback. + + In order for a stream to maintain glitch-free operation the + callback must consume and return audio data faster than it + is recorded and/or played. PortAudio anticipates that each + callback invocation may execute for a duration approaching + the duration of *frames* audio frames at the stream's + sampling frequency. It is reasonable to expect to be able + to utilise 70% or more of the available CPU time in the + PortAudio callback. However, due to buffer size adaption + and other factors, not all host APIs are able to guarantee + audio stability under heavy CPU load with arbitrary fixed + callback buffer sizes. When high callback CPU utilisation + is required the most robust behavior can be achieved by + using ``blocksize=0``. + finished_callback : callable, optional + User-supplied function which will be called when the stream + becomes inactive (i.e. once a call to `stop()` will not + block). + + A stream will become inactive after the stream callback + raises an exception or when `stop()` or `abort()` is called. + For a stream providing audio output, if the stream callback + raises `CallbackStop`, or `stop()` is called, the stream + finished callback will not be called until all generated + sample data has been played. The callback must have this + signature: + + .. code-block:: text + + finished_callback() -> None + + clip_off : bool, optional + See `default.clip_off`. + dither_off : bool, optional + See `default.dither_off`. + never_drop_input : bool, optional + See `default.never_drop_input`. + prime_output_buffers_using_stream_callback : bool, optional + See `default.prime_output_buffers_using_stream_callback`. + + """ + _StreamBase.__init__(self, kind='duplex', wrap_callback='array', + **_remove_self(locals())) + + +class DeviceList(tuple): + """A list with information about all available audio devices. + + This class is not meant to be instantiated by the user. + Instead, it is returned by `query_devices()`. + It contains a dictionary for each available device, holding the keys + described in `query_devices()`. + + This class has a special string representation that is shown as + return value of `query_devices()` if used in an interactive + Python session. It will also be shown when using the :func:`print` + function. Furthermore, it can be obtained with :func:`repr` and + :class:`str() `. + + """ + + __slots__ = () + + def __repr__(self): + idev = _get_device_id(default.device['input'], 'input') + odev = _get_device_id(default.device['output'], 'output') + digits = len(str(_lib.Pa_GetDeviceCount() - 1)) + hostapi_names = [hostapi['name'] for hostapi in query_hostapis()] + + def get_mark(idx): + return (' ', '>', '<', '*')[(idx == idev) + 2 * (idx == odev)] + + text = '\n'.join( + '{mark} {idx:{dig}} {name}, {ha} ({ins} in, {outs} out)'.format( + mark=get_mark(info['index']), + idx=info['index'], + dig=digits, + name=info['name'], + ha=hostapi_names[info['hostapi']], + ins=info['max_input_channels'], + outs=info['max_output_channels']) + for info in self) + return text + + +class CallbackFlags: + """Flag bits for the *status* argument to a stream *callback*. + + If you experience under-/overflows, you can try to increase the + ``latency`` and/or ``blocksize`` settings. + You should also avoid anything that could block the callback + function for a long time, e.g. extensive computations, waiting for + another thread, reading/writing files, network connections, etc. + + See Also + -------- + Stream + + Examples + -------- + This can be used to collect the errors of multiple *status* objects: + + >>> import sounddevice as sd + >>> errors = sd.CallbackFlags() + >>> errors |= status1 + >>> errors |= status2 + >>> errors |= status3 + >>> # and so on ... + >>> errors.input_overflow + True + + The values may also be set and cleared by the user: + + >>> import sounddevice as sd + >>> cf = sd.CallbackFlags() + >>> cf + + >>> cf.input_underflow = True + >>> cf + + >>> cf.input_underflow = False + >>> cf + + + """ + + __slots__ = '_flags' + + def __init__(self, flags=0x0): + self._flags = flags + + def __repr__(self): + flags = str(self) + if not flags: + flags = 'no flags set' + return f'' + + def __str__(self): + return ', '.join(name.replace('_', ' ') for name in dir(self) + if not name.startswith('_') and getattr(self, name)) + + def __bool__(self): + return bool(self._flags) + + def __ior__(self, other): + if not isinstance(other, CallbackFlags): + return NotImplemented + self._flags |= other._flags + return self + + @property + def input_underflow(self): + """Input underflow. + + In a stream opened with ``blocksize=0``, indicates that input + data is all silence (zeros) because no real data is available. + In a stream opened with a non-zero *blocksize*, it indicates + that one or more zero samples have been inserted into the input + buffer to compensate for an input underflow. + + This can only happen in full-duplex streams (including + `playrec()`). + + """ + return self._hasflag(_lib.paInputUnderflow) + + @input_underflow.setter + def input_underflow(self, value): + self._updateflag(_lib.paInputUnderflow, value) + + @property + def input_overflow(self): + """Input overflow. + + In a stream opened with ``blocksize=0``, indicates that data + prior to the first sample of the input buffer was discarded due + to an overflow, possibly because the stream callback is using + too much CPU time. In a stream opened with a non-zero + *blocksize*, it indicates that data prior to one or more samples + in the input buffer was discarded. + + This can happen in full-duplex and input-only streams (including + `playrec()` and `rec()`). + + """ + return self._hasflag(_lib.paInputOverflow) + + @input_overflow.setter + def input_overflow(self, value): + self._updateflag(_lib.paInputOverflow, value) + + @property + def output_underflow(self): + """Output underflow. + + Indicates that output data (or a gap) was inserted, possibly + because the stream callback is using too much CPU time. + + This can happen in full-duplex and output-only streams + (including `playrec()` and `play()`). + + """ + return self._hasflag(_lib.paOutputUnderflow) + + @output_underflow.setter + def output_underflow(self, value): + self._updateflag(_lib.paOutputUnderflow, value) + + @property + def output_overflow(self): + """Output overflow. + + Indicates that output data will be discarded because no room is + available. + + This can only happen in full-duplex streams (including + `playrec()`), but only when ``never_drop_input=True`` was + specified. See `default.never_drop_input`. + + """ + return self._hasflag(_lib.paOutputOverflow) + + @output_overflow.setter + def output_overflow(self, value): + self._updateflag(_lib.paOutputOverflow, value) + + @property + def priming_output(self): + """Priming output. + + Some of all of the output data will be used to prime the stream, + input data may be zero. + + This will only take place with some of the host APIs, and only + if ``prime_output_buffers_using_stream_callback=True`` was + specified. + See `default.prime_output_buffers_using_stream_callback`. + + """ + return self._hasflag(_lib.paPrimingOutput) + + def _hasflag(self, flag): + """Check a given flag.""" + return bool(self._flags & flag) + + def _updateflag(self, flag, value): + """Set/clear a given flag.""" + if value: + self._flags |= flag + else: + self._flags &= ~flag + + +class _InputOutputPair: + """Parameter pairs for device, channels, dtype and latency.""" + + _indexmapping = {'input': 0, 'output': 1} + + def __init__(self, parent, default_attr): + self._pair: ... = [None, None] + self._parent = parent + self._default_attr = default_attr + + def __getitem__(self, index): + index = self._indexmapping.get(index, index) + value = self._pair[index] + if value is None: + value = getattr(self._parent, self._default_attr)[index] + return value + + def __setitem__(self, index, value): + index = self._indexmapping.get(index, index) + self._pair[index] = value + + def __repr__(self): + return '[{0[0]!r}, {0[1]!r}]'.format(self) + + +class default: + """Get/set defaults for the *sounddevice* module. + + The attributes `device`, `channels`, `dtype`, `latency` and + `extra_settings` accept single values which specify the given + property for both input and output. However, if the property + differs between input and output, pairs of values can be used, where + the first value specifies the input and the second value specifies + the output. All other attributes are always single values. + + Examples + -------- + >>> import sounddevice as sd + >>> sd.default.samplerate = 48000 + >>> sd.default.dtype + ['float32', 'float32'] + + Different values for input and output: + + >>> sd.default.channels = 1, 2 + + A single value sets both input and output at the same time: + + >>> sd.default.device = 5 + >>> sd.default.device + [5, 5] + + An attribute can be set to the "factory default" by assigning + ``None``: + + >>> sd.default.samplerate = None + >>> sd.default.device = None, 4 + + Use `reset()` to reset all attributes: + + >>> sd.default.reset() + + """ + + _pairs = 'device', 'channels', 'dtype', 'latency', 'extra_settings' + # The class attributes listed in _pairs are only provided here for static + # analysis tools and for the docs. They're overwritten in __init__(). + device: ... = (None, None) + """Index or query string of default input/output device. + + If not overwritten, this is queried from PortAudio. + + See Also + -------- + `default`, `query_devices()`, the *device* argument of `Stream` + + """ + _default_channels = None, None + channels: ... = _default_channels + """Default number of input/output channels. + + See Also + -------- + `default`, `query_devices()`, the *channels* argument of `Stream` + + """ + _default_dtype = 'float32', 'float32' + dtype: ... = _default_dtype + """Default data type used for input/output samples. + + The types ``'float32'``, ``'int32'``, ``'int16'``, ``'int8'`` and + ``'uint8'`` can be used for all streams and functions. + Additionally, `play()`, `rec()` and `playrec()` support + ``'float64'`` (for convenience, data is merely converted from/to + ``'float32'``) and `RawInputStream`, `RawOutputStream` and + `RawStream` support ``'int24'`` (packed 24 bit format, which is + *not* supported in NumPy!). + + See Also + -------- + `default`, `numpy:numpy.dtype`, the *dtype* argument of `Stream` + + """ + _default_latency = 'high', 'high' + latency: ... = _default_latency + """See the *latency* argument of `Stream`.""" + _default_extra_settings = None, None + extra_settings: ... = _default_extra_settings + """Host-API-specific input/output settings. + + See Also + -------- + AsioSettings, CoreAudioSettings, WasapiSettings + + """ + samplerate: ... = None + """Sampling frequency in Hertz (= frames per second). + + See Also + -------- + `default`, `query_devices()` + + """ + blocksize: ... = _lib.paFramesPerBufferUnspecified + """See the *blocksize* argument of `Stream`.""" + clip_off = False + """Disable clipping. + + Set to ``True`` to disable default clipping of out of range samples. + + """ + dither_off = False + """Disable dithering. + + Set to ``True`` to disable default dithering. + + """ + never_drop_input = False + """Set behavior for input overflow of full-duplex streams. + + Set to ``True`` to request that where possible a full duplex stream + will not discard overflowed input samples without calling the stream + callback. This flag is only valid for full-duplex callback streams + (i.e. only `Stream` and `RawStream` and only if *callback* was + specified; this includes `playrec()`) and only when used in + combination with ``blocksize=0`` (the default). Using this flag + incorrectly results in an error being raised. See also + http://www.portaudio.com/docs/proposals/001-UnderflowOverflowHandling.html. + + """ + prime_output_buffers_using_stream_callback = False + """How to fill initial output buffers. + + Set to ``True`` to call the stream callback to fill initial output + buffers, rather than the default behavior of priming the buffers + with zeros (silence). This flag has no effect for input-only + (`InputStream` and `RawInputStream`) and blocking read/write streams + (i.e. if *callback* wasn't specified). See also + http://www.portaudio.com/docs/proposals/020-AllowCallbackToPrimeStream.html. + + """ + + def __init__(self): + for attr in self._pairs: + # __setattr__() must be avoided here + vars(self)[attr] = _InputOutputPair(self, '_default_' + attr) + + def __setattr__(self, name, value): + """Only allow setting existing attributes.""" + if name in self._pairs: + getattr(self, name)._pair[:] = _split(value) + elif name in dir(self) and name != 'reset': + object.__setattr__(self, name, value) + else: + raise AttributeError( + "'default' object has no attribute " + repr(name)) + + @property + def _default_device(self): + return (_lib.Pa_GetDefaultInputDevice(), + _lib.Pa_GetDefaultOutputDevice()) + + @property + def hostapi(self): # type: ignore + """Index of the default host API (read-only).""" + return _check(_lib.Pa_GetDefaultHostApi()) + + hostapi: int + + def reset(self): + """Reset all attributes to their "factory default".""" + vars(self).clear() + self.__init__() + + +if not hasattr(_ffi, 'I_AM_FAKE'): + # This object shadows the 'default' class, except when building the docs. + _default_instance: ... = default() + default = _default_instance + + +class PortAudioError(Exception): + """This exception will be raised on PortAudio errors. + + Attributes + ---------- + args + A variable length tuple containing the following elements when + available: + + 1) A string describing the error + 2) The PortAudio ``PaErrorCode`` value + 3) A 3-tuple containing the host API index, host error code, and the + host error message (which may be an empty string) + + """ + + def __str__(self): + errormsg = self.args[0] if self.args else '' + if len(self.args) > 1: + errormsg = f'{errormsg} [PaErrorCode {self.args[1]}]' + if len(self.args) > 2: + host_api, hosterror_code, hosterror_text = self.args[2] + if host_api == _lib.paHostApiNotFound: + hostname = '' + elif host_api < 0: + hostname = f'' + else: + hostname = query_hostapis(host_api)['name'] + errormsg = "{}: '{}' [{} error {}]".format( + errormsg, hosterror_text, hostname, hosterror_code) + + return errormsg + + +class CallbackStop(Exception): + """Exception to be raised by the user to stop callback processing. + + If this is raised in the stream callback, the callback will not be + invoked anymore (but all pending audio buffers will be played). + + See Also + -------- + CallbackAbort, `Stream.stop()`, Stream + + """ + + +class CallbackAbort(Exception): + """Exception to be raised by the user to abort callback processing. + + If this is raised in the stream callback, all pending buffers are + discarded and the callback will not be invoked anymore. + + See Also + -------- + CallbackStop, `Stream.abort()`, Stream + + """ + + +class AsioSettings: + + def __init__(self, channel_selectors): + """ASIO-specific input/output settings. + + Objects of this class can be used as *extra_settings* argument + to `Stream()` (and variants) or as `default.extra_settings`. + + Parameters + ---------- + channel_selectors : list of int + Support for opening only specific channels of an ASIO + device. *channel_selectors* is a list of integers + specifying the (zero-based) channel numbers to use. + The length of *channel_selectors* must match the + corresponding *channels* parameter of `Stream()` (or + variants), otherwise a crash may result. + The values in the *channel_selectors* array must specify + channels within the range of supported channels. + + Examples + -------- + Setting output channels when calling `play()`: + + >>> import sounddevice as sd + >>> asio_out = sd.AsioSettings(channel_selectors=[12, 13]) + >>> sd.play(..., extra_settings=asio_out) + + Setting default output channels: + + >>> sd.default.extra_settings = asio_out + >>> sd.play(...) + + Setting input channels as well: + + >>> asio_in = sd.AsioSettings(channel_selectors=[8]) + >>> sd.default.extra_settings = asio_in, asio_out + >>> sd.playrec(..., channels=1, ...) + + """ + if isinstance(channel_selectors, int): + raise TypeError('channel_selectors must be a list or tuple') + # int array must be kept alive! + self._selectors = _ffi.new('int[]', channel_selectors) + self._streaminfo = _ffi.new('PaAsioStreamInfo*', dict( + size=_ffi.sizeof('PaAsioStreamInfo'), + hostApiType=_lib.paASIO, + version=1, + flags=_lib.paAsioUseChannelSelectors, + channelSelectors=self._selectors)) + + +class CoreAudioSettings: + + def __init__(self, channel_map=None, change_device_parameters=False, + fail_if_conversion_required=False, conversion_quality='max'): + """Mac Core Audio-specific input/output settings. + + Objects of this class can be used as *extra_settings* argument + to `Stream()` (and variants) or as `default.extra_settings`. + + Parameters + ---------- + channel_map : sequence of int, optional + Support for opening only specific channels of a Core Audio + device. Note that *channel_map* is treated differently + between input and output channels. + + For input devices, *channel_map* is a list of integers + specifying the (zero-based) channel numbers to use. + + For output devices, *channel_map* must have the same length + as the number of output channels of the device. Specify + unused channels with -1, and a 0-based index for any desired + channels. + + See the example below. For additional information, see the + `PortAudio documentation`__. + + __ https://github.com/PortAudio/portaudio/blob/master/ + src/hostapi/coreaudio/notes.txt + change_device_parameters : bool, optional + If ``True``, allows PortAudio to change things like the + device's frame size, which allows for much lower latency, + but might disrupt the device if other programs are using it, + even when you are just querying the device. ``False`` is + the default. + fail_if_conversion_required : bool, optional + In combination with the above flag, ``True`` causes the + stream opening to fail, unless the exact sample rates are + supported by the device. + conversion_quality : {'min', 'low', 'medium', 'high', 'max'}, optional + This sets Core Audio's sample rate conversion quality. + ``'max'`` is the default. + + Example + ------- + This example assumes a device having 6 input and 6 output + channels. Input is from the second and fourth channels, and + output is to the device's third and fifth channels: + + >>> import sounddevice as sd + >>> ca_in = sd.CoreAudioSettings(channel_map=[1, 3]) + >>> ca_out = sd.CoreAudioSettings(channel_map=[-1, -1, 0, -1, 1, -1]) + >>> sd.playrec(..., channels=2, extra_settings=(ca_in, ca_out)) + + """ + conversion_dict = { + 'min': _lib.paMacCoreConversionQualityMin, + 'low': _lib.paMacCoreConversionQualityLow, + 'medium': _lib.paMacCoreConversionQualityMedium, + 'high': _lib.paMacCoreConversionQualityHigh, + 'max': _lib.paMacCoreConversionQualityMax, + } + + # Minimal checking on channel_map to catch errors that might + # otherwise go unnoticed: + if isinstance(channel_map, int): + raise TypeError('channel_map must be a list or tuple') + + try: + self._flags = conversion_dict[conversion_quality.lower()] + except (KeyError, AttributeError) as e: + raise ValueError('conversion_quality must be one of ' + + repr(list(conversion_dict))) from e + if change_device_parameters: + self._flags |= _lib.paMacCoreChangeDeviceParameters + if fail_if_conversion_required: + self._flags |= _lib.paMacCoreFailIfConversionRequired + + # this struct must be kept alive! + self._streaminfo = _ffi.new('PaMacCoreStreamInfo*') + _lib.PaMacCore_SetupStreamInfo(self._streaminfo, self._flags) + + if channel_map is not None: + # this array must be kept alive! + self._channel_map = _ffi.new('SInt32[]', channel_map) + if len(self._channel_map) == 0: + raise TypeError('channel_map must not be empty') + _lib.PaMacCore_SetupChannelMap(self._streaminfo, + self._channel_map, + len(self._channel_map)) + + +class WasapiSettings: + + def __init__(self, exclusive=False, auto_convert=False, explicit_sample_format=False): + """WASAPI-specific input/output settings. + + Objects of this class can be used as *extra_settings* argument + to `Stream()` (and variants) or as `default.extra_settings`. + They can also be used in `check_input_settings()` and + `check_output_settings()`. + + Parameters + ---------- + exclusive : bool + Exclusive mode allows to deliver audio data directly to + hardware bypassing software mixing. + + auto_convert : bool + Allow WASAPI backend to insert system-level channel matrix + mixer and sample rate converter to support playback formats + that do not match the current configured system settings. + This is in particular required for streams not matching the + system mixer sample rate. This only applies in *shared + mode* and has no effect when *exclusive* is set to ``True``. + + explicit_sample_format : bool + Force explicit sample format and do not allow PortAudio to + select suitable working format. API will fail if provided + sample format is not supported by audio hardware in Exclusive + mode or system mixer in Shared mode. This is required for + accurate native format detection. + + Examples + -------- + Setting exclusive mode when calling `play()`: + + >>> import sounddevice as sd + >>> wasapi_exclusive = sd.WasapiSettings(exclusive=True) + >>> sd.play(..., extra_settings=wasapi_exclusive) + + Setting exclusive mode as default: + + >>> sd.default.extra_settings = wasapi_exclusive + >>> sd.play(...) + + """ + flags = 0x0 + if exclusive: + flags |= _lib.paWinWasapiExclusive + if auto_convert: + flags |= _lib.paWinWasapiAutoConvert + if explicit_sample_format: + flags |= _lib.paWinWasapiExplicitSampleFormat + self._streaminfo = _ffi.new('PaWasapiStreamInfo*', dict( + size=_ffi.sizeof('PaWasapiStreamInfo'), + hostApiType=_lib.paWASAPI, + version=1, + flags=flags, + )) + + +class _CallbackContext: + """Helper class for reuse in play()/rec()/playrec() callbacks.""" + + frame = 0 + frames: int + input_channels = output_channels = None + input_dtype = output_dtype = None + silent_channels = None + + def __init__(self, loop=False): + import threading + try: + import numpy + assert numpy # avoid "imported but unused" message (W0611) + except ImportError as e: + raise ImportError( + 'NumPy must be installed for play()/rec()/playrec()') from e + self.loop = loop + self.event = threading.Event() + self.status = CallbackFlags() + + def check_data(self, data, mapping, device): + """Check data and output mapping.""" + import numpy as np + data = np.asarray(data) + if data.ndim < 2: + data = data.reshape(-1, 1) + elif data.ndim > 2: + raise ValueError( + 'audio data to be played back must be one- or two-dimensional') + frames, channels = data.shape + dtype = _check_dtype(data.dtype) + mapping_is_explicit = mapping is not None + mapping, channels = _check_mapping(mapping, channels) + if data.shape[1] == 1: + pass # No problem, mono data is duplicated into arbitrary channels + elif data.shape[1] != len(mapping): + raise ValueError( + 'number of output channels != size of output mapping') + # Apparently, some PortAudio host APIs duplicate mono streams to the + # first two channels, which is unexpected when specifying mapping=[1]. + # In this case, we play silence on the second channel, but only if the + # device actually supports a second channel: + if (mapping_is_explicit and np.array_equal(mapping, [0]) and + query_devices(device, 'output')['max_output_channels'] >= 2): + channels = 2 + silent_channels = np.setdiff1d(np.arange(channels), mapping) + if len(mapping) + len(silent_channels) != channels: + raise ValueError('each channel may only appear once in mapping') + + self.data: np.typing.NDArray = data + self.output_channels = channels + self.output_dtype = dtype + self.output_mapping = mapping + self.silent_channels = silent_channels + return frames + + def check_out(self, out, frames, channels, dtype, mapping): + """Check out, frames, channels, dtype and input mapping.""" + import numpy as np + if out is None: + if frames is None: + raise TypeError('frames must be specified') + if channels is None: + channels = default.channels['input'] + if channels is None: + if mapping is None: + raise TypeError( + 'Unable to determine number of input channels') + else: + channels = len(np.atleast_1d(mapping)) + if dtype is None: + dtype = default.dtype['input'] + try: + out = np.empty((frames, channels), dtype, order='C') + except TypeError as e: + from numbers import Integral + if not isinstance(frames, Integral): + raise TypeError("'frames' must be an integer") from e + if not isinstance(channels, Integral): + raise TypeError("'channels' must be an integer") from e + raise e + else: + frames, channels = out.shape + dtype = out.dtype + dtype = _check_dtype(dtype) + mapping, channels = _check_mapping(mapping, channels) + if out.shape[1] != len(mapping): + raise ValueError( + 'number of input channels != size of input mapping') + + self.out = out + self.input_channels = channels + self.input_dtype = dtype + self.input_mapping = mapping + return out, frames + + def callback_enter(self, status, data): + """Check status and blocksize.""" + self.status |= status + self.blocksize = min(self.frames - self.frame, len(data)) + + def read_indata(self, indata): + # We manually iterate over each channel in mapping because + # numpy.take(..., out=...) has a bug: + # https://github.com/numpy/numpy/pull/4246. + # Note: using indata[:blocksize, mapping] (a.k.a. 'fancy' indexing) + # would create unwanted copies (and probably memory allocations). + for target, source in enumerate(self.input_mapping): + # If out.dtype is 'float64', 'float32' data is "upgraded" here: + self.out[self.frame:self.frame + self.blocksize, target] = \ + indata[:self.blocksize, source] + + def write_outdata(self, outdata): + # 'float64' data is cast to 'float32' here: + outdata[:self.blocksize, self.output_mapping] = \ + self.data[self.frame:self.frame + self.blocksize] + outdata[:self.blocksize, self.silent_channels] = 0 + if self.loop and self.blocksize < len(outdata): + self.frame = 0 + outdata = outdata[self.blocksize:] + self.blocksize = min(self.frames, len(outdata)) + self.write_outdata(outdata) + else: + outdata[self.blocksize:] = 0 + + def callback_exit(self): + if not self.blocksize: + raise CallbackAbort + self.frame += self.blocksize + + def finished_callback(self): + self.event.set() + # Drop temporary audio buffers to free memory + with _contextlib.suppress(AttributeError): + del self.data + with _contextlib.suppress(AttributeError): + del self.out + # Drop CFFI objects to avoid reference cycles + self.stream._callback = None + self.stream._finished_callback = None + + def start_stream(self, StreamClass, samplerate, channels, dtype, callback, + blocking, **kwargs): + stop() # Stop previous playback/recording + self.stream = StreamClass(samplerate=samplerate, + channels=channels, + dtype=dtype, + callback=callback, + finished_callback=self.finished_callback, + **kwargs) + self.stream.start() + global _last_callback + _last_callback = self + if blocking: + self.wait() + + def wait(self, ignore_errors=True): + """Wait for finished_callback. + + Can be interrupted with a KeyboardInterrupt. + + """ + try: + self.event.wait() + finally: + self.stream.close(ignore_errors) + return self.status if self.status else None + + +def _ffi_string(cdata) -> bytes: + return _ffi.string(cdata) # type: ignore + + +def _remove_self(d): + """Return a copy of d without the 'self' entry.""" + d = d.copy() + del d['self'] + return d + + +def _check_mapping(mapping, channels): + """Check mapping, obtain channels.""" + import numpy as np + if mapping is None: + mapping = np.arange(channels) + else: + mapping = np.array(mapping, copy=True) + mapping = np.atleast_1d(mapping) + if mapping.min() < 1: + raise ValueError('channel numbers must not be < 1') + channels = mapping.max() + mapping -= 1 # channel numbers start with 1 + return mapping, channels + + +def _check_dtype(dtype): + """Check dtype.""" + import numpy as np + dtype = np.dtype(dtype).name + if dtype in _sampleformats: + pass + elif dtype == 'float64': + dtype = 'float32' + else: + raise TypeError('Unsupported data type: ' + repr(dtype)) + return dtype + + +def _get_stream_parameters(kind, device, channels, dtype, latency, + extra_settings: ..., samplerate): + """Get parameters for one direction (input or output) of a stream.""" + assert kind in ('input', 'output') + if device is None: + device = default.device + device = _get_device_id(device, kind, raise_on_error=True) + if channels is None: + channels = default.channels + channels = _select_input_or_output(channels, kind) + if dtype is None: + dtype = default.dtype + dtype = _select_input_or_output(dtype, kind) + if latency is None: + latency = default.latency + latency = _select_input_or_output(latency, kind) + if extra_settings is None: + extra_settings = default.extra_settings + extra_settings = _select_input_or_output(extra_settings, kind) + if samplerate is None: + samplerate = default.samplerate + + info = query_devices(device) + if channels is None: + channels = info['max_' + kind + '_channels'] + try: + # If NumPy is available, get canonical dtype name + dtype = _sys.modules['numpy'].dtype(dtype).name + except Exception: + pass # NumPy not available or invalid dtype (e.g. 'int24') or ... + try: + sampleformat = _sampleformats[dtype] + except KeyError as e: + raise ValueError('Invalid ' + kind + ' sample format') from e + samplesize = _check(_lib.Pa_GetSampleSize(sampleformat)) + if latency in ('low', 'high'): + latency = info['default_' + latency + '_' + kind + '_latency'] + if samplerate is None: + samplerate = info['default_samplerate'] + parameters: ... = _ffi.new('PaStreamParameters*', ( + device, channels, sampleformat, latency, + extra_settings._streaminfo if extra_settings else _ffi.NULL)) + return parameters, dtype, samplesize, samplerate + + +def _wrap_callback(callback, *args): + """Invoke callback function and check for custom exceptions.""" + args = args[:-1] + (CallbackFlags(args[-1]),) + try: + callback(*args) + except CallbackStop: + return _lib.paComplete + except CallbackAbort: + return _lib.paAbort + return _lib.paContinue + + +def _buffer(ptr, frames, channels, samplesize): + """Create a buffer object from a pointer to some memory.""" + return _ffi.buffer(ptr, frames * channels * samplesize) + + +def _array(buffer, channels, dtype): + """Create NumPy array from a buffer object.""" + import numpy as np + data = np.frombuffer(buffer, dtype=dtype) + data.shape = -1, channels + return data + + +def _split(value): + """Split input/output value into two values. + + This can be useful for generic code that allows using the same value + for input and output but also a pair of two separate values. + + """ + if isinstance(value, (str, bytes)): + # iterable, but not meant for splitting + return value, value + try: + invalue, outvalue = value + except TypeError: + invalue = outvalue = value + except ValueError as e: + raise ValueError( + f'Only single values and pairs are allowed, not {value!r}') from e + return invalue, outvalue + + +def _check(err, msg=''): + """Raise PortAudioError for below-zero error codes.""" + if err >= 0: + return err + + errormsg = _ffi_string(_lib.Pa_GetErrorText(err)).decode() + if msg: + errormsg = f'{msg}: {errormsg}' + + if err == _lib.paUnanticipatedHostError: + # (gh82) We grab the host error info here rather than inside + # PortAudioError since _check should only ever be called after a + # failing API function call. This way we can avoid any potential issues + # in scenarios where multiple APIs are being used simultaneously. + info = _lib.Pa_GetLastHostErrorInfo() + host_api = _lib.Pa_HostApiTypeIdToHostApiIndex(info.hostApiType) + hosterror_text = _ffi_string(info.errorText).decode() + hosterror_info = host_api, info.errorCode, hosterror_text + raise PortAudioError(errormsg, err, hosterror_info) + + raise PortAudioError(errormsg, err) + + +def _select_input_or_output(value_or_pair, kind): + """Given a pair (or a single value for both), select input or output.""" + ivalue, ovalue = _split(value_or_pair) + if kind == 'input': + return ivalue + elif kind == 'output': + return ovalue + assert False + + +def _get_device_id(id_or_query_string, kind, raise_on_error=False): + """Return device ID given space-separated substrings.""" + assert kind in ('input', 'output', None) + + if id_or_query_string is None: + id_or_query_string = default.device + + idev, odev = _split(id_or_query_string) + if kind == 'input': + id_or_query_string = idev + elif kind == 'output': + id_or_query_string = odev + else: + if idev == odev: + id_or_query_string = idev + else: + raise ValueError('Input and output device are different: {!r}' + .format(id_or_query_string)) + + if isinstance(id_or_query_string, int): + return id_or_query_string + device_list = [] + for id, info in enumerate(query_devices()): + if not kind or info['max_' + kind + '_channels'] > 0: + hostapi_info = query_hostapis(info['hostapi']) + device_list.append((id, info['name'], hostapi_info['name'])) + + query_string = id_or_query_string.lower() + substrings = query_string.split() + matches = [] + exact_device_matches = [] + for id, device_string, hostapi_string in device_list: + full_string = device_string + ', ' + hostapi_string + pos = 0 + for substring in substrings: + pos = full_string.lower().find(substring, pos) + if pos < 0: + break + pos += len(substring) + else: + matches.append((id, full_string)) + if query_string in [device_string.lower(), full_string.lower()]: + exact_device_matches.append(id) + + if kind is None: + kind = 'input/output' # Just used for error messages + + if not matches: + if raise_on_error: + raise ValueError( + 'No ' + kind + ' device matching ' + repr(id_or_query_string)) + else: + return -1 + if len(matches) > 1: + if len(exact_device_matches) == 1: + return exact_device_matches[0] + if raise_on_error: + raise ValueError('Multiple ' + kind + ' devices found for ' + + repr(id_or_query_string) + ':\n' + + '\n'.join(f'[{id}] {name}' + for id, name in matches)) + else: + return -1 + return matches[0][0] + + +def _initialize(): + """Initialize PortAudio. + + This temporarily forwards messages from stderr to ``/dev/null`` + (where supported). + + In most cases, this doesn't have to be called explicitly, because it + is automatically called with the ``import sounddevice`` statement. + + """ + old_stderr = None + try: + old_stderr = _os.dup(2) + devnull = _os.open(_os.devnull, _os.O_WRONLY) + _os.dup2(devnull, 2) + _os.close(devnull) + except OSError: + pass + try: + _check(_lib.Pa_Initialize(), 'Error initializing PortAudio') + global _initialized + _initialized += 1 + finally: + if old_stderr is not None: + _os.dup2(old_stderr, 2) + _os.close(old_stderr) + + +def _terminate(): + """Terminate PortAudio. + + In most cases, this doesn't have to be called explicitly. + + """ + global _initialized + _check(_lib.Pa_Terminate(), 'Error terminating PortAudio') + _initialized -= 1 + + +def _exit_handler(): + assert _initialized >= 0 + + # We cleanup any open streams here since older versions of portaudio don't + # manage this (see github issue #1) + if _last_callback: + # NB: calling stop() first is required; without it portaudio hangs when + # calling close() + _last_callback.stream.stop() + _last_callback.stream.close() + + while _initialized: + _terminate() + + +_atexit.register(_exit_handler) +_initialize() + +if __name__ == '__main__': + print(query_devices()) diff --git a/dev/dialog_check.py b/dev/dialog_check.py new file mode 100644 index 0000000..7534b04 --- /dev/null +++ b/dev/dialog_check.py @@ -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("对话框后台任务链路校验全部通过") diff --git a/dev/gui_check.py b/dev/gui_check.py new file mode 100644 index 0000000..6563001 --- /dev/null +++ b/dev/gui_check.py @@ -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("界面程序化校验全部通过") diff --git a/dev/lint_scan.py b/dev/lint_scan.py new file mode 100644 index 0000000..35b629b --- /dev/null +++ b/dev/lint_scan.py @@ -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)} 条提示") diff --git a/dev/shots/01_idle.png b/dev/shots/01_idle.png new file mode 100644 index 0000000..c2256e8 Binary files /dev/null and b/dev/shots/01_idle.png differ diff --git a/dev/shots/02_recording.png b/dev/shots/02_recording.png new file mode 100644 index 0000000..3c84f2a Binary files /dev/null and b/dev/shots/02_recording.png differ diff --git a/dev/shots/03_after.png b/dev/shots/03_after.png new file mode 100644 index 0000000..8647349 Binary files /dev/null and b/dev/shots/03_after.png differ diff --git a/dev/shots/04_report.png b/dev/shots/04_report.png new file mode 100644 index 0000000..a788a3b Binary files /dev/null and b/dev/shots/04_report.png differ diff --git a/dev/shots/dlg/dialog_src.wav b/dev/shots/dlg/dialog_src.wav new file mode 100644 index 0000000..ac0f0a5 Binary files /dev/null and b/dev/shots/dlg/dialog_src.wav differ diff --git a/dev/shots/dlg/dialog_src_processed.flac b/dev/shots/dlg/dialog_src_processed.flac new file mode 100644 index 0000000..cdff9dc Binary files /dev/null and b/dev/shots/dlg/dialog_src_processed.flac differ diff --git a/dev/shots/dlg/dialog_src_processed.wav b/dev/shots/dlg/dialog_src_processed.wav new file mode 100644 index 0000000..0af4950 Binary files /dev/null and b/dev/shots/dlg/dialog_src_processed.wav differ diff --git a/dev/shots/dlg/dialog_src_processed_wav_24.wav b/dev/shots/dlg/dialog_src_processed_wav_24.wav new file mode 100644 index 0000000..be1aed5 Binary files /dev/null and b/dev/shots/dlg/dialog_src_processed_wav_24.wav differ diff --git a/install_deps.py b/install_deps.py new file mode 100644 index 0000000..5ad3249 --- /dev/null +++ b/install_deps.py @@ -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()) diff --git a/recorder/__init__.py b/recorder/__init__.py new file mode 100644 index 0000000..0091fb4 --- /dev/null +++ b/recorder/__init__.py @@ -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__"] diff --git a/recorder/cli.py b/recorder/cli.py new file mode 100644 index 0000000..3031ce4 --- /dev/null +++ b/recorder/cli.py @@ -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()) diff --git a/recorder/dsp.py b/recorder/dsp.py new file mode 100644 index 0000000..0f4bd27 --- /dev/null +++ b/recorder/dsp.py @@ -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) diff --git a/recorder/engine.py b/recorder/engine.py new file mode 100644 index 0000000..d02072a --- /dev/null +++ b/recorder/engine.py @@ -0,0 +1,1082 @@ +"""录音引擎:设备枚举、能力探测、低延迟采集、落盘线程、自动分段。 + +线程模型的取舍(直接决定"有没有爆音/掉采样"): + +* **音频回调线程只做一件事**——把数据从 PortAudio 的缓冲区拷进队列。 + 任何文件 IO、FFT、滤波都不允许出现在回调里,否则必然丢采样; +* **写入线程**负责软件增益、线性相位低切、电平/响度计量、写 WAV、 + 自动分段与磁盘速度监控; +* **界面线程**只读快照,通过 ``threading.Lock`` 保护,30 Hz 刷新。 + +其它"高质量"保障:录音前检查可用空间、监控写盘队列积压、统计 PortAudio +xrun(溢出)次数、设备掉线提示、每秒回写文件头(崩溃后文件仍可播放)。 +""" + +from __future__ import annotations + +import os +import queue +import shutil +import threading +import time +from dataclasses import dataclass, field, replace +from datetime import datetime +from enum import Enum +from typing import Any + +import numpy as np + +from . import dsp +from .wavfile import WavFormat, WavWriter, _fmt_for_bitdepth, _RiffOverflow + +try: # 允许在没有音频后端的机器上 import(自检/离线处理仍可用) + import sounddevice as sd + SD_IMPORT_ERROR: str | None = None +except Exception as exc: # pragma: no cover + sd = None # type: ignore[assignment] + SD_IMPORT_ERROR = str(exc) + + +# --------------------------------------------------------------- 工具函数 +def format_bytes(n: float) -> str: + n = float(n) + for unit in ("B", "KB", "MB", "GB", "TB"): + if abs(n) < 1024.0 or unit == "TB": + return f"{int(n)} B" if unit == "B" else f"{n:.1f} {unit}" + n /= 1024.0 + return f"{n:.1f} TB" + + +def format_duration(seconds: float) -> str: + seconds = max(0.0, float(seconds)) + h = int(seconds // 3600) + m = int((seconds % 3600) // 60) + s = seconds % 60 + if h: + return f"{h:d}:{m:02d}:{s:04.1f}" + return f"{m:02d}:{s:04.1f}" + + +def free_space_bytes(path: str) -> int: + try: + probe = os.path.abspath(path) + while probe and not os.path.exists(probe): + parent = os.path.dirname(probe) + if parent == probe: + break + probe = parent + return int(shutil.disk_usage(probe).free) + except Exception: + return -1 + + +# ------------------------------------------------------------- 设备枚举 +@dataclass +class DeviceInfo: + index: int + name: str + hostapi: str + max_input_channels: int + default_samplerate: float + default_low_input_latency: float + default_high_input_latency: float + is_default: bool = False + + @property + def label(self) -> str: + return f"{self.name} [{self.hostapi}]" + + @property + def quality_rank(self) -> int: + """宿主 API 的保真度/延迟排序,越小越优先。""" + order = ("WASAPI", "WDM-KS", "ASIO", "DirectSound", "MME") + for i, key in enumerate(order): + if key.lower() in self.hostapi.lower(): + return i + return len(order) + + +def list_input_devices() -> list[DeviceInfo]: + """枚举所有可用的输入设备并按音质/延迟优劣排序。""" + if sd is None: + return [] + devices = sd.query_devices() + apis = sd.query_hostapis() + try: + default_in = sd.default.device[0] + except Exception: + default_in = -1 + out: list[DeviceInfo] = [] + for idx, dev in enumerate(devices): + if int(dev.get("max_input_channels", 0)) <= 0: + continue + api = str(apis[int(dev["hostapi"])]["name"]) + out.append(DeviceInfo( + index=idx, + name=str(dev["name"]).strip(), + hostapi=api, + max_input_channels=int(dev["max_input_channels"]), + default_samplerate=float(dev.get("default_samplerate") or 48000), + default_low_input_latency=float(dev.get("default_low_input_latency") or 0.0), + default_high_input_latency=float(dev.get("default_high_input_latency") or 0.0), + is_default=(idx == default_in), + )) + out.sort(key=lambda d: (d.quality_rank, not d.is_default, d.name)) + return out + + +def default_device_index() -> int | None: + devs = list_input_devices() + if not devs: + return None + for d in devs: + if d.is_default and d.quality_rank == 0: + return d.index + return devs[0].index + + +def probe_capabilities(device: int, *, channels: int | None = None, + candidate_rates: tuple[int, ...] = (44100, 48000, 88200, 96000, 192000), + try_exclusive: bool = True) -> dict: + """探测设备支持的采样率/位深组合(界面上的"检测设备支持")。""" + result: dict[str, Any] = {"device": device, "channels": 0, + "hostapi": "", "rates": {}, "errors": []} + if sd is None: + result["errors"].append("缺少 sounddevice") + return result + info = sd.query_devices(device) + max_ch = int(info["max_input_channels"]) + ch = int(channels or min(2, max_ch)) + api = str(sd.query_hostapis(int(info["hostapi"]))["name"]) + is_wasapi = "wasapi" in api.lower() + result["channels"] = ch + result["hostapi"] = api + for rate in candidate_rates: + entry: dict[str, Any] = {"supported": False, "dtypes": [], + "exclusive": False, "error": ""} + modes = (True, False) if (is_wasapi and try_exclusive) else (False,) + for excl in modes: + for dtype in ("float32", "int16", "int32"): + try: + extra = sd.WasapiSettings(exclusive=excl) if is_wasapi else None + sd.check_input_settings(device=device, channels=ch, + samplerate=rate, dtype=dtype, + extra_settings=extra) + entry["supported"] = True + if dtype not in entry["dtypes"]: + entry["dtypes"].append(dtype) + entry["exclusive"] = entry["exclusive"] or bool(excl) + except Exception as exc: + entry["error"] = str(exc) + result["rates"][rate] = entry + return result + + +# --------------------------------------------------------------- 录音配置 +class RecorderState(str, Enum): + IDLE = "idle" + RECORDING = "recording" + PAUSED = "paused" + STOPPING = "stopping" + ERROR = "error" + + +@dataclass +class RecordConfig: + device: int | None = None + samplerate: int = 48000 + channels: int = 2 + bit_depth: str = "24" + gain_db: float = 0.0 + exclusive: bool = True + blocksize: int = 0 # 0 = 交给 PortAudio 自动选择 + latency: str = "high" # 'low' | 'high':录音优先稳定 + lowcut_hz: float = 0.0 # 0 = 关闭 + dither: bool = True + rf64: bool = False + output_dir: str = "" + name_template: str = "{datetime}_{device}" + split_seconds: float = 0.0 # 0 = 不按时间分段 + split_megabytes: float = 0.0 # 0 = 不按体积分段 + split_on_silence: bool = False + silence_threshold_dbfs: float = -50.0 + silence_gap_seconds: float = 2.0 + auto_stop_silence_seconds: float = 0.0 # 0 = 不自动停止 + + def wav_format(self) -> WavFormat: + encoding, bits = _fmt_for_bitdepth(self.bit_depth) + return WavFormat(int(self.samplerate), int(self.channels), encoding, bits) + + def sanitized(self) -> "RecordConfig": + cfg = replace(self) + cfg.samplerate = int(np.clip(cfg.samplerate, 8000, 384000)) + cfg.channels = int(np.clip(cfg.channels, 1, 32)) + cfg.gain_db = float(np.clip(cfg.gain_db, -60.0, 60.0)) + cfg.blocksize = int(np.clip(cfg.blocksize, 0, 65536)) + cfg.lowcut_hz = float(np.clip(cfg.lowcut_hz, 0.0, 500.0)) + cfg.split_seconds = max(0.0, float(cfg.split_seconds)) + cfg.split_megabytes = max(0.0, float(cfg.split_megabytes)) + cfg.silence_gap_seconds = float(np.clip(cfg.silence_gap_seconds, 0.2, 3600.0)) + cfg.silence_threshold_dbfs = float( + np.clip(cfg.silence_threshold_dbfs, -120.0, -10.0)) + cfg.auto_stop_silence_seconds = max(0.0, float(cfg.auto_stop_silence_seconds)) + return cfg + + def estimate_bytes_per_hour(self) -> float: + return self.wav_format().bytes_per_second * 3600.0 + + +@dataclass +class Marker: + label: str + file: str + seconds: float + timestamp: str + + +@dataclass +class LiveStats: + state: str = RecorderState.IDLE.value + elapsed: float = 0.0 + frames: int = 0 + bytes_written: int = 0 + current_file: str = "" + files: list[str] = field(default_factory=list) + queue_backlog: int = 0 + queue_backlog_ms: float = 0.0 + overflow_blocks: int = 0 + xruns: int = 0 + disk_write_mbps: float = 0.0 + free_space: int = -1 + meter: dsp.MeterSnapshot = field(default_factory=dsp.MeterSnapshot) + clips_total: int = 0 + error: str = "" + markers: int = 0 + paused_seconds: float = 0.0 + peak_dbfs: float = dsp.SILENCE_DBFS_FLOOR + + +@dataclass +class TakeResult: + files: list[str] = field(default_factory=list) + duration: float = 0.0 + frames: int = 0 + bytes_written: int = 0 + peak_dbfs: float = float("-inf") + clipped_samples: int = 0 + xruns: int = 0 + overflow_blocks: int = 0 + markers: list[Marker] = field(default_factory=list) + config: RecordConfig | None = None + started_at: str = "" + ended_at: str = "" + device_label: str = "" + format_label: str = "" + analysis: dict | None = None + notes: list[str] = field(default_factory=list) + + @property + def primary_file(self) -> str: + return self.files[0] if self.files else "" + + +# --------------------------------------------- 线性相位低切(时间轴对齐) +class AlignedLowCut: + """把线性相位 FIR 的输出对齐回原始时间轴。 + + 线性相位 FIR 有 ``latency`` 个采样的群延迟。这里在开头丢弃 + ``latency`` 个输出样本、停止时再补出尾部 ``latency`` 个样本, + 从而保证 **写出的样本数 == 采集的样本数**,既不偏移也不丢头掉尾。 + """ + + def __init__(self, cutoff_hz: float, samplerate: int, channels: int): + self.coeffs = dsp.design_highpass_fir(cutoff_hz, samplerate) + self.filter = dsp.FIRFilter(self.coeffs, channels) + self.latency = self.filter.latency_samples + self.channels = channels + self._dropped = 0 + + @property + def taps(self) -> int: + return int(self.coeffs.size) + + def process(self, block: np.ndarray) -> np.ndarray: + y = self.filter.process(block) + if self._dropped < self.latency: + drop = min(self.latency - self._dropped, y.shape[0]) + self._dropped += drop + y = y[drop:] + return y + + def flush(self) -> np.ndarray: + need = 2 * self.latency + if need <= 0: + return np.zeros((0, self.channels)) + y = self.filter.process(np.zeros((need, self.channels))) + return y[:self.latency] + + +# ------------------------------------------------------------------ 录音器 +class Recorder: + """一次录音会话。``live()`` 可在任意时刻从其它线程安全调用。""" + + def __init__(self, config: RecordConfig): + self.config = config.sanitized() + self.state = RecorderState.IDLE + self._lock = threading.RLock() + self._q: queue.Queue[Any] = queue.Queue(maxsize=512) + self._thread: threading.Thread | None = None + self._stream: Any = None + self._stop_flag = threading.Event() + self._pause_flag = threading.Event() + + self._writer: WavWriter | None = None + self._writer_format: WavFormat | None = None + self._lowcut: AlignedLowCut | None = None + self._meter: dsp.LevelMeter | None = None + self._out_dir = self.config.output_dir or os.path.join(os.getcwd(), "recordings") + + self._frames = 0 + self._frames_current = 0 + self._bytes = 0 + self._peak = 0.0 + self._start_monotonic = 0.0 + self._started_at = "" + self._files: list[str] = [] + self._markers: list[Marker] = [] + self._seq = 0 + self._notes: list[str] = [] + self._overflow_blocks = 0 + self._xruns = 0 + self._paused_samples = 0 + self._write_times: list[tuple[float, int]] = [] + self._split_requested = False + self._result: TakeResult | None = None + + self._silence_run = 0 + + # 界面波形数据 + self.scope_seconds = 1.0 + self._scope_len = 0 + self._scope: np.ndarray | None = None + self._scope_pos = 0 + self._env_bucket = 256 + self._env: list[np.ndarray] = [] + self._env_pending: np.ndarray | None = None + self._env_arr: np.ndarray | None = None + self._env_dirty = False + + self._live = LiveStats() + self._free_space = -1 + + # ------------------------------------------------------------- 生命周期 + def start(self) -> None: + if sd is None: + raise RuntimeError(f"音频后端不可用:{SD_IMPORT_ERROR or 'sounddevice 未安装'}") + if self.state in (RecorderState.RECORDING, RecorderState.STOPPING): + raise RuntimeError("录音已在进行中") + + cfg = self.config + if cfg.device is None: + cfg.device = default_device_index() + if cfg.device is None: + raise RuntimeError("没有找到任何可用的录音输入设备") + + dev = sd.query_devices(cfg.device) + max_ch = int(dev["max_input_channels"]) + if max_ch <= 0: + raise RuntimeError("所选设备没有输入通道") + if cfg.channels > max_ch: + self._note(f"设备最多支持 {max_ch} 个输入通道,已自动调整为 {max_ch}") + cfg.channels = max_ch + + api_name = str(sd.query_hostapis(int(dev["hostapi"]))["name"]) + is_wasapi = "wasapi" in api_name.lower() + extra = None + if is_wasapi: + try: + extra = sd.WasapiSettings(exclusive=bool(cfg.exclusive)) + except Exception: + extra = None + elif cfg.exclusive: + self._note(f"{api_name} 不支持独占模式,已按共享模式录音") + + try: + sd.check_input_settings(device=cfg.device, channels=cfg.channels, + samplerate=cfg.samplerate, dtype="float32", + extra_settings=extra) + except Exception as exc: + raise RuntimeError(self._explain_open_error(exc, dev, is_wasapi)) from exc + + os.makedirs(self._out_dir, exist_ok=True) + self._free_space = free_space_bytes(self._out_dir) + if 0 <= self._free_space < 512 * 1024 * 1024: + self._note(f"磁盘可用空间不足 512 MB(剩余 {format_bytes(self._free_space)})," + "长时间录音可能中断") + + self._reset_state() + self._meter = dsp.LevelMeter(cfg.samplerate, cfg.channels) + self._scope_len = max(1024, int(self.scope_seconds * cfg.samplerate)) + self._scope = np.zeros((self._scope_len, cfg.channels), dtype=np.float32) + self._lowcut = AlignedLowCut(cfg.lowcut_hz, cfg.samplerate, cfg.channels) \ + if cfg.lowcut_hz > 0 else None + if self._lowcut is not None: + self._note(f"已启用 {cfg.lowcut_hz:.0f} Hz 线性相位低切" + f"({self._lowcut.taps} 抽头,群延迟 " + f"{self._lowcut.latency / cfg.samplerate * 1000:.1f} ms," + "输出已对齐,不会丢头掉尾)") + + self._open_writer() + self._started_at = datetime.now().isoformat(timespec="seconds") + self._start_monotonic = time.monotonic() + self._stop_flag.clear() + self._pause_flag.clear() + self._payload_type = np.float32 + + self._thread = threading.Thread(target=self._writer_loop, + name="recorder-writer", daemon=True) + self._thread.start() + + try: + self._stream = sd.InputStream( + device=cfg.device, + channels=cfg.channels, + samplerate=cfg.samplerate, + dtype="float32", + blocksize=int(cfg.blocksize) or 0, + latency="low" if cfg.latency == "low" else "high", + callback=self._audio_callback, + extra_settings=extra, + never_drop_input=False, + ) + self._stream.start() + except Exception as exc: + self._stop_flag.set() + self._q.put(None) + if self._thread is not None: + self._thread.join(timeout=5.0) + self._thread = None + self._close_writer() + self.state = RecorderState.ERROR + raise RuntimeError(self._explain_open_error(exc, dev, is_wasapi)) from exc + + real_rate = int(getattr(self._stream, "samplerate", cfg.samplerate) or cfg.samplerate) + if real_rate != cfg.samplerate: + self._note(f"设备实际采样率为 {real_rate} Hz(请求 {cfg.samplerate} Hz)") + self.state = RecorderState.RECORDING + self._live.state = self.state.value + + def _explain_open_error(self, exc: Exception, dev: Any, is_wasapi: bool) -> str: + msg = f"无法打开录音设备「{dev['name']}」:{exc}" + hints: list[str] = [] + low = str(exc).lower() + if "device unavailable" in low or "busy" in low or "-9985" in low: + hints.append("设备可能正被其它程序占用(浏览器 / 会议软件 / 直播工具),请先关闭它们") + if "invalid sample rate" in low or "-9997" in low: + hints.append(f"该设备不支持 {self.config.samplerate} Hz:可改用 44100 或 48000 Hz," + "或关闭独占模式让系统做重采样") + if is_wasapi and self.config.exclusive: + hints.append("也可以关闭「WASAPI 独占模式」再试(共享模式兼容性更好)") + if "invalid number of channels" in low: + hints.append(f"通道数超出设备能力(最多 {dev['max_input_channels']} 个)") + if "unanticipated host error" in low or "-9999" in low: + hints.append("检查 Windows 隐私设置里是否允许桌面应用访问麦克风") + if hints: + msg += "\n\n建议:\n- " + "\n- ".join(hints) + return msg + + def stop(self) -> TakeResult: + with self._lock: + if self.state not in (RecorderState.RECORDING, RecorderState.PAUSED, + RecorderState.ERROR): + return self._result or TakeResult(config=self.config) + self.state = RecorderState.STOPPING + self._live.state = self.state.value + + if self._stream is not None: + try: + self._stream.stop() + self._stream.close() + except Exception as exc: + self._note(f"关闭音频流时出错:{exc}") + self._stream = None + + # 先置停止标志、再投毒丸:队列是 FIFO,之前排队的音频一定会先被写完 + self._stop_flag.set() + try: + self._q.put_nowait(None) + except queue.Full: + pass + if self._thread is not None: + self._thread.join(timeout=30.0) + self._thread = None + + result = self._result or TakeResult(config=self.config) + result.notes = list(self._notes) + if result.files: + try: + result.analysis = self._analyze(result.files) + except Exception as exc: + self._note(f"录音体检失败:{exc}") + result.notes = list(self._notes) + self._result = result + with self._lock: + self.state = RecorderState.IDLE + self._live.state = self.state.value + return result + + def _analyze(self, files: list[str]) -> dict: + segments = [] + for f in files: + try: + if os.path.getsize(f) <= 44: + continue + segments.append(dsp.analyze_file(f)) + except Exception: + continue + return _merge_analysis(segments) or {} + + def pause(self) -> None: + if self.state == RecorderState.RECORDING: + self._pause_flag.set() + self.state = RecorderState.PAUSED + self._live.state = self.state.value + + def resume(self) -> None: + if self.state == RecorderState.PAUSED: + self._pause_flag.clear() + self.state = RecorderState.RECORDING + self._live.state = self.state.value + + def toggle_pause(self) -> None: + if self.state == RecorderState.RECORDING: + self.pause() + elif self.state == RecorderState.PAUSED: + self.resume() + + def split_now(self) -> None: + with self._lock: + self._split_requested = True + + def add_marker(self, label: str = "") -> Marker | None: + with self._lock: + if self.state not in (RecorderState.RECORDING, RecorderState.PAUSED): + return None + m = Marker( + label=label or f"标记 {len(self._markers) + 1}", + file=os.path.basename(self._writer.path) if self._writer else "", + seconds=self._frames_current / max(1, self.config.samplerate), + timestamp=datetime.now().isoformat(timespec="milliseconds"), + ) + self._markers.append(m) + self._live.markers = len(self._markers) + return m + + def shutdown(self) -> None: + """程序退出兜底:确保流关闭、文件收尾。""" + try: + if self.state in (RecorderState.RECORDING, RecorderState.PAUSED): + self.stop() + except Exception: + pass + + # ------------------------------------------------------------- 音频回调 + def _audio_callback(self, indata, frames, time_info, status) -> None: # noqa: ANN001 + """PortAudio 回调:只做拷贝 + 入队,绝不做任何重活。""" + if status is not None and getattr(status, "input_overflow", False): + with self._lock: + self._xruns += 1 + if self._pause_flag.is_set(): + with self._lock: + self._paused_samples += frames + return + try: + self._q.put_nowait(np.array(indata, dtype=np.float32, copy=True)) + except queue.Full: + with self._lock: + self._overflow_blocks += 1 + + # ------------------------------------------------------------- 写入线程 + def _writer_loop(self) -> None: + q = self._q + last_checkpoint = time.monotonic() + while True: + try: + block = q.get(timeout=0.25) + except queue.Empty: + if self._stop_flag.is_set(): + break + self._refresh_backlog() + continue + if block is None: + break + try: + self._handle_block(block) + except _RiffOverflow: + self._note("已达 RIFF 4 GiB 上限,自动分段继续录音") + self._roll_over() + except Exception as exc: + self._note(f"写入文件失败:{exc}") + self._fail(f"写入文件失败:{exc}") + break + + now = time.monotonic() + if now - last_checkpoint > 1.0: + last_checkpoint = now + if self._writer is not None: + try: + self._writer.checkpoint(now) + except Exception: + pass + self._refresh_backlog() + + self._finalize() + + def _handle_block(self, block: np.ndarray) -> None: + cfg = self.config + if self._pause_flag.is_set(): + return + + x = block + if cfg.gain_db != 0.0: + x = x * dsp.db_to_lin(cfg.gain_db) + if self._lowcut is not None: + x = self._lowcut.process(x) + if x.shape[0] == 0: + return + + blk_peak = float(np.max(np.abs(x))) + if blk_peak > self._peak: + self._peak = blk_peak + + if self._meter is not None: + self._meter.process(x) + + self._push_scope(x) + self._push_envelope(x) + self._track_silence(x) + + if self._writer is None: + return + n = self._writer.write(x) + self._frames += n + self._frames_current += n + written = n * (self._writer_format.frame_bytes if self._writer_format else 0) + self._bytes += written + self._write_times.append((time.monotonic(), written)) + if len(self._write_times) > 400: + del self._write_times[:200] + + with self._lock: + self._live.frames = self._frames + self._live.bytes_written = self._bytes + self._live.elapsed = self._frames / max(1, cfg.samplerate) + self._live.peak_dbfs = dsp.lin_to_db(self._peak) + if self._writer is not None: + self._live.current_file = self._writer.path + self._live.clips_total = self._writer.clipped_samples + if self._meter is not None: + self._live.meter = self._meter.snapshot() + + if self._should_split(): + self._roll_over() + + # --------------------------------------------------- 静音 / 分段 / 轮转 + def _engine_stop(self, reason: str) -> None: + """由引擎内部主动结束录音(例如静音自动停止 / 写入失败)。 + + 必须同时关闭 PortAudio 流:只置停止标志的话,回调会继续往队列里塞数据, + 而写入线程已经退出,队列很快被塞满并开始统计溢出。 + """ + with self._lock: + if self._stop_flag.is_set(): + return + self._notes.append(reason) + self._stop_flag.set() + stream = self._stream + if stream is not None: + try: + stream.stop() + except Exception as exc: + self._note(f"自动停止时关闭音频流出错:{exc}") + try: + self._q.put_nowait(None) + except queue.Full: + pass + + def _track_silence(self, x: np.ndarray) -> None: + cfg = self.config + if not (cfg.split_on_silence or cfg.auto_stop_silence_seconds > 0): + return + thr = dsp.db_to_lin(cfg.silence_threshold_dbfs) + win = max(1, int(0.01 * cfg.samplerate)) + n = (x.shape[0] // win) * win + if n == 0: + return + env = np.max(np.abs(x[:n]), axis=1).reshape(-1, win).max(axis=1) + run = self._silence_run + limit = int(cfg.silence_gap_seconds * cfg.samplerate) + stop_limit = int(cfg.auto_stop_silence_seconds * cfg.samplerate) + triggered_split = False + triggered_stop = False + for v in env: + if v < thr: + run += win + if cfg.split_on_silence and not triggered_split and run >= limit: + triggered_split = True + if cfg.auto_stop_silence_seconds > 0 and not triggered_stop \ + and run >= stop_limit: + triggered_stop = True + else: + run = 0 + self._silence_run = run + if triggered_split: + self._split_requested = True + if triggered_stop: + self._engine_stop( + f"静音持续 {cfg.auto_stop_silence_seconds:.1f} 秒,已自动停止录音") + + def _should_split(self) -> bool: + cfg = self.config + if self._split_requested: + self._split_requested = False + return True + if cfg.split_seconds > 0 and \ + self._frames_current >= cfg.split_seconds * cfg.samplerate: + return True + if cfg.split_megabytes > 0 and self._writer is not None and \ + self._writer.bytes_written >= cfg.split_megabytes * 1024 * 1024: + return True + return False + + def _roll_over(self) -> None: + self._close_writer() + self._frames_current = 0 + self._silence_run = 0 + self._open_writer() + + def _open_writer(self) -> None: + path = self._next_path() + fmt = self.config.wav_format() + self._writer = WavWriter(path, fmt, dither=self.config.dither, + rf64=self.config.rf64) + self._writer_format = fmt + self._files.append(path) + with self._lock: + self._live.files = list(self._files) + self._live.current_file = path + self._note(f"写入 {os.path.basename(path)}({fmt.describe()})") + + def _close_writer(self) -> None: + if self._writer is None: + return + try: + stats = self._writer.close() + with self._lock: + self._live.current_file = stats["path"] + self._live.clips_total = stats["clipped_samples"] + except Exception as exc: + self._note(f"收尾文件时出错:{exc}") + self._writer = None + + def _next_path(self) -> str: + cfg = self.config + self._seq += 1 + dev_name = "input" + if cfg.device is not None and sd is not None: + try: + dev_name = str(sd.query_devices(cfg.device)["name"]).strip() + except Exception: + pass + safe_dev = "".join(c for c in dev_name if c not in '<>:"/\\|?*').strip() or "input" + now = datetime.now() + mapping = { + "date": now.strftime("%Y%m%d"), + "time": now.strftime("%H%M%S"), + "datetime": now.strftime("%Y%m%d_%H%M%S"), + "device": safe_dev, + "sr": str(cfg.samplerate), + "bits": "f32" if "float" in str(cfg.bit_depth).lower() else str(cfg.bit_depth), + "ch": f"{cfg.channels}ch", + "seq": f"{self._seq:03d}", + } + name = cfg.name_template or "{datetime}_{device}" + for k, v in mapping.items(): + name = name.replace("{" + k + "}", v) + name = "".join(c for c in name if c not in '<>:"/\\|?*').strip() or "recording" + path = os.path.join(self._out_dir, f"{name}.wav") + k = 2 + while os.path.exists(path): + path = os.path.join(self._out_dir, f"{name}_{k}.wav") + k += 1 + return path + + # ------------------------------------------------------------ 波形数据 + def _push_scope(self, x: np.ndarray) -> None: + if self._scope is None: + return + n = x.shape[0] + with self._lock: + if n >= self._scope_len: + self._scope[:] = x[-self._scope_len:] + self._scope_pos = 0 + return + end = self._scope_pos + n + if end <= self._scope_len: + self._scope[self._scope_pos:end] = x + else: + first = self._scope_len - self._scope_pos + self._scope[self._scope_pos:] = x[:first] + self._scope[:n - first] = x[first:] + self._scope_pos = end % self._scope_len + + def scope_data(self) -> np.ndarray: + """最近约 1 秒的波形(按时间顺序)。""" + with self._lock: + if self._scope is None: + return np.zeros((0, self.config.channels), dtype=np.float32) + if self._scope_pos == 0: + return self._scope.copy() + return np.concatenate((self._scope[self._scope_pos:], + self._scope[:self._scope_pos]), axis=0) + + def _push_envelope(self, x: np.ndarray) -> None: + data = x + if self._env_pending is not None and self._env_pending.shape[0]: + data = np.concatenate((self._env_pending, data), axis=0) + b = self._env_bucket + usable = (data.shape[0] // b) * b + if usable: + chunk = data[:usable].reshape(-1, b, data.shape[1]) + env = np.stack((chunk.min(axis=1), chunk.max(axis=1)), axis=1) + with self._lock: + self._env.append(env.astype(np.float32)) + self._env_dirty = True + self._env_pending = data[usable:] if usable < data.shape[0] else None + + def envelope(self) -> tuple[np.ndarray, np.ndarray, int]: + """整段录音的峰值包络 ``(mins, maxs, bucket_frames)``。 + + 结果做了增量缓存——界面每秒调用几十次也不会重复拼接大数组。 + """ + with self._lock: + if not self._env: + ch = self.config.channels + return (np.zeros((0, ch), np.float32), + np.zeros((0, ch), np.float32), self._env_bucket) + if self._env_dirty or self._env_arr is None \ + or self._env_arr.shape[0] != len(self._env): + self._env_arr = np.concatenate(self._env, axis=0) + self._env_dirty = False + arr = self._env_arr + return arr[:, 0, :], arr[:, 1, :], self._env_bucket + + # ------------------------------------------------------------ 只读访问 + def notes(self) -> list[str]: + """本次录音的运行日志(含自动调整、低切参数等提示)。""" + with self._lock: + return list(self._notes) + + def current_markers(self) -> list[Marker]: + with self._lock: + return list(self._markers) + + # ---------------------------------------------------------------- 快照 + def live(self) -> LiveStats: + with self._lock: + snap = replace(self._live) + snap.files = list(self._live.files) + snap.meter = self._meter.snapshot() if self._meter is not None \ + else self._live.meter + snap.free_space = self._free_space + snap.paused_seconds = self._paused_samples / max(1, self.config.samplerate) + snap.overflow_blocks = self._overflow_blocks + snap.xruns = self._xruns + return snap + + def _refresh_backlog(self) -> None: + backlog = self._q.qsize() + now = time.monotonic() + recent = [(t, b) for t, b in self._write_times if now - t <= 2.0] + mbps = sum(b for _, b in recent) / 2.0 / (1024 * 1024) + block_frames = self.config.blocksize or 1024 + with self._lock: + self._live.queue_backlog = backlog + self._live.queue_backlog_ms = backlog * block_frames / \ + max(1, self.config.samplerate) * 1000.0 + self._live.disk_write_mbps = mbps + self._live.free_space = free_space_bytes(self._out_dir) + self._live.overflow_blocks = self._overflow_blocks + self._live.xruns = self._xruns + + # -------------------------------------------------------------- 收尾 + def _finalize(self) -> None: + if self._lowcut is not None and self._writer is not None: + try: + tail = self._lowcut.flush() + if tail.shape[0]: + if self.config.gain_db != 0.0: + tail = tail * dsp.db_to_lin(self.config.gain_db) + n = self._writer.write(np.asarray(tail, dtype=np.float32)) + self._frames += n + self._frames_current += n + self._bytes += n * (self._writer_format.frame_bytes + if self._writer_format else 0) + except Exception as exc: + self._note(f"低切尾部处理失败:{exc}") + + self._close_writer() + + fmt = self._writer_format or self.config.wav_format() + self._result = TakeResult( + files=list(self._files), + duration=self._frames / max(1, self.config.samplerate), + frames=self._frames, + bytes_written=self._bytes, + peak_dbfs=dsp.lin_to_db(self._peak), + clipped_samples=self._live.clips_total, + xruns=self._xruns, + overflow_blocks=self._overflow_blocks, + markers=list(self._markers), + config=self.config, + started_at=self._started_at, + ended_at=datetime.now().isoformat(timespec="seconds"), + device_label=self._device_label(), + format_label=fmt.describe(), + notes=list(self._notes), + ) + with self._lock: + self._live.state = RecorderState.IDLE.value + self._live.frames = self._frames + self._live.elapsed = self._result.duration + self._live.bytes_written = self._bytes + self._live.peak_dbfs = self._result.peak_dbfs + + def _device_label(self) -> str: + if self.config.device is None or sd is None: + return "" + try: + d = sd.query_devices(self.config.device) + api = sd.query_hostapis(int(d["hostapi"]))["name"] + return f"{d['name']} [{api}]" + except Exception: + return "" + + def _reset_state(self) -> None: + self._frames = 0 + self._frames_current = 0 + self._bytes = 0 + self._peak = 0.0 + self._files = [] + self._markers = [] + self._seq = 0 + self._notes = [] + self._overflow_blocks = 0 + self._xruns = 0 + self._paused_samples = 0 + self._write_times = [] + self._env = [] + self._env_pending = None + self._env_arr = None + self._env_dirty = False + self._silence_run = 0 + self._split_requested = False + self._result = None + self._live = LiveStats(state=self.state.value) + + def _note(self, msg: str) -> None: + with self._lock: + self._notes.append(msg) + + def _fail(self, msg: str) -> None: + with self._lock: + self._notes.append(msg) + self._live.error = msg + self.state = RecorderState.ERROR + self._live.state = self.state.value + + +def _merge_analysis(segments: list[dict]) -> dict | None: + """把多个分段文件的体检结果合并成一份总结。""" + if not segments: + return None + if len(segments) == 1: + return segments[0] + merged = dict(segments[0]) + merged["segments"] = segments + merged["segment_count"] = len(segments) + merged["duration"] = round(sum(s.get("duration", 0.0) for s in segments), 3) + + def _max_of(key: str) -> float | None: + vals = [] + for s in segments: + v = s.get(key) + if isinstance(v, list): + vals.extend([x for x in v if x is not None]) + elif v is not None: + vals.append(v) + return max(vals) if vals else None + + peak = _max_of("peak_dbfs") + tp = _max_of("true_peak_dbtp") + if peak is not None: + merged["peak_dbfs"] = [peak] + if tp is not None: + merged["true_peak_dbtp"] = [tp] + merged["clipped_total"] = sum(s.get("clipped_total", 0) for s in segments) + + num = 0.0 + den = 0.0 + for s in segments: + lufs = s.get("integrated_lufs") + if lufs is not None: + num += dsp.db_to_lin(lufs) ** 2 * s.get("duration", 0.0) + den += s.get("duration", 0.0) + merged["integrated_lufs"] = round( + float(10.0 * np.log10(num / den)), 3) if den > 0 and num > 0 else None + return merged + + +# ------------------------------------------------------------ 便捷函数 +def quick_record(seconds: float, path: str, *, device: int | None = None, + samplerate: int = 48000, channels: int = 2, + bit_depth: str = "24", **kwargs) -> TakeResult: + """定时录音(命令行 / 自动化的便捷入口)。``path`` 不带扩展名。""" + out_dir = os.path.dirname(os.path.abspath(path)) or os.getcwd() + base = os.path.splitext(os.path.basename(path))[0] + cfg = RecordConfig(device=device, samplerate=samplerate, channels=channels, + bit_depth=bit_depth, output_dir=out_dir, + name_template=base, **kwargs) + rec = Recorder(cfg) + rec.start() + try: + time.sleep(max(0.1, float(seconds))) + finally: + result = rec.stop() + return result + + +def device_summary() -> str: + """人类可读的设备清单(命令行 --list-devices)。""" + devs = list_input_devices() + if not devs: + extra = f"({SD_IMPORT_ERROR})" if SD_IMPORT_ERROR else "" + return f"未发现可用输入设备。{extra}" + lines = [f"共 {len(devs)} 个录音输入设备(★ = 系统默认):", ""] + current_api = None + for d in devs: + if d.hostapi != current_api: + current_api = d.hostapi + lines.append(f"── {current_api} ──") + mark = "★" if d.is_default else " " + lines.append(f" {mark} [{d.index:3d}] {d.name}" + f" ({d.max_input_channels} ch, 默认 {d.default_samplerate:.0f} Hz)") + lines += [ + "", + "选择建议:", + " · 追求最高清晰度:WASAPI + 独占模式 + 24-bit/48 kHz(绕开系统混音器,不重采样)", + " · 录制电脑内部声音:选 WASAPI 下的“立体声混音 / Stereo Mix”,", + " 或 WDM-KS 下的“主声音捕获驱动程序”", + " · 兼容性优先:MME / DirectSound(但会经过系统混音器,可能被重采样)", + ] + return "\n".join(lines) diff --git a/recorder/gui.py b/recorder/gui.py new file mode 100644 index 0000000..7cbf761 --- /dev/null +++ b/recorder/gui.py @@ -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()) diff --git a/recorder/post.py b/recorder/post.py new file mode 100644 index 0000000..b647256 --- /dev/null +++ b/recorder/post.py @@ -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) diff --git a/recorder/selftest.py b/recorder/selftest.py new file mode 100644 index 0000000..a94e6b8 --- /dev/null +++ b/recorder/selftest.py @@ -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=" 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()) diff --git a/recorder/wavfile.py b/recorder/wavfile.py new file mode 100644 index 0000000..a075545 --- /dev/null +++ b/recorder/wavfile.py @@ -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=" np.ndarray: + """WAV 载荷字节 -> float64 数组([-1, 1] 标度)。""" + if encoding == "float": + return np.frombuffer(raw, dtype=" 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( + " 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(" 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("= 40: + (bits,) = struct.unpack("= 4: + (self._fact_frames,) = struct.unpack(" 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 diff --git a/recorder/widgets.py b/recorder/widgets.py new file mode 100644 index 0000000..d4835cb --- /dev/null +++ b/recorder/widgets.py @@ -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() diff --git a/recorder/window.py b/recorder/window.py new file mode 100644 index 0000000..5af5b26 --- /dev/null +++ b/recorder/window.py @@ -0,0 +1,1471 @@ +"""主窗口:设备选择、参数面板、实时计量、体检报告、后期处理与导出。""" + +from __future__ import annotations + +import os +import traceback + +import numpy as np +from PyQt5.QtCore import QSettings, Qt, QThread, QTimer, QUrl, pyqtSignal +from PyQt5.QtGui import QDesktopServices, QFont, QKeySequence +from PyQt5.QtWidgets import (QAction, QApplication, QCheckBox, QComboBox, + QDialog, QDialogButtonBox, QDoubleSpinBox, + QFileDialog, QFormLayout, QFrame, QGridLayout, + QGroupBox, QHBoxLayout, QLabel, QLineEdit, + QMainWindow, QMessageBox, QPlainTextEdit, + QProgressBar, QPushButton, QScrollArea, + QShortcut, QSizePolicy, QSlider, QSpinBox, + QSplitter, QStatusBar, QVBoxLayout, QWidget) + +from . import dsp, engine, post +from .engine import (RecordConfig, Recorder, RecorderState, TakeResult, + format_bytes, format_duration, free_space_bytes, + list_input_devices) +from .widgets import (BORDER, HistoryPlot, LevelMeter, LoudnessBar, + MarkerRail, OK, PANEL, Scope, StatusPill, TEXT, + TEXT_DIM, WARN, RecordButton) + +BIT_DEPTHS = [ + ("24 位 PCM(推荐)", "24", "动态范围 144 dB,远超人耳与话筒本底,兼容性极好"), + ("16 位 PCM", "16", "CD 标准。动态范围 96 dB,后期余量较小,建议录 24 位再降位深"), + ("32 位 PCM", "32", "32 位整数,极少使用;一般选 24 位或 32 位浮点更实用"), + ("32 位浮点", "float32", "录音不会因超过 0 dBFS 而削波,适合需要大量后期的素材"), +] +SAMPLE_RATES = [44100, 48000, 88200, 96000, 176400, 192000] +LOWCUT_CHOICES = [ + ("关闭(完全不处理)", 0.0, "保留全部低频,链路最透明"), + ("40 Hz", 40.0, "去掉空调声、桌面震动等极低频隆隆声"), + ("60 Hz", 60.0, "男声录音推荐"), + ("80 Hz", 80.0, "人声通用推荐(自检用的就是这一档)"), + ("120 Hz", 120.0, "去除近讲喷麦造成的低频堆积"), + ("180 Hz", 180.0, "播客/语音,声音最干净但会明显变薄"), +] +LUFS_TARGETS = [("不设定", None), ("播客 -16 LUFS", -16.0), + ("流媒体 -14 LUFS", -14.0), ("EBU R128 -23 LUFS", -23.0)] + + +def _short(text: str, limit: int = 22) -> str: + return text if len(text) <= limit else text[:limit - 1] + "…" + + +def _clock(seconds: float) -> str: + """大号计时器用的紧凑格式:不足 1 小时显示 MM:SS.d,超过则显示 H:MM:SS。 + + 这样字符串最长只有 8 个字符,28pt 粗体也能在窄窗口里完整显示, + 不会出现时间被截断看不清的情况。 + """ + s = max(0.0, float(seconds)) + h = int(s // 3600) + if h: + return f"{h}:{int((s % 3600) // 60):02d}:{int(s % 60):02d}" + return f"{int(s // 60):02d}:{s % 60:04.1f}" + + +# ------------------------------------------------------------------ 后台任务 +class Task(QThread): + progress = pyqtSignal(str) + done = pyqtSignal(object) + failed = pyqtSignal(str) + + def __init__(self, fn, *args, **kwargs): + super().__init__() + self._fn = fn + self._args = args + self._kwargs = kwargs + + def run(self) -> None: # noqa: D102 + fn = self._fn + try: + accepts_progress = self._accepts_progress(fn) + except (TypeError, ValueError): + accepts_progress = False + try: + if accepts_progress: + result = fn(*self._args, + progress=lambda m: self.progress.emit(m), + **self._kwargs) + else: + result = fn(*self._args, **self._kwargs) + except Exception as exc: # pragma: no cover + self.failed.emit(f"{exc}\n\n{traceback.format_exc()}") + return + self.done.emit(result) + + @staticmethod + def _accepts_progress(fn) -> bool: + import inspect + params = inspect.signature(fn).parameters + if "progress" in params: + return True + return any(p.kind == p.VAR_KEYWORD for p in params.values()) + + +# ------------------------------------------------------------------ 报告窗口 +class ReportDialog(QDialog): + """录音体检报告 + 后续操作入口。""" + + def __init__(self, result: TakeResult, parent=None): + super().__init__(parent) + self.result = result + self.setWindowTitle("录音完成 · 质量体检") + self.resize(760, 620) + lay = QVBoxLayout(self) + + head = QLabel(self._headline(result)) + head.setStyleSheet(f"color:{TEXT}; font-size:15px; font-weight:600;") + head.setWordWrap(True) + lay.addWidget(head) + + self.view = QPlainTextEdit() + self.view.setReadOnly(True) + self.view.setPlainText(post.render_report(result)) + self.view.setStyleSheet( + f"background:{PANEL}; color:{TEXT}; border:1px solid {BORDER};" + "border-radius:6px; padding:8px;") + font = QFont("Consolas") + font.setStyleHint(QFont.Monospace) + font.setPointSize(10) + self.view.setFont(font) + lay.addWidget(self.view, 1) + + row = QHBoxLayout() + for text, slot in (("打开文件位置", self._open_folder), + ("打开波形图", self._open_preview), + ("生成波形图与报告", self._write_extras), + ("后期处理…", self._process), + ("导出为其它格式…", self._export)): + btn = QPushButton(text) + btn.clicked.connect(slot) + row.addWidget(btn) + row.addStretch(1) + close = QPushButton("关闭") + close.clicked.connect(self.accept) + row.addWidget(close) + lay.addLayout(row) + + @staticmethod + def _headline(result: TakeResult) -> str: + an = result.analysis or {} + peak = an.get("true_peak_dbtp") + peak = max([v for v in peak if v is not None], default=None) \ + if isinstance(peak, list) else peak + parts = [f"已保存 {len(result.files)} 个文件", + f"总时长 {format_duration(result.duration)}", + f"数据量 {format_bytes(result.bytes_written)}"] + if peak is not None: + parts.append(f"真峰值 {peak:+.1f} dBTP") + if an.get("integrated_lufs") is not None: + parts.append(f"整体响度 {an['integrated_lufs']} LUFS") + if result.clipped_samples: + parts.append(f"⚠ 削波 {result.clipped_samples} 个样本") + if result.xruns or result.overflow_blocks: + parts.append(f"⚠ 驱动 xrun {result.xruns} / 溢出 {result.overflow_blocks}") + return " · ".join(parts) + + def _open_folder(self) -> None: + if self.result.primary_file: + QDesktopServices.openUrl( + QUrl.fromLocalFile(os.path.dirname(os.path.abspath( + self.result.primary_file)))) + + def _open_preview(self) -> None: + png = post.make_preview_for(self.result) + if png: + QDesktopServices.openUrl(QUrl.fromLocalFile(png)) + else: + QMessageBox.information(self, "提示", "无法生成波形预览图。") + + def _write_extras(self) -> None: + files = post.write_metadata(self.result) + png = post.make_preview_for(self.result) + if png: + files.append(png) + QMessageBox.information(self, "已生成", + "已生成:\n" + "\n".join(os.path.basename(f) + for f in files)) + + def _process(self) -> None: + dlg = ProcessDialog(self.result.primary_file, self) + dlg.exec_() + + def _export(self) -> None: + dlg = ExportDialog(self.result.primary_file, self) + dlg.exec_() + + +# ------------------------------------------------------------ 后期处理窗口 +class ProcessDialog(QDialog): + """后期处理(写出新文件,绝不动原始母版)。""" + + def __init__(self, src: str, parent=None): + super().__init__(parent) + self.src = src + self.setWindowTitle(f"后期处理 · {os.path.basename(src)}") + self.resize(560, 560) + lay = QVBoxLayout(self) + + tip = QLabel("所有处理都会另存为新文件(*_processed.wav),原文件保持不变。") + tip.setWordWrap(True) + tip.setStyleSheet(f"color:{TEXT_DIM};") + lay.addWidget(tip) + + form = QFormLayout() + self.dc = QCheckBox("去除直流偏移") + self.dc.setChecked(True) + form.addRow(self.dc) + + self.lowcut = QComboBox() + for item in LOWCUT_CHOICES: + self.lowcut.addItem(item[0]) + if item[2]: + self.lowcut.setItemData(self.lowcut.count() - 1, item[2], + Qt.ToolTipRole) + self.lowcut.setCurrentIndex(2) + form.addRow("低切(高通)", self.lowcut) + + self.gate = QCheckBox("启用噪声门") + self.gate_thr = QDoubleSpinBox() + self.gate_thr.setRange(-100, -20) + self.gate_thr.setValue(-60) + self.gate_thr.setSuffix(" dBFS") + form.addRow(self.gate, self.gate_thr) + + self.trim = QCheckBox("裁剪首尾静音") + self.trim.setChecked(True) + self.trim_thr = QDoubleSpinBox() + self.trim_thr.setRange(-90, -20) + self.trim_thr.setValue(-50) + self.trim_thr.setSuffix(" dBFS") + form.addRow(self.trim, self.trim_thr) + + self.norm = QComboBox() + self.norm.addItem("不归一化", "none") + self.norm.addItem("峰值归一化到 -1 dBFS", "peak") + self.norm.addItem("响度归一化(LUFS)", "lufs") + form.addRow("归一化", self.norm) + + self.norm_val = QDoubleSpinBox() + self.norm_val.setRange(-40, 0) + self.norm_val.setValue(-16) + self.norm_val.setSuffix(" LUFS") + form.addRow("目标响度", self.norm_val) + + self.fade_in = QDoubleSpinBox() + self.fade_in.setRange(0, 5) + self.fade_in.setSingleStep(0.01) + self.fade_in.setValue(0.01) + self.fade_in.setSuffix(" s") + self.fade_out = QDoubleSpinBox() + self.fade_out.setRange(0, 5) + self.fade_out.setSingleStep(0.01) + self.fade_out.setValue(0.05) + self.fade_out.setSuffix(" s") + form.addRow("淡入", self.fade_in) + form.addRow("淡出", self.fade_out) + + self.mono = QCheckBox("混合为单声道") + form.addRow(self.mono) + + self.depth = QComboBox() + for item in BIT_DEPTHS: + self.depth.addItem(item[0], item[1]) + if len(item) > 2 and item[2]: + self.depth.setItemData(self.depth.count() - 1, item[2], + Qt.ToolTipRole) + form.addRow("输出位深", self.depth) + + self.dither = QCheckBox("启用 TPDF 抖动(降位深时推荐)") + self.dither.setChecked(True) + form.addRow(self.dither) + lay.addLayout(form) + + self.progress = QProgressBar() + self.progress.setVisible(False) + lay.addWidget(self.progress) + + self.log = QPlainTextEdit() + self.log.setReadOnly(True) + self.log.setMaximumHeight(120) + lay.addWidget(self.log) + + row = QHBoxLayout() + self.run_btn = QPushButton("开始处理") + self.run_btn.clicked.connect(self._run) + row.addWidget(self.run_btn) + self.open_btn = QPushButton("打开输出目录") + self.open_btn.setEnabled(False) + self.open_btn.clicked.connect(self._open_output) + row.addWidget(self.open_btn) + row.addStretch(1) + close = QPushButton("关闭") + close.clicked.connect(self.reject) + row.addWidget(close) + lay.addLayout(row) + self._task: Task | None = None + self._last_output = "" + + def _open_output(self) -> None: + if self._last_output and os.path.exists(self._last_output): + QDesktopServices.openUrl(QUrl.fromLocalFile( + os.path.dirname(os.path.abspath(self._last_output)))) + + def _options(self) -> post.ProcessOptions: + return post.ProcessOptions( + trim_silence=self.trim.isChecked(), + trim_threshold_dbfs=self.trim_thr.value(), + remove_dc=self.dc.isChecked(), + lowcut_hz=LOWCUT_CHOICES[self.lowcut.currentIndex()][1], + noise_gate=self.gate.isChecked(), + gate_threshold_dbfs=self.gate_thr.value(), + normalize=self.norm.currentData(), + normalize_target_lufs=self.norm_val.value(), + fade_in=self.fade_in.value(), + fade_out=self.fade_out.value(), + mono=self.mono.isChecked(), + bit_depth=self.depth.currentData(), + dither=self.dither.isChecked(), + ) + + def _run(self) -> None: + opts = self._options() + self.run_btn.setEnabled(False) + self.progress.setVisible(True) + self.progress.setRange(0, 0) + self.log.appendPlainText("开始处理…") + + def job(progress=None): + return post.process_file(self.src, opts, progress=progress) + + self._task = Task(job) + self._task.progress.connect(lambda m: self.log.appendPlainText(m)) + self._task.done.connect(self._finished) + self._task.failed.connect(self._failed) + self._task.start() + + def _finished(self, report: object) -> None: + self.progress.setRange(0, 100) + self.progress.setValue(100) + self.run_btn.setEnabled(True) + rep = report if isinstance(report, dict) else {} + self.log.appendPlainText("完成:" + os.path.basename(str(rep.get("output", "")))) + for s in rep.get("steps", []): + self.log.appendPlainText(" · " + str(s)) + an = rep.get("analysis") or {} + if an: + self.log.appendPlainText( + f" 输出:峰值 {an.get('peak_dbfs')} dBFS," + f"真峰值 {an.get('true_peak_dbtp')} dBTP," + f"响度 {an.get('integrated_lufs')} LUFS") + # 注意:这里不弹模态对话框——本函数由工作线程的跨线程信号触发, + # 在信号回调里开嵌套事件循环容易造成重入崩溃。改为启用按钮由用户点击。 + if rep.get("output"): + self._last_output = str(rep["output"]) + self.open_btn.setEnabled(True) + self.log.appendPlainText(" → 点「打开输出目录」可直接查看结果") + + def _failed(self, msg: str) -> None: + self.progress.setVisible(False) + self.run_btn.setEnabled(True) + self.log.appendPlainText("失败:" + msg.splitlines()[0]) + QMessageBox.critical(self, "处理失败", msg) + + +class ExportDialog(QDialog): + """导出为 FLAC / MP3 / Opus / AAC 等格式。""" + + def __init__(self, src: str, parent=None): + super().__init__(parent) + self.src = src + self.setWindowTitle(f"导出 · {os.path.basename(src)}") + self.resize(520, 300) + lay = QVBoxLayout(self) + + ff = post.find_ffmpeg() + info = QLabel(("已检测到 ffmpeg:" + ff) if ff else + "未检测到 ffmpeg:FLAC/MP3/Opus/AAC 导出不可用," + "请安装 ffmpeg 并加入 PATH(WAV 导出不受影响)。") + info.setWordWrap(True) + info.setStyleSheet(f"color:{OK if ff else WARN};") + lay.addWidget(info) + + form = QFormLayout() + self.preset = QComboBox() + for key, meta in post.EXPORT_PRESETS.items(): + self.preset.addItem(meta["label"], key) + self.preset.setCurrentIndex(1) + form.addRow("目标格式", self.preset) + lay.addLayout(form) + + self.progress = QProgressBar() + self.progress.setVisible(False) + lay.addWidget(self.progress) + self.log = QPlainTextEdit() + self.log.setReadOnly(True) + lay.addWidget(self.log, 1) + + row = QHBoxLayout() + self.run_btn = QPushButton("开始导出") + self.run_btn.clicked.connect(self._run) + row.addWidget(self.run_btn) + row.addStretch(1) + close = QPushButton("关闭") + close.clicked.connect(self.reject) + row.addWidget(close) + lay.addLayout(row) + self._task: Task | None = None + + def _run(self) -> None: + key = self.preset.currentData() + dst = post.default_export_path(self.src, key) + self.run_btn.setEnabled(False) + self.progress.setVisible(True) + self.progress.setRange(0, 0) + self.log.appendPlainText(f"目标文件:{os.path.basename(dst)}") + + def job(): + return post.export_audio(self.src, dst, key) + + self._task = Task(job) + self._task.done.connect(self._finished) + # 同样不在跨线程信号里弹模态框,只写日志,由用户决定下一步 + self._task.failed.connect(self._failed) + self._task.start() + + def _failed(self, msg: str) -> None: + self.progress.setVisible(False) + self.run_btn.setEnabled(True) + self.log.appendPlainText("失败:" + msg.splitlines()[0]) + + def _finished(self, res: object) -> None: + ok, msg = res if isinstance(res, tuple) else (False, "未知结果") + self.progress.setVisible(False) + self.run_btn.setEnabled(True) + self.log.appendPlainText(("成功:" if ok else "失败:") + str(msg)) + + +# ------------------------------------------------------------------- 主窗口 +class MainWindow(QMainWindow): + def __init__(self): + super().__init__() + self.setWindowTitle("RecorderStudio · 高清录音机") + self.resize(1240, 860) + self.setMinimumSize(1120, 700) + + self.recorder: Recorder | None = None + self.result: TakeResult | None = None + self._devices: list[engine.DeviceInfo] = [] + self._settings = QSettings("RecorderStudio", "RecorderStudio") + self._cap_task: Task | None = None + self._calib_task: Task | None = None + self._last_files: list[str] = [] + self._pulse = 0.0 + self._device_signature = "" + self._peak_alerted = False + self._tick_count = 0 + self._history_ready = False + + self._build_ui() + self._load_settings() + self.refresh_devices() + self._install_shortcuts() + + self.timer = QTimer(self) + self.timer.setInterval(33) + self.timer.timeout.connect(self._tick) + self.timer.start() + + self.watchdog = QTimer(self) + self.watchdog.setInterval(3000) + self.watchdog.timeout.connect(self._check_devices) + self.watchdog.start() + + self._set_idle_ui() + + # ------------------------------------------------------------------ UI + def _build_ui(self) -> None: + central = QWidget() + self.setCentralWidget(central) + outer = QVBoxLayout(central) + outer.setContentsMargins(12, 10, 12, 8) + outer.setSpacing(10) + + split = QSplitter(Qt.Horizontal) + split.addWidget(self._build_left_panel()) + split.addWidget(self._build_right_panel()) + split.setStretchFactor(0, 0) + split.setStretchFactor(1, 1) + split.setSizes([372, 808]) + outer.addWidget(split, 1) + + # 底部状态栏 + bar = QStatusBar() + self.setStatusBar(bar) + self.status_left = QLabel("就绪") + self.status_right = QLabel("") + bar.addWidget(self.status_left, 1) + bar.addPermanentWidget(self.status_right) + + self._build_menu() + + def _build_menu(self) -> None: + m_file = self.menuBar().addMenu("文件(&F)") + act_dir = QAction("选择输出目录…", self) + act_dir.triggered.connect(self.choose_output_dir) + m_file.addAction(act_dir) + act_open = QAction("打开输出目录", self) + act_open.setShortcut(QKeySequence("Ctrl+O")) + act_open.triggered.connect(self.open_output_dir) + m_file.addAction(act_open) + m_file.addSeparator() + act_quit = QAction("退出", self) + act_quit.setShortcut(QKeySequence("Ctrl+Q")) + act_quit.triggered.connect(self.close) + m_file.addAction(act_quit) + + m_tool = self.menuBar().addMenu("工具(&T)") + act_probe = QAction("检测设备支持能力", self) + act_probe.triggered.connect(self.probe_device) + m_tool.addAction(act_probe) + act_calib = QAction("校准本底噪声(测 3 秒静音)", self) + act_calib.triggered.connect(self.calibrate_noise) + m_tool.addAction(act_calib) + m_tool.addSeparator() + act_post = QAction("对最近的录音做后期处理…", self) + act_post.triggered.connect(self.open_process_last) + m_tool.addAction(act_post) + + m_help = self.menuBar().addMenu("帮助(&H)") + act_keys = QAction("快捷键", self) + act_keys.triggered.connect(self.show_shortcuts) + m_help.addAction(act_keys) + act_about = QAction("关于 / 高清晰度录音指南", self) + act_about.triggered.connect(self.show_about) + m_help.addAction(act_about) + + def _group(self, title: str) -> tuple[QGroupBox, QFormLayout]: + box = QGroupBox(title) + form = QFormLayout(box) + form.setLabelAlignment(Qt.AlignLeft | Qt.AlignVCenter) + form.setFormAlignment(Qt.AlignLeft | Qt.AlignTop) + form.setContentsMargins(10, 14, 10, 10) + form.setSpacing(7) + return box, form + + def _combo(self, items: list[tuple], *, tip: str = "") -> QComboBox: + """创建下拉框。 + + 关键点:默认的 ``sizeHint`` 会按最长条目计算最小宽度, + 在窄侧栏里会把整个面板撑宽导致右侧被裁切; + 这里改用 ``AdjustToMinimumContentsLengthWithIcon`` 并限定长度, + 窗口变窄时由 Qt 自动省略号处理。 + """ + c = QComboBox() + c.setSizeAdjustPolicy(QComboBox.AdjustToMinimumContentsLengthWithIcon) + c.setMinimumContentsLength(9) + for item in items: + label, value = item[0], item[1] + c.addItem(label, value) + if len(item) > 2 and item[2]: + c.setItemData(c.count() - 1, item[2], Qt.ToolTipRole) + if tip: + c.setToolTip(tip) + return c + + def _spin(self, *, suffix: str = "", rng: tuple[float, float] = (0, 100), + value: float = 0.0, special: str = "", decimals: int = 2, + step: float = 0.5, tip: str = "") -> QDoubleSpinBox: + """创建数值输入框。 + + QDoubleSpinBox 的 ``minimumSizeHint`` 会按"最大值+后缀+微调按钮"预留宽度 + (动辄 160~180 px),在窄侧栏里会把面板撑宽。这里把水平尺寸策略设为 + ``Ignored`` 并给一个明确的最小宽度,让它既能收缩到 88 px 又不至于不可用。 + """ + s = QDoubleSpinBox() + s.setRange(*rng) + s.setDecimals(decimals) + s.setSingleStep(step) + if suffix: + s.setSuffix(suffix) + if special: + s.setSpecialValueText(special) + s.setValue(value) + s.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed) + s.setMinimumWidth(88) + if tip: + s.setToolTip(tip) + return s + + def _build_left_panel(self) -> QWidget: + """左侧设置栏:四组参数 + 滚动容器。""" + panel = QWidget() + v = QVBoxLayout(panel) + v.setContentsMargins(0, 0, 6, 0) + v.setSpacing(10) + v.addWidget(self._panel_device()) + v.addWidget(self._panel_format()) + v.addWidget(self._panel_processing()) + v.addWidget(self._panel_files()) + v.addStretch(1) + + scroll = QScrollArea() + scroll.setWidget(panel) + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.NoFrame) + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + scroll.setMinimumWidth(330) + scroll.setMaximumWidth(430) + return scroll + + def _panel_device(self) -> QGroupBox: + box, form = self._group("① 输入设备") + self.device_combo = self._combo([], tip="设备按保真度排序:WASAPI 最优先") + self.device_combo.currentIndexChanged.connect(self._on_device_changed) + form.addRow("设备", self.device_combo) + + row = QHBoxLayout() + self.refresh_btn = QPushButton("刷新设备") + self.refresh_btn.clicked.connect(self.refresh_devices) + self.probe_btn = QPushButton("检测能力") + self.probe_btn.clicked.connect(self.probe_device) + row.addWidget(self.refresh_btn) + row.addWidget(self.probe_btn) + wrap = QWidget() + wrap.setLayout(row) + form.addRow("", wrap) + + self.device_info = QLabel("—") + self.device_info.setWordWrap(True) + self.device_info.setStyleSheet(f"color:{TEXT_DIM}; font-size:11px;") + form.addRow(self.device_info) + return box + + def _panel_format(self) -> QGroupBox: + box, form = self._group("② 录音格式") + self.rate_combo = self._combo([(f"{r} Hz", r) for r in SAMPLE_RATES], + tip="设备原生采样率无需重采样,保真度最高") + self.rate_combo.setCurrentIndex(1) + self.rate_combo.currentIndexChanged.connect(self._update_estimate) + form.addRow("采样率", self.rate_combo) + + self.depth_combo = self._combo([(l, v, t) for l, v, t in BIT_DEPTHS], + tip="24 位是录音的甜点:动态范围 144 dB") + idx = self.depth_combo.findData("24") + if idx >= 0: + self.depth_combo.setCurrentIndex(idx) + self.depth_combo.currentIndexChanged.connect(self._update_estimate) + form.addRow("位深", self.depth_combo) + + self.channels_spin = QSpinBox() + self.channels_spin.setRange(1, 8) + self.channels_spin.setValue(2) + self.channels_spin.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed) + self.channels_spin.setMinimumWidth(88) + self.channels_spin.valueChanged.connect(self._update_estimate) + form.addRow("声道数", self.channels_spin) + + self.exclusive_check = QCheckBox("WASAPI 独占模式") + self.exclusive_check.setChecked(True) + self.exclusive_check.setToolTip( + "绕开 Windows 系统混音器,不做重采样、不被其它程序改动音量,\n" + "是 Windows 上最接近“原始信号”的采集方式。") + form.addRow(self.exclusive_check) + + self.blocksize_combo = self._combo( + [("自动(推荐)", 0), ("512 采样", 512), ("1024 采样", 1024), + ("2048 采样", 2048), ("4096 采样(最稳)", 4096)], + tip="录音不需要低延迟,缓冲区大一点更不容易出现爆音/掉采样") + form.addRow("缓冲区", self.blocksize_combo) + + self.latency_combo = self._combo( + [("高(更抗掉采样,推荐)", "high"), ("低(追求实时监听)", "low")]) + form.addRow("延迟策略", self.latency_combo) + return box + + def _panel_processing(self) -> QGroupBox: + box, form = self._group("③ 信号处理") + self.lowcut_combo = self._combo(LOWCUT_CHOICES, + tip="线性相位 FIR,不引入相位失真,且输出已对齐时间轴") + form.addRow("低切滤波", self.lowcut_combo) + + self.gain_slider = QSlider(Qt.Horizontal) + self.gain_slider.setRange(-240, 240) # 0.1 dB 步进 + self.gain_slider.setValue(0) + self.gain_slider.valueChanged.connect(self._on_gain_changed) + self.gain_label = QLabel("+0.0 dB") + self.gain_label.setMinimumWidth(66) + grow = QHBoxLayout() + grow.addWidget(self.gain_slider, 1) + grow.addWidget(self.gain_label) + gw = QWidget() + gw.setLayout(grow) + form.addRow("软件增益", gw) + + self.dither_check = QCheckBox("启用 TPDF 抖动") + self.dither_check.setChecked(True) + self.dither_check.setToolTip( + "量化到 16/24 位前加入 1 LSB 峰峰值的三角分布抖动,\n" + "把“与信号相关的非线性失真”变成无关的宽带白噪,弱信号细节更干净。") + form.addRow(self.dither_check) + + self.rf64_check = QCheckBox("RF64 大文件容器") + self.rf64_check.setToolTip( + "RIFF 容器上限 4 GB。开启后使用 RF64(EBU Tech 3306),\n" + "单文件可超过 4 GB;不开启时程序会在接近上限处自动分段。") + form.addRow(self.rf64_check) + + self.target_combo = self._combo(LUFS_TARGETS, + tip="在响度条上画一条目标参考线") + self.target_combo.currentIndexChanged.connect(self._on_target_changed) + form.addRow("响度参考", self.target_combo) + return box + + def _panel_files(self) -> QGroupBox: + box, form = self._group("④ 文件与分段") + self.dir_edit = QLineEdit() + self.dir_edit.textChanged.connect(self._update_estimate) + browse = QPushButton("…") + browse.setFixedWidth(32) + browse.clicked.connect(self.choose_output_dir) + drow = QHBoxLayout() + drow.addWidget(self.dir_edit, 1) + drow.addWidget(browse) + dw = QWidget() + dw.setLayout(drow) + form.addRow("输出目录", dw) + + self.name_edit = QLineEdit("{datetime}_{device}") + self.name_edit.setToolTip("可用变量:{datetime} {date} {time} {device} " + "{sr} {bits} {ch} {seq}") + form.addRow("命名模板", self.name_edit) + + self.split_time = self._spin(suffix=" 秒", rng=(0, 7200), value=0.0, + special="不分段", decimals=0, step=60, + tip="达到该时长自动切到新文件继续录") + form.addRow("按时长分段", self.split_time) + + self.split_size = self._spin(suffix=" MB", rng=(0, 20480), value=0.0, + special="不限制", decimals=0, step=100, + tip="单个文件达到该体积自动分段") + form.addRow("按体积分段", self.split_size) + + self.split_silence = QCheckBox("静音处自动分段") + self.split_silence.setToolTip("检测到持续静音时立即切到新文件," + "适合长时间会议/采访自动拆分") + form.addRow(self.split_silence) + + self.silence_thr = self._spin(suffix=" dBFS", rng=(-90, -20), value=-50.0, + decimals=1, step=1.0, + tip="低于该电平即视为静音") + form.addRow("静音阈值", self.silence_thr) + + self.silence_gap = self._spin(suffix=" 秒", rng=(0.2, 60), value=2.0, + decimals=1, step=0.5, + tip="静音持续超过该时长才触发分段") + form.addRow("静音时长", self.silence_gap) + + self.auto_stop = self._spin(suffix=" 秒", rng=(0, 600), value=0.0, + special="不自动停止", decimals=0, step=5, + tip="持续静音达到该时长后自动停止录音") + form.addRow("静音自动停止", self.auto_stop) + + self.estimate_label = QLabel("—") + self.estimate_label.setStyleSheet(f"color:{TEXT_DIM}; font-size:11px;") + self.estimate_label.setWordWrap(True) + self.estimate_label.setMinimumWidth(0) + form.addRow(self.estimate_label) + + self.auto_report_check = QCheckBox("录音后显示体检报告") + self.auto_report_check.setChecked(True) + form.addRow(self.auto_report_check) + + calib_btn = QPushButton("校准本底噪声") + calib_btn.setToolTip("播放 3 秒静音测量房间/设备本底噪声," + "并给出低切与噪声门建议") + calib_btn.clicked.connect(self.calibrate_noise) + form.addRow(calib_btn) + return box + + def _build_right_panel(self) -> QWidget: + panel = QWidget() + v = QVBoxLayout(panel) + v.setContentsMargins(0, 0, 0, 0) + v.setSpacing(10) + + # 传输控制条 + transport = QFrame() + transport.setObjectName("card") + t = QHBoxLayout(transport) + t.setContentsMargins(14, 12, 14, 12) + t.setSpacing(14) + + self.rec_button = RecordButton() + self.rec_button.clicked.connect(self.toggle_record) + t.addWidget(self.rec_button) + + tcol = QVBoxLayout() + tcol.setSpacing(2) + self.time_label = QLabel("00:00.0") + tf = QFont("Consolas") + tf.setStyleHint(QFont.Monospace) + tf.setPointSize(26) + tf.setBold(True) + self.time_label.setFont(tf) + self.time_label.setMinimumWidth(280) + self.time_label.setStyleSheet(f"color:{TEXT};") + tcol.addWidget(self.time_label) + self.state_label = QLabel("待机") + self.state_label.setStyleSheet(f"color:{TEXT_DIM};") + tcol.addWidget(self.state_label) + t.addLayout(tcol) + + t.addStretch(1) + + self.pause_btn = QPushButton("暂停") + self.pause_btn.setToolTip("暂停 / 继续(快捷键 P);暂停期间的数据不写入文件") + self.pause_btn.clicked.connect(self.toggle_pause) + self.marker_btn = QPushButton("标记") + self.marker_btn.setToolTip("在当前时间点打标记(快捷键 M),写入元数据") + self.marker_btn.clicked.connect(self.add_marker) + self.split_btn = QPushButton("分段") + self.split_btn.setToolTip("立即切到新文件继续录音(快捷键 Ctrl+S)") + self.split_btn.clicked.connect(self.split_now) + self.stop_btn = QPushButton("停止") + self.stop_btn.setToolTip("停止录音并收尾文件(快捷键 空格)") + self.stop_btn.clicked.connect(self.stop_record) + for b in (self.pause_btn, self.marker_btn, self.split_btn, self.stop_btn): + b.setMinimumHeight(34) + b.setMinimumWidth(72) + b.setFocusPolicy(Qt.NoFocus) + t.addWidget(b) + self.rec_button.setFocusPolicy(Qt.NoFocus) + v.addWidget(transport) + + # 电平表 + box = QGroupBox("电平表(RMS / 峰值保持 / 真峰值)") + bl = QVBoxLayout(box) + bl.setContentsMargins(10, 14, 10, 8) + self.meter = LevelMeter(channels=2) + bl.addWidget(self.meter) + v.addWidget(box) + + # 中段:示波器 + 响度 + box2 = QGroupBox("实时波形") + s2 = QVBoxLayout(box2) + s2.setContentsMargins(10, 14, 10, 8) + self.scope = Scope() + s2.addWidget(self.scope) + v.addWidget(box2, 2) + + box3 = QGroupBox("响度(ITU-R BS.1770 / EBU R128)") + l3 = QVBoxLayout(box3) + l3.setContentsMargins(10, 14, 10, 8) + self.loudness = LoudnessBar() + self.loudness.set_target(-16.0) + l3.addWidget(self.loudness) + v.addWidget(box3) + + # 总览 + box4 = QGroupBox("整段录音总览") + o4 = QVBoxLayout(box4) + o4.setContentsMargins(10, 14, 10, 8) + self.history = HistoryPlot() + self.rail = MarkerRail() + o4.addWidget(self.history) + o4.addWidget(self.rail) + v.addWidget(box4, 1) + + # 状态标签 + pills = QGridLayout() + pills.setHorizontalSpacing(6) + self.pill_file = StatusPill("未开始", "dim") + self.pill_disk = StatusPill("磁盘 —", "dim") + self.pill_queue = StatusPill("缓冲队列 0", "dim") + self.pill_health = StatusPill("驱动 xrun 0", "dim") + pills.addWidget(self.pill_file, 0, 0) + pills.addWidget(self.pill_disk, 0, 1) + pills.addWidget(self.pill_queue, 0, 2) + pills.addWidget(self.pill_health, 0, 3) + v.addLayout(pills) + return panel + + def _install_shortcuts(self) -> None: + QShortcut(QKeySequence(Qt.Key_Space), self, activated=self.toggle_record) + QShortcut(QKeySequence("P"), self, activated=self.toggle_pause) + QShortcut(QKeySequence("M"), self, activated=self.add_marker) + QShortcut(QKeySequence("Ctrl+S"), self, activated=self.split_now) + + # ------------------------------------------------------------ 设备逻辑 + def refresh_devices(self) -> None: + current = self.device_combo.currentData() + self._devices = list_input_devices() + self.device_combo.blockSignals(True) + self.device_combo.clear() + last_api = None + for d in self._devices: + if d.hostapi != last_api: + self.device_combo.addItem(f"── {d.hostapi} ──", None) + idx = self.device_combo.count() - 1 + self.device_combo.model().item(idx).setEnabled(False) + last_api = d.hostapi + mark = "★ " if d.is_default else "" + self.device_combo.addItem( + f"{mark}{d.name} ({d.max_input_channels} ch)", d.index) + # 恢复原选择或选择默认设备 + target = current if current is not None else engine.default_device_index() + for i in range(self.device_combo.count()): + if self.device_combo.itemData(i) == target: + self.device_combo.setCurrentIndex(i) + break + else: + for i in range(self.device_combo.count()): + if self.device_combo.itemData(i) is not None: + self.device_combo.setCurrentIndex(i) + break + self.device_combo.blockSignals(False) + self._on_device_changed() + self._device_signature = self._signature() + + def _signature(self) -> str: + return "|".join(f"{d.index}:{d.name}:{d.hostapi}" for d in self._devices) + + def _current_device(self) -> engine.DeviceInfo | None: + idx = self.device_combo.currentData() + for d in self._devices: + if d.index == idx: + return d + return None + + def _on_device_changed(self) -> None: + d = self._current_device() + if d is None: + self.device_info.setText("未选择设备") + return + self.channels_spin.setMaximum(max(1, d.max_input_channels)) + if self.channels_spin.value() > d.max_input_channels: + self.channels_spin.setValue(d.max_input_channels) + # 采样率对齐设备默认值 + for i in range(self.rate_combo.count()): + if self.rate_combo.itemData(i) == int(d.default_samplerate): + self.rate_combo.setCurrentIndex(i) + break + hint = "" + rank = d.quality_rank + if rank >= 3: + hint = ("\n提示:MME/DirectSound 会经过 Windows 混音器,可能被重采样," + "建议改用同一设备的 WASAPI 条目以获得更高保真度。") + elif rank == 0 and "wasapi" in d.hostapi.lower(): + hint = "\n提示:WASAPI + 独占模式可完全绕开系统混音器,是最高保真的选择。" + self.device_info.setText( + f"宿主 API:{d.hostapi} 最大输入通道:{d.max_input_channels}\n" + f"驱动默认采样率:{d.default_samplerate:.0f} Hz " + f"报告延迟:低 {d.default_low_input_latency * 1000:.1f} ms / " + f"高 {d.default_high_input_latency * 1000:.1f} ms{hint}") + self._update_estimate() + + def _check_devices(self) -> None: + devs = list_input_devices() + sig = "|".join(f"{d.index}:{d.name}:{d.hostapi}" for d in devs) + if sig != self._device_signature: + self._device_signature = sig + self._devices = devs + if self.recorder is None: + self.status_left.setText("检测到设备变化,已刷新设备列表") + self.refresh_devices() + + def probe_device(self) -> None: + d = self._current_device() + if d is None: + QMessageBox.warning(self, "提示", "请先选择一个输入设备。") + return + self.probe_btn.setEnabled(False) + self.status_left.setText("正在检测设备支持能力…") + + def job(progress=None): + return engine.probe_capabilities(d.index, channels=self.channels_spin.value()) + + self._cap_task = Task(job) + self._cap_task.done.connect(self._show_capabilities) + self._cap_task.failed.connect(lambda m: ( + self.probe_btn.setEnabled(True), + QMessageBox.critical(self, "检测失败", m))) + self._cap_task.start() + + def _show_capabilities(self, res: object) -> None: + self.probe_btn.setEnabled(True) + r = res if isinstance(res, dict) else {} + lines = [f"设备:{self.device_combo.currentText().strip()}", + f"宿主 API:{r.get('hostapi')} 检测通道数:{r.get('channels')}", ""] + for rate, entry in (r.get("rates") or {}).items(): + if entry.get("supported"): + mode = "独占" if entry.get("exclusive") else "共享" + lines.append(f" ✓ {rate:>6} Hz {mode} 支持格式:" + f"{', '.join(entry.get('dtypes', []))}") + else: + lines.append(f" ✗ {rate:>6} Hz 不支持({entry.get('error', '')[:60]})") + lines += ["", "结论:优先选择标注 ✓ 且为「独占」的采样率;", + "若想录 96 kHz 却没有 ✓,说明设备本身不支持该采样率。"] + QMessageBox.information(self, "设备能力检测", "\n".join(lines)) + self.status_left.setText("设备能力检测完成") + + def calibrate_noise(self) -> None: + d = self._current_device() + if d is None: + QMessageBox.warning(self, "提示", "请先选择一个输入设备。") + return + if self.recorder is not None: + QMessageBox.warning(self, "提示", "请先停止当前录音再做校准。") + return + QMessageBox.information(self, "校准本底噪声", + "接下来 3 秒请保持安静(不要说话、不要敲键盘)。\n" + "将测量房间/设备的本底噪声,并给出低切与噪声门建议。") + self.status_left.setText("正在测量本底噪声(保持安静)…") + + rate = int(self.rate_combo.currentData()) + ch = int(self.channels_spin.value()) + dev = d.index + excl = self.exclusive_check.isChecked() + + def job(progress=None): + import sounddevice as sd + extra = sd.WasapiSettings(exclusive=excl) \ + if "wasapi" in d.hostapi.lower() else None + data = sd.rec(int(3.0 * rate), samplerate=rate, channels=ch, + dtype="float32", device=dev, extra_settings=extra, + blocking=True) + sd.wait() + return dsp.analyze_data(np.asarray(data, dtype=np.float64), rate) + + self._calib_task = Task(job) + self._calib_task.done.connect(self._show_calibration) + self._calib_task.failed.connect(lambda m: ( + self.status_left.setText("校准失败"), QMessageBox.critical(self, "校准失败", m))) + self._calib_task.start() + + def _show_calibration(self, res: object) -> None: + an = res if isinstance(res, dict) else {} + floor = an.get("noise_floor_dbfs") or [] + peak = an.get("peak_dbfs") or [] + rms = an.get("rms_dbfs") or [] + lines = ["本底噪声测量结果(3 秒)", ""] + lines.append(f" 采样峰值:{peak if peak else '—'} dBFS") + lines.append(f" RMS 电平:{rms if rms else '—'} dBFS") + lines.append(f" 本底噪声:{floor if floor else '—(3 秒内没有检测到安静段)'}") + lines.append(f" 直流偏移:{an.get('dc_offset')}") + lines.append("") + suggest_gate = None + if floor: + f0 = float(floor[0]) + suggest_gate = round(min(-30.0, f0 + 6.0), 1) + lines.append(f"建议噪声门阈值:{suggest_gate} dBFS(本底 +6 dB)") + lines.append("建议低切:若本底噪声在 100 Hz 以下明显更高,选择 80~120 Hz 低切") + lines.append("建议增益:让正常说话时峰值落在 -12 ~ -6 dBFS 之间最理想") + if peak and peak[0] is not None and peak[0] < -30: + lines.append(f" 当前峰值偏低({peak[0]:.1f} dBFS)," + "可在「软件增益」里提升 10~20 dB") + if peak and peak[0] is not None and peak[0] > -3: + lines.append(f" 当前峰值偏高({peak[0]:.1f} dBFS),建议降低系统麦克风音量") + box = QMessageBox(self) + box.setWindowTitle("本底噪声校准") + box.setText("\n".join(lines)) + apply_btn = box.addButton("应用建议", QMessageBox.AcceptRole) + box.addButton("关闭", QMessageBox.RejectRole) + box.exec_() + if box.clickedButton() is apply_btn and suggest_gate is not None: + self.silence_thr.setValue(suggest_gate) + self.status_left.setText(f"已把静音阈值设为 {suggest_gate} dBFS") + + # ------------------------------------------------------------ 参数收集 + def _config_from_ui(self) -> RecordConfig: + lowcut = self.lowcut_combo.currentData() or 0.0 + return RecordConfig( + device=self.device_combo.currentData(), + samplerate=int(self.rate_combo.currentData()), + channels=int(self.channels_spin.value()), + bit_depth=self.depth_combo.currentData(), + gain_db=self.gain_slider.value() / 10.0, + exclusive=self.exclusive_check.isChecked(), + blocksize=int(self.blocksize_combo.currentData() or 0), + latency=self.latency_combo.currentData(), + lowcut_hz=float(lowcut), + dither=self.dither_check.isChecked(), + rf64=self.rf64_check.isChecked(), + output_dir=self.dir_edit.text().strip(), + name_template=self.name_edit.text().strip() or "{datetime}_{device}", + split_seconds=float(self.split_time.value()), + split_megabytes=float(self.split_size.value()), + split_on_silence=self.split_silence.isChecked(), + silence_threshold_dbfs=float(self.silence_thr.value()), + silence_gap_seconds=float(self.silence_gap.value()), + auto_stop_silence_seconds=float(self.auto_stop.value()), + ) + + def _on_gain_changed(self, value: int) -> None: + db = value / 10.0 + self.gain_label.setText(f"{db:+.1f} dB") + if self.recorder is not None and self.recorder.state == RecorderState.RECORDING: + self.recorder.config.gain_db = db # 软件增益支持录音中实时调整 + + def _on_target_changed(self) -> None: + target = self.target_combo.currentData() + if target is not None and hasattr(self, "loudness"): + self.loudness.set_target(float(target)) + + def _update_estimate(self) -> None: + try: + cfg = self._config_from_ui() + except Exception: + return + bps = cfg.estimate_bytes_per_hour() + free = free_space_bytes(cfg.output_dir or os.getcwd()) + bits = cfg.bit_depth + note = "" + if bits == "24": + note = "24 位/48 kHz 是录音的甜点:动态范围 144 dB,兼容性极好。" + elif bits == "16": + note = "16 位适合直接发布,但后期处理余量小,建议录 24 位再降。" + elif bits == "float32": + note = "32 位浮点不会因超 0 dBFS 而削波,适合需要大量后期的素材。" + else: + note = "32 位整数相对少见,一般选 24 位或 32 位浮点更实用。" + self.estimate_label.setText( + f"约 {format_bytes(bps)} / 小时 · 剩余空间 {format_bytes(free) if free > 0 else '—'}" + f" · 可录约 {(free / bps):.1f} 小时\n{note}" + if free > 0 else f"约 {format_bytes(bps)} / 小时\n{note}") + + # ------------------------------------------------------------ 录音控制 + def toggle_record(self) -> None: + if self.recorder is None: + self.start_record() + else: + self.stop_record() + + def start_record(self) -> None: + try: + cfg = self._config_from_ui() + except Exception as exc: + QMessageBox.critical(self, "参数错误", str(exc)) + return + if not cfg.output_dir: + cfg.output_dir = os.path.join(os.path.expanduser("~"), "Recordings") + try: + os.makedirs(cfg.output_dir, exist_ok=True) + except Exception as exc: + QMessageBox.critical(self, "无法创建输出目录", str(exc)) + return + + self.recorder = Recorder(cfg) + try: + self.recorder.start() + except Exception as exc: + self.recorder = None + QMessageBox.critical(self, "无法开始录音", str(exc)) + self._set_idle_ui() + return + + self._peak_alerted = False + self._history_ready = False + self.meter.set_channels(cfg.channels) + self.scope.set_format(cfg.samplerate, cfg.channels) + self.meter.reset() + self.rail.set_marks([], 1.0) + self._set_recording_ui(True) + self.status_left.setText(f"正在录音:{cfg.output_dir}") + notes = self.recorder.notes() + if notes: + self.status_left.setText(notes[-1]) + + def stop_record(self) -> None: + if self.recorder is None: + return + self.status_left.setText("正在收尾文件…") + QApplication.processEvents() + try: + result = self.recorder.stop() + except Exception as exc: + QMessageBox.critical(self, "停止录音出错", str(exc)) + result = None + self.recorder = None + self._set_idle_ui() + if result is None or not result.files: + self.status_left.setText("录音已停止(没有产生文件)") + return + self.result = result + self._last_files = list(result.files) + self.status_left.setText( + f"已保存:{os.path.basename(result.primary_file)} " + f"{format_duration(result.duration)} {format_bytes(result.bytes_written)}") + self.rail.set_marks([(m.seconds, m.label) for m in result.markers], + max(1e-6, result.duration)) + if self.auto_report_check.isChecked(): + ReportDialog(result, self).exec_() + + def toggle_pause(self) -> None: + if self.recorder is None: + return + self.recorder.toggle_pause() + paused = self.recorder.state == RecorderState.PAUSED + self.rec_button.set_state("paused" if paused else "recording") + self.pause_btn.setText("▶ 继续" if paused else "⏸ 暂停") + self.state_label.setText("已暂停(此段不写入文件)" if paused else "录音中") + + def add_marker(self) -> None: + if self.recorder is None: + return + m = self.recorder.add_marker() + if m: + self.status_left.setText(f"已添加标记:{m.seconds:.2f} 秒") + + def split_now(self) -> None: + if self.recorder is None: + return + self.recorder.split_now() + self.status_left.setText("已请求分段,正在切换新文件…") + + def choose_output_dir(self) -> None: + start = self.dir_edit.text().strip() or os.path.expanduser("~") + path = QFileDialog.getExistingDirectory(self, "选择录音保存目录", start) + if path: + self.dir_edit.setText(path) + self._save_settings() + + def open_output_dir(self) -> None: + path = self.dir_edit.text().strip() + if path and os.path.isdir(path): + QDesktopServices.openUrl(QUrl.fromLocalFile(path)) + else: + QMessageBox.information(self, "提示", "输出目录还不存在。") + + def open_process_last(self) -> None: + src = self.result.primary_file if self.result else ( + self._last_files[0] if self._last_files else "") + if not src or not os.path.exists(src): + path, _ = QFileDialog.getOpenFileName(self, "选择要处理的音频", "", + "WAV 文件 (*.wav)") + if not path: + return + src = path + ProcessDialog(src, self).exec_() + + # ------------------------------------------------------------ 界面状态 + def _set_recording_ui(self, recording: bool) -> None: + self.rec_button.set_state("recording" if recording else "idle") + self.pause_btn.setText("⏸ 暂停") + for w in (self.device_combo, self.refresh_btn, self.rate_combo, + self.depth_combo, self.channels_spin, self.exclusive_check, + self.blocksize_combo, self.latency_combo, self.lowcut_combo, + self.rf64_check, self.dir_edit, self.name_edit, + self.split_time, self.split_size, self.split_silence, + self.silence_thr, self.silence_gap, self.auto_stop): + w.setEnabled(not recording) + for w in (self.pause_btn, self.marker_btn, self.split_btn, self.stop_btn): + w.setEnabled(recording) + self.state_label.setText("录音中…" if recording else "待机") + + def _set_idle_ui(self) -> None: + self._set_recording_ui(False) + self.rec_button.set_state("idle") + self.pause_btn.setEnabled(False) + self.marker_btn.setEnabled(False) + self.split_btn.setEnabled(False) + self.stop_btn.setEnabled(False) + + def _tick(self) -> None: + self._pulse = (self._pulse + 0.033) % 1.0 + self.rec_button.set_pulse(self._pulse) + if self.recorder is None: + return + snap = self.recorder.live() + # 引擎可能自己停了(静音自动停止 / 写入失败):此时要替用户走完收尾流程, + # 否则界面会一直停在"录音中"而文件其实已经收尾。 + if snap.state == RecorderState.IDLE.value \ + and self.recorder.state != RecorderState.IDLE: + self.status_left.setText("录音已由引擎自动结束,正在收尾…") + self.stop_record() + return + self.meter.update_levels(snap.meter) + self.scope.update_data(self.recorder.scope_data(), + self.recorder.state == RecorderState.RECORDING) + self.loudness.update_levels(snap.meter.momentary_lufs, + snap.meter.short_term_lufs, + snap.meter.integrated_lufs) + self.time_label.setText(_clock(snap.elapsed)) + + # 总览图是整段聚合,刷新率降到约 3 Hz,长录音也不会拖累界面 + self._tick_count += 1 + if self._tick_count % 8 == 0 or not self._history_ready: + mins, maxs, bucket = self.recorder.envelope() + self.history.update_envelope(mins, maxs, bucket, + self.recorder.config.samplerate, + snap.elapsed) + self._history_ready = True + marks = [(m.seconds, m.label) for m in self.recorder.current_markers()] + self.rail.set_marks(marks, max(1e-6, snap.elapsed)) + + if snap.current_file: + self.pill_file.set(f"{os.path.basename(snap.current_file)} " + f"{format_bytes(snap.bytes_written)}", "info") + free = snap.free_space + if free > 0: + lvl = "ok" if free > 2 * 1024 ** 3 else ("warn" if free > 512 * 1024 ** 2 + else "err") + self.pill_disk.set(f"剩余空间 {format_bytes(free)}", lvl) + self.pill_queue.set( + f"缓冲队列 {snap.queue_backlog}({snap.queue_backlog_ms:.0f} ms)" + f" 写盘 {snap.disk_write_mbps:.1f} MB/s", + "ok" if snap.queue_backlog < 8 else ("warn" if snap.queue_backlog < 32 + else "err")) + health_lvl = "ok" if (snap.xruns == 0 and snap.overflow_blocks == 0) else "warn" + self.pill_health.set( + f"驱动 xrun {snap.xruns} 溢出块 {snap.overflow_blocks}", health_lvl) + if snap.meter.clipped and not self._peak_alerted: + self._peak_alerted = True + self.status_left.setText("⚠ 检测到削波:请降低系统麦克风音量或软件增益") + self.status_right.setText( + f"{self.recorder.config.wav_format().describe()} " + f"{'独占' if self.recorder.config.exclusive else '共享'} " + f"标记 {snap.markers}") + + # ------------------------------------------------------------ 设置持久化 + def _settings_path(self) -> str: + return os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "recordings") + + def _load_settings(self) -> None: + s = self._settings + default_dir = s.value("output_dir", self._settings_path(), type=str) + self.dir_edit.setText(str(default_dir)) + self.name_edit.setText(s.value("name_template", "{datetime}_{device}", type=str)) + idx = self.depth_combo.findData(s.value("bit_depth", "24", type=str)) + if idx >= 0: + self.depth_combo.setCurrentIndex(idx) + idx = self.rate_combo.findData(s.value("samplerate", 48000, type=int)) + if idx >= 0: + self.rate_combo.setCurrentIndex(idx) + self.channels_spin.setValue(s.value("channels", 2, type=int)) + self.exclusive_check.setChecked(s.value("exclusive", True, type=bool)) + self.dither_check.setChecked(s.value("dither", True, type=bool)) + idx = self.lowcut_combo.findData(s.value("lowcut", 0.0, type=float)) + if idx >= 0: + self.lowcut_combo.setCurrentIndex(idx) + self.gain_slider.setValue(int(s.value("gain_db_x10", 0, type=int))) + idx = self.latency_combo.findData(s.value("latency", "high", type=str)) + if idx >= 0: + self.latency_combo.setCurrentIndex(idx) + self.window_state = s.value("geometry") + + def _save_settings(self) -> None: + s = self._settings + s.setValue("output_dir", self.dir_edit.text().strip()) + s.setValue("name_template", self.name_edit.text().strip()) + s.setValue("bit_depth", self.depth_combo.currentData()) + s.setValue("samplerate", int(self.rate_combo.currentData())) + s.setValue("channels", int(self.channels_spin.value())) + s.setValue("exclusive", self.exclusive_check.isChecked()) + s.setValue("dither", self.dither_check.isChecked()) + s.setValue("lowcut", float(self.lowcut_combo.currentData() or 0.0)) + s.setValue("gain_db_x10", int(self.gain_slider.value())) + s.setValue("latency", self.latency_combo.currentData()) + s.sync() + + def closeEvent(self, event) -> None: # noqa: N802 + if self.recorder is not None: + answer = QMessageBox.question( + self, "正在录音", "录音尚未停止,是否停止并保存后退出?", + QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes) + if answer == QMessageBox.No: + event.ignore() + return + self.recorder.shutdown() + self._save_settings() + event.accept() + + # ------------------------------------------------------------ 帮助信息 + def show_shortcuts(self) -> None: + QMessageBox.information(self, "快捷键", "\n".join([ + "空格   开始 / 停止录音", + "P     暂停 / 继续(暂停段不写入文件)", + "M     添加标记(写入元数据)", + "Ctrl+S  立即分段(切到新文件继续录)", + "Ctrl+O  打开输出目录", + "Ctrl+Q  退出", + ])) + + def show_about(self) -> None: + dlg = QDialog(self) + dlg.setWindowTitle("关于 / 高清晰度录音指南") + dlg.resize(680, 620) + lay = QVBoxLayout(dlg) + text = QPlainTextEdit() + text.setReadOnly(True) + text.setPlainText(HIGH_QUALITY_GUIDE) + lay.addWidget(text) + btns = QDialogButtonBox(QDialogButtonBox.Ok) + btns.accepted.connect(dlg.accept) + lay.addWidget(btns) + dlg.exec_() + + +HIGH_QUALITY_GUIDE = """RecorderStudio 高清晰度录音指南 +===================================================== + +一、这条链路为什么"清晰" +----------------------------------------------------- +1. 采集端默认走 WASAPI 独占模式:绕开 Windows 混音器, + 不会被系统重采样、不会被其它程序的音量条动过手脚。 +2. 内部全程 32 位浮点运算,写盘再按你选的位深量化; + 16/24 位写入时可选 TPDF 抖动,把量化失真从"与信号相关的 + 非线性失真"变成无关的宽带白噪,弱信号细节听得更干净。 +3. 低切滤波使用 5000+ 抽头的线性相位 FIR:不引入相位失真, + 并对输出做群延迟补偿,不会丢头掉尾。 +4. 音频回调线程只做内存拷贝,滤波/写盘/FFT 都在写入线程, + 从根上避免 xrun(爆音、掉采样)。 +5. 计量按国际标准:真峰值 4 倍过采样(ITU-R BS.1770 附录 2), + 响度按 K 加权 + 门限算法(EBU R128)。本程序的自检里 + 已用 ffmpeg 的 ebur128 交叉验证,误差 < 0.1 LU。 + +二、参数怎么选 +----------------------------------------------------- +· 采样率:48000 Hz。它同时满足语音、音乐与视频后期; + 设备原生就是 48 kHz 时选它可完全避免重采样。 +· 位深:24 位。动态范围 144 dB,远超人耳与话筒本底; + 需要发给别人听就录 24 位、导出 16 位(带抖动)。 +· 声道:话筒用 1 声道;立体声乐器/环境音用 2 声道。 +· 缓冲区:优先"自动/4096 + 高延迟策略",录音不需要低延迟, + 稳定不出 xrun 才是第一优先级。 +· 软件增益:只在系统麦克风音量已经拉满还不够时使用; + 优先调节系统输入音量,因为它作用在更靠前的增益级。 + +三、电平与房间 +----------------------------------------------------- +· 目标:正常说话时峰值落在 -12 ~ -6 dBFS,绝不碰到 0 dBFS。 + 数字削波是不可逆的,且比模拟过载难听得多。 +· 房间噪声比设备更影响"清晰度":关空调、关风扇、 + 在桌上垫软布、话筒离嘴 15~25 cm 并稍偏轴(避开喷气)。 +· 先用「校准本底噪声」测一下:如果本底高于 -55 dBFS, + 先改善环境,再考虑噪声门。 + +四、录制电脑内部声音 +----------------------------------------------------- +在设备列表里选择「立体声混音 / Stereo Mix」或 +「主声音捕获驱动程序」(通常在 WASAPI/WDM-KS 分组下)。 +若列表中没有,需要在 Windows「声音设置 → 录制 → +显示禁用的设备」里启用"立体声混音"。 + +五、录完之后 +----------------------------------------------------- +· 每次录音都会生成体检报告:采样峰值、真峰值、RMS、 + 整体响度、动态范围、直流偏移、本底噪声、削波统计。 +· 「后期处理」可做:DC 去除、低切、噪声门、裁剪首尾静音、 + 峰值/响度归一化、淡入淡出、混单声道 —— 全部另存为新文件, + 原始母版永不被覆盖。 +· 「导出」支持 FLAC(无损压缩)、MP3、Opus、AAC; + 需要 ffmpeg,程序会自动检测。 + +六、常见问题 +----------------------------------------------------- +· 打不开设备:多半被其它程序独占,关掉浏览器/会议软件再试; + 或关闭"独占模式"改用共享模式。 +· 提示采样率不支持:看「检测能力」的结果,选一个带 ✓ 的采样率。 +· 录出来是静音:检查 Windows 隐私设置是否允许桌面应用访问麦克风, + 以及系统输入设备是否选对。 +""" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1c10ad0 --- /dev/null +++ b/requirements.txt @@ -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 全功能不受影响。 diff --git a/run.bat b/run.bat new file mode 100644 index 0000000..ee1ad57 --- /dev/null +++ b/run.bat @@ -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 diff --git a/启动录音机.bat b/启动录音机.bat new file mode 100644 index 0000000..28a1b50 --- /dev/null +++ b/启动录音机.bat @@ -0,0 +1,6 @@ +@echo off +rem ============================================================ +rem 中文名启动器(内容保持纯 ASCII,避免任何编码问题) +rem ============================================================ +cd /d "%~dp0" +call "%~dp0run.bat" %*