Initial commit of current state
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
"""Pipecat local-audio transport backed by sounddevice instead of PyAudio.
|
||||
|
||||
Pipecat ships `pipecat.transports.local.audio.LocalAudioTransport`, but it needs
|
||||
PyAudio, which has no macOS wheel and must be compiled against a Homebrew
|
||||
portaudio. This machine blocks both, so we talk to the same portaudio through
|
||||
sounddevice, whose wheel bundles a prebuilt dylib. The frame contract is
|
||||
identical to the upstream transport.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import sounddevice as sd
|
||||
from loguru import logger
|
||||
|
||||
from pipecat.frames.frames import InputAudioRawFrame, OutputAudioRawFrame, StartFrame
|
||||
from pipecat.processors.frame_processor import FrameProcessor
|
||||
from pipecat.transports.base_input import BaseInputTransport
|
||||
from pipecat.transports.base_output import BaseOutputTransport
|
||||
from pipecat.transports.base_transport import BaseTransport, TransportParams
|
||||
|
||||
|
||||
class SoundDeviceTransportParams(TransportParams):
|
||||
"""Configuration for the sounddevice transport.
|
||||
|
||||
Parameters:
|
||||
input_device: sounddevice device index or name substring. None uses the default.
|
||||
output_device: sounddevice device index or name substring. None uses the default.
|
||||
"""
|
||||
|
||||
input_device: int | str | None = None
|
||||
output_device: int | str | None = None
|
||||
|
||||
|
||||
class SoundDeviceInputTransport(BaseInputTransport):
|
||||
"""Captures microphone audio and pushes it into the pipeline."""
|
||||
|
||||
_params: SoundDeviceTransportParams
|
||||
|
||||
def __init__(self, params: SoundDeviceTransportParams):
|
||||
super().__init__(params)
|
||||
self._in_stream: sd.RawInputStream | None = None
|
||||
self._sample_rate = 0
|
||||
|
||||
async def start(self, frame: StartFrame):
|
||||
await super().start(frame)
|
||||
|
||||
if self._in_stream:
|
||||
return
|
||||
|
||||
self._sample_rate = self._params.audio_in_sample_rate or frame.audio_in_sample_rate
|
||||
blocksize = int(self._sample_rate / 100) * 2 # 20ms
|
||||
|
||||
self._in_stream = sd.RawInputStream(
|
||||
samplerate=self._sample_rate,
|
||||
blocksize=blocksize,
|
||||
device=self._params.input_device,
|
||||
channels=self._params.audio_in_channels,
|
||||
dtype="int16",
|
||||
callback=self._audio_in_callback,
|
||||
)
|
||||
self._in_stream.start()
|
||||
|
||||
device_name = sd.query_devices(self._in_stream.device, "input")["name"]
|
||||
logger.info(f"Microphone: {device_name} @ {self._sample_rate} Hz")
|
||||
|
||||
await self.set_transport_ready(frame)
|
||||
|
||||
async def cleanup(self):
|
||||
await super().cleanup()
|
||||
if self._in_stream:
|
||||
self._in_stream.stop()
|
||||
self._in_stream.close()
|
||||
self._in_stream = None
|
||||
|
||||
def _audio_in_callback(self, indata, frame_count, time_info, status):
|
||||
if status:
|
||||
logger.trace(f"Audio input status: {status}")
|
||||
|
||||
frame = InputAudioRawFrame(
|
||||
audio=bytes(indata),
|
||||
sample_rate=self._sample_rate,
|
||||
num_channels=self._params.audio_in_channels,
|
||||
)
|
||||
|
||||
asyncio.run_coroutine_threadsafe(self.push_audio_frame(frame), self.get_event_loop())
|
||||
|
||||
|
||||
class SoundDeviceOutputTransport(BaseOutputTransport):
|
||||
"""Plays pipeline audio out through the speakers."""
|
||||
|
||||
_params: SoundDeviceTransportParams
|
||||
|
||||
def __init__(self, params: SoundDeviceTransportParams):
|
||||
super().__init__(params)
|
||||
self._out_stream: sd.RawOutputStream | None = None
|
||||
self._sample_rate = 0
|
||||
# Writes are serialized by the pipeline, so one worker is enough.
|
||||
self._executor = ThreadPoolExecutor(max_workers=1)
|
||||
|
||||
async def start(self, frame: StartFrame):
|
||||
await super().start(frame)
|
||||
|
||||
if self._out_stream:
|
||||
return
|
||||
|
||||
self._sample_rate = self._params.audio_out_sample_rate or frame.audio_out_sample_rate
|
||||
|
||||
self._out_stream = sd.RawOutputStream(
|
||||
samplerate=self._sample_rate,
|
||||
device=self._params.output_device,
|
||||
channels=self._params.audio_out_channels,
|
||||
dtype="int16",
|
||||
)
|
||||
self._out_stream.start()
|
||||
|
||||
device_name = sd.query_devices(self._out_stream.device, "output")["name"]
|
||||
logger.info(f"Speaker: {device_name} @ {self._sample_rate} Hz")
|
||||
|
||||
await self.set_transport_ready(frame)
|
||||
|
||||
async def cleanup(self):
|
||||
await super().cleanup()
|
||||
if self._out_stream:
|
||||
self._out_stream.stop()
|
||||
self._out_stream.close()
|
||||
self._out_stream = None
|
||||
|
||||
async def write_audio_frame(self, frame: OutputAudioRawFrame) -> bool:
|
||||
if not self._out_stream:
|
||||
return False
|
||||
await self.get_event_loop().run_in_executor(
|
||||
self._executor, self._out_stream.write, frame.audio
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
class SoundDeviceTransport(BaseTransport):
|
||||
"""Local microphone + speaker transport."""
|
||||
|
||||
def __init__(self, params: SoundDeviceTransportParams):
|
||||
super().__init__()
|
||||
self._params = params
|
||||
self._input: SoundDeviceInputTransport | None = None
|
||||
self._output: SoundDeviceOutputTransport | None = None
|
||||
|
||||
def input(self) -> FrameProcessor:
|
||||
if not self._input:
|
||||
self._input = SoundDeviceInputTransport(self._params)
|
||||
return self._input
|
||||
|
||||
def output(self) -> FrameProcessor:
|
||||
if not self._output:
|
||||
self._output = SoundDeviceOutputTransport(self._params)
|
||||
return self._output
|
||||
|
||||
|
||||
def list_devices() -> str:
|
||||
"""Render the audio devices sounddevice can see, for `--list-devices`."""
|
||||
lines = []
|
||||
for index, device in enumerate(sd.query_devices()):
|
||||
capability = []
|
||||
if device["max_input_channels"]:
|
||||
capability.append(f"in:{device['max_input_channels']}")
|
||||
if device["max_output_channels"]:
|
||||
capability.append(f"out:{device['max_output_channels']}")
|
||||
lines.append(f" [{index}] {device['name']} ({', '.join(capability)})")
|
||||
return "\n".join(lines)
|
||||
Reference in New Issue
Block a user