Examples#

Note

The Sender and Receiver examples both make use of UNIX pipes and will only work on supported operating systems.

Sender#

Video frames are generated by ffmpeg running in a subprocess which outputs the raw data to its stdout.

Audio samples of a continuous sine wave are generated as numpy arrays.

A Sender is created and instances of VideoSendFrame and AudioSendFrame are added to it.

The raw data from ffmpeg is then fed using the Sender’s write_video_async() method.

The audio data is then fed to the sender using the write_audio() method.

Since the video frames are sent using the asynchronous sending method built into NDI®, the sender will handle syncing the audio and video frames.

  1from __future__ import annotations
  2
  3from typing import NamedTuple, Literal, Generator, Any, cast, get_args
  4from typing_extensions import Self
  5import enum
  6import io
  7import subprocess
  8import shlex
  9from fractions import Fraction
 10from contextlib import contextmanager
 11
 12import numpy as np
 13
 14import click
 15
 16from cyndilib import (
 17    Sender,
 18    VideoSendFrame,
 19    AudioSendFrame,
 20    FourCC,
 21    AudioReference,
 22)
 23
 24TestSource = Literal[
 25    'testsrc2', 'yuvtestsrc', 'rgbtestsrc', 'smptebars', 'smptehdbars',
 26    'zoneplate', 'colorspectrum',
 27]
 28
 29FF_CMD = '{ffmpeg} -f lavfi -i {source}=size={xres}x{yres}:rate={fps} \
 30    -pix_fmt {pix_fmt.name} -f rawvideo pipe: '
 31
 32
 33
 34class PixFmt(enum.Enum):
 35    """Maps ffmpeg's ``pix_fmt`` names to their corresponding
 36    :class:`FourCC <cyndilib.wrapper.ndi_structs.FourCC>` types
 37    """
 38    uyvy422 = FourCC.UYVY   #: uyvy422
 39    nv12 = FourCC.NV12      #: nv12
 40    rgba = FourCC.RGBA      #: rgba
 41    rgb0 = FourCC.RGBX      #: rgb0
 42    bgra = FourCC.BGRA      #: bgra
 43    bgr0 = FourCC.BGRX      #: bgr0
 44    p216be = FourCC.P216    #: p216be
 45    yuv420p = FourCC.I420   #: yuv420p (i420)
 46
 47    @classmethod
 48    def from_str(cls, name: str) -> Self:
 49        return cls.__members__[name]
 50
 51
 52class Options(NamedTuple):
 53    """Options set through the cli
 54    """
 55    source: TestSource                  #: Source to use for the test pattern
 56    pix_fmt: PixFmt                     #: Pixel format to send
 57    xres: int                           #: Horizontal resolution
 58    yres: int                           #: Vertical resolution
 59    fps: str                            #: Frame rate
 60    sender_name: str = 'ffmpeg_sender'  #: NDI name for the sender
 61    sine_freq: float = 1000.0           #: Frequency of the sine wave
 62    sine_vol_dB: float = -20            #: Volume of the sine wave in dBVU
 63    sample_rate: int = 48000            #: Sample rate of the audio
 64    audio_channels: int = 2             #: Number of audio channels
 65    audio_reference: AudioReference = AudioReference.dBVU #: Audio reference level
 66    ffmpeg: str = 'ffmpeg'              #: Name/Path of the "ffmpeg" executable
 67
 68
 69def parse_frame_rate(fr: str) -> Fraction:
 70    """Helper for NTSC frame rates (29.97, 59.94)
 71    """
 72    if '/' in fr:
 73        n, d = [int(s) for s in fr.split('/')]
 74    elif '.' in fr:
 75        n = round(float(fr)) * 1000
 76        d = 1001
 77    else:
 78        n = int(fr)
 79        d = 1
 80    return Fraction(n, d)
 81
 82class Signal:
 83    """Signal helper
 84
 85    Allows for iteration over samples of a sine wave signal aligned with the
 86    frame rate.
 87    """
 88    def __init__(self, opts: Options) -> None:
 89        self.fps = parse_frame_rate(opts.fps)
 90        self.opts = opts
 91        self.amplitude = 10 ** (opts.sine_vol_dB / 20.0)
 92        spf = opts.sample_rate / self.fps
 93        if spf % 1 == 0:
 94            samples_per_frame = [int(spf)]
 95            max_samples_per_frame = int(spf)
 96        else:
 97            assert opts.sample_rate == 48000
 98            if self.fps == Fraction(30000, 1001):
 99                # These sample counts will align with the frame rate every 5 frames.
