Skip to content

Audio: latency reduction, true stereo/5.1 surround, and a GFN-style statistics HUD#62

Merged
aarikmudgal merged 8 commits into
owenselles:mainfrom
oliversluke:feature/audio-format
Jul 10, 2026
Merged

Audio: latency reduction, true stereo/5.1 surround, and a GFN-style statistics HUD#62
aarikmudgal merged 8 commits into
owenselles:mainfrom
oliversluke:feature/audio-format

Conversation

@oliversluke

@oliversluke oliversluke commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Supersedes and absorbs #59 — this PR is now self-contained (three commits, reviewable independently) and no longer depends on any other PR.

What this PR does

1. Audio latency reduction + AudioSync diagnostics (commit 1)

Audio played ~160 ms behind the (deliberately unbuffered) video. Since the GFN server sends no RTCP sender reports, WebRTC lip-sync can never engage — trimming audio-path latency is the only lever:

  • Bypass Apple's Voice-Processing I/O unit (built for calls; adds output latency and voice-band processing to game audio)
  • .default playback session mode + 10 ms IO buffer (was .moviePlayback, which measured ~100 ms device playout)
  • NetEQ: audioJitterBufferFastAccelerate + capped max packets, so the buffer drains back down after network hiccups instead of latency creeping up
  • New AudioSync os_log diagnostics (NetEQ delay/target, device playout, stretch/concealment, packet loss)

Measured: ~160 ms → ~115 ms total audio latency (NetEQ settles at ~35 ms in clean sessions; the remaining ~80 ms is fixed HDMI/TV route latency).

2. True stereo & 5.1 surround with an Audio Format setting (commit 2)

The bundled WebRTC audio device plays mono on Apple platforms. This PR replaces it with a custom RTCAudioDevice (GFNAudioDevice) built on AVAudioEngine:

  • True stereo out of the box for everyone
  • 5.1 surround: requests surroundAudioInfo from CloudMatch, accepts the server's multiopus offer via answer munging (libwebrtc rejects the m-line in its own answer; the LiveKit binary supports multiopus decode once accepted), and plays out 6 channels with an explicit 5.1 channel layout
  • New Audio Format setting (Automatic / Stereo / 5.1 Surround). Automatic requests 5.1 when the route reports a surround-capable sink (maximumOutputNumberOfChannels >= 6) and lets tvOS downmix gracefully otherwise — device-verified in a stereo room (TV with spatial audio) and a 5.1 AVR room (receiver locks discrete 5.1)
  • Strips the RED payload from the audio answer (server never retransmits with it; removing it avoids pointless overhead)

3. GFN-style Statistics HUD (commit 3)

Replicates the official client's Statistics overlay, and directly addresses the review feedback on #59 (audio diagnostics were console-only, and video vs audio metrics were easy to confuse):

  • Pause menu is now a left sidebar like the official in-game overlay, with a Statistics button cycling Off → Compact → Standard — effective immediately, persisted across sessions, default Off. B/Menu closes the sidebar.
  • Compact: FPS, ping, bitrate, packet loss (with sparklines) + server location
  • Standard: sectioned panel — Network, Video (incl. clearly-labeled video jitter buffer and decode time), Video Format, Audio (audio jitter buffer current/target, concealment, time stretch, output latency), Audio Format (Opus Stereo / Multiopus 5.1, output channels, route), Session (GPU, server, duration)
  • The HUD is passive: never takes focus, never pauses input, hidden nothing — it stays visible while the menu is open
  • Settings → Diagnostics becomes a simple toggle for the developer extras (pipeline tracing, AudioSync logs, RTC event log, Debug section). Persisted settings migrate losslessly (old hud/diagnostic values map sensibly; unknown values can't reset settings).

Testing

  • Built (tvOS Simulator arm64) + SwiftFormat/SwiftLint clean
  • Device-tested across two rooms: stereo TV (Atmos-capable) and 5.1 AVR — Audio Format rows confirm the negotiated/played format; receiver shows discrete 5.1
  • Settings-migration decode paths covered by a compiled test harness (legacy values, bogus values, round-trip)
  • Statistics HUD exercised on device: cycling levels live from the menu, input unaffected, persistence across relaunch

🤖 Generated with Claude Code

@aarikmudgal aarikmudgal changed the title True stereo & 5.1 surround audio output with Audio Format setting [ONLY Merge after #59] True stereo & 5.1 surround audio output with Audio Format setting Jul 10, 2026
@oliversluke oliversluke force-pushed the feature/audio-format branch from 596c100 to a98704a Compare July 10, 2026 07:24
@aarikmudgal

Copy link
Copy Markdown
Collaborator

testing changes

@aarikmudgal aarikmudgal self-requested a review July 10, 2026 10:35
@oliversluke oliversluke marked this pull request as draft July 10, 2026 11:19
oliversluke and others added 2 commits July 10, 2026 14:14
Audio played noticeably behind video (~160 ms measured). The video path is
deliberately unbuffered (frames display immediately on decode to minimize
input latency), while every stage of the audio path buffered: WebRTC's NetEQ
jitter buffer (~60 ms), Apple's Voice-Processing I/O unit, and a .moviePlayback
audio session whose quality-optimized output path measured 100 ms of device
playout delay. The GFN server sends no RTCP sender reports, so WebRTC's
built-in lip-sync can never engage — trimming audio latency is the only lever.

Fixes, all verified with the new diagnostics:
- Create the peer connection factory with bypassVoiceProcessing: the VPIO
  unit is built for calls (AEC/AGC/NS) and adds output latency plus
  voice-band processing to full-range game audio. Trade-off: no echo
  cancellation on the rarely-used microphone path.
- Use .default audio session mode instead of .moviePlayback and request a
  10 ms preferred IO buffer; movie mode engages tvOS's high-latency,
  quality-optimized output path.
- Keep the audio jitter buffer lean: audioJitterBufferFastAccelerate drains
  excess above target; audioJitterBufferMaxPackets=50 caps worst-case
  buildup at 500 ms (default was 2 s).

Also adds an AudioSync diagnostic log (Diagnostics stats mode only): per-
second NetEQ actual/target/floor delay, device playout delay, live OS output
latency, time-stretch/concealment activity, and the audio-video estimated-
playout-timestamp offset when RTCP SR is available.

Remaining structural latency, documented for follow-up: NetEQ's ~40 ms
self-computed target (no public API lowers it below the computed value) and
the HDMI route's reported 80 ms output latency (tvOS expects apps to delay
video to compensate; we deliberately do not, to keep input latency minimal).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The app has always played MONO: WebRTC's built-in Apple audio device module
pins playout to 1 channel (audio_device_ios.mm asserts channels == 1 and
reports stereo unavailable), so the stereo we negotiate via stereo=1 was
silently downmixed before reaching the speaker. This PR replaces the built-in
module with a custom AVAudioEngine-based RTCAudioDevice and adds full 5.1
surround support on top.

Audio Format setting (Automatic / Stereo / 5.1 Surround, default Automatic):
- Automatic requests 5.1 on surround-capable routes and lets tvOS render it
  for the actual speakers: discrete 5.1 on receivers (device-verified: AVR
  locks onto multichannel PCM), spatial virtualization on Atmos TVs, graceful
  6:2 downmix elsewhere. tvOS exposes no exact sink-capability API (the route
  port reports only the currently active format), so preferring surround with
  graceful degradation is the reliable behavior.
- Localized in all 34 languages.

5.1 negotiation (probe-verified against the GFN server):
- CloudMatch create/resume requests declare surround via surroundAudioInfo
  (GameStream encoding, 5.1 = (0x3F << 16) | 6), upon which the server offers
  multiopus/48000/6 instead of stereo opus — the same multichannel Opus the
  official web client uses.
- WebRTC does not advertise multiopus as a receive codec, so createAnswer
  rejects the audio m-line (port 0, mid dropped from BUNDLE), breaking the
  bundle transport. SDPMunger.mungeAudioAnswer rebuilds the answer's audio
  section to accept multiopus with the offer's exact fmtp — the SDP-munging
  path libwebrtc explicitly supports for this codec.

Custom audio device (GFNAudioDevice):
- AVAudioSourceNode pulls interleaved Int16 straight from the native ADM at
  the negotiated channel count: 2ch (finally true stereo) or 6ch with an
  explicit MPEG_5_1_A layout matching WebRTC's multiopus decode order.
- AVAudioSinkNode keeps the GFN microphone path working (Float32 to Int16
  conversion); route changes rebuild the graph and re-notify the ADM.

Latency: the answer munge also drops RED (redundant audio) from the accepted
payloads — NetEQ no longer budgets delay for redundancy the healthy network
never needs. Combined with the fast-accelerate drain, NetEQ now settles as
low as 35 ms in clean sessions (was pinned at 55-65 ms).

Diagnostics: the AudioSync/AudioDevice os_log lines report the negotiated
audio codec, requested/granted/port channel counts and route, gated to
Diagnostics stats mode.

Verified on real hardware across a stereo-TV room and a 5.1 AVR room:
negotiation, 6-channel decode and playout, AVR multichannel lock, stereo
regression, HDR-switch resilience, and correct channel placement by ear.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@oliversluke oliversluke force-pushed the feature/audio-format branch from a98704a to 7577f6a Compare July 10, 2026 12:14
…evels

Add a persistent statistics HUD during gameplay, mirroring the official
client's Statistics overlay: Compact shows the vital signs (FPS, ping,
bitrate, loss — with sparklines), Standard a full sectioned panel
(Network, Video, Video Format, Audio, Audio Format, Session) including
server location and, for the first time, the audio pipeline: NetEQ
jitter buffer, concealment, time stretching, output latency, negotiated
codec/channels and the active output route.

The pause menu becomes a left sidebar like the official overlay, with a
Statistics button cycling Off → Compact → Standard that takes effect
immediately (the HUD stays visible while the menu is open, never takes
focus, and never pauses input). B/Menu closes the sidebar like Resume.

The old statsMode picker splits into two independent settings: the HUD
level (statsMode: off/compact/standard, default off, cycled in-stream)
and a Diagnostics toggle in Settings gating the developer extras
(video-pipeline tracing, AudioSync logging, RTC event log, Debug HUD
section, which also absorbs the pause menu's old diagnostic rows).
Persisted settings migrate losslessly: "hud" → off (its stats now
always show in the overlay), "diagnostic" → standard + diagnostics on;
unknown values fall back to off instead of resetting all settings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@oliversluke oliversluke changed the title [ONLY Merge after #59] True stereo & 5.1 surround audio output with Audio Format setting Audio: latency reduction, true stereo/5.1 surround, and a GFN-style statistics HUD Jul 10, 2026
@oliversluke oliversluke marked this pull request as ready for review July 10, 2026 12:18
@oliversluke

Copy link
Copy Markdown
Contributor Author

@aarikmudgal following up from #59: this PR now includes the Statistics HUD — open the pause menu during a stream and press Statistics to cycle Off → Compact → Standard (takes effect immediately, HUD stays up while playing).

Standard mode shows separate Audio and Video sections, each with its own jitter buffer (current/target) — the video jitter buffer and decode time you compared on #59 live under Video; the audio path this PR changes is under Audio / Audio Format. Compact is just FPS/ping/bitrate/loss + server location.

For an A/B against main: same game + region + codec, give it 2–3 minutes to settle, and ideally a couple of runs each — GFN hands out a different rig per session, so single-session deltas of a few ms are noise.

@aarikmudgal

Copy link
Copy Markdown
Collaborator

Nice @oliversluke , Will do the testing in a bit and share my observations.

@aarikmudgal

aarikmudgal commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

@oliversluke Once in stream, after opening Diagnostics. There are no further options you mentioned. tested on simulator as well as real apple tv. I also noticed that you have incoming and outgoing commits in the PR. can you please check once and confirm? (Maybe a rebase)

Press "Play/Pause" button on Siri remote or hold options button opens the diagnostic menu
But shows default overlay with no new changes (Statistics option not found)

image

@oliversluke

Copy link
Copy Markdown
Contributor Author

I will fix the commits later today. I need to rebase them.

@oliversluke oliversluke marked this pull request as draft July 10, 2026 13:39
@aarikmudgal

Copy link
Copy Markdown
Collaborator

Sounds good, @oliversluke Tag me after, now just to see the changes I am trying to directly test on your forked branch. Maybe that works.

@aarikmudgal

aarikmudgal commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

@oliversluke Tested on your fork and branch, features work, haven't tested in depth but on high level it works and I found a UI bug when we switch from compact to standard. The UI breaks, seems like the entire stream tries to fit the diagnostics menu from top to bottom and zooms into the stream. Maybe you can fix that as well.

Here are the screenshots, tested both on simulator as well as ATV 4K.

Simulator Screenshot - Apple TV 4K (3rd generation) - 2026-07-10 at 15 57 49 Simulator Screenshot - Apple TV 4K (3rd generation) - 2026-07-10 at 15 57 52 Simulator Screenshot - Apple TV 4K (3rd generation) - 2026-07-10 at 15 57 57

You can see the cropped FH6 logo on left bottom

@aarikmudgal aarikmudgal added the enhancement New feature or request label Jul 10, 2026
…ontrast

Review fixes for the statistics HUD:

- The HUD's outer padding sat outside its full-screen frame, so panel
  height + margins exceeded the screen, grew the ZStack, and stretched
  the video underneath (reported as the stream 'zooming' when switching
  to Standard). Padding now applies inside the frame; the HUD can no
  longer influence the video layout.
- Standard level condensed from six sections to four with combined
  rows: jitter/loss, bitrate (max bandwidth inline), drops/freezes,
  one-line video format (codec · color mode · HW), conceal/stretch,
  and audio format/output (layout @ rate · route). Full decoder
  implementation moved to the Debug section.
- Show both FPS values like the official client: game engine FPS first,
  then stream FPS, in Compact and Standard. The game FPS arrives on a
  new 'stats_channel' data channel (unordered, no retransmits) whose
  binary messages the server sends for the official overlay: message
  type 3/4, then little-endian float64 at payload offset 25.
- Pause-menu buttons switch from borderedProminent (always-filled, focus
  barely visible) to bordered, restoring the native tvOS focus contrast:
  translucent chips with the focused button in a solid bright fill.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@oliversluke

Copy link
Copy Markdown
Contributor Author

@aarikmudgal thanks for the thorough testing — all addressed in 27e27c1:

  • UI bug (stream zooming in Standard): fixed. The HUD's margins were applied outside its full-screen frame, so a tall panel grew the layout container beyond the screen and stretched the video underneath — exactly the cropped-logo effect in your screenshots. The HUD can no longer influence the video layout.
  • Overlay slimmed down: Standard went from six sections to four, with related values combined per line (jitter/loss, drops/freezes, one-line video format H265 · HDR10 · HW, audio output 5.1 Surround @ 48 kHz · HDMI). Deep-debug rows only appear with the Settings → Diagnostics toggle.
  • Bonus: both FPS values like the official client — FPS (game / stream) in Compact and Standard, fed by the same stats channel the official overlay uses.
  • Pause-menu buttons now use the native tvOS focus treatment (translucent chips, focused = solid fill) instead of always-filled buttons.

About the incoming/outgoing commits: nothing is wrong with the PR itself — it's based on current main and GitHub reports it mergeable. What you saw is your local checkout of the branch being stale after the rebases (rewritten history shows as divergence). A git fetch && git reset --hard origin/feature/audio-format on your clone fixes it.

Would appreciate a re-test of the Compact ↔ Standard switch on your ATV!

oliversluke and others added 3 commits July 10, 2026 18:23
surroundAudioInfo only switches the WebRTC transport to multiopus — the
session was carried as 6-channel audio, but audioMode stayed at the
stereo default, so the cloud rig's audio endpoint remained 2.0 and games
rendered stereo: front speakers played, rear channels carried encoded
silence. The official client sets audioMode to the selected channel
count (audioMode = audioChannelCount); do the same.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 6-channel source fed AVAudioEngine's main mixer, which folds
multichannel input into the front channels when the input and output
layouts differ: the HDMI link stayed 6-channel (receivers locked onto
5.1) but the rear channels left the mixer silent. Device-verified with
per-channel level metering — all six channels arrive from WebRTC's
multiopus decode; only the mixer dropped the rears.

Surround now connects the source straight to the output node, which
maps by channel layout; stereo keeps the mixer path. Also logs the
output hardware format/layout once per graph build for diagnostics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Connection candidates raced their state callbacks on a concurrent global
queue, so a losing candidate's cancellation could run concurrently with
(or right after) its own .ready callback and resume the same checked
continuation twice — crashing with SWIFT TASK CONTINUATION MISUSE when
both candidates connected near-simultaneously.

Each connection now starts on its own serial queue and every resume goes
through a lock-guarded resume-once gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@oliversluke

Copy link
Copy Markdown
Contributor Author

Two more device-verified fixes pushed:

  • 5.1 rear channels were silent (9f62346's sibling 7d8e86f): two independent causes found with per-channel level metering — the session request's audioMode stayed at the stereo default so the cloud rig rendered stereo (fixed in 0c8ec2b: audioMode = channel count, like the official client), and AVAudioEngine's main mixer folded the 6-channel stream into the fronts (fixed by connecting the surround source directly to the output node). Full discrete 5.1 confirmed on a receiver.
  • Startup crash fix (9f62346): the signaling connect race could resume a Swift continuation twice when both connection candidates succeeded simultaneously (SWIFT TASK CONTINUATION MISUSE). State callbacks are now serialized with a resume-once guard.

The badge showed whenever a game supports Reflex, but the session
request only enables it at 120+ fps, which tvOS's 60 Hz cap rules out —
so it advertised a feature no session ever uses. HDR and RTX badges
already gate on actual usability; Reflex now simply never shows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@oliversluke oliversluke marked this pull request as ready for review July 10, 2026 17:00
@oliversluke

Copy link
Copy Markdown
Contributor Author

@owenselles @aarikmudgal From our side this PR is now complete and ready for review/merge — no further changes planned unless review or testing turns something up.

Final state, 8 commits in reading order:

  1. 320397f audio latency reduction + AudioSync diagnostics
  2. 7577f6a true stereo & 5.1 surround with the Audio Format setting
  3. 8822690 GFN-style statistics HUD (Compact/Standard) + left-sidebar pause menu
  4. 27e27c1 review fixes: HUD layout bug (video zoom), condensed overlay, game/stream FPS, focus contrast
  5. 0c8ec2b audioMode = channel count (rig endpoint; first half of the silent-rears fix)
  6. 7d8e86f surround playout bypasses the main mixer (second half — rears confirmed on a 5.1 receiver)
  7. 9f62346 signaling crash fix (double continuation resume when both connect candidates win)
  8. adab6a7 drop the Reflex loading badge (client never enables Reflex at 60 Hz)

Device-tested across a stereo room and a 5.1 receiver room: latency, discrete 5.1 with working rears, HUD levels/persistence, and the Compact↔Standard switch. Happy to address anything that comes up.

@aarikmudgal

aarikmudgal commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

Thanks @oliversluke for explaining the changes well and in detail. I will pick the testing as soon as possible. If not today then tomorrow for sure.
Will tag you in subsequent update.
Enjoy the Friday evening!

@aarikmudgal

Copy link
Copy Markdown
Collaborator

Testing @oliversluke , quick question, holding option doesn't open the in-stream overlay. Was this explicitly modified, now we have to click the siri remote to open the overlay, we should keep the hold option button as well since with controller you kind of replace the Siri Remote experience of tvOS.

@oliversluke

Copy link
Copy Markdown
Contributor Author

@aarikmudgal - no there was no change for this. it is one of the two buttons (depending on your settings). and a long press opens the HUD which shows on the left side.

maybe a side effect of some other PR you are testing? I can also retest later today, but I am quite sure I did not change this.

@oliversluke

Copy link
Copy Markdown
Contributor Author

@aarikmudgal Double-checked the diff: InputSender.swift (which contains the whole hold-to-open mechanism) isn't touched by this PR, and the menu-press wiring in GFNStreamController/StreamView is unchanged — this PR only adds that B/Menu closes the open menu.

Two things to check on your end: the overlay trigger is a single configurable button (Settings → Controller, default "Start (≡)" = the ☰ Menu button on Xbox / Options on DualSense) — holding the other small button triggers the Steam-overlay chord instead, which shows nothing outside Steam games. And the hold needs to be fairly long (~1.8 s); shorter presses are passed through to the game on purpose. Both behaviors are unchanged since the configurable-trigger commit from April. If it still doesn't open with the right button and a long hold, can you check whether it works on a main build with the same controller?

@aarikmudgal

Copy link
Copy Markdown
Collaborator

Absolutely @oliversluke will Test it thoroughly. For now I started testing and noticed few things.

  • App load, menu loads are much much slower. Not sure if you noticed that as well.
  • And I'm unable to start the game. It says cleaning up previous session and then says server took too long to respond. I wonder if the sessions are not resumed gracefully and only cleaned up and requested new everytime. Meaning new session id everytime we resume or retry. Will wait a bit so sessions are cleaned in background.

Take this with a grain of salt since it's really fresh testing observations. Would need to test it thoroughly for extensive feedback and review.

@oliversluke

Copy link
Copy Markdown
Contributor Author

@aarikmudgal One more thought: does your test build include #65 (tvOS keyboard input) by any chance? Its default keyboard shortcut is Options + Y, and its shortcut state machine intercepts the Options button on press — it withholds the button while waiting for the rest of the sequence and actively suppresses the overlay/Steam hold gestures while engaged (applyKeyboardShortcutStatesuppressesOverlayGestures resets the hold counter). So on a build with #65 mixed in, holding Options can no longer trigger the overlay hold. This PR doesn't touch InputSender at all, so a pure #62 build shouldn't show that behavior — a #62-only build would be the cleanest way to confirm.

@aarikmudgal

Copy link
Copy Markdown
Collaborator

@oliversluke you were right. I took clean build and now the game started and overlays stats etc are working well. 5.1 audio also working well. Will do one by one test of all the listed changes.

@aarikmudgal aarikmudgal left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested on ATV with FH6 and Witcher 3. All mentioned features are working.

@aarikmudgal

Copy link
Copy Markdown
Collaborator

@oliversluke Tested, looks good., May I recommend to also update Readme.md for all your PRs, I think last few PRs were also missing the documentation and I completely overlooked that.

@aarikmudgal aarikmudgal merged commit 35df06d into owenselles:main Jul 10, 2026
1 check passed
@oliversluke

Copy link
Copy Markdown
Contributor Author

@aarikmudgal Good point, thanks. To keep this PR's approval intact I'll do the docs as a dedicated follow-up PR right after this one merges — it will cover the README debt from the recent merged PRs too (0% deadzone from #46, the H.265 Main10/HDR work from #49) plus this PR's statistics HUD and surround audio. The still-open #67 and #68 will get their README updates pushed directly into those PRs so they land documented.

@aarikmudgal

Copy link
Copy Markdown
Collaborator

Thank you @oliversluke Sounds like a very good idea.

aarikmudgal pushed a commit that referenced this pull request Jul 10, 2026
…ree (#69)

Documents what recent PRs shipped but the README missed: 0-30%
controller deadzone (#46), H.265 Main10 SDP handling and the
HDR-preserving VideoToolbox decoder (#49), the pause menu sidebar,
statistics HUD, and surround audio (#62). Also adds the nine source
files missing from the architecture tree.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@oliversluke oliversluke deleted the feature/audio-format branch July 11, 2026 11:04
@aarikmudgal aarikmudgal self-assigned this Jul 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants