From ca57285ec77b2f0b0fb21f04166172e5f9cce24c Mon Sep 17 00:00:00 2001 From: Stephen Huan Date: Tue, 9 Aug 2022 10:04:01 -0400 Subject: [PATCH] cython contributed configuration --- contrib/cython/README.md | 36 +++++ contrib/cython/src/audio-record | 55 ++++++++ contrib/cython/src/cbackend.pxd | 24 ++++ contrib/cython/src/cbackend.pyx | 124 +++++++++++++++++ contrib/cython/src/crecord.pyx | 223 +++++++++++++++++++++++++++++++ contrib/cython/src/portaudio.pxd | 95 +++++++++++++ contrib/cython/src/setup.py | 23 ++++ 7 files changed, 580 insertions(+) create mode 100644 contrib/cython/README.md create mode 100755 contrib/cython/src/audio-record create mode 100644 contrib/cython/src/cbackend.pxd create mode 100644 contrib/cython/src/cbackend.pyx create mode 100644 contrib/cython/src/crecord.pyx create mode 100644 contrib/cython/src/portaudio.pxd create mode 100644 contrib/cython/src/setup.py diff --git a/contrib/cython/README.md b/contrib/cython/README.md new file mode 100644 index 0000000..00d8fe4 --- /dev/null +++ b/contrib/cython/README.md @@ -0,0 +1,36 @@ +# Recording Audio with PortAudio and Cython + +This is a drop-in replacement for [audio-record](../python/src/audio-record), +replacing the Python library `sounddevice` which wraps +PortAudio with a direct Cython interface to PortAudio. +The rationale is that it might improve performance and stability. + +### Requirements + +The requirements are [Cython](https://cython.org/), +[cysignals](https://github.com/sagemath/cysignals), +[PortAudio](http://portaudio.com/) (for recording +audio), and [pydub](https://github.com/jiaaro/pydub/) (for saving recordings). +```console +pip install cython cysignals pydub +``` +(Cython is packaged in Community as `cython`, +cysignals is packaged in Community as `python-cysignals`, +PortAudio is packaged in Community as `portaudio`, +and pydub is packaged in the AUR as `python-pydub`). + +Once the dependencies have been installed, build the extension modules with +```console +python setup.py build_ext --inplace +``` + +### Caveats + +The program only responds to `SIGINT` (2) +and not `SIGTERM` (15) to stop recording. + +The program does not actually begin recording faster than its +Python counterpart since Python interpreter overhead is minimal; +the main startup costs are really in initializing PortAudio. +That is, there'll still be a ~0.25 second delay before the recording starts. + diff --git a/contrib/cython/src/audio-record b/contrib/cython/src/audio-record new file mode 100755 index 0000000..789c6de --- /dev/null +++ b/contrib/cython/src/audio-record @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +import argparse +import crecord + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="record audio") + parser.add_argument("path", help="file path") + parser.add_argument("-d", "--device", help="device name") + parser.add_argument("-c", "--channels", type=int, default=2, + help="number of audio channels") + parser.add_argument("-s", "--samplerate", type=float, default=48000, + help="audio sample rate (pass 0 for default rate)") + parser.add_argument("-l", "--latency", default="high", + help="audio latency") + parser.add_argument("-b", "--bitrate", default="64k", + help="audio bitrate") + + args = parser.parse_args() + + # device can either be string identifier or index + try: + device = int(args.device) + except (ValueError, TypeError): + device = args.device + # use default sounddevice selection + if device is not None and \ + (len(device) == 0 or device.lower() == "none"): + device = None + + # begin recording and let Cython handle interrupts + try: + crecord.record( + args.samplerate, + device, + args.channels, + args.latency, + ) + except KeyboardInterrupt: + samplerate, data = crecord.get_recording() + + # load data into pydub for exporting + import pydub + recording = pydub.AudioSegment( + data=data, + sample_width=4, + frame_rate=samplerate, + channels=args.channels, + ) + # write to file (uses ffmpeg) + recording.export( + args.path, + format=args.path.split(".")[-1], + bitrate=args.bitrate, + ) + diff --git a/contrib/cython/src/cbackend.pxd b/contrib/cython/src/cbackend.pxd new file mode 100644 index 0000000..d424a05 --- /dev/null +++ b/contrib/cython/src/cbackend.pxd @@ -0,0 +1,24 @@ +cdef void copy_array(const int *input_array, Node *node) nogil +cdef (char *) to_bytes(LinkedList *arrays, size_t *output_size) nogil + +# linked list + +cdef struct Node: + size_t size + int *data + Node *next_node + +cdef struct LinkedList: + size_t size, node_size + Py_ssize_t i, buffer + Node *head + Node *addr + Node *tail + bint cleanup + +cdef (LinkedList *) linkedlist_init(size_t node_size, Py_ssize_t buffer) nogil +cdef void linkedlist_dealloc(LinkedList *self) nogil + +cdef void linkedlist_fill(LinkedList *self) nogil +cdef (Node *) linkedlist_get(LinkedList *self, size_t size) nogil + diff --git a/contrib/cython/src/cbackend.pyx b/contrib/cython/src/cbackend.pyx new file mode 100644 index 0000000..94c2878 --- /dev/null +++ b/contrib/cython/src/cbackend.pyx @@ -0,0 +1,124 @@ +# cython: profile=False +from libc.stdlib cimport malloc, free + +cdef (Node *) __new_node(size_t size) nogil: + """ Dynamically allocate an Node struct with the given size. """ + cdef Node *node = malloc(sizeof(Node)) + node.size = size + node.data = malloc(size*sizeof(int)) + node.next_node = NULL + return node + +cdef void copy_array(const int *input_array, Node *node) nogil: + """ Copy the input array's data into the node. """ + cdef: + size_t i + + for i in range(node.size): + node.data[i] = input_array[i] + +cdef (char *) to_bytes(LinkedList *arrays, size_t *output_size) nogil: + """ Serialize the list of 32-bit integer arrays as bytes. """ + cdef: + Py_ssize_t size, length, i, j, k + int x + Node *node + char *output + + # get the total size + size = 0 + length = arrays.i + node = arrays.head + for i in range(length): + size += node.size + node = node.next_node + # allocate contiguous data + output_size[0] = size*sizeof(int) + output = malloc(output_size[0]) + k = 0 + node = arrays.head + for i in range(length): + for j in range( node.size): + x = node.data[j] + output[k + 0] = (x >> 0) & 0xFF + output[k + 1] = (x >> 8) & 0xFF + output[k + 2] = (x >> 16) & 0xFF + output[k + 3] = (x >> 24) & 0xFF + k += 4 + node = node.next_node + + return output + +# Singly linked list data structure with a bit of a lookahead buffer + +cdef (LinkedList *) linkedlist_init(size_t node_size, + Py_ssize_t buffer) nogil: + """ Dynamically allocate an LinkedList struct with the given size. """ + cdef LinkedList *self = malloc(sizeof(LinkedList)) + self.node_size = node_size + self.buffer = buffer + self.size = 0 + self.i = -1 + self.head = NULL + self.addr = NULL + self.tail = NULL + + linkedlist_fill(self) + return self + +cdef void linkedlist_dealloc(LinkedList *self) nogil: + """ Teardown to free associated explicitly allocated memory. """ + cdef: + Node *node + Node *next_node + + node = self.head + while node != NULL: + next_node = node.next_node + free(node.data) + free(node) + node = next_node + free(self) + +cdef void linkedlist__add(LinkedList *self, size_t size) nogil: + """ Add a node of the given size to the end of the linked list. """ + cdef: + Node *node + + node = __new_node(size) + if self.size == 0: + self.head = node + self.addr = node + else: + self.tail.next_node = node + self.tail = node + self.size += 1 + +cdef void linkedlist_fill(LinkedList *self) nogil: + """ Fill the buffer with uninitialized nodes. """ + cdef: + Py_ssize_t i + + for i in range(self.i + self.buffer - ( self.size) + 1): + linkedlist__add(self, self.node_size) + +cdef (Node *) linkedlist_get(LinkedList *self, size_t size) nogil: + """ Get the node corresponding to the last currently empty position. """ + cdef: + Node *node + + node = self.addr + # reached end of linked list, fallback to dynamically allocated memory + if node == NULL: + linkedlist__add(self, size) + node = self.tail + # not enough space, overwrite with dynamically allocated memory + if node.size < size: + node.data = malloc(size*sizeof(int)) + + # update the size with the actual size + node.size = size + self.addr = node.next_node + self.i += 1 + return node + diff --git a/contrib/cython/src/crecord.pyx b/contrib/cython/src/crecord.pyx new file mode 100644 index 0000000..0dddca6 --- /dev/null +++ b/contrib/cython/src/crecord.pyx @@ -0,0 +1,223 @@ +# cython: profile=False +from libc.stdlib cimport malloc, free +from cysignals.signals cimport ( + sig_on, sig_on_no_except, sig_off, cython_check_exception, +) +from portaudio cimport ( + Pa_Initialize, + Pa_Terminate, + Pa_Sleep, + # error handling + paNoError, + PaError, + Pa_GetErrorText, + # device handling + PaDeviceIndex, + PaDeviceInfo, + Pa_GetDeviceCount, + Pa_GetDefaultInputDevice, + Pa_GetDeviceInfo, + # callback + paContinue, + PaStreamCallbackTimeInfo, + PaStreamCallbackFlags, + # stream + paInt32, + PaTime, + paFramesPerBufferUnspecified, + paNoFlag, + PaStreamParameters, + PaStream, + Pa_OpenStream, + Pa_StartStream, + Pa_AbortStream, + Pa_GetStreamInfo, +) +from cbackend cimport ( + copy_array, + to_bytes, + # queue data structure for "malloc" without dynamic memory allocation + Node, + LinkedList, + linkedlist_init, + linkedlist_dealloc, + linkedlist_fill, + linkedlist_get, +) + +cdef PaDeviceIndex NOT_FOUND = -1 + +cdef struct UserData: + int channels + LinkedList *queue + +cdef double actual_samplerate = 0 +cdef size_t recording_size = 0 +cdef char *recording_data = NULL + +def get_recording(): + """ Return the recording data as bytes. """ + if recording_data == NULL: + raise ValueError("Empty recording. \ +Please wait for at least ~0.25 seconds.") + # return without copying, memoryview + return (actual_samplerate, recording_data) + +cdef void __raise_error(PaError error): + """ Raise an error if necessary. """ + if error != paNoError: + raise ValueError(f"PortAudio error: {Pa_GetErrorText(error)}") + +cdef PaDeviceIndex __get_device_by_name(char *name): + """ Get the integer device index by a string name. """ + cdef: + PaDeviceIndex device + const PaDeviceInfo *info + + for device in range(Pa_GetDeviceCount()): + info = Pa_GetDeviceInfo(device) + if name in info.name: + return device + return NOT_FOUND + +def get_device(device_name: None | int | str) -> int: + """ Get the device index from a Python object. """ + cdef: + PaDeviceIndex device + + # use default device + if device_name is None: + device = Pa_GetDefaultInputDevice() + # device index already provided + elif isinstance(device_name, int): + device = device_name + # device name, look for substring + else: + device = __get_device_by_name(device_name.encode()) + if device == NOT_FOUND: + raise ValueError(f"Device {device_name} not found.") + return device + +def get_latency(suggested_latency: str, + low_latency: float, high_latency: float) -> int: + """ Get the device index from a Python object. """ + # use preferred latency from device + if suggested_latency.lower() == "low": + return low_latency + elif suggested_latency.lower() == "high": + return high_latency + # latency already provided + else: + return float(suggested_latency) + +cdef int stream_callback(const void* in_buffer, + void* out_buffer, + unsigned long frame_count, + const PaStreamCallbackTimeInfo* time_info, + PaStreamCallbackFlags status_flags, + void* user_data) nogil: + """ Callback for each audio block while recording. """ + cdef: + UserData *data + Node *node + + data = user_data + node = linkedlist_get(data.queue, data.channels*frame_count) + copy_array( in_buffer, node) + + return paContinue + +cdef int __record(double samplerate, + PaDeviceIndex device, + int channels, + PaTime latency) except -1 nogil: + """ Recording connection to PortAudio. """ + global actual_samplerate, recording_size, recording_data + cdef: + PaStreamParameters *input_params + PaStream *stream + UserData *user_data + + input_params = malloc(sizeof(PaStreamParameters)) + input_params.device = device + input_params.channelCount = channels + input_params.sampleFormat = paInt32 + input_params.suggestedLatency = latency + input_params.hostApiSpecificStreamInfo = NULL + + user_data = malloc(sizeof(UserData)) + user_data.channels = channels + # the optimal size may vary between devices, tune to your circumstances + user_data.queue = linkedlist_init(node_size=2048, buffer=1024) + + Pa_OpenStream( + &stream, # pointer to stream + input_params, # input parameters + NULL, # output parameters + samplerate, # sample rate + paFramesPerBufferUnspecified, # frames per buffer + paNoFlag, # flags + stream_callback, # callback function + user_data, # user data + ) + # actual sample rate can slightly differ from + # provided rate due to hardware limitations + # http://files.portaudio.com/docs/v19-doxydocs/structPaStreamInfo.html + actual_samplerate = Pa_GetStreamInfo(stream).sampleRate + # cleanup + # https://cysignals.readthedocs.io/en/latest/sigadvanced.html#advanced-sig + if not sig_on_no_except(): + # unsafe to raise errors within a sig_on() ... sig_off() block + # and impossible in the nogil context anyways + Pa_AbortStream(stream) + Pa_Terminate() + + recording_data = to_bytes(user_data.queue, &recording_size) + + free(input_params) + linkedlist_dealloc(user_data.queue) + + cython_check_exception() + + # begin recording + Pa_StartStream(stream) + while True: + # add back dynamically allocated memory while blocked + linkedlist_fill(user_data.queue) + Pa_Sleep(1000) + + sig_off() + +def record(sample_rate: float, + device_name: None | int | str, + num_channels: int, + suggested_latency: str) -> None: + """ Start recording. """ + cdef: + PaDeviceIndex device + const PaDeviceInfo *info + int channels + PaTime latency + + __raise_error(Pa_Initialize()) + + device = get_device(device_name) + info = Pa_GetDeviceInfo(device) + samplerate = sample_rate if sample_rate > 0 else info.defaultSampleRate + channels = num_channels + if info.maxInputChannels < channels: + raise ValueError(f"Input only has {info.maxInputChannels} channels, " \ +f"requested {channels}.") + latency = get_latency(suggested_latency, + info.defaultLowInputLatency, + info.defaultHighInputLatency) + + # not sure if releasing the GIL does anything here but it can't hurt + with nogil: + __record( + samplerate, + device, + channels, + latency, + ) + diff --git a/contrib/cython/src/portaudio.pxd b/contrib/cython/src/portaudio.pxd new file mode 100644 index 0000000..f12b5ad --- /dev/null +++ b/contrib/cython/src/portaudio.pxd @@ -0,0 +1,95 @@ +cdef extern from "portaudio.h": + ctypedef int PaError + ctypedef int PaDeviceIndex + ctypedef int PaHostApiIndex + ctypedef double PaTime + ctypedef unsigned long PaSampleFormat + ctypedef unsigned long PaStreamFlags + ctypedef unsigned long PaStreamCallbackFlags + ctypedef void PaStream + ctypedef int PaStreamCallback( + const void *input, void *output, + unsigned long frameCount, + const PaStreamCallbackTimeInfo* timeInfo, + PaStreamCallbackFlags statusFlags, + void *userData) + + enum PaErrorCode: + paNoError + + enum PaStreamCallbackResult: + paContinue + paComplete + paAbort + + enum: + paFramesPerBufferUnspecified + + struct PaDeviceInfo: + int structVersion + const char *name + PaHostApiIndex hostApi + + int maxInputChannels + int maxOutputChannels + + PaTime defaultLowInputLatency + PaTime defaultLowOutputLatency + + PaTime defaultHighInputLatency + PaTime defaultHighOutputLatency + + double defaultSampleRate + + struct PaStreamCallbackTimeInfo: + PaTime inputBufferAdcTime + PaTime currentTime + PaTime outputBufferDacTime + + struct PaStreamParameters: + PaDeviceIndex device; + int channelCount; + PaSampleFormat sampleFormat; + PaTime suggestedLatency; + void *hostApiSpecificStreamInfo; + + struct PaStreamInfo: + int structVersion + PaTime inputLatency + PaTime outputLatency + double sampleRate + + PaError Pa_Initialize() + PaError Pa_Terminate() nogil + + const char *Pa_GetErrorText(PaError errorCode) + + void Pa_Sleep(long msec) nogil + + # devices + PaDeviceIndex paNoDevice + + PaDeviceIndex Pa_GetDeviceCount() + PaDeviceIndex Pa_GetDefaultInputDevice() + PaDeviceIndex Pa_GetDefaultOutputDevice() + const PaDeviceInfo* Pa_GetDeviceInfo(PaDeviceIndex device) + + # streams + PaSampleFormat paFloat32 + PaSampleFormat paInt32 + PaStreamFlags paNoFlag + + PaError Pa_OpenStream(PaStream** stream, + const PaStreamParameters *inputParameters, + const PaStreamParameters *outputParameters, + double sampleRate, + unsigned long framesPerBuffer, + PaStreamFlags streamFlags, + PaStreamCallback *streamCallback, + void *userData) nogil + + PaError Pa_StartStream(PaStream *stream) nogil + PaError Pa_StopStream(PaStream *stream) + PaError Pa_AbortStream(PaStream *stream) nogil + const PaStreamInfo* Pa_GetStreamInfo(PaStream *stream) nogil + diff --git a/contrib/cython/src/setup.py b/contrib/cython/src/setup.py new file mode 100644 index 0000000..0b33049 --- /dev/null +++ b/contrib/cython/src/setup.py @@ -0,0 +1,23 @@ +from setuptools import setup, Extension +from Cython.Build import cythonize + +extensions = [ + Extension( + "*", ["*.pyx"], + libraries=["portaudio"], + ), +] + +setup( + ext_modules=\ + cythonize(extensions, + annotate=True, + compiler_directives={ + "language_level": 3, + "boundscheck": False, + "wraparound": False, + "initializedcheck": False, + "cdivision": True, + }, + ), +)