- Python 85.5%
- QML 14.5%
| .forgejo/workflows | ||
| app | ||
| config | ||
| docs/img | ||
| ntsc_mixer | ||
| ntsc_rt | ||
| tests | ||
| translate | ||
| .gitignore | ||
| demo.gif | ||
| icon.png | ||
| LICENSE | ||
| ntsc_core.py | ||
| ntscQT.spec | ||
| ntscqt_icon.ico | ||
| pyproject.toml | ||
| README.md | ||
| uv.lock | ||
ntsc-rt-py
Live NTSC / VHS look for webcam or UVC (ATEM) → NDI (for example into Resolume). The effect core lives in app/ntsc.py. The supported interactive entry point is:
ntsc-mixer— a PySide6 + QML live mixer: sliders, preview, Preferences for capture / output / NDI / engine / control / health / MIDI, plus optional OSC, all driving the same pipeline through a thread-safeParamBus.
Python 3.10–3.12, uv recommended.
Getting started (normal use)
-
Install dependencies
uv sync -
Start the mixer
uv run ntsc-mixer -
Tune everything in Preferences (toolbar button or menu). Use Apply on each tab to push settings into the running pipeline. You do not need to edit JSON by hand for day-to-day work.
The app loads the bundled default preset (ntsc_rt/default.preset.json) automatically (or your --preset file first, if given); that preset ships with moderate VHS-style NTSC overrides so the first run is not an all-zero “flat” look. If ~/.config/ntsc-rt-py/mixer_state.json exists from Preferences → Control → Save for next launch, it is deep-merged on top of that loaded preset (unknown JSON keys under each model are ignored; see merge_preset_with_mixer_state) so last-session tweaks return without discarding the whole file when one field is stale.
NDI sender (visiongraph-ndi) is a normal dependency: uv sync installs it alongside visiongraph and cyndilib. Advanced: PyPI wheels are platform-specific; if import visiongraph_ndi still fails after a successful sync, check that your OS/arch/Python minor version has a published wheel, or install from a matching index.
Where settings are stored
| What | Path |
|---|---|
| Window geometry, OSC port/host, fullscreen screen, preview throttle | ~/.config/ntsc-rt-py/ui_preferences.json |
| Last full mixer snapshot for “Save for next launch” | ~/.config/ntsc-rt-py/mixer_state.json |
| MIDI CC → parameter map (also editable in Preferences → MIDI) | ~/.config/ntsc-rt-py/midi_map.json |
Power users and automation
--preset/-p— load any validMixerPresetJSON instead of starting only from the bundled default (the optionalmixer_state.jsonoverlay still applies afterward if present).--midi-port N— open MIDI input port indexN(requirespython-rtmidi). Configure CC routing in Preferences → MIDI; the same data is written tomidi_map.json.--osc/--osc-host— override the saved OSC bind for this process; see Preferences → Control for the usual workflow.
Use --version to print the package version.
Live mixer UI notes
If visiongraph_ndi cannot be imported (unusual after uv sync; see note above), the UI still starts; the pipeline thread prints recovery hints and the preview stays idle until the import works.
-
Preview — processed frames via a
QQuickImageProvider. Optional throttle (ms) inui_preferences.jsoncaps preview work (BGR→QImage + UI refresh); the NDI sender still receives every processed frame. -
Toolbar / Health — measured output FPS and a running HH:MM:SS:FF timecode derived from
ndi.frame_rate(display-only). Expect FPS to scale roughly with output resolution × NTSC cost; on Apple Silicon, lower Output height in Preferences (or presetoutput.target_height/output.ntsc_internal_max_width) until you reach ~15fps at your desired look. Clearingoutput.ntsc_internal_max_width(full-width NTSC) or choosing cubic / Lanczos for output interpolation increases CPU cost quickly—use area (default) for downscales when tuning for speed. Health FPS is the ground truth while you change these. Ringing below1.0runs an extra frequency-domain path per frame; the bundled default preset keepsringing: 1.0for real-time headroom (raise it toward0.52in Preferences → Engine when you want that artifact back). -
Output / Capture tabs — Output exposes target height, NTSC internal max width (0 = full raster), interpolation, plus quick buttons (480p/486/540p caps). Capture exposes driver request width×height with 960×540 / 720p / 1080p shortcuts (apply before picking FourCC/backend). Defaults favor 480-line working height and a 720px NTSC cap so the core stays near SD-ish cost.
-
Dropout / black flashes — the pipeline always holds the last good NDI frame on hiccups (no black placeholders). Stale-stream keepalives fire near the nominal frame period so Resolume does not blank when NTSC work runs long. Use Capture → stall watchdog only if you need forced reopen on a stuck camera.
-
Performance (Apple Silicon) —
ntsc-mixersetscv2.setNumThreadsfromNTSC_CV2_THREADS(default 4 on macOS, 1 elsewhere). Lower to 1 if you see CPU oversubscription; raise cautiously on M-series. The NTSC stack also batches VHS luma/chroma/sharpen IIRs along each frame plane (instead of one SciPy call per scanline), vectorizes the chroma-from-luma luma decode, and avoidsscipy.ndimage.shiftin the noise paths. Composite chroma lowpass still uses long 1-Dlfilterpasses. If profiling still shows time inside SciPy linear algebra, you can cap BLAS for this process only when launching, for example:OPENBLAS_NUM_THREADS=1 VECLIB_MAXIMUM_THREADS=1 OMP_NUM_THREADS=1 uv run ntsc-mixer. To inspect hotspots yourself, profile in-process (disable video noise so the RNG path does not dominate):uv run python -c " import cProfile, io, pstats import numpy as np from app.ntsc import Ntsc nt = Ntsc() nt._ringing = 1.0 nt._enable_ringing2 = False nt._video_noise = 0 f = np.zeros((480, 854, 3), np.uint8) pr = cProfile.Profile() pr.enable() for _ in range(30): nt.composite_layer(f, f, 0, 1) pr.disable() buf = io.StringIO() pstats.Stats(pr, stream=buf).sort_stats('cumtime').print_stats(20) print(buf.getvalue()) "After the chroma blur change, cumulative time should not show four heavy
cv2.resizeLanczos passes per frame at the top of the list for typical rasters. -
GPU (Metal / MPS) — The pipeline is NumPy + SciPy + OpenCV-Python on the CPU; the
opencv-python-headlesswheel does not run these kernels on Metal. Real GPU gain needs rewritten stages (e.g. MLX / PyTorch MPS / Metal shaders) plus careful buffer management—not a small flag. Until then, resolution caps and light NTSC remain the practical levers toward ~15fps. -
Fullscreen preview — F11 or the toolbar button; screen index from Preferences → Control.
-
Device picker — choose the capture device in the main window; fingerprints still appear for ambiguous hardware.
-
New random base — bumps
engine.seedand rebuilds theNtscinstance.
Troubleshooting
On macOS, if you see Objective‑C / objc duplicate class messages or FFmpeg libavdevice / libavformat symbol warnings when importing OpenCV together with NDI HX or other capture stacks, two different builds of FFmpeg or device glue are being loaded into one process. That is usually a harmless one‑time warning; if the app crashes instead, try loading components in a consistent order (avoid mixing a custom DYLD_LIBRARY_PATH that prepends another FFmpeg with the one your Python wheels expect), use a single capture path for a session (e.g. OpenCV or a separate NDI receiver, not both fighting the same AVFoundation device layer), and align NDI HX runtime versions with what your OpenCV / cv2 build was linked against. Reinstalling from a clean uv sync virtualenv isolates many accidental double‑loads.
| Symptom | What to check |
|---|---|
| Blank preview | Confirm the pipeline thread is running (Health FPS moving). If stderr mentions the NDI module, reinstall from the repo with uv sync (or check platform wheels); pick a capture device in the main window; try lowering preview throttle (ms) in Preferences → Control. |
| NDI missing in Resolume | Confirm uv sync completed and the stream appears under the configured stream name and resolution in Resolume’s NDI source list; macOS: allow local network for Python if prompted. |
| Bad preset / app exits on start | Run with a known-good --preset path; if mixer_state.json is corrupt, delete or rename ~/.config/ntsc-rt-py/mixer_state.json (unknown keys are ignored, but invalid values still fall back to the base preset for that file). |
Advanced: preset JSON and schema
Internally the engine still uses a single validated MixerPreset object (ntsc_rt/config_models.py: capture, output, NDI, engine, NTSC overrides). Preferences maps onto that model via ParamBus.
Optional JSON Schema for editor tooling can be regenerated from the repo root:
uv run python -c "from pathlib import Path; from ntsc_rt.schema_export import write_mixer_preset_schema; write_mixer_preset_schema(Path('config/mixer_preset.schema.json'))"
That writes config/mixer_preset.schema.json. Save preset in Preferences → Control exports the current live state (including engine + NTSC) through the app without hand-merging files.
CI (Forgejo / Gitea Actions)
.forgejo/workflows/ci.yml runs uv sync --group dev, then uv run black --check ., uv run isort --check ., uv run flake8 ., and uv run python -m unittest discover -s tests -p 'test_*.py' — the same style of commands as in Development below. On tags v*, it builds with uv build and runs uv publish using the PYPI_API_TOKEN repository secret.
Development
uv sync --group dev
uv run black .
uv run isort .
uv run flake8 .
uv run python -m unittest discover -s tests -p 'test_*.py'
macOS cameras
Grant Camera privacy for the terminal app running Python. Use the in-app device list to read fingerprints when descriptions collide.