spatialaudio / jackclient-python

🂻 JACK Audio Connection Kit (JACK) Client for Python :snake:
https://jackclient-python.readthedocs.io/
MIT License
131 stars 26 forks source link

Example program for playing audio data from memory? #119

Open mrzapp opened 1 year ago

mrzapp commented 1 year ago

It would be nice with an example program that demonstrates in the simplest way possible to record and play audio. Like the thru_client.py, but with record/playback implemented.

This is how far I got with my limited understanding of both numpy and the python JACK client.

EDIT: This updated example actually seems to work. Is this in your opinion how this should be done? If so, it could be very helpful for newcomers to have it on the project page along with the other examples.

#!/usr/bin/env python3

"""
Create a JACK client that records input audio and plays it back through the outputs.
"""
import sys
import os
import jack
import numpy
import threading

argv = iter(sys.argv)
# By default, use script name without extension as client name:
defaultclientname = os.path.splitext(os.path.basename(next(argv)))[0]
clientname = next(argv, defaultclientname)
servername = next(argv, None)

client = jack.Client(clientname, servername=servername)

if client.status.server_started:
    print('JACK server started')
if client.status.name_not_unique:
    print(f'unique name {client.name!r} assigned')

input_data = numpy.empty(client.blocksize, dtype = 'float32')   # For storing the recorded audio
recording_duration = 5.0                                        # Record audio for 5 seconds
playback_position = 0                                           # For tracking the current playback position in frames

event = threading.Event()

@client.set_process_callback
def process(frames):
    global input_data
    global playback_position

    assert len(client.inports) == len(client.outports)
    assert frames == client.blocksize

    # If the recorded audio data is less that 5 seconds,
    # keep adding the input buffer data to it
    if len(input_data) < recording_duration * client.samplerate:
        mono_sample = numpy.zeros(frames, dtype = 'float32')

        for i in client.inports:
            mono_sample = numpy.add(mono_sample, i.get_array())

        input_data = numpy.append(input_data, mono_sample)

    # If the recording is complete and the playback position isn't yet at the end,
    # keep adding the recorded data to the output buffer
    elif playback_position + frames < len(input_data):
        for o in client.outports:
            o.get_buffer()[:] = input_data[playback_position:playback_position + frames]

        playback_position += frames

@client.set_shutdown_callback
def shutdown(status, reason):
    print('JACK shutdown!')
    print('status:', status)
    print('reason:', reason)
    event.set()

# create two port pairs
for number in 1, 2:
    client.inports.register(f'input_{number}')
    client.outports.register(f'output_{number}')

with client:
    # When entering this with-statement, client.activate() is called.
    # This tells the JACK server that we are ready to roll.
    # Our process() callback will start running now.

    # Connect the ports.  You can't do this before the client is activated,
    # because we can't make connections to clients that aren't running.
    # Note the confusing (but necessary) orientation of the driver backend
    # ports: playback ports are "input" to the backend, and capture ports
    # are "output" from it.

    capture = client.get_ports(is_physical=True, is_output=True)
    if not capture:
        raise RuntimeError('No physical capture ports')

    for src, dest in zip(capture, client.inports):
        client.connect(src, dest)

    playback = client.get_ports(is_physical=True, is_input=True)
    if not playback:
        raise RuntimeError('No physical playback ports')

    for src, dest in zip(client.outports, playback):
        client.connect(src, dest)

    print('Press Ctrl+C to stop')
    try:
        event.wait()
    except KeyboardInterrupt:
        print('\nInterrupted by user')

# When the above with-statement is left (either because the end of the
# code block is reached, or because an exception was raised inside),
# client.deactivate() and client.close() are called automatically.
mgeier commented 1 year ago

Thanks for this suggestion!

There is only one thing really wrong with your code: you should always write to the output buffer(s). If you don't have anything to write to them, write zeros. I just tried your code (on Linux) and it worked as expected until playback was finished, but then the last block was repeated indefinitely (because I guess the buffer was simply re-used and still contained the signal of the last block). The annoying thing is that on other systems (and maybe on your system) it might just work, because the last block might not be re-used for some reason.

Other than that, it really depends on your assumptions for this example and what use case you want to illustrate with it.

If the duration of the recording is actually known in advance, it might make more sense to create a buffer of the full length and then write to it (instead of repeatedly using append()).

However, we might want to pretend that the recording duration is not yet known when starting the recording? In this case there is still the question whether we only want to use entire blocks of audio. Because if yes, it might make more sense to store the blocks in a list (or maybe a collections.deque) of NumPy arrays?

I think the whole playback_position thing would only make sense if we would allow the signal to start/stop mid-block.

If you want to add this as an "official" example, feel free to create a PR with it, then we can discuss further details about it.

mgeier commented 1 year ago

It would also probably be more interesting (and realistic?) to stop the recording and start playback from the non-audio thread?

This way, the example could show some inter-thread communication which might be helpful for some applications?