Skip to content

FR: add callback event to indicate that PortAudio has physically finished playback #92

@radio-miskovice

Description

@radio-miskovice

Missing Physical Playback Completion Callback/Event in naudiodon

Problem

naudiodon provides no event that fires when the last audio sample has been physically played by the hardware DAC.

The existing quit('WAIT') method maps to PortAudio's Pa_StopStream, which only flushes the currently active native callback buffer (a few milliseconds). It does not wait for the entire ring-buffer queue — already transferred to the audio driver — to finish playing. As a result, applications must use a setTimeout workaround based on the known buffer duration:

const playbackMs = Math.ceil(NUM_SAMPLES / SAMPLE_RATE * 1000);
ao.start();
ao.write(buffer);
setTimeout(() => {
  ao.quit(() => console.log('Playback complete.'));
}, playbackMs);

This is fragile for dynamic or streamed content where the duration is not known in advance.

PortAudio Capability

PortAudio already provides the exact mechanism needed: Pa_SetStreamFinishedCallback.

PaError Pa_SetStreamFinishedCallback(
    PaStream *stream,
    PaStreamFinishedCallback *streamFinishedCallback
);

typedef void PaStreamFinishedCallback(void *userData);

This callback is invoked by PortAudio after the last sample has been played by the hardware, making it suitable for precise playback-completion detection.

Reference: PortAudio API docs — Pa_SetStreamFinishedCallback

Required Changes to naudiodon

1. C++ addon — register the callback after stream open

In the output stream initialisation (likely src/AudioOutput.cc), after Pa_OpenStream(...) succeeds:

static void OnStreamFinished(void* userData) {
  // userData points to the N-API callback reference
  CallbackData* cb = static_cast<CallbackData*>(userData);
  // schedule napi_call_function on the libuv event loop (use napi_threadsafe_function)
  cb->tsfn.NonBlockingCall();
}

// after Pa_OpenStream succeeds:
Pa_SetStreamFinishedCallback(stream, OnStreamFinished);

Because OnStreamFinished is called from the PortAudio audio thread, it must not call N-API directly. Use napi_create_threadsafe_function / napi_threadsafe_function to safely schedule the JS callback back onto the Node.js event loop.

2. JavaScript layer — expose as an event

In index.js, emit a new event from the threadsafe callback landing:

// inside AudioIO(), after stream creation:
audioIOAdon.onStreamFinished(() => {
  ioStream.emit('playbackEnd');
});

3. Usage (proposed API)

const ao = naudiodon.AudioIO({ outOptions: { ... } });

ao.on('playbackEnd', () => {
  console.log('Last sample physically played.');
});

ao.start();
ao.write(buffer);

Summary

Current behaviour After fix
quit('WAIT') Drains active native buffer only (~few ms) Unchanged
'finish' event Node.js Writable stream finished Unchanged
'finished' event Fires after quit('WAIT') returns Unchanged
'playbackEnd' event Missing Fires when last sample leaves the DAC

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions