Skip to content

Linux engine: 2.0 engine contract (Phase C) - #82

Merged
kalopsia-space merged 8 commits into
linux-2.0from
port/linux-2.0-engine-contract
Oct 17, 2026
Merged

kalopsia-space merged 8 commits into
linux-2.0from
port/linux-2.0-engine-contract

Conversation

@kalopsia-space

Copy link
Copy Markdown
Collaborator

Phase C of docs/linux-2.0-catch-up.md: the Linux capture engine now speaks the 2.0 UI's engine contract. Changes are confined to FTHRcapture_linux/ plus docs/linux.md and docs/engine-startup.md. The shared-memory layout and both enums are unchanged (Linux char ui_string[1024] / engine_string[2048]).

Contract changes

Item Before Now
-- arguments --list-playback-devices was read as fps 0, clamped to 1, and started a full engine that took the shm name Any -- argument exits before shared memory is touched. The two listing flags, used alone, print one schema-1 JSON line. Anything else exits 2 with usage
--list-playback-devices — PulseAudio sinks via libpulse: endpoint_id = sink name, display_name = description (falls back to the name), is_default from server info, "microphones" key as on Windows
--list-microphones — {"schema_version":1,"microphones":[]}, because the engine does not capture a mic
argv parsing strtol, 24 slots Pure ParseEngineArgs() using the shared ParseU32. 26 slots. An empty string means default in every slot. Each invalid value gets a [Config] warning
argv[24] ignored PulseAudio sink: the engine records its monitor, resolved through the server. A missing sink is logged and falls back to the default monitor. Only the default case follows default-sink changes
argv[25] — parsed and ignored (logged)
Limits 300 s, 60 Mbps 900 s, 200 Mbps (capture_config_limits.h)
Unknown encoder value min(v, 4), so 5+ meant Software Auto. Unknown codec values also map to Auto
Encoder policy Auto ended with libopenh264/kvazaar/svtav1 Auto = NVENC, VA-API/AMF, QSV only. Software only with pref 4, as in Windows 2.0
nvenc_active true only for *_nvenc (false on AMD VA-API!) true while any compressed replay encoder is open, software included, which is what the 2.0 UI expects
argv[6] ignored Byte budget for the encoded video ring (64–16384 MiB, 0 = none). Over budget, whole GOPs are dropped from the front, so the oldest packet is always a keyframe; the current GOP is never dropped. Startup logs the budget against the expected size; the first trim is logged
START_RECORDING + path answered RECORDING_STARTED (a recording that never existed) MANUAL_RECORDING_ERROR with a message in engine_string. An empty ui_string still means resume

Kept: the Linux transactional_save.h (fsync before publish). replay_interval.h needed no changes.

Risk: stale ui_string on resume (UI side)

capture_bridge.resume_recording() writes only ui_command. After any save, ui_string still holds the clip path, so a strict "path means recording" check would have refused every focus resume after the first save (Linux uses focus pause: focus_pause_supported() is true off Windows). To handle this, the engine now clears ui_string as soon as it copies a SAVE_CLIP path, before publishing SAVE_STARTED. The physical run below exercises exactly this sequence. The UI should still clear ui_string in resume_recording() and keep the Linux manual-recording skip (the UI agent is restoring it). Any other future writer of ui_string followed by START_RECORDING would be read as a recording request.

Judgement calls

  • Audio ring stays time-bounded. It is ~375 KiB/s whatever the bitrate (~340 MiB at 900 s), and it has to cover the whole video window or clips start silent. Only video scales with bitrate × time (up to ~22 GB unbounded).
  • The budget really bites at the UI's current argv[6]. The 2.0 UI sizes argv[6] as a raw-frame estimate capped at 2048 MiB, so a 900 s buffer above ~18 Mbps keeps less than 900 s. The engine logs this at startup (The budget covers only ~572 s at this bitrate). Raising the cap or sending an encoded-size budget is a UI decision.
  • Software HEVC/AV1 under pref Software (libkvazaar/libsvtav1) remain candidates. The 2.0 UI accepts them through its nvenc_active and codec != 'libopenh264' branch; only H.264 software is labelled as software there.
  • An explicitly chosen sink that disappears mid-session is retried every second, like a lost stream, rather than silently switching to the default.
  • FPS/duration 0 now become the defaults (60/30) instead of 1. Half-specified or >8K resolutions fall back to native, as on Windows.

Tests

New CTests: linux_encoder_candidates (pins Auto = hardware only, software only when explicit, LGPL only), linux_engine_args (26-slot UI launch, empty = default, limits, ParseU32 edge cases, -- classification), linux_audio_endpoint_json (exact schema-1 line, escaping, empty description). linux_encoded_ring_duration gains byte-budget cases: whole-GOP drops, keyframe-aligned snapshots after trimming, a single oversized GOP survives, exact byte accounting across time trims and Clear(). linux_encoder_timestamp/resize already selected Software explicitly, so they needed no changes.

