### Install from source using pip Source: https://cyndilib.readthedocs.io/en/latest/overview.html Install the project in the current directory using pip. ```bash pip install . ``` -------------------------------- ### Install cyndilib via pip Source: https://cyndilib.readthedocs.io/en/latest/overview.html Standard installation method using pre-built wheels from PyPI. ```bash pip install cyndilib ``` -------------------------------- ### Build documentation Source: https://cyndilib.readthedocs.io/en/latest/_sources/development.rst.txt Commands to install documentation dependencies and generate the HTML documentation. ```bash pip install -r doc/requirements.txt ``` ```bash python setup.py build_ext --inplace -j 12 cd doc make html ``` -------------------------------- ### Install dependencies Source: https://cyndilib.readthedocs.io/en/latest/_sources/development.rst.txt Commands to install project dependencies using either uv or pip. ```bash uv sync ``` ```bash pip install setuptools cython numpy pip install -e . ``` -------------------------------- ### Install test dependencies Source: https://cyndilib.readthedocs.io/en/latest/_sources/development.rst.txt Installs the necessary packages for running the test suite. ```bash pip install pytest pytest-doctestplus psutil ``` -------------------------------- ### Router Example Source: https://cyndilib.readthedocs.io/en/latest/_sources/examples.rst.txt Demonstrates creating virtual NDI sources on the network using the cyndilib.router module and a RoutingMatrix. ```python .. literalinclude:: ../../examples/router.py :language: python :linenos: :name: router :caption: :github_permalink:`examples/router.py` ``` -------------------------------- ### FFmpeg Sender Example Source: https://cyndilib.readthedocs.io/en/latest/_sources/examples.rst.txt Sends video frames generated by an ffmpeg subprocess and audio samples generated by numpy. Requires ffmpeg installed on the system. ```python .. literalinclude:: ../../examples/ffmpeg_sender.py :language: python :linenos: :name: ffmpeg_sender :caption: :github_permalink:`examples/ffmpeg_sender.py` ``` -------------------------------- ### Install dependencies using uv Source: https://cyndilib.readthedocs.io/en/latest/overview.html Use the uv package manager to sync project dependencies. ```bash uv sync ``` -------------------------------- ### Example NDI Metadata XML Source: https://cyndilib.readthedocs.io/en/latest/reference/metadata_frame.html Example XML structures for NDI tally and product information. ```xml ``` -------------------------------- ### PTZ Control Example Source: https://cyndilib.readthedocs.io/en/latest/_sources/examples.rst.txt Showcases how to invoke PTZ control methods on an NDI receiver. ```python .. literalinclude:: ../../examples/ptz.py :language: python :linenos: :name: ptz :caption: :github_permalink:`examples/ptz.py` ``` -------------------------------- ### Audio Player Example Source: https://cyndilib.readthedocs.io/en/latest/_sources/examples.rst.txt Receives audio frames from an NDI source and plays them using the sounddevice library. ```python .. literalinclude:: ../../examples/audio_player.py :language: python :linenos: :name: audio_player :caption: :github_permalink:`examples/audio_player.py` ``` -------------------------------- ### NDI Sender Implementation with ffmpeg Source: https://cyndilib.readthedocs.io/en/latest/examples.html This example demonstrates creating an NDI sender that processes video frames from an ffmpeg subprocess and generates audio samples using numpy. It requires a UNIX-like environment for pipe support. ```python 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 ` 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 ] ``` -------------------------------- ### Audio Sender Example Source: https://cyndilib.readthedocs.io/en/latest/_sources/examples.rst.txt Sends audio frames using a sine wave generated by numpy and blank video frames. Uses the write_video_and_audio method for synchronized transmission. ```python .. literalinclude:: ../../examples/audio_sender.py :language: python :linenos: :name: audio_sender :caption: :github_permalink:`examples/audio_sender.py` ``` -------------------------------- ### FFplay Receiver Example Source: https://cyndilib.readthedocs.io/en/latest/_sources/examples.rst.txt Receives video frames from an NDI source and displays them using ffplay. Utilizes the buffer protocol to feed data directly to the ffplay subprocess. ```python .. literalinclude:: ../../examples/ffplay_receiver.py :language: python :linenos: :name: ffplay_receiver :caption: :github_permalink:`examples/ffplay_receiver.py` ``` -------------------------------- ### FrameSync.audio_samples_available Source: https://cyndilib.readthedocs.io/en/latest/reference/framesync.html Get the number of audio samples currently available for capture. ```APIDOC ## FrameSync.audio_samples_available() ### Description Returns the number of audio samples currently available for capture. ### Returns - **int** - The count of available audio samples. ``` -------------------------------- ### Retrieve Source Names Source: https://cyndilib.readthedocs.io/en/latest/_sources/guide/finder.rst.txt Get a list of names for all sources currently discovered by the Finder. ```python >>> source_names = finder.get_source_names() >>> source_names ['... (Example Video Source)'] ``` -------------------------------- ### Initialize and Wait for Sources Source: https://cyndilib.readthedocs.io/en/latest/_sources/guide/finder.rst.txt Open the Finder and block until sources are discovered within the specified timeout. ```python >>> from cyndilib.finder import Finder >>> finder = Finder() >>> finder.open() >>> changed = finder.wait_for_sources(timeout=5) >>> changed True ``` -------------------------------- ### Clone the repository Source: https://cyndilib.readthedocs.io/en/latest/_sources/development.rst.txt Initial step to obtain the project source code. ```bash git clone https://github.com/nocarryr/cyndilib.git cd cyndilib ``` -------------------------------- ### Routing Matrix Implementation Source: https://cyndilib.readthedocs.io/en/latest/examples.html Demonstrates creating a RoutingMatrix, setting a routing table, and monitoring status via a progress bar. ```python 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: ``` -------------------------------- ### Iterate over NDI sources Source: https://cyndilib.readthedocs.io/en/latest/reference/finder.html Demonstrates how to iterate over available NDI sources using the Finder instance as an iterator within a context manager. ```python >>> with Finder() as finder: ... for source in finder: ... ... ``` -------------------------------- ### Control NDI PTZ Functions Source: https://cyndilib.readthedocs.io/en/latest/examples.html Demonstrates how to discover an NDI source, initialize a receiver, and execute various PTZ commands including movement, zoom, and preset recall. ```python import time from cyndilib.finder import Finder from cyndilib.receiver import Receiver from cyndilib.wrapper.ndi_recv import RecvColorFormat, RecvBandwidth def main(): finder = Finder() finder.open() for i in range(5): has_source = finder.wait_for_sources(timeout=5) if has_source: break print(f"No sources detected ({i})") source_names = finder.get_source_names() print(source_names) source = source_names[0] source_obj = finder.get_source(source) print(source_obj) receiver = Receiver( color_format=RecvColorFormat.fastest, bandwidth=RecvBandwidth.metadata_only, recv_name="obs_ndi_ptz" ) receiver.set_source(source_obj) time.sleep(1.5) if not receiver.is_ptz_supported(): raise f"The NDI '{source}' does not indicate PTZ support." ptz = receiver.ptz print("pan to center, tilt to middle") ptz.set_pan_and_tilt_values(0.0, 0.0) time.sleep(5) print("zoom to min") ptz.set_zoom_level(0) time.sleep(2) print("zoom to max") ptz.set_zoom_level(1) time.sleep(2) print("zoom to min") ptz.set_zoom_level(0) time.sleep(2) print("zoom in") for _ in range(0, 100): time.sleep(0.01) ptz.zoom(1) print("zoom out") for _ in range(0, 100): time.sleep(0.01) ptz.zoom(-0.5) print("zoom to min") ptz.set_zoom_level(0) time.sleep(2) print("pan to left, tilt to middle") ptz.set_pan_and_tilt_values(-0.5, 0.0) time.sleep(5) print("pan to center, tilt to middle") ptz.set_pan_and_tilt_values(0.0, 0.0) time.sleep(5) print("pan to right, tilt to middle") ptz.set_pan_and_tilt_values(0.5, 0.0) time.sleep(5) print("pan to center, tilt to middle") ptz.set_pan_and_tilt_values(0.0, 0.0) time.sleep(5) print("pan to center, title to down") ptz.set_pan_and_tilt_values(0.0, -1.0) time.sleep(5) print("pan to center, tilt to middle") ptz.set_pan_and_tilt_values(0.0, 0.0) time.sleep(5) print("pan to center, tilt to up") ptz.set_pan_and_tilt_values(0.0, 1.0) time.sleep(5) print("continuously pan left") for _ in range(0, 100): time.sleep(0.05) ptz.pan(.5) print("continuously pan right") for _ in range(0, 100): time.sleep(0.05) ptz.pan(-.5) print("pan to center, title to middle") ptz.set_pan_and_tilt_values(0.0, 0.0) time.sleep(2) print("continuously tilt down") for _ in range(0, 100): time.sleep(0.05) ptz.tilt(-.5) print("continuously tilt up") for _ in range(0, 100): time.sleep(0.05) ptz.tilt(.5) print("pan to center, title to middle") ptz.set_pan_and_tilt_values(0.0, 0.0) time.sleep(2) print("store as preset to slot 10") ptz.store_preset(10) print("move") ptz.set_pan_and_tilt_values(.5, .5) time.sleep(2) print("store as preset to slot 11") ptz.store_preset(11) print("recall preset in slot 10") ptz.recall_preset(10, 1.0) time.sleep(2) print("recall preset in slot 11") ptz.recall_preset(11, 1.0) time.sleep(2) print("recall preset in slot 10, slower") ptz.recall_preset(10, 0.5) time.sleep(4) print("recall preset in slot 11, slowest") ptz.recall_preset(11, 0.0) time.sleep(6) print("pan to center, title to middle") ptz.set_pan_and_tilt_values(0.0, 0.0) time.sleep(2) ``` -------------------------------- ### CLI Configuration for Audio Player Source: https://cyndilib.readthedocs.io/en/latest/examples.html Defines command-line options for the audio player using the Click library. ```python @click.command() @click.option( '-s', '--source-name', type=str, default='audio_sender', show_default=True, help='NDI source name to receive from', ) @click.option('--audio-channels', type=int, default=2, show_default=True) @click.option( '--sample-rate', type=int, default=48000, show_default=True) @click.option( '--audio-reference', type=click.Choice([m.name for m in AudioReference]), default=AudioReference.dBVU.name, show_default=True, help='Audio reference level', ) @click.option( '--block-size', type=int, default=1024, show_default=True, help='Block size for sounddevice output stream', ) def main( source_name: str, audio_channels: int, sample_rate: int, audio_reference: str, block_size: int, ) -> None: options = Options( source_name=source_name, audio_channels=audio_channels, sample_rate=sample_rate, audio_reference=AudioReference[audio_reference], block_size=block_size, ) play(options) if __name__ == '__main__': main() ``` -------------------------------- ### NDI Receiver with ffplay integration Source: https://cyndilib.readthedocs.io/en/latest/examples.html This script uses a Finder to locate an NDI source, initializes a Receiver with a VideoFrameSync, and pipes the captured video data to ffplay. ```python 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 ` 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') ``` -------------------------------- ### Configure Audio Scaling for NDI Source: https://cyndilib.readthedocs.io/en/latest/_sources/guide/audio.rst.txt Set the reference level to dBFS_smpte to automatically scale normalized audio samples when sending or receiving. ```python >>> from cyndilib import AudioReference, AudioSendFrame, AudioFrameSync >>> send_frame = AudioSendFrame() >>> send_frame.reference_level = AudioReference.dBFS_smpte >>> # Now send_frame can be filled with normalized audio samples >>> # and they will be scaled up by 20 dB (10x amplitude) when sent. >>> recv_frame = AudioFrameSync() >>> recv_frame.reference_level = AudioReference.dBFS_smpte >>> # Now recv_frame will provide normalized audio samples >>> # by scaling down the received samples by 20 dB (0.1x amplitude). ``` -------------------------------- ### Command Line Interface for Audio Sender Source: https://cyndilib.readthedocs.io/en/latest/examples.html Configuration of command-line options for the audio sender using the click library. ```python @click.command() @click.option('--xres', type=int, default=640, show_default=True) @click.option('--yres', type=int, default=480, show_default=True) @click.option('--fps', type=int, default=30, show_default=True) @click.option('-f', '--sine-freq', type=float, default=1000.0, show_default=True) @click.option( '-s', '--sine-vol', type=float, default=-20.0, show_default=True, help='Volume of the sine wave in dB (unit depends on audio reference)', ) @click.option( '--audio-reference', type=click.Choice([m.name for m in AudioReference]), default=AudioReference.dBVU.name, show_default=True, help='Audio reference level', ) @click.option('--sample-rate', type=int, default=48000, show_default=True) @click.option('--audio-channels', type=int, default=2, show_default=True) @click.option( '-n', '--num-frames', type=int, default=None, show_default=True, help='Number of frames to send, or None for infinite', ) @click.option( '--sender-name', type=str, default='audio_sender', show_default=True, help='NDI name for the sender', ) def main( xres: int, yres: int, fps: int, sine_freq: float, sine_vol: float, audio_reference: str, sample_rate: int, audio_channels: int, num_frames: int | None, sender_name: str, ) -> None: """Send a sine wave audio signal as an NDI stream.""" audio_reference_enum = AudioReference[audio_reference] opts = Options( xres=xres, yres=yres, fps=fps, sine_freq=sine_freq, sine_vol_dB=sine_vol, audio_reference=audio_reference_enum, sample_rate=sample_rate, audio_channels=audio_channels, num_frames=num_frames, sender_name=sender_name, ) try: send(opts) finally: click.echo('') if __name__ == '__main__': main() ``` -------------------------------- ### Run tests Source: https://cyndilib.readthedocs.io/en/latest/_sources/development.rst.txt Executes the test suite or doctests. ```bash py.test ``` ```bash py.test doc/ ``` -------------------------------- ### Control PTZ Camera Settings Source: https://cyndilib.readthedocs.io/en/latest/examples.html Demonstrates how to adjust focus, white balance, and exposure settings on a PTZ camera. Requires a configured ptz object and time module for delays. ```python print("trigger autofocus") ptz.autofocus() time.sleep(1) print("focus min (infinity)") ptz.set_focus(0.0) time.sleep(2) print("focus max") ptz.set_focus(1.0) time.sleep(2) print("decrease focus") for _ in range(0, 100): time.sleep(0.05) ptz.focus(-.5) print("increase focus") for _ in range(0, 100): time.sleep(0.05) ptz.focus(.5) print("trigger autofocus") ptz.autofocus() time.sleep(1) print("trigger auto white-balance") ptz.white_balance_auto() time.sleep(2) print("set indoor white-balance") ptz.white_balance_indoor() time.sleep(2) print("set outdoor white-balance") ptz.white_balance_outdoor() time.sleep(2) print("trigger oneshot white-balance") ptz.white_balance_oneshot() time.sleep(2) print("set white-balance to min") ptz.set_white_balance(0.0, 0.0) time.sleep(2) print("set white-balance to max") ptz.set_white_balance(1.0, 1.0) time.sleep(2) print("trigger auto white-balance") ptz.white_balance_auto() time.sleep(2) print("(re-)enable auto exposure") ptz.exposure_auto() time.sleep(2) print("set exposure to dark") ptz.set_exposure_coarse(0.0) time.sleep(2) print("set exposure to bright") ptz.set_exposure_coarse(1.0) time.sleep(2) print("set exposure to dark (fine adjustment)") ptz.set_exposure_fine(.0, .0, .0) time.sleep(2) print("set exposure to bright (fine adjustment)") ptz.set_exposure_fine(1.0, 1.0, 1.0) time.sleep(2) print("re-enable auto exposure") ptz.exposure_auto() time.sleep(2) if __name__ == "__main__": main() ``` -------------------------------- ### Use Finder as Context Manager Source: https://cyndilib.readthedocs.io/en/latest/_sources/guide/finder.rst.txt Manage the Finder lifecycle automatically using the with statement. ```python >>> with Finder() as finder: ... changed = finder.wait_for_sources(timeout=5) ... [source for source in finder] [] ``` -------------------------------- ### Configure AudioSendFrame for normalized audio Source: https://cyndilib.readthedocs.io/en/latest/guide/audio.html Sets the reference level to dBFS_smpte to automatically scale normalized audio samples up by 20 dB when sending. ```python >>> from cyndilib import AudioReference, AudioSendFrame, AudioFrameSync >>> send_frame = AudioSendFrame() >>> send_frame.reference_level = AudioReference.dBFS_smpte >>> # Now send_frame can be filled with normalized audio samples >>> # and they will be scaled up by 20 dB (10x amplitude) when sent. ``` -------------------------------- ### Audio Sender Implementation Source: https://cyndilib.readthedocs.io/en/latest/examples.html A complete implementation for sending NDI audio and video streams. Requires numpy for signal generation and cyndilib for NDI communication. ```python 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) ``` -------------------------------- ### CLI Command Configuration for Sender Source: https://cyndilib.readthedocs.io/en/latest/examples.html Defines command-line options for configuring the sender, including source selection, resolution, and audio parameters using the Click library. ```python @click.command() @click.option( '--source', type=click.Choice( choices=[m for m in get_args(TestSource)], ), default='testsrc2', show_default=True, help='Name of the ffmpeg test source to use', ) @click.option( '--pix-fmt', type=click.Choice(choices=[m.name for m in PixFmt]), default=PixFmt.uyvy422.name, show_default=True, show_choices=True, ) @click.option('-x', '--x-res', type=int, default=1920, show_default=True) @click.option('-y', '--y-res', type=int, default=1080, show_default=True) @click.option('--fps', type=str, default='30', show_default=True) @click.option( '-n', '--sender-name', type=str, default='ffmpeg_sender', show_default=True, help='NDI name for the sender', ) @click.option('-f', '--sine-freq', type=float, default=1000.0, show_default=True) @click.option( '-s', '--sine-vol', type=float, default=-20.0, show_default=True, help='Volume of the sine wave in dB (unit depends on audio reference)', ) @click.option( '--audio-reference', type=click.Choice([m.name for m in AudioReference]), default=AudioReference.dBVU.name, show_default=True, help='Audio reference level', ) @click.option('--sample-rate', type=int, default=48000, show_default=True) @click.option('--audio-channels', type=int, default=2, show_default=True) @click.option( '--ffmpeg', type=str, default='ffmpeg', show_default=True, help='Name/Path of the "ffmpeg" executable', ) def main( source: TestSource, pix_fmt: str, x_res: int, y_res: int, fps: str, sender_name: str, sine_freq: float, sine_vol: float, audio_reference: str, sample_rate: int, audio_channels: int, ffmpeg: str ): opts = Options( source=source, pix_fmt=PixFmt.from_str(pix_fmt), xres=x_res, yres=y_res, fps=fps, sine_freq=sine_freq, sine_vol_dB=sine_vol, audio_reference=AudioReference[audio_reference], sample_rate=sample_rate, audio_channels=audio_channels, sender_name=sender_name, ffmpeg=ffmpeg, ) send(opts) if __name__ == '__main__': main() ``` -------------------------------- ### Configure CLI for NDI Receiver Source: https://cyndilib.readthedocs.io/en/latest/examples.html Defines command-line options for NDI source name, pixel format, bandwidth, and ffplay path using the click library. ```python @click.command() @click.option( '-s', '--sender-name', type=str, default='ffmpeg_sender', show_default=True, help='The NDI source name to connect to', ) @click.option( '-f', '--recv-fmt', type=click.Choice(choices=[m.name for m in RecvFmt]), default='uyvy', show_default=True, show_choices=True, help='Pixel format' ) @click.option( '-b', '--recv-bandwidth', type=click.Choice(choices=[m.name for m in Bandwidth]), default='highest', show_default=True, show_choices=True, ) @click.option( '--ffplay', type=str, default='ffplay', show_default=True, help='Name/Path of the "ffplay" executable', ) def main(sender_name: str, recv_fmt: str, recv_bandwidth: str, ffplay: str): options = Options( sender_name=sender_name, recv_fmt=RecvFmt.from_str(recv_fmt), recv_bandwidth=Bandwidth.from_str(recv_bandwidth), ffplay=ffplay, ) play(options) if __name__ == '__main__': main() ``` -------------------------------- ### FrameSync.capture_available_audio Source: https://cyndilib.readthedocs.io/en/latest/reference/framesync.html Capture all currently available audio samples. ```APIDOC ## FrameSync.capture_available_audio() ### Description Capture all available audio samples. After this call, the captured data is available in the audio_frame. ### Returns - **int** - The number of samples captured. ``` -------------------------------- ### Configure AudioFrameSync for normalized audio Source: https://cyndilib.readthedocs.io/en/latest/guide/audio.html Sets the reference level to dBFS_smpte to automatically scale received audio samples down by 20 dB for normalization. ```python >>> recv_frame = AudioFrameSync() >>> recv_frame.reference_level = AudioReference.dBFS_smpte >>> # Now recv_frame will provide normalized audio samples >>> # by scaling down the received samples by 20 dB (0.1x amplitude). ```