"""파일/합성 오디오 캡처 백엔드. 실제 오디오 장치 없이 파이프라인 전체를 돌려볼 수 있게 해준다. 개발·테스트·데모용이며 어느 OS에서나 동작한다. """ from __future__ import annotations import time import wave from pathlib import Path import numpy as np from ..constants import FRAME_SAMPLES, SAMPLE_RATE from .base import AudioSource, CaptureBackend, CaptureError, to_mono_16k class FileCapture(CaptureBackend): """WAV 파일을 실시간 속도로 재생하듯 흘려보낸다.""" name = "file" per_process = False def __init__(self, path: str | Path, loop: bool = False, realtime: bool = True) -> None: super().__init__() self.path = Path(path) self.loop = loop self.realtime = realtime @staticmethod def available() -> bool: return True @staticmethod def list_sources() -> list[AudioSource]: return [] def _read_wav(self) -> np.ndarray: if not self.path.is_file(): raise CaptureError(f"오디오 파일을 찾을 수 없습니다: {self.path}") with wave.open(str(self.path), "rb") as wf: channels = wf.getnchannels() rate = wf.getframerate() width = wf.getsampwidth() raw = wf.readframes(wf.getnframes()) if width == 2: data = np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0 elif width == 4: data = np.frombuffer(raw, dtype=np.float32) else: raise CaptureError(f"지원하지 않는 샘플 폭입니다: {width * 8}bit") return to_mono_16k(data, channels, rate) def _run(self) -> None: audio = self._read_wav() chunk = FRAME_SAMPLES * 5 # 100ms while not self._stop.is_set(): for start in range(0, audio.size, chunk): if self._stop.is_set(): return self._emit(audio[start : start + chunk].copy()) if self.realtime: time.sleep(chunk / SAMPLE_RATE) if not self.loop: return