Clean Release build with gcc 16.2.1 and the pinned FFmpeg: 0 warnings, 23/23 tests passed (vaapi encoder probe ran rather than skipped). tools/verify_engine_response_contract.py: intact. (verify_shared_memory_contract.py still fails on the Linux UI layout, which is Phase B and the UI agent's work.)

Physical evidence (Arch, Hyprland, PipeWire-Pulse, AMD VA-API)

$ ./FTHRclips --list-playback-devices
{"schema_version":1,"microphones":[{"endpoint_id":"alsa_output.pci-0000_03_00.1.hdmi-stereo-extra1","display_name":"Navi 31 HDMI/DP Audio Digital Stereo (HDMI 2) [LS24DG30X]","device_state":1,"is_default":false},{"endpoint_id":"alsa_output.pci-0000_13_00.6.analog-stereo","display_name":"Ryzen HD Audio Controller Analog Stereo","device_state":1,"is_default":true},{"endpoint_id":"easyeffects_sink","display_name":"Easy Effects Sink","device_state":1,"is_default":false}]}
rc=0   (no /dev/shm/FTHR_SharedMemory_v4 created)
$ ./FTHRclips --list-microphones   -> {"schema_version":1,"microphones":[]}  rc=0
$ ./FTHRclips --bogus              -> FTHRclips: unknown option '--bogus' + usage, rc=2
$ ./FTHRclips --list-playback-devices x -> '...' takes no other arguments, rc=2

Standalone engine with 26 args, non-default sink in argv[24], 900 s, 30 Mbps, argv[6]=2048, driven over /dev/shm by a script:

[FTHR] fps=60 buffer=900s enc=0x0 bitrate=30000kbps scaling=0 encoder_pref=0 playback=alsa_output.pci-0000_03_00.1.hdmi-stereo-extra1
[FTHR] microphone_mono ignored: the Linux engine does not capture a microphone
[Ring] Byte budget 2048 MiB; ~3236 MiB expected for 900 s at 30000 kbps
[Ring] The budget covers only ~572 s at this bitrate; older history will be dropped
[Audio] Playback device 'alsa_output.pci-0000_03_00.1.hdmi-stereo-extra1' -> alsa_output.pci-0000_03_00.1.hdmi-stereo-extra1.monitor
[Audio] Capturing output monitor: alsa_output.pci-0000_03_00.1.hdmi-stereo-extra1.monitor
[Encoder] Using: h264_vaapi  1920x1080  60fps  30000kbps  preset=P4

is_initialized 1 nvenc_active 1 active_codec h264_vaapi health 0x1
SAVE_CLIP -> CLIP_SAVED | ui_string after save: ''        (3.000 s h264 + aac, ffprobe)
STOP_RECORDING (pause) -> RECORDING_STOPPED   health 0x11
START_RECORDING (resume, ui_string untouched since save) -> RECORDING_STARTED   health 0x1
START_RECORDING with path -> MANUAL_RECORDING_ERROR | engine_string: 'Manual recording is not available on Linux yet. Save replay clips instead.'
SHUTDOWN -> exit 0, shm unlinked

Other runs:

  • Missing sink: Playback device 'no_such_sink_xyz' not found; capturing the default output monitor instead then Capturing default output monitor: alsa_output.pci-0000_13_00.6.analog-stereo.monitor.
  • Empty argv[24]: default monitor.
  • Pref 4 (Software): Using: libopenh264.
  • Auto with VA-API forced unavailable (FTHR_VAAPI_DEVICE=/dev/null): tries only h264_nvenc, h264_vaapi, h264_amf, h264_qsv, then All codecs failed — no encoder available (no automatic software fallback; ...). Recovery is exhausted and the engine exits. libopenh264 is never tried.

The 2.0 UI follows the Windows engine's policy: Auto means hardware
only, and a software replay encoder is accepted only when the user chose
Software. The Linux candidate list still appended the software encoders
to Auto, so a broken VA-API driver silently produced a CPU-heavy
libopenh264 replay that the 2.0 UI then rejected as "hardware encoding
unavailable" without the engine saying why.

Move the candidate list into a pure encoder_policy unit, make Auto try
NVENC, VA-API/AMF and QSV only, and pin it with the
linux_encoder_candidates test. nvenc_active now means "a compressed
replay encoder is open" (any backend), which is what the 2.0 UI reads
it as; before, an AMD VA-API engine reported false. docs/linux.md no
longer claims a software fallback.
The 2.0 UI launches the engine with 26 positional arguments and runs
`--list-playback-devices` / `--list-microphones` to fill its device
pickers. The Linux engine read argv with strtol, so
`--list-playback-devices` parsed as fps 0, was clamped to 1 and started a
full engine that took over the shared-memory name; argv[24] and [25] were
not read at all, and the buffer and bitrate ceilings (300 s, 60 Mbps)
were below the 2.0 UI's 900 s and 200 Mbps.

- Parse argv in a pure ParseEngineArgs() using the shared ParseU32, with
  the 2.0 limits, empty-means-default in every slot, and a warning for
  each invalid value. Unknown codec or encoder values become Auto; the
  old min(value, 4) turned an unknown encoder value into Software.
- Any argument starting with `--` exits before shared memory is touched:
  the two listing flags (alone) print one schema-1 JSON line, anything
  else exits 2 with usage.
- `--list-playback-devices` lists PulseAudio sinks through libpulse
  (endpoint_id = sink name, display_name = description, is_default from
  the server). `--list-microphones` prints an empty list: the Linux
  engine records no microphone.
- argv[24] (playback sink) and argv[6] (byte budget) are carried in
  CaptureConfig for the following commits; argv[25] (microphone mono)
  is parsed and ignored.

Tests: linux_engine_args, linux_audio_endpoint_json.
argv[24] carries the 2.0 playback endpoint. On Linux it is a PulseAudio
sink name: capture that sink's monitor (resolved through the server, so
a sink that no longer exists is detected) instead of always the default
output. A missing sink is logged and falls back to the default monitor,
and only that default case keeps following default-sink changes; an
explicitly chosen sink that drops out mid-session is retried every
second like any lost stream. Reconfigure keeps the selection.
With 2.0 a replay buffer can be 900 s at up to 200 Mbps, about 22 GB
of encoded video held in memory with nothing but the time window to
stop it. argv[6] (max_buffer_mb) was ignored.