100                samples_per_frame = [
101                    1602, 1601, 1602, 1601, 1602,
102                ]
103            elif self.fps == Fraction(60000, 1001):
104                # These sample counts will align with the frame rate every 5 frames.
105                samples_per_frame = [
106                    801, 801, 801, 800, 801,
107                ]
108            total_samples = sum(samples_per_frame)
109            assert total_samples == spf * len(samples_per_frame)
110            max_samples_per_frame = max(samples_per_frame)
111        self.samples_per_frame = samples_per_frame
112        self.max_samples_per_frame = max_samples_per_frame
113
114        one_sample = Fraction(1, opts.sample_rate)
115        fc = 1 / Fraction(opts.sine_freq)
116        self.samples_per_cycle = fc / one_sample
117        self.cycles_per_frame = [spf / self.samples_per_cycle for spf in samples_per_frame]
118        self.total_samples = 0
119
120    @property
121    def time_offset(self) -> float:
122        """Time offset in seconds for the current frame."""
123        return self.total_samples / self.opts.sample_rate
124
125    def __iter__(self):
126        while True:
127            for spf in self.samples_per_frame:
128                sig = gen_sine_wave(
129                    sample_rate=self.opts.sample_rate,
130                    num_channels=self.opts.audio_channels,
131                    center_freq=self.opts.sine_freq,
132                    amplitude=self.amplitude,
133                    num_samples=spf,
134                    t_offset=self.time_offset,
135                )
136                assert sig.shape == (self.opts.audio_channels, spf)
137                yield sig
138                self.total_samples += spf
139
140
141def gen_sine_wave(
142    sample_rate: int,
143    num_channels: int,
144    center_freq: float,
145    num_samples: int,
146    amplitude: float = 1.0,
147    t_offset: float = 0.0,
148):
149    """Build a sine wave signal
150    """
151    t = np.arange(num_samples) / sample_rate
152    t += t_offset
153    sig = amplitude * np.sin(2 * np.pi * center_freq * t)
154    sig = np.reshape(sig, (1, num_samples))
155    if num_channels > 1:
156        sig = np.repeat(sig, num_channels, axis=0)
157    assert sig.shape == (num_channels, num_samples)
158    return sig.astype(np.float32)
159
160
161@contextmanager
162def ffmpeg_proc(opts: Options) -> Generator[subprocess.Popen[bytes], Any, None]:
163    """Context manager for the ffmpeg subprocess generating frames
164    """
165    ff_cmd = FF_CMD.format(**opts._asdict())
166    ff_proc = subprocess.Popen(shlex.split(ff_cmd), stdout=subprocess.PIPE)
167    try:
168        ff_proc.poll()
169        if ff_proc.returncode is None:
170            yield ff_proc
171    finally:
172        ff_proc.kill()
173
174
175def send(opts: Options) -> None:
176    """Send frames generated by ffmpeg's ``testsrc2`` as an |NDI| stream
177
178    The raw frames generated by ffmpeg are sent to a pipe and read from its
179    :attr:`~subprocess.Popen.stdout`.  The data is then fed directly to
180    :meth:`cyndilib.sender.Sender.write_video_async` using an
181    intermediate :class:`memoryview`
182    """
183    sender = Sender(opts.sender_name)
184
185    # Build a VideoSendFrame and set its resolution and frame rate
186    # to match the options argument
187    vf = VideoSendFrame()
188    vf.set_resolution(opts.xres, opts.yres)
189    fr = parse_frame_rate(opts.fps)
190    vf.set_frame_rate(fr)
191    vf.set_fourcc(opts.pix_fmt.value)
192
193    sig_generator = Signal(opts)
194    sample_iter = iter(sig_generator)
195
196    # Build an AudioSendFrame and set its properties to match the options argument
197    af = AudioSendFrame()
198    af.sample_rate = opts.sample_rate
199    af.num_channels = opts.audio_channels
200    af.reference_level = opts.audio_reference
201
202    # Set `max_num_samples` to the number of samples per frame
203    af.set_max_num_samples(sig_generator.max_samples_per_frame)
204
205    # Add the VideoSendFrame and AudioSendFrame to the sender
206    sender.set_video_frame(vf)
207    sender.set_audio_frame(af)
208
209    # Pre-allocate a bytearray to hold frame data and create a view of it
210    # So we can buffer into it from ffmpeg then pass directly to the sender
211    frame_size_bytes = vf.get_data_size()
212    ba = bytearray(frame_size_bytes)
213    mv = memoryview(ba)
214
215    i = 0
216    frame_sent = False
217
218    with sender:
219        with ffmpeg_proc(opts) as ff_proc:
220            stdout = cast(io.BytesIO, ff_proc.stdout)
221            while True:
222                i += 1
223                if ff_proc.returncode is not None:
224                    break
225                # Read from the ffmpeg process into a view of the bytearray
226                num_read = stdout.readinto(mv)
227
228                # The first few reads might be empty, ignore
229                if num_read == 0:
230                    if frame_sent:
231                        # If we've sent a frame and there's no output,
232                        # ffmpeg has likely quit without setting returncode
233                        break
234                    elif i > 1000:
235                        # Check for ffmpeg startup errors
236                        print('Timeout waiting for ffmpeg to produce output')
237                        break
238                    continue
239                frame_sent = True
240
241                # Pass the memoryview of the video frame data directly to the sender
242                # (using the buffer protocol)
243                sender.write_video_async(mv)
244
245
246                # Generate the next audio frame's worth of samples and write to the sender.
247                # Since the video was sent asynchronously,
248                # the sender will handle syncing the audio and video frames.
249                samples = next(sample_iter)
250                sender.write_audio(samples)
251
252                if i % 10 == 0:
253                    ff_proc.poll()
254
255
256
257@click.command()
258@click.option(
259    '--source',
260    type=click.Choice(
261        choices=[m for m in get_args(TestSource)],
262    ),
263    default='testsrc2',
264    show_default=True,
265    help='Name of the ffmpeg test source to use',
266)
267@click.option(
268    '--pix-fmt',
269    type=click.Choice(choices=[m.name for m in PixFmt]),
270    default=PixFmt.uyvy422.name,
271    show_default=True,
272    show_choices=True,
273)
274@click.option('-x', '--x-res', type=int, default=1920, show_default=True)
275@click.option('-y', '--y-res', type=int, default=1080, show_default=True)
276@click.option('--fps', type=str, default='30', show_default=True)
277@click.option(
278    '-n', '--sender-name',
279    type=str,
280    default='ffmpeg_sender',
281    show_default=True,
282    help='NDI name for the sender',
283)
284@click.option('-f', '--sine-freq', type=float, default=1000.0, show_default=True)
285@click.option(
286    '-s', '--sine-vol', type=float, default=-20.0, show_default=True,
287    help='Volume of the sine wave in dB (unit depends on audio reference)',
288)
289@click.option(
290    '--audio-reference',
291    type=click.Choice([m.name for m in AudioReference]),
292    default=AudioReference.dBVU.name,
293    show_default=True,
294    help='Audio reference level',
295)
296@click.option('--sample-rate', type=int, default=48000, show_default=True)
297@click.option('--audio-channels', type=int, default=2, show_default=True)
298@click.option(
299    '--ffmpeg',
300    type=str,
301    default='ffmpeg',
302    show_default=True,
303    help='Name/Path of the "ffmpeg" executable',
304)
305def main(
306    source: TestSource,
307    pix_fmt: str,
308    x_res: int,
309    y_res: int,
310    fps: str,
311    sender_name: str,
312    sine_freq: float,
313    sine_vol: float,
314    audio_reference: str,
315    sample_rate: int,
316    audio_channels: int,
317    ffmpeg: str
318):
319    opts = Options(
320        source=source,
321        pix_fmt=PixFmt.from_str(pix_fmt),
322        xres=x_res,
323        yres=y_res,
324        fps=fps,
325        sine_freq=sine_freq,
326        sine_vol_dB=sine_vol,
327        audio_reference=AudioReference[audio_reference],
328        sample_rate=sample_rate,
329        audio_channels=audio_channels,
330        sender_name=sender_name,
331        ffmpeg=ffmpeg,
332    )
333    send(opts)
334
335
336if __name__ == '__main__':
337    main()

Audio Sender#

This example sends audio frames using a sine wave generated by numpy. The video frames are blank frames generated manually.

A Sender is created and AudioSendFrame and VideoSendFrame instances are added to it.

The audio and video data are then written using the Sender’s write_video_and_audio() method.

  1from __future__ import annotations
  2from typing import NamedTuple, Generator
  3from fractions import Fraction
  4import time
  5
  6import numpy as np
  7import click
  8
  9from cyndilib.wrapper.ndi_structs import FourCC
 10from cyndilib import VideoSendFrame, AudioSendFrame, AudioReference
 11from cyndilib.sender import Sender
 12
 13
 14FloatArray2D = np.ndarray[tuple[int, int], np.dtype[np.float32]]
 15FloatArray3D = np.ndarray[tuple[int, int, int], np.dtype[np.float32]]
 16
 17
 18
 19class Options(NamedTuple):
 20    """Options set through the cli
 21    """
 22    xres: int                           #: Horizontal resolution
 23    yres: int                           #: Vertical resolution
 24    fps: int                            #: Frame rate
 25    sine_freq: float = 1000.0           #: Frequency of the sine wave
 26    sine_vol_dB: float = -20            #: Volume of the sine wave in dBVU
 27    sample_rate: int = 48000            #: Sample rate of the audio
 28    audio_channels: int = 2             #: Number of audio channels
 29    audio_reference: AudioReference = AudioReference.dBVU
 30    """Audio reference level"""
 31    num_frames: int|None = None         #: Number of frames to send, or None for infinite
 32    sender_name: str = 'audio_sender'   #: NDI name for the sender
 33
 34
 35
 36def build_blank_frame(xres: int, yres: int):
 37    """Build an array of black pixels in UYVY422 format."""
 38    cw, ch = xres >> 1, yres
 39    num_bytes = xres * yres + (cw * ch * 2)
 40    data = np.zeros(num_bytes, dtype=np.uint8)
 41    data[1::2] = 16   # Y channel
 42    data[0::2] = 128  # U/V channels
 43    return data
 44
 45
 46def gen_sine_wave(
 47    sample_rate: int,
 48    num_channels: int,
 49    center_freq: float,
 50    num_samples: int,
 51    amplitude: float = 1.0,
 52    t_offset: float = 0.0,
 53):
 54    """Build a sine wave signal.
 55    """
 56    t = np.arange(num_samples) / sample_rate
 57    t += t_offset
 58    sig = amplitude * np.sin(2 * np.pi * center_freq * t)
 59    sig = np.reshape(sig, (1, num_samples))
 60    if num_channels > 1:
 61        sig = np.repeat(sig, num_channels, axis=0)
 62    assert sig.shape == (num_channels, num_samples)
 63    return sig.astype(np.float32)
 64
 65
 66
 67class Signal:
 68    """Signal helper
 69
 70    Allows for iteration over samples of a sine wave signal aligned with the
 71    frame rate.
 72    """
 73    def __init__(self, opts: Options) -> None:
 74        self.opts = opts
 75        self.amplitude = 10 ** (opts.sine_vol_dB / 20.0)
 76        self.samples_per_frame = opts.sample_rate // opts.fps
 77        one_sample = Fraction(1, opts.sample_rate)
 78        fc = 1 / Fraction(opts.sine_freq)
 79        self.samples_per_cycle = fc / one_sample
 80        self.cycles_per_frame = self.samples_per_frame / self.samples_per_cycle
 81        self.frame_count = 0
 82
 83    @property
 84    def time_offset(self) -> float:
 85        """Time offset in seconds for the current frame."""
 86        return self.frame_count / self.opts.fps
 87
 88    def __iter__(self) -> Generator[FloatArray2D, None, None]:
 89        while True:
 90            sig = gen_sine_wave(
 91                sample_rate=self.opts.sample_rate,
 92                num_channels=self.opts.audio_channels,
 93                center_freq=self.opts.sine_freq,
 94                amplitude=self.amplitude,
 95                num_samples=self.samples_per_frame,
 96                t_offset=self.time_offset,
 97            )
 98            assert sig.shape == (self.opts.audio_channels, self.samples_per_frame)
 99            yield sig
100            self.frame_count += 1
101
102
103
104def send(opts: Options) -> None:
105    """Send a sine wave audio signal as an NDI stream."""
106
107    sig_generator = Signal(opts)
108
109    sender = Sender(opts.sender_name)
110
111    # Build a VideoSendFrame and set its resolution and frame rate
112    # to match the options argument.
113    vf = VideoSendFrame()
114    vf.set_resolution(opts.xres, opts.yres)
115    vf.set_frame_rate(Fraction(opts.fps))
116    vf.set_fourcc(FourCC.UYVY)
117
118    # Build an AudioSendFrame and set its sample rate and number of channels
119    af = AudioSendFrame()
120    af.sample_rate = opts.sample_rate
121    af.num_channels = opts.audio_channels
122    af.reference_level = opts.audio_reference
123
124    # Set `max_num_samples` to the number of samples per frame
125    af.set_max_num_samples(sig_generator.samples_per_frame)
126
127    # Add the video and audio frames to the sender
128    sender.set_video_frame(vf)
129    sender.set_audio_frame(af)
130
131    # Build data for a blank video frame
132    vid_data = build_blank_frame(opts.xres, opts.yres)
133
134    start_time = time.monotonic()
135    num_frames_sent = 0
136
137    with sender:
138        for samples in sig_generator:
139            if opts.num_frames is not None:
140                if num_frames_sent >= opts.num_frames:
141                    break
142
143            # Write the video and audio data to the sender
144            # Note that we don't have to wait in between frames,
145            # as the sender will handle the timing for us.
146            sender.write_video_and_audio(
147                video_data=vid_data,
148                audio_data=samples,
149            )
150
151            num_frames_sent += 1
152            now = time.monotonic()
153            elapsed = now - start_time
154            click.echo(f'\rFrames: {num_frames_sent:04d}\tDuration: {elapsed:.3f}s', nl=False)
155
156
157
158@click.command()
159@click.option('--xres', type=int, default=640, show_default=True)
160@click.option('--yres', type=int, default=480, show_default=True)
161@click.option('--fps', type=int, default=30, show_default=True)
162@click.option('-f', '--sine-freq', type=float, default=1000.0, show_default=True)
163@click.option(
164    '-s', '--sine-vol', type=float, default=-20.0, show_default=True,
165    help='Volume of the sine wave in dB (unit depends on audio reference)',
166)
167@click.option(
168    '--audio-reference',
169    type=click.Choice([m.name for m in AudioReference]),
170    default=AudioReference.dBVU.name,
171    show_default=True,
172    help='Audio reference level',
173)
174@click.option('--sample-rate', type=int, default=48000, show_default=True)
175@click.option('--audio-channels', type=int, default=2, show_default=True)
176@click.option(
177    '-n', '--num-frames', type=int, default=None, show_default=True,
178    help='Number of frames to send, or None for infinite',
179)
180@click.option(
181    '--sender-name', type=str, default='audio_sender', show_default=True,
182    help='NDI name for the sender',
183)
184def main(
185    xres: int,
186    yres: int,
187    fps: int,
188    sine_freq: float,
189    sine_vol: float,
190    audio_reference: str,
191    sample_rate: int,
192    audio_channels: int,
193    num_frames: int | None,
194    sender_name: str,
195) -> None:
196    """Send a sine wave audio signal as an NDI stream."""
197    audio_reference_enum = AudioReference[audio_reference]
198    opts = Options(
199        xres=xres,
200        yres=yres,
201        fps=fps,
202        sine_freq=sine_freq,
203        sine_vol_dB=sine_vol,
204        audio_reference=audio_reference_enum,
205        sample_rate=sample_rate,
206        audio_channels=audio_channels,
207        num_frames=num_frames,
208        sender_name=sender_name,
209    )
210    try:
211        send(opts)
212    finally:
213        click.echo('')
214
215
216if __name__ == '__main__':
217    main()

Receiver#

This example receives video frames from an NDI® Source and shows them using ffplay.

Finder is used to locate the Source with the given name.

A Receiver is then created and an instance of VideoFrameSync is added to it.

Video frames are then read using the FrameSync.capture_video method which is available from the frame_sync attribute on the receiver.

The data is then fed to the stdin of the ffplay subprocess directly from the video frame using the buffer protocol.

  1from __future__ import annotations
  2
  3from typing import NamedTuple, TYPE_CHECKING
  4from typing_extensions import Self
  5import enum
  6import time
  7import subprocess
  8import shlex
  9
 10import click
 11
 12from cyndilib.wrapper.ndi_structs import FourCC
 13from cyndilib.wrapper.ndi_recv import RecvColorFormat, RecvBandwidth
 14from cyndilib.video_frame import VideoFrameSync
 15from cyndilib.receiver import Receiver
 16from cyndilib.finder import Finder
 17if TYPE_CHECKING:
 18    from cyndilib.finder import Source
 19
 20
 21FF_PLAY = '{ffplay} -video_size {xres}x{yres} -pixel_format {pix_fmt} -f rawvideo -i pipe:'
 22"""ffplay command line format"""
 23
 24
 25pix_fmts = {
 26    FourCC.UYVY: 'uyvy422',
 27    FourCC.NV12: 'nv12',
 28    FourCC.RGBA: 'rgba',
 29    FourCC.BGRA: 'bgra',
 30    FourCC.RGBX: 'rgba',
 31    FourCC.BGRX: 'bgra',
 32}
 33"""Mapping of :class:`FourCC <cyndilib.wrapper.ndi_structs.FourCC>` types to
 34ffmpeg's ``pix_fmt`` definitions
 35"""
 36
 37
 38class RecvFmt(enum.Enum):
 39    """Pixel format to receive (mapped to values of
 40    :class:`cyndilib.wrapper.ndi_recv.RecvColorFormat`)
 41    """
 42    uyvy = RecvColorFormat.UYVY_RGBA    #: UYVY (RGBA if alpha is present)
 43    rgb = RecvColorFormat.RGBX_RGBA     #: RGB / RGBA
 44    bgr = RecvColorFormat.BGRX_BGRA     #: BGR / BGRA
 45
 46    @classmethod
 47    def from_str(cls, name: str) -> Self:
 48        return cls.__members__[name]
 49
 50
 51class Bandwidth(enum.Enum):
 52    """Receive bandwidth
 53    """
 54    lowest = RecvBandwidth.lowest       #: Lowest
 55    highest = RecvBandwidth.highest     #: Highest
 56
 57    @classmethod
 58    def from_str(cls, name: str) -> Self:
 59        return cls.__members__[name]
 60
 61
 62class Options(NamedTuple):
 63    """Options set through the cli
 64    """
 65    sender_name: str = 'ffmpeg_sender'
 66    """The name of the |NDI| source to connect to"""
 67
 68    recv_fmt: RecvFmt = RecvFmt.uyvy
 69    """Receive pixel format"""
 70
 71    recv_bandwidth: Bandwidth = Bandwidth.highest
 72    """Receive bandwidth"""
 73
 74    ffplay: str = 'ffplay'
 75    """Name/Path of the ``ffplay`` executable"""
 76
 77
 78def get_source(finder: Finder, name: str) -> Source:
 79    """Use the Finder to search for an NDI source by name using either its
 80    full name or its :attr:`~cyndilib.finder.Source.stream_name`
 81    """
 82    click.echo('waiting for ndi sources...')
 83    finder.wait_for_sources(10)
 84    for source in finder:
 85        if source.name == name or source.stream_name == name:
 86            return source
 87    raise Exception(f'source not found. {finder.get_source_names()=}')
 88
 89
 90def wait_for_first_frame(receiver: Receiver) -> None:
 91    """The first few frames contain no data. Capture frames until the first
 92    non-empty one
 93    """
 94    vf = receiver.frame_sync.video_frame
 95    assert vf is not None
 96    frame_rate = vf.get_frame_rate()
 97    wait_time = float(1 / frame_rate)
 98    click.echo('waiting for frame...')
 99    while receiver.is_connected():
100        receiver.frame_sync.capture_video()
101        resolution = vf.get_resolution()
102        if min(resolution) > 0 and vf.get_data_size() > 0:
103            click.echo('have frame')
104            return
105        time.sleep(wait_time)
106
107
108def play(options: Options) -> None:
109    """Create the :class:`~cyndilib.receiver.Receiver` and send the frames to
110    ``ffplay``
111    """
112    # Get the NDI source and keep the Finder open until exit
113    with Finder() as finder:
114        source = get_source(finder, options.sender_name)
115
116        # Build the receiver and video frame
117        receiver = Receiver(
118            color_format=options.recv_fmt.value,
119            bandwidth=options.recv_bandwidth.value,
120        )
121        vf = VideoFrameSync()
122        frame_sync = receiver.frame_sync
123        frame_sync.set_video_frame(vf)
124
125        # Set the receiver source and wait for it to connect
126        receiver.set_source(source)
127        click.echo(f'connecting to "{source.name}"...')
128        i = 0
129        while not receiver.is_connected():
130            if i > 30:
131                raise Exception('timeout waiting for connection')
132            time.sleep(.5)
133            i += 1
134        click.echo('connected')
135
136        proc: subprocess.Popen|None = None
137
138        try:
139            wait_for_first_frame(receiver)
140            # At this point we should have received a frame, so the pixel format,
141            # resolution and frame rate should be populated.
142            fourcc = vf.get_fourcc()
143            frame_rate = vf.get_frame_rate()
144            wait_time = float(1 / frame_rate)
145            xres, yres = vf.get_resolution()
146
147            cmd_str = FF_PLAY.format(
148                xres=xres,
149                yres=yres,
150                pix_fmt=pix_fmts[fourcc],
151                ffplay=options.ffplay,
152            )
153            click.echo(f'{cmd_str=}')
154            proc = subprocess.Popen(shlex.split(cmd_str), stdin=subprocess.PIPE)
155            assert proc.stdin is not None
156
157            # Since we already have a frame with data, write it to ffplay
158            # Note that the frame object itself is directly used as the data source
159            # (since `VideoFrameSync` supports the buffer protocol)
160            proc.stdin.write(vf)
161
162            while receiver.is_connected():
163                # Not the best timing method, but we're using `FrameSync` to
164                # capture frames, so it'll correct things for us (within reason).
165                time.sleep(wait_time)
166                receiver.frame_sync.capture_video()
167                proc.poll()
168                if proc.returncode is not None:
169                    break
170                proc.stdin.write(vf)
171
172        finally:
173            if proc is not None:
174                proc.kill()
175
176
177@click.command()
178@click.option(
179    '-s', '--sender-name',
180    type=str,
181    default='ffmpeg_sender',
182    show_default=True,
183    help='The NDI source name to connect to',
184)
185@click.option(
186    '-f', '--recv-fmt',
187    type=click.Choice(choices=[m.name for m in RecvFmt]),
188    default='uyvy',
189    show_default=True,
190    show_choices=True,
191    help='Pixel format'
192)
193@click.option(
194    '-b', '--recv-bandwidth',
195    type=click.Choice(choices=[m.name for m in Bandwidth]),
196    default='highest',
197    show_default=True,
198    show_choices=True,
199)
200@click.option(
201    '--ffplay',
202    type=str,
203    default='ffplay',
204    show_default=True,
205    help='Name/Path of the "ffplay" executable',
206)
207def main(sender_name: str, recv_fmt: str, recv_bandwidth: str, ffplay: str):
208    options = Options(
209        sender_name=sender_name,
210        recv_fmt=RecvFmt.from_str(recv_fmt),
211        recv_bandwidth=Bandwidth.from_str(recv_bandwidth),
212        ffplay=ffplay,
213    )
214    play(options)
215
216
217if __name__ == '__main__':
218    main()

Audio Player#

This example receives audio frames from an NDI® Source and plays them using the sounddevice library.

Finder is used to locate the Source with the given name.

A Receiver is then created and an instance of AudioFrameSync is added to it.

Audio frames are then read using the FrameSync.capture_audio method which is available from the frame_sync attribute on the receiver.

The audio data is then played using the sounddevice library.

  1from __future__ import annotations
  2from typing import NamedTuple, TYPE_CHECKING
  3import time
  4
  5import click
  6import numpy as np
  7import sounddevice as sd
  8
  9from cyndilib import (
 10    AudioFrameSync,
 11    AudioReference,
 12    VideoFrameSync,
 13    Receiver,
 14    Finder,
 15)
 16if TYPE_CHECKING:
 17    from cyndilib.finder import Source
 18
 19
 20
 21class Options(NamedTuple):
 22    """Options set through the cli
 23    """
 24    source_name: str = 'audio_sender'   #: NDI name for the source to receive from
 25    audio_channels: int = 2             #: Number of audio channels
 26    sample_rate: int = 48000            #: Sample rate of the audio
 27    audio_reference: AudioReference = AudioReference.dBVU
 28    """Audio reference level"""
 29    block_size: int = 1024              #: Block size for sounddevice output stream
 30    sender_name: str = 'audio_sender'   #: NDI name for the sender
 31
 32
 33
 34def get_source(finder: Finder, name: str) -> Source:
 35    """Use the Finder to search for an NDI source by name using either its
 36    full name or its :attr:`~cyndilib.finder.Source.stream_name`
 37    """
 38    click.echo('waiting for ndi sources...')
 39    finder.wait_for_sources(10)
 40    for source in finder:
 41        if source.name == name or source.stream_name == name:
 42            return source
 43    raise Exception(f'source not found. {finder.get_source_names()=}')
 44
 45
 46
 47def play(options: Options) -> None:
 48    """Receive audio from an NDI source using :class:`~cyndilib.audio_frame.AudioFrameSync`
 49    and play it using sounddevice.
 50    """
 51
 52    # This is the number of samples we will capture in each chunk.
 53    # It's important to choose a chunk size that's larger than the number of
 54    # samples in a frame, otherwise the FrameSync won't have enough room to
 55    # buffer the incoming audio data.
 56    chunk_size = 6400
 57
 58    receiver = Receiver()
 59
 60    # We're using a VideoFrameSync here even though we only care about the audio frames.
 61    # It's only needed so the FrameSync can manage timing for us.
 62    vf = VideoFrameSync()
 63    receiver.frame_sync.set_video_frame(vf)
 64
 65    af = AudioFrameSync()
 66    af.num_channels = options.audio_channels
 67    af.sample_rate = options.sample_rate
 68    af.reference_level = options.audio_reference
 69
 70    # Set `af.num_samples` to the number of samples we want to capture in each chunk.
 71    af.num_samples = chunk_size
 72    receiver.frame_sync.set_audio_frame(af)
 73
 74    # We'll use this array to feed data to sounddevice. The shape will be (chunk_size, num_channels).
 75    write_samples = np.zeros((chunk_size, options.audio_channels), dtype=np.float32)
 76
 77    # This is a transposed view of the same array to read data from the audio frame
 78    # (which has shape (num_channels, num_samples)).
 79    # We're doing this since sounddevice expects contiguous arrays.
 80    read_samples = write_samples.T
 81
 82    with Finder() as finder:
 83        source = get_source(finder, options.source_name)
 84
 85        # Set the receiver source and wait for it to connect
 86        receiver.set_source(source)
 87        click.echo(f'connecting to "{source.name}"...')
 88        i = 0
 89        while not receiver.is_connected():
 90            if i > 30:
 91                raise Exception('timeout waiting for connection')
 92            time.sleep(.5)
 93            i += 1
 94        click.echo('connected')
 95        click.echo('receiving audio... Press Ctrl-C to stop.')
 96
 97        i = 0
 98        with sd.OutputStream(
 99            samplerate=options.sample_rate,
100            channels=options.audio_channels,
101            dtype='float32',
102            blocksize=options.block_size,
103        ) as stream:
104            while receiver.is_connected():
105                if i > 0:
106                    # Simulate waiting for the next frame based on the frame rate.
107                    time.sleep(float(1 / vf.get_frame_rate()))
108
109                # Even though we aren't interested in the video frames,
110                # we need to capture them so the FrameSync can advance and make the audio frames available.
111                receiver.frame_sync.capture_video()
112
113                samples_available = receiver.frame_sync.audio_samples_available()
114                if samples_available < chunk_size:
115                    continue
116
117                # If there are enough samples available to fill our chunk size,
118                # read them into our transposed read_samples array, which will feed into sounddevice.
119                receiver.frame_sync.capture_audio(chunk_size)
120
121                # Fill read_samples using the audio frame's buffer interface
122                read_samples[:,:] = af
123
124                # Now write the samples to sounddevice.
125                # Since `write_samples` and `read_samples` are views of the same data,
126                # this will write the audio data we just read from the frame.
127                stream.write(write_samples)
128
129                i += 1
130
131
132@click.command()
133@click.option(
134    '-s', '--source-name',
135    type=str,
136    default='audio_sender',
137    show_default=True,
138    help='NDI source name to receive from',
139)
140@click.option('--audio-channels', type=int, default=2, show_default=True)
141@click.option(
142    '--sample-rate', type=int, default=48000, show_default=True)
143@click.option(
144    '--audio-reference',
145    type=click.Choice([m.name for m in AudioReference]),
146    default=AudioReference.dBVU.name,
147    show_default=True,
148    help='Audio reference level',
149)
150@click.option(
151    '--block-size', type=int, default=1024, show_default=True,
152    help='Block size for sounddevice output stream',
153)
154def main(
155    source_name: str,
156    audio_channels: int,
157    sample_rate: int,
158    audio_reference: str,
159    block_size: int,
160) -> None:
161    options = Options(
162        source_name=source_name,
163        audio_channels=audio_channels,
164        sample_rate=sample_rate,
165        audio_reference=AudioReference[audio_reference],
166        block_size=block_size,
167    )
168    play(options)
169
170
171if __name__ == '__main__':
172    main()

Router#

This example demonstrates the use of the router module to create virtual NDI® sources on the network.

A RoutingMatrix is created which manages multiple Router instances (specified by the command line arguments).

  1from __future__ import annotations
  2from typing import TYPE_CHECKING
  3import time
  4
  5import click
  6if TYPE_CHECKING:
  7    from click._termui_impl import ProgressBar
  8
  9
 10from cyndilib.router import Router, RoutingMatrix
 11
 12
 13
 14def format_matrix(matrix: RoutingMatrix|None) -> str:
 15    """Format the routing matrix for display in the progress bar
 16
 17    Each :class:`~cyndilib.router.Router` will be displayed in the format
 18    ``dest -> source - N connections`` with text styled to indicate the
 19    status of the router.
 20    """
 21
 22    def format_router(router: Router) -> str:
 23        is_active = router.is_active
 24        num_connections = router.get_num_connections()
 25
 26        sep = click.style(' -> ', fg='white', bold=True)
 27        dest_src = sep.join([
 28            click.style(f'{router.dest}', fg='cyan', bold=is_active),
 29            click.style(f'{router.source}', fg='magenta', bold=is_active)
 30        ])
 31        c_str = click.style(f'{num_connections} connections', fg='white', dim=True)
 32        return f'{dest_src} - {c_str}'
 33
 34    if matrix is None:
 35        return ''
 36    routers = [format_router(router) for router in matrix]
 37    sep = click.style(' | ', fg='white', bold=True)
 38    prefix = click.style('Routing Matrix:', fg='yellow', bold=True)
 39    return sep.join([prefix] + routers)
 40
 41
 42
 43def main(routing_table: dict[str, str | None], duration: float|None):
 44    """Main function to run the routing matrix with the specified routing table
 45    and duration.
 46    """
 47    sleep_interval = 0.1
 48
 49    # Calculate progress bar parameters using milliseconds
 50    # since it requires integer values.
 51    duration_ms = int(duration * 1000) if duration is not None else None
 52    sleep_interval_ms = int(sleep_interval * 1000)
 53    n_steps = duration_ms // sleep_interval_ms if duration_ms is not None else 1
 54
 55
 56    click.echo('Building matrix with routing table:')
 57    for dest, source in routing_table.items():
 58        click.echo(f"  '{dest}' -> '{source}'")
 59    click.echo('')
 60
 61    # Build the routing matrix and set the routing table
 62    matrix = RoutingMatrix()
 63    matrix.set_routing_table(routing_table)
 64
 65
 66    if duration is not None:
 67        click.echo(f"Running for {duration} seconds...")
 68    else:
 69        click.echo("Running indefinitely (press Ctrl+C to stop)...")
 70
 71
 72    # Open the routing matrix and display the routing status in a progress bar
 73    # until the specified duration has elapsed or the user interrupts with Ctrl+C
 74    with matrix:
 75
 76        # The progress bar is only used to display the routing status on the
 77        # terminal without filling it with log messages.
 78        bar: ProgressBar[RoutingMatrix] = click.progressbar(
 79            length=n_steps,
 80            show_eta=False,
 81            show_percent=False,
 82            bar_template='%(label)s    %(info)s',
 83            item_show_func=format_matrix,
 84        )
 85
 86        with bar:
 87            start_time = time.time()
 88            i = 0
 89            while True:
 90                try:
 91                    time.sleep(sleep_interval)
 92                    elapsed = time.time() - start_time
 93                    bar.label = time.strftime("%H:%M:%S", time.gmtime(elapsed))
 94                    bar.update(1, current_item=matrix)
 95                    if duration_ms is not None:
 96                        i += 1
 97                        if i >= n_steps:
 98                            break
 99                except KeyboardInterrupt:
100                    break
101    click.echo("Routing matrix closed.")
102
103
104
105@click.command()
106@click.option(
107    '--duration',
108    default=None,
109    show_default=True,
110    type=float,
111    help='Time in seconds to run. If not specified, run indefinitely until interrupted.'
112)
113@click.option(
114    '--route', '-r',
115    multiple=True,
116    help='Routing in the format "dest:source". Can be specified multiple times.'
117)
118def cli(duration: float|None, route: list[str]):
119    """Create a routing matrix with the specified routing table and run it for
120    the specified duration.
121
122    Each route specified ('-r' or '--route') should be in the format "dest:source",
123    where "dest" is the name of the router to create
124    and "source" is the full name of the |NDI| source to connect to that router.
125
126    If "source" is "None", the router will be created with no source,
127    effectively a blank route.
128
129    \b
130    Example usage:
131    python router.py -r "MyDest:SOURCEHOSTNAME (SourceStreamName)" -r "MyOtherDest:None" --duration 60
132
133    """
134    routing_table: dict[str, str | None] = {}
135
136    # Parse the routing table from the command line arguments
137    for r in route:
138        try:
139            dest, source = r.split(':', 1)
140            dest, source = dest.strip(), source.strip()
141            if source == 'None':
142                source = None
143            routing_table[dest] = source
144        except ValueError:
145            print(f"Invalid route format: {r}. Expected format is 'dest:source'.")
146            return
147
148    # Run the main function with the parsed routing table and duration
149    main(routing_table, duration)
150
151
152if __name__ == '__main__':
153    cli()

PTZ#

This example showcases the PTZ functions on an NDI® Receiver.

Finder is used to locate a Source. A Receiver is then created.

Various PTZ methods are then invoked.

  1import time
  2from cyndilib.finder import Finder
  3from cyndilib.receiver import Receiver
  4from cyndilib.wrapper.ndi_recv import RecvColorFormat, RecvBandwidth
  5
  6
  7def main():
  8    finder = Finder()
  9    finder.open()
 10    for i in range(5):
 11        has_source = finder.wait_for_sources(timeout=5)
 12        if has_source:
 13            break
 14        print(f"No sources detected ({i})")
 15
 16    source_names = finder.get_source_names()
 17    print(source_names)
 18
 19    source = source_names[0]
 20    source_obj = finder.get_source(source)
 21    print(source_obj)
 22
 23    receiver = Receiver(
 24        color_format=RecvColorFormat.fastest,
 25        bandwidth=RecvBandwidth.metadata_only,
 26        recv_name="obs_ndi_ptz"
 27    )
 28
 29    receiver.set_source(source_obj)
 30    time.sleep(1.5)
 31
 32    if not receiver.is_ptz_supported():
 33        raise f"The NDI '{source}' does not indicate PTZ support."
 34
 35    ptz = receiver.ptz
 36
 37    print("pan to center, tilt to middle")
 38    ptz.set_pan_and_tilt_values(0.0, 0.0)
 39    time.sleep(5)
 40
 41    print("zoom to min")
 42    ptz.set_zoom_level(0)
 43    time.sleep(2)
 44
 45    print("zoom to max")
 46    ptz.set_zoom_level(1)
 47    time.sleep(2)
 48
 49    print("zoom to min")
 50    ptz.set_zoom_level(0)
 51    time.sleep(2)
 52
 53    print("zoom in")
 54    for _ in range(0, 100):
 55        time.sleep(0.01)
 56        ptz.zoom(1)
 57
 58    print("zoom out")
 59    for _ in range(0, 100):
 60        time.sleep(0.01)
 61        ptz.zoom(-0.5)
 62
 63    print("zoom to min")
 64    ptz.set_zoom_level(0)
 65    time.sleep(2)
 66
 67    print("pan to left, tilt to middle")
 68    ptz.set_pan_and_tilt_values(-0.5, 0.0)
 69    time.sleep(5)
 70
 71    print("pan to center, tilt to middle")
 72    ptz.set_pan_and_tilt_values(0.0, 0.0)
 73    time.sleep(5)
 74
 75    print("pan to right, tilt to middle")
 76    ptz.set_pan_and_tilt_values(0.5, 0.0)
 77    time.sleep(5)
 78
 79    print("pan to center, tilt to middle")
 80    ptz.set_pan_and_tilt_values(0.0, 0.0)
 81    time.sleep(5)
 82
 83    print("pan to center, title to down")
 84    ptz.set_pan_and_tilt_values(0.0, -1.0)
 85    time.sleep(5)
 86
 87    print("pan to center, tilt to middle")
 88    ptz.set_pan_and_tilt_values(0.0, 0.0)
 89    time.sleep(5)
 90
 91    print("pan to center, tilt to up")
 92    ptz.set_pan_and_tilt_values(0.0, 1.0)
 93    time.sleep(5)
 94
 95    print("continuously pan left")
 96    for _ in range(0, 100):
 97        time.sleep(0.05)
 98        ptz.pan(.5)
 99
100    print("continuously pan right")
101    for _ in range(0, 100):
102        time.sleep(0.05)
103        ptz.pan(-.5)
104
105    print("pan to center, title to middle")
106    ptz.set_pan_and_tilt_values(0.0, 0.0)
107    time.sleep(2)
108
109    print("continuously tilt down")
110    for _ in range(0, 100):
111        time.sleep(0.05)
112        ptz.tilt(-.5)
113
114    print("continuously tilt up")
115    for _ in range(0, 100):
116        time.sleep(0.05)
117        ptz.tilt(.5)
118
119    print("pan to center, title to middle")
120    ptz.set_pan_and_tilt_values(0.0, 0.0)
121    time.sleep(2)
122
123    print("store as preset to slot 10")
124    ptz.store_preset(10)
125
126    print("move")
127    ptz.set_pan_and_tilt_values(.5, .5)
128    time.sleep(2)
129
130    print("store as preset to slot 11")
131    ptz.store_preset(11)
132
133    print("recall preset in slot 10")
134    ptz.recall_preset(10, 1.0)
135    time.sleep(2)
136
137    print("recall preset in slot 11")
138    ptz.recall_preset(11, 1.0)
139    time.sleep(2)
140
141    print("recall preset in slot 10, slower")
142    ptz.recall_preset(10, 0.5)
143    time.sleep(4)
144
145    print("recall preset in slot 11, slowest")
146    ptz.recall_preset(11, 0.0)
147    time.sleep(6)
148
149    print("pan to center, title to middle")
150    ptz.set_pan_and_tilt_values(0.0, 0.0)
151    time.sleep(2)
152
153    print("trigger autofocus")
154    ptz.autofocus()
155    time.sleep(1)
156
157    print("focus min (infinity)")
158    ptz.set_focus(0.0)
159    time.sleep(2)
160
161    print("focus max")
162    ptz.set_focus(1.0)
163    time.sleep(2)
164
165    print("decrease focus")
166    for _ in range(0, 100):
167        time.sleep(0.05)
168        ptz.focus(-.5)
169
170    print("increase focus")
171    for _ in range(0, 100):
172        time.sleep(0.05)
173        ptz.focus(.5)
174
175    print("trigger autofocus")
176    ptz.autofocus()
177    time.sleep(1)
178
179    print("trigger auto white-balance")
180    ptz.white_balance_auto()
181    time.sleep(2)
182
183    print("set indoor white-balance")
184    ptz.white_balance_indoor()
185    time.sleep(2)
186
187    print("set outdoor white-balance")
188    ptz.white_balance_outdoor()
189    time.sleep(2)
190
191    print("trigger oneshot white-balance")
192    ptz.white_balance_oneshot()
193    time.sleep(2)
194
195    print("set white-balance to min")
196    ptz.set_white_balance(0.0, 0.0)
197    time.sleep(2)
198
199    print("set white-balance to max")
200    ptz.set_white_balance(1.0, 1.0)
201    time.sleep(2)
202
203    print("trigger auto white-balance")
204    ptz.white_balance_auto()
205    time.sleep(2)
206
207    print("(re-)enable auto exposure")
208    ptz.exposure_auto()
209    time.sleep(2)
210
211    print("set exposure to dark")
212    ptz.set_exposure_coarse(0.0)
213    time.sleep(2)
214
215    print("set exposure to bright")
216    ptz.set_exposure_coarse(1.0)
217    time.sleep(2)
218
219    print("set exposure to dark (fine adjustment)")
220    ptz.set_exposure_fine(.0, .0, .0)
221    time.sleep(2)
222
223    print("set exposure to bright (fine adjustment)")
224    ptz.set_exposure_fine(1.0, 1.0, 1.0)
225    time.sleep(2)
226
227    print("re-enable auto exposure")
228    ptz.exposure_auto()
229    time.sleep(2)
230
231
232if __name__ == "__main__":
233    main()