1083 lines
41 KiB
Python
1083 lines
41 KiB
Python
"""录音引擎:设备枚举、能力探测、低延迟采集、落盘线程、自动分段。
|
||
|
||
线程模型的取舍(直接决定"有没有爆音/掉采样"):
|
||
|
||
* **音频回调线程只做一件事**——把数据从 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)
|