19

I want to use the PyAudio library in an async context, but the main entry point for the library only has a callback-based API:

import pyaudio

def callback(in_data, frame_count, time_info, status):
    # Do something with data

pa = pyaudio.PyAudio()
self.stream = self.pa.open(
    stream_callback=callback
)

How I'm hoping to use it is something like this:

pa = SOME_ASYNC_COROUTINE()
async def listen():
    async for block in pa:
        # Do something with block

The problem is, I'm not sure how to convert this callback syntax to a future that completes when the callback fires. In JavaScript I would use promise.promisify(), but Python doesn't seem to have anything like that.

mkrieger1
  • 19,194
  • 5
  • 54
  • 65
Migwell
  • 18,631
  • 21
  • 91
  • 160

2 Answers2

19

An equivalent of promisify wouldn't work for this use case for two reasons:

  • PyAudio's async API doesn't use the asyncio event loop - the documentation specifies that the callback is invoked from a background thread. This requires precautions to correctly communicate with asyncio.
  • The callback cannot be modeled by a single future because it is invoked multiple times, whereas a future can only have one result. Instead, it must be converted to an async iterator, just as shown in your sample code.

Here is one possible implementation:

def make_iter():
    loop = asyncio.get_event_loop()
    queue = asyncio.Queue()
    def put(*args):
        loop.call_soon_threadsafe(queue.put_nowait, args)
    async def get():
        while True:
            yield await queue.get()
    return get(), put

make_iter returns a pair of <async iterator, put-callback>. The returned objects hold the property that invoking the callback causes the iterator to produce its next value (the arguments passed to the callback). The callback may be called to call from an arbitrary thread and is thus safe to pass to pyaudio.open, while the async iterator should be given to async for in an asyncio coroutine, which will be suspended while waiting for the next value:

async def main():
    stream_get, stream_put = make_iter()
    stream = pa.open(stream_callback=stream_put)
    stream.start_stream()
    async for in_data, frame_count, time_info, status in stream_get:
        # ...

asyncio.get_event_loop().run_until_complete(main())

Note that, according to the documentation, the callback must also return a meaningful value, a tuple of frames and a Boolean flag. This can be incorporated in the design by changing the fill function to also receive the data from the asyncio side. The implementation is not included because it might not make much sense without an understanding of the domain.

user4815162342
  • 141,790
  • 18
  • 296
  • 355
  • Thanks, this is very helpful! Although something that might make this clearer is making your example `make_iter()` use a class instead, because I had trouble grasping that it was a function that returns a tuple of functions initially. – Migwell Jan 06 '19 at 12:34
  • Also, could you explain why you need to use `call_soon_threadsafe()`, if it will always be called by the same thread anyway? – Migwell Jan 06 '19 at 12:34
  • 2
    @Miguel Because the callback will be invoked in a background thread managed by PyAudio and not the event loop thread. `call_soon_threadsafe` is designed for exactly that usage. It schedules the function to the event loop without breaking it (e.g. by corrupting its data structures without holding the proper locks), and wakes it up in case the event loop was sleeping at the time. – user4815162342 Jan 07 '19 at 22:57
  • But if only one thread is manipulating the queue, then where is the risk? – Migwell Jan 08 '19 at 05:05
  • 1
    The event loop thread is also manipulating the queue, because the event loop removes stuff from the queue (and uses `call_soon` itself for its own needs). But even if there were no corruption risk, the event loop simply wouldn't wake if you don't use the threadsafe variant, because it wouldn't know it needs to. The typical symptom is that the presence of an unrelated heartbeat coroutine "fixes" the problem, as in [this question](https://stackoverflow.com/q/49906034/1600898). – user4815162342 Jan 08 '19 at 08:06
  • 1
    Ohh it wakes the event loop! That explains why my tests hang forever when I remove the `call_soon_threadsafe`. Thank you! – Migwell Jan 08 '19 at 08:08
  • 1
    Based on this answer, I've created an example for the `sounddevice` module: https://github.com/spatialaudio/python-sounddevice/blob/master/examples/asyncio_generators.py. This seems to work quite well! – Matthias Mar 07 '19 at 18:44
  • Any idea why I get TypeError: cannot unpack non-iterable coroutine object, when calling stream_get, stream_put = make_iter()? – feature_engineer Dec 01 '19 at 13:11
  • @feature_engineer It's a typo in the implementation, `make_iter` itself shouldn't be `async`. It's weird that it got unnoticed, as it could never work as written. (I couldn't test it because I don't use `pyaudio`, but I did base the implementation on similar code I used elsewhere.) I've now amended the answer with the fixed function, thanks for pointing it out! – user4815162342 Dec 01 '19 at 14:04
  • @user4815162342, I think that `call_soon_threadsafe` blocks my event loop (see this question: https://stackoverflow.com/questions/76912246/python-synchronous-pyaudio-data-in-asynchronous-code). When there is something else waiting, it seems that it simply gets blocked and do not send anything... I might be doing something really wrong since I dont have a deep understanding of the concetps at hand. Thank you for your help – HGLR Aug 16 '23 at 14:23
3

You may want to use a Future

class asyncio.Future(*, loop=None)¶

A Future represents an eventual result of an asynchronous operation. Not thread-safe.

Future is an awaitable object. Coroutines can await on Future objects until they either have a result or an exception set, or until they are cancelled.

Typically Futures are used to enable low-level callback-based code (e.g. in protocols implemented using asyncio transports) to interoperate with high-level async/await code.

The rule of thumb is to never expose Future objects in user-facing APIs, and the recommended way to create a Future object is to call loop.create_future(). This way alternative event loop implementations can inject their own optimized implementations of a Future object.

A silly example:

def my_func(loop):
    fut = loop.create_future()
    pa.open(
        stream_callback=lambda *a, **kw: fut.set_result([a, kw])
    )
    return fut


async def main(loop):
    result = await my_func(loop)  # returns a list with args and kwargs 

I assume that pa.open runs in a thread or a subprocess. If not, you may also need to wrap the call to open with asyncio.loop.run_in_executor

Oleksandr Fedorov
  • 1,213
  • 10
  • 17
  • What if the callback is called multiple times, each with a chunk of the data? It seems that set_result can only be called once. – ospider Mar 17 '23 at 00:28