docs: the guest READMEs described code that is gone

All three came across with their standalone repos and none had been
touched since the transport changed.

nescapture claimed to packetize with Reed-Solomon FEC and stream RTP/UDP
to a Moonlight client. It sends encoded frames to neshub over a Unix
socket. packetizer.rs, control.rs and shard_batch.rs do not exist, and
neither does ARCHITECTURE.md. Nine documented environment variables are
not read by anything -- NESCAPTURE_RTP_HOST was listed as *required* --
and five that are read were undocumented, including the one that says
where frames go. Someone following that quick start would have set a
required variable that does nothing and got no output.

Documented the three sockets, since nescapture binds one and connects to
two and that was written down nowhere.

neswire documented --rtp-addr and --channels against a gstreamer pipeline.
It has --ipc-path, --channels, --packet-duration-ms and
--bitrate-per-channel, and hub-stub exists precisely so it can be tested
without a hub. Kept the reason hub-stub decodes rather than counts bytes:
Opus codes silence at ~3 kbps, so a dead sink looks alive on a meter.

nescope was mostly right. It called the capture layer "vkcapture", listed
seven of fifteen modules and five of nine flags, and its TODO list was
three items that neshub and nescapture now do. Added compositor mode,
which is the shape a real session uses and was undocumented.

All three licence lines were "TBD" or "See project repository".
This commit is contained in:
Wanjohi
2026-08-26 19:09:53 +03:00
parent c103e1257f
commit 6ea241c910
3 changed files with 239 additions and 191 deletions

View File

@@ -1,28 +1,30 @@
# nescapture # nescapture
A Vulkan implicit layer that captures frames from a running game, encodes them A Vulkan implicit layer that captures frames from inside the workload's own
with **Vulkan Video** hardware acceleration (H.264 / H.265 / AV1), packetizes with process, encodes them with **Vulkan Video** on the GPU that drew them, and
Reed-Solomon FEC, and streams over RTP/UDP to a Moonlight-compatible client — sends them to [`neshub`](../neshub) over a Unix socket — no copy out to the
all with **zero CPU copies** — fully GPU from game rendering to RTP output. CPU and back.
Being a layer rather than a screen-scraper is the whole point: the frame is
already on the GPU when we get it, and it never leaves.
--- ---
## Architecture ## Where it sits
``` ```
Game process Game process
│ Vulkan calls │ Vulkan calls
┌────────────────────────────────────────────── ┌──────────────────────────────────────────────┐
│ nescapture Vulkan implicit layer │ │ nescapture implicit layer
│ │ │ │
│ vkCreateShaderModule → SHA-256 hash │ │ vkCreateShaderModule → SHA-256 hash │
│ vkCreateGraphicsPipelines → track hashes │ │ vkCreateGraphicsPipelines → track hashes │
│ vkCmdBindPipeline → detect HUD shaders │ vkCmdBindPipeline → detect HUD
│ vkCmdEndRenderPass/Rendering → inject copy │
│ vkQueuePresentKHR → capture+encode │ │ vkQueuePresentKHR → capture+encode │
└────────────────────────────────────────────── └──────────────────────────────────────────────┘
│ GPU blit (same device) │ GPU blit, same device
final_image (DMA-BUF exportable) final_image (DMA-BUF exportable)
│ get_dmabuf_fd(final_memory) │ get_dmabuf_fd(final_memory)
@@ -33,110 +35,122 @@ DmaBufImporter (pixelforge VkDevice)
ColorConverter (GPU compute shader) ColorConverter (GPU compute shader)
│ BGRA/RGB10/FP16 → NV12/P010/YUV444 │ BGRA/RGB10/FP16 → NV12/P010/YUV444
Encoder (Vulkan Video, hardware H.264/H.265) Encoder (Vulkan Video: H.264 / H.265 / AV1)
encode() Annex-B packets
EncodedPacket (Annex-B) Unix datagram → neshub → the client
Packetizer (Moonlight wire format, Reed-Solomon FEC)
│ UDP datagrams
Moonlight client (or any RTP/UDP receiver)
CPU fallback: only when DMA-BUF export unavailable (rare driver config)
``` ```
CPU fallback exists only for driver configurations without DMA-BUF external
memory export.
---
## Sockets
| path | direction | carries |
| --- | --- | --- |
| `/tmp/nestri-video.sock` | nescapture → neshub | encoded frames |
| `/tmp/nestri-stats.sock` | nescapture → neshub | capture fps, encode ms, drops |
| `/tmp/nescapture-cmd.sock` | neshub → nescapture | IDR requests, encode settings |
nescapture binds the command socket and connects to the other two. The stats
path is derived from `NESCAPTURE_IPC_PATH`'s directory, so moving the video
socket moves both.
--- ---
## Quick start ## Quick start
```bash ```bash
# 1. Build # 1. Build
cargo build --release cargo build --release -p nescapture
# 2. Install the layer manifest # 2. Install the layer manifest
sudo cp manifest/VK_LAYER_nescapture.json /usr/share/vulkan/implicit_layer.d/ sudo cp apps/nescapture/manifest/VK_LAYER_nescapture.json /usr/share/vulkan/implicit_layer.d/
# Edit the manifest's `library_path` to point at target/release/libnescapture.so # Point the manifest's `library_path` at target/release/libnescapture_layer.so
# 3. Configure and launch a game # 3. Run something that draws
export NESCAPTURE_ENABLE=1 export NESCAPTURE_ENABLE=1
export NESCAPTURE_RTP_HOST=192.168.1.50 # Moonlight / receiver IP export NESCAPTURE_CODEC=h265
export NESCAPTURE_RTP_PORT=47998 # default export NESCAPTURE_BITRATE=10000
export NESCAPTURE_CODEC=h265 # h264 | h265 | av1 (auto-probes if unset) export RUST_LOG=info
export NESCAPTURE_BITRATE=10000 # kbps (CBR; ignored if NESCAPTURE_QP is set)
export NESCAPTURE_FPS=60
export RUST_LOG=info # or NESCAPTURE_LOG=debug
wine MyGame.exe # or native Vulkan game ./my-vulkan-app
``` ```
The layer is inert unless `NESCAPTURE_ENABLE=1`. That is deliberate — an
implicit layer is loaded into *every* Vulkan process on the system.
--- ---
## Environment variables ## Environment variables
| Variable | Default | Description | | Variable | Default | Description |
| ------------------------- | -------------- | ------------------------------------------------------ | | --- | --- | --- |
| `NESCAPTURE_ENABLE` | _(unset)_ | Set to `1` to activate the layer | | `NESCAPTURE_ENABLE` | _(unset)_ | Set to `1` to activate the layer. Nothing happens otherwise |
| `NESCAPTURE_RTP_HOST` | _(required)_ | Destination IP / hostname for RTP stream | | `NESCAPTURE_IPC_PATH` | `/tmp/nestri-video.sock` | Where to send encoded frames |
| `NESCAPTURE_RTP_PORT` | `47998` | Destination UDP port | | `NESCAPTURE_CODEC` | best available | `h264`, `h265` or `av1`; probes if unset |
| `NESCAPTURE_CODEC` | auto | `h264`, `h265` or `av1` — falls back to h264 if unsupported | | `NESCAPTURE_FORMAT` | `yuv420` | `yuv420` or `yuv444` |
| `NESCAPTURE_BITRATE` | `10000` | CBR target bitrate in kbps | | `NESCAPTURE_DEPTH` | auto | `8` or `10`; inferred from the swapchain `VkFormat` if unset |
| `NESCAPTURE_QP` | _(unset)_ | If set, use CQP with this quality level instead of CBR | | `NESCAPTURE_BITRATE` | `10000` | CBR target in kbps. Ignored when `NESCAPTURE_QP` is set |
| `NESCAPTURE_QP` | _(unset)_ | Constant QP instead of CBR |
| `NESCAPTURE_FPS` | `60` | Target frame rate | | `NESCAPTURE_FPS` | `60` | Target frame rate |
| `NESCAPTURE_IDR_INTERVAL` | `120` | Force an IDR keyframe every N frames | | `NESCAPTURE_IDR_INTERVAL` | `4` | Force an IDR every N **seconds** |
| `NESCAPTURE_FEC_PCT` | `20` | Reed-Solomon FEC percentage | | `NESCAPTURE_TUNE` | _(unset)_ | `highquality`, `lowlatency`, `ultralowlatency`, `lossless` |
| `NESCAPTURE_MIN_FEC` | `2` | Minimum FEC packets per block | | `NESCAPTURE_CONFIG` | _(unset)_ | Path to the per-app shader-hash TOML |
| `NESCAPTURE_PACKET_SIZE` | `1392` | Max UDP payload size (bytes) | | `NESCAPTURE_GAME_NAME` | exe basename | Override app identification for that config |
| `NESCAPTURE_CTRL_PORT` | `47999` | UDP port for the control stream | | `NESCAPTURE_DISCOVER` | _(unset)_ | Set to `1` to log every draw, for finding HUD shaders |
| `NESCAPTURE_CAPTURE_HUDLESS` | _(unset)_ | Set to `1` to also capture HUDless frames | | `RUST_LOG` | `error` | Standard `env_logger` filter, e.g. `nescapture_layer=debug` |
| `NESCAPTURE_CONFIG` | _(unset)_ | Path to per-game shader-hash TOML config |
| `NESCAPTURE_GAME_NAME` | (exe basename) | Override game identification | Everything else is decided at runtime: the client asks `neshub` for a codec or
| `NESCAPTURE_DISCOVER` | _(unset)_ | Set to `1` to enable discovery mode (logs all draws) | bitrate change and it arrives on the command socket, so the encoder is
| `NESCAPTURE_LOG` | `info` | Log level (`error`, `warn`, `info`, `debug`, `trace`) | reconfigured without a restart.
| `NESCAPTURE_RTP_FORMAT` | `moonlight` | `standard` for RFC 6184/7798 RTP (GStreamer/FFmpeg compatible), `moonlight` for Moonlight wire format with FEC |
--- ---
## Control stream ## Per-app shader-hash config
Send single-byte UDP datagrams to `NESCAPTURE_CTRL_PORT` (default 47999): HUD detection needs to know which pipelines draw the HUD, and that is
per-application. Run once with `NESCAPTURE_DISCOVER=1` to log every shader,
| Byte | Command | then pick out the HUD pipelines by the `[SUSPECT]` marker — blend on, depth
| ------ | -------------------- | off, six vertices or fewer.
| `0x01` | Start streaming |
| `0x02` | Stop streaming |
| `0x03` | Request IDR keyframe |
Example with netcat:
```bash
# Request IDR
printf '\x03' | nc -u -q1 localhost 47999
```
The control handle wiring in `present.rs` is currently a commented-out stub.
See `control.rs` for the full wiring instructions.
---
## Per-game shader-hash config
HUD shader detection requires a per-game config. Run the game once with
`NESCAPTURE_DISCOVER=1` to log all shaders, then identify HUD pipelines by the
`[SUSPECT]` marker (blend=true, depth=false, ≤6 vertices).
```toml ```toml
# ~/.config/nescapture/games.toml # ~/.config/nescapture/apps.toml
[game."GameName.exe"] [game."MyApp.exe"]
hud_fragment_shaders = ["0xaabbccddeeff0011"] hud_fragment_shaders = ["0xaabbccddeeff0011"]
hud_vertex_shaders = ["0x1a2b3c4d5e6f7890"] hud_vertex_shaders = ["0x1a2b3c4d5e6f7890"]
skip_fragment_shaders = ["0x1122334455667788"] skip_fragment_shaders = ["0x1122334455667788"]
``` ```
```bash ```bash
export NESCAPTURE_CONFIG=~/.config/nescapture/games.toml export NESCAPTURE_CONFIG=~/.config/nescapture/apps.toml
export NESCAPTURE_GAME_NAME=GameName.exe export NESCAPTURE_GAME_NAME=MyApp.exe
```
---
## Modules
```
src/
├── lib.rs entry points, dispatch routing
├── dispatch.rs Vulkan function-pointer types and tables
├── state.rs global DashMaps, DeviceState, CbState
├── instance.rs vkCreateInstance / vkDestroyInstance
├── device.rs vkCreateDevice / vkDestroyDevice
├── shader.rs SPIR-V hashing
├── pipeline.rs graphics pipeline tracking
├── framebuffer.rs image view and framebuffer tracking
├── commands.rs vkCmdBind*, vkCmdDraw*, vkCmdBeginRenderPass
├── swapchain.rs vkCreateSwapchainKHR, image enumeration
├── capture.rs GPU blit to the capture image, DMA-BUF export
├── present.rs vkQueuePresentKHR, encode dispatch
├── encode.rs pixelforge pipeline, codec probing, IPC send
├── dmabuf_import.rs cross-device zero-copy import
├── config.rs per-app TOML shader-hash config
└── discovery.rs draw-call logging for shader discovery
``` ```
--- ---
@@ -144,66 +158,19 @@ export NESCAPTURE_GAME_NAME=GameName.exe
## Dependencies ## Dependencies
| Crate | Purpose | | Crate | Purpose |
| ---------------------- | -------------------------------------------- | | --- | --- |
| `pixelforge` | Vulkan Video hardware encode (H.264 / H.265) | | `pixelforge` | Vulkan Video hardware encode |
| `ash` | Vulkan bindings | | `ash` | Vulkan bindings |
| `reed-solomon-erasure` | FEC for RTP packetizer | | `nesprotocol` | The IPC frame format `neshub` reads |
| `sha2` + `bytemuck` | SPIR-V shader fingerprinting | | `sha2`, `bytemuck` | SPIR-V fingerprinting |
| `dashmap` | Lock-free concurrent state maps | | `dashmap`, `once_cell` | Lock-free concurrent state |
| `serde` + `toml` | Per-game shader config | | `serde`, `toml` | Per-app shader config |
`ash` and `pixelforge` are pinned to git revisions — both track Vulkan Video
support that has not landed in a release.
--- ---
## Zero-copy GPU pipeline ## Licence
The full GPU path is implemented — no CPU color conversion: Apache 2.0. See [LICENSE](../../LICENSE).
1. `final_image` allocated with `DMA_BUF_EXT` external memory (`capture.rs`).
2. `get_dmabuf_fd()` exports the DMA-BUF fd (`capture.rs`).
3. `DmaBufImporter::import_or_reuse()` imports the fd as a `vk::Image` (`dmabuf_import.rs`).
4. `ColorConverter::convert()` runs a GPU compute shader:
BGRA/RGBA/RGB10/FP16 → NV12/P010/YUV444 (`encode.rs`).
5. `Encoder::encode()` encodes from the converter's output directly.
6. RTP packetizer + UDP send.
CPU fallback exists only for rare driver configurations that don't support
DMA-BUF external memory export.
---
## File structure
```
nescapture/
├── Cargo.toml
├── README.md
├── ARCHITECTURE.md
├── manifest/
│ └── VK_LAYER_nescapture.json
└── src/
├── lib.rs — entry points, dispatch routing
├── dispatch.rs — Vulkan function-pointer types + tables
├── state.rs — global DashMaps, DeviceState, CbState
├── instance.rs — vkCreateInstance / vkDestroyInstance
├── device.rs — vkCreateDevice / vkDestroyDevice
├── shader.rs — SPIR-V hashing
├── pipeline.rs — graphics pipeline tracking
├── framebuffer.rs — image view / framebuffer tracking
├── commands.rs — vkCmdBind*, vkCmdDraw*, vkCmdBeginRenderPass
├── swapchain.rs — vkCreateSwapchainKHR, image enumeration
├── capture.rs — GPU blit to capture image, DMA-BUF export
├── present.rs — vkQueuePresentKHR, encode dispatch
├── encode.rs — pixelforge pipeline, codec probing, RTP send
├── dmabuf_import.rs — DmaBufImporter (cross-device zero-copy import)
├── packetizer.rs — Moonlight RTP packetizer (Reed-Solomon FEC)
├── shard_batch.rs — zero-alloc shard buffer
├── control.rs — UDP control stream (IDR / start / stop)
├── config.rs — per-game TOML shader-hash config
└── discovery.rs — draw-call logging for shader discovery
```
---
## License
TBD

View File

@@ -24,7 +24,7 @@ nescope is built on top of [Smithay](https://smithay.org/), the Rust Wayland com
│ frame capture │ frame capture
┌─────────────────┐ ┌─────────────────┐
vkcapture layer │ ← External Vulkan interception nescapture │ ← Vulkan layer, inside the game's own process
└─────────────────┘ └─────────────────┘
``` ```
@@ -33,14 +33,21 @@ nescope is built on top of [Smithay](https://smithay.org/), the Rust Wayland com
### Core Components ### Core Components
| Module | Purpose | | Module | Purpose |
| ------------------ | -------------------------------------------------------------------------------- | | -------------------- | -------------------------------------------------------------------------------- |
| `main.rs` | CLI entry point, event loop, process management | | `main.rs` | CLI entry point, event loop, process management |
| `state.rs` | `NescopeState` — central compositor state, Wayland globals | | `state.rs` | `NescopeState` — central compositor state, Wayland globals |
| `handlers.rs` | Smithay protocol handlers (`CompositorHandler`, `XdgShellHandler`, `XwmHandler`) | | `handlers.rs` | Smithay protocol handlers (`CompositorHandler`, `XdgShellHandler`) |
| `input.rs` | Programmatic input injection via `calloop::channel` | | `xwm.rs` | X11 window management |
| `hdr.rs` | HDR/color management protocol handlers |
| `focus.rs` | Keyboard focus routing for X11 windows | | `focus.rs` | Keyboard focus routing for X11 windows |
| `protocols/mod.rs` | Generated gamescope swapchain bindings | | `input.rs` | Input decoding and injection via `calloop::channel` |
| `input_ipc.rs` | The client side of `neshub`'s input socket |
| `libinput_backend.rs`| Real input devices, when there are any |
| `hdr.rs` | HDR / colour management protocol handlers |
| `gpu_readback.rs` | Reading a dmabuf back to the CPU, for screenshots |
| `screenshot_ipc.rs` | Serving captures to whoever is listening |
| `screenshot_wire.rs` | The screenshot frame format |
| `protocols/` | Generated gamescope swapchain bindings |
| `bin/nescope-shot.rs`| Ask a running nescope what is on screen |
### Design Decisions ### Design Decisions
@@ -50,7 +57,9 @@ nescope is built on top of [Smithay](https://smithay.org/), the Rust Wayland com
3. **Input via Channel**: Input events arrive through a `calloop::channel::Sender<InputEvent>` rather than being decoded from a proxy connection. This allows external code (streaming servers, test harnesses) to inject input. 3. **Input via Channel**: Input events arrive through a `calloop::channel::Sender<InputEvent>` rather than being decoded from a proxy connection. This allows external code (streaming servers, test harnesses) to inject input.
4. **Process Subreaper**: nescope registers as a subreaper (`PR_SET_CHILD_SUBREAPER`) so orphaned descendants (e.g., Steam launcher → game client) are reparented to it, enabling reliable cleanup. 4. **Process Subreaper**: nescope registers as a subreaper (`PR_SET_CHILD_SUBREAPER`) so orphaned descendants — a launcher that execs the real client and exits, say — are reparented to it rather than to PID 1, and can be cleaned up reliably.
5. **Two shapes, one binary**: given a command after `--`, nescope launches it and exits when it and its windows are gone. Given none, it comes up as a plain compositor and waits for something to connect. The second is what a real session needs, where the processes that draw are started by something else entirely.
## Building ## Building
@@ -61,7 +70,7 @@ cargo build --release
## Usage ## Usage
```text ```text
nescope [OPTIONS] -- <command> [args...] nescope [OPTIONS] [-- <command> [args...]]
Options: Options:
--width <N> Output width [default: 1920] --width <N> Output width [default: 1920]
@@ -69,8 +78,14 @@ Options:
--fps <N> Virtual refresh rate [default: 60] --fps <N> Virtual refresh rate [default: 60]
--hdr Enable HDR protocols --hdr Enable HDR protocols
--socket <NAME> Wayland socket name [default: nescope-0] --socket <NAME> Wayland socket name [default: nescope-0]
--input-ipc <PATH> neshub's input socket [default: /tmp/nestri-input.sock]
--screenshot-ipc <PATH> Serve screenshots here [default: off]
--render-device <PATH> GPU to pin the workload to, e.g. /dev/dri/renderD128
--x-display <N> XWayland display number [default: 1]
``` ```
The command is optional; see design decision 5 above.
### Environment Variables ### Environment Variables
| Variable | Effect | | Variable | Effect |
@@ -81,18 +96,37 @@ Options:
| `NESCOPE_FPS` | Override `--fps` | | `NESCOPE_FPS` | Override `--fps` |
| `NESCOPE_HDR` | Enable HDR | | `NESCOPE_HDR` | Enable HDR |
| `NESCOPE_SOCKET` | Override socket name | | `NESCOPE_SOCKET` | Override socket name |
| `NESCOPE_INPUT_IPC` | Override `--input-ipc` |
| `NESCOPE_SCREENSHOT_IPC` | Override `--screenshot-ipc` |
| `NESCOPE_RENDER_DEVICE` | Override `--render-device` |
| `NESCOPE_X_DISPLAY` | Override `--x-display` |
| `RUST_LOG` | Tracing filter (e.g., `nescope=debug`) | | `RUST_LOG` | Tracing filter (e.g., `nescope=debug`) |
### Example ### Example
```sh ```sh
# Launch a game at 1440p with HDR # Wrap one program at 1440p with HDR
nescope --width 2560 --height 1440 --hdr -- %command% nescope --width 2560 --height 1440 --hdr -- %command%
# 1080p with debug logging # Plain compositor mode: come up and wait for clients
nescope --x-display 1
# Debug logging
RUST_LOG=nescope=debug nescope -- %command% RUST_LOG=nescope=debug nescope -- %command%
``` ```
### Seeing what is on screen
nescope is headless, so a black stream, a window that never mapped and a client
rendering fine all look identical from outside. `nescope-shot` is the listener
side of the screenshot socket:
```sh
# start this first — nescope dials out
nescope-shot --socket /tmp/nestri-screenshot.sock --watch --out shot.ppm
nescope --screenshot-ipc /tmp/nestri-screenshot.sock -- <program>
```
## HDR Support ## HDR Support
nescope supports two HDR signaling paths: nescope supports two HDR signaling paths:
@@ -123,12 +157,14 @@ nescope waits 5 seconds after the last mapped window disappears before exiting,
- **clap** — CLI parsing - **clap** — CLI parsing
- **x11rb** — X11 atom management - **x11rb** — X11 atom management
## License ## What nescope does not do
See project repository. Frames reach the client through [`nescapture`](../nescapture), which captures
inside the workload's process, and [`neshub`](../neshub), which muxes and
sends. Input arrives the same way in reverse. nescope provides the display
those components need and injects what they hand it; it never touches the
network.
## TODO ## Licence
- [ ] Handle sending frames thru to the client Apache 2.0. See [LICENSE](../../LICENSE).
- [ ] Handle packetizing and sharding
- [ ] Handle mouse and kb input from the client

View File

@@ -1,36 +1,81 @@
## neswire # neswire
A small custom PipeWire sink for cloud gaming audio capture. A PipeWire sink that Opus-encodes guest audio and sends it to
[`neshub`](../neshub).
Currently for debugging uses RTP to send Opus (with FEC enabled by default) over to target address. Audio in the guest has no speakers to reach, so neswire registers itself as an
output device and takes what anything plays into it. From the application's
side it is an ordinary sink.
---
### Testing ## Running it
#### Mono/Stereo
Launch gstreamer pipeline to receive audio as so (will save incoming RTP audio into test.mkv):
```bash ```bash
gst-launch-1.0 udpsrc port=12345 caps="application/x-rtp,media=audio,encoding-name=OPUS,clock-rate=48000,payload=111" ! rtpopusdepay2 ! opusdec ! matroskamux ! filesink location=test.mkv sync=false cargo run --release --bin neswire
``` ```
Then run neswire like so for example: Then select the **neswire** sink as the system output, or point an application
at it.
| Flag | Env | Default | Description |
| --- | --- | --- | --- |
| `--ipc-path` | `NESWIRE_IPC_PATH` | `/tmp/nestri-audio.sock` | Where to send Opus packets |
| `--channels` | `NESWIRE_CHANNELS` | `2` | `2` stereo, `6` for 5.1, `8` for 7.1 |
| `--packet-duration-ms` | `NESWIRE_PACKET_DURATION_MS` | `5` | Opus frame size in ms |
| `--bitrate-per-channel` | `NESWIRE_BITRATE_PER_CHANNEL` | `64` | kbps per channel |
`RUST_LOG` takes a standard tracing filter, e.g. `RUST_LOG=neswire=debug`.
Sample rate is fixed at 48 kHz, which is what Opus wants and what every
consumer device runs at anyway.
---
## Testing without a hub
neswire's only output is a Unix datagram socket that `neshub` binds, so running
it on a desktop means it has nothing to talk to — it retries `connect` forever
and there is no way to see what it would have sent. `hub-stub` binds that
socket and reports what arrives:
```bash ```bash
cargo run --release --bin neswire -- --rtp-addr 127.0.0.1:12345 # terminal 1
cargo run --bin hub-stub
# terminal 2
cargo run --bin neswire
``` ```
#### Surround It **decodes** the Opus rather than counting bytes, and that distinction
matters more than it looks. Opus codes digital silence in about two bytes a
packet, so a sink that is receiving nothing but zeros still produces a steady
~3 kbps and looks correct on every meter downstream. Peak amplitude is what
tells a working sink from a silent one.
Launch gstreamer pipeline to receive audio as so (will save incoming RTP audio into test_multi.mkv):
```bash ```bash
gst-launch-1.0 udpsrc port=12345 caps='application/x-rtp,media=audio,encoding-name=MULTIOPUS,clock-rate=48000,payload=111,encoding-params=(string)8,num_streams=(string)5,coupled_streams=(string)3,channel_mapping=(string)"0,6,1,2,3,4,5,7"' ! rtpopusdepay2 ! opusdec ! matroskamux ! filesink location=test.mkv sync=false cargo run --bin hub-stub -- --channels 8 # match neswire's channel count
``` ```
Then run neswire like so for example: `hub-stub` mirrors `ipc_listener::run_audio_listener` in `neshub`: same bind,
```bash same `0o666`, same stream-type and codec checks. Where the two disagree,
cargo run --release --bin neswire -- --rtp-addr 127.0.0.1:12345 --channels 8 `neshub` is right and the stub should be corrected.
---
## Layout
```
src/
├── main.rs CLI, wiring, the sink→encoder→IPC chain
├── sink.rs the PipeWire sink itself
├── encoder.rs Opus encode, including the multichannel mappings
└── bin/
└── hub-stub.rs
``` ```
---
For both afterwards, set the neswire sink as audio output source in your system (or specify it as output in some app/game), ## Licence
then play some audio, stop gst pipeline and sink, listen to results after.
Apache 2.0. See [LICENSE](../../LICENSE).