When argv[6] is non-zero the encoded video ring now has a byte budget.
Over budget it drops whole GOPs from the front, so the oldest packet is
always a keyframe and saves stay keyframe-aligned; the GOP being filled
is never dropped, even when it alone exceeds the budget. The first trim
per encoder generation is logged with the history it kept, and startup
logs the budget against the expected size, warning when the bitrate
outruns it.

Desktop audio stays time-bounded on purpose: it is ~375 KiB/s whatever
the bitrate (~340 MiB at 900 s) and has to cover the whole video
window, or a clip starts silent.

Note the 2.0 UI still sizes argv[6] as a raw-frame estimate capped at
2048 MiB, so at that cap a 900 s buffer above roughly 18 Mbps is
trimmed (and says so in the log).
In 2.0, START_RECORDING with a path in ui_string is the manual
(continuous) recording request. The Linux engine treated every
START_RECORDING as "resume" and answered RECORDING_STARTED, so the 2.0
UI would believe a recording was running that was never written.

START_RECORDING with a path now answers MANUAL_RECORDING_ERROR with a
message in engine_string (protected from the health-reason writer until
the UI consumes it) and leaves the pause state alone; an empty
ui_string still means resume.

The 2.0 UI's resume_recording() does not clear ui_string, which still
holds the last clip path after a save, so every focus resume after the
first save would have been refused. The engine now consumes ui_string
when it takes a SAVE_CLIP path (before publishing SAVE_STARTED, so the
UI cannot have written a new one yet), which keeps resume working with
the UI as it is.
Record argv[24]/[25], the 900 s and argv[6] budget semantics, the
listing modes and the `--` exit, the hardware-only encoder policy and
what nvenc_active means, and how the Linux engine answers
START_RECORDING with and without a path.
The engine can now record a chosen sink's monitor instead of only the default; say so where the Linux guide describes desktop audio.
…report sink-list errors

Review follow-ups:
- StartForSink treated an unreachable server like a missing sink and fell
  back to the default monitor for the whole session, so an engine started
  while pipewire-pulse was still coming up never used the chosen device.
  Only a server that answered without the sink falls back now; otherwise
  the capture loop keeps retrying <sink>.monitor.
- pa_context_get_sink_info_list reports a server error as eol < 0; that
  printed an empty, successful device list. It is an error now.
@kalopsia-space

Copy link
Copy Markdown
Collaborator Author

Review follow-ups pushed:

  • A server that is not up yet no longer drops the chosen playback device. With PULSE_SERVER pointing at a missing socket and argv[24]=alsa_output.pci-0000_03_00.1.hdmi-stereo-extra1, the log shows PulseAudio unreachable; will keep trying playback device '…hdmi-stereo-extra1' and the capture loop retries …hdmi-stereo-extra1.monitor every second. Before, it settled on the default monitor.
  • A sink-list error (eol < 0) is now reported as an error instead of an empty successful list.
  • 0 warnings, 23/23 CTests.

Not covered by CTest (needs a PulseAudio server or the shm loop): EnumeratePlaybackSinks, StartForSink, HandleStartRecording and the ui_string clear. These were exercised physically as described above.

@kalopsia-space
kalopsia-space merged commit 9b49995 into linux-2.0 Oct 17, 2026
2 of 9 checks passed
@kalopsia-space
kalopsia-space deleted the port/linux-2.0-engine-contract branch October 17, 2026 13:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant