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()