Files

1083 lines
41 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""录音引擎:设备枚举、能力探测、低延迟采集、落盘线程、自动分段。
线程模型的取舍(直接决定"有没有爆音/掉采样"):
* **音频回调线程只做一件事**——把数据从 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)