PTPv2 slave + playback stability fixes#99
Open
AsBrings wants to merge 3 commits into
Open
Conversation
added 2 commits
May 23, 2026 03:01
Tested against iPhone 17 Pro on iOS 26.4.2 (the receiver was previously unusable on iOS 17 onwards: SpringBoard would respring and the session torn down before audio could start). See the breakdown below. iOS 17+/26 compatibility fixes ------------------------------ - ap2-receiver.py: device_info['name'] was None when launched with the argparse default (-m's default value never persists), and iOS 17+ tears down the session immediately after probing /info between SETUP and RECORD when name is null. Persist the default name on first run. - ap2-receiver.py: do_OPTIONS Public header had a Python implicit-string- concatenation bug producing "FLUSHFLUSHBUFFERED" and "PUTSETPEERSXSETMAGICCOOKIE", and was missing SETPEERS / SETRATEANCHORTIME entirely. Newer iOS clients parse this strictly. - ap2-receiver.py: timingPeerInfo.Addresses now enumerates every non-loopback IPv4 + IPv6 address on the host (was only one IP); iOS uses this list for PTP peer reachability discovery. ID is now an IP string rather than the MAC, matching shairport-sync. - ap2/bitflags.py: default feature bitmask aligned with shairport-sync's known-working value (0x1c340405c4a00): add Ft11 (AudioRedundant) and Ft38 (ControlChannelEncrypt); drop Ft16/Ft17 metadata bits and Ft47. - ap2-receiver.py: set_volume_pid now uses the *data* process PID (where PyAudio's sink lives), not the RTCP control process — pycaw matches sessions by PID and was silently no-oping. PTPv2 slave (sub-ms accuracy on Wi-Fi) -------------------------------------- - ap2/connections/ptp_messages.py: PTPv2 (IEEE 1588-2008) message codec for Announce / Sync / Follow_Up / Delay_Req / Delay_Resp. - ap2/connections/ptp.py: ordinary-clock SLAVE state machine running as a multiprocessing child. Listens on UDP 319 (event) + 320 (general), handles one-step and two-step Sync, sends Delay_Req every 1s, and feeds a PI servo that locks the local disciplined clock to the iOS PTP grandmaster. Self-test on loopback converges to <2ms residual; measured residual against an iPhone 17 Pro on Wi-Fi is ~1ms. - ap2/connections/ptp_clock.py: PTPDisciplinedClock wraps a multiprocessing.Array so the slave (writer) and audio processes (readers) share offset + freq_ppb without IPC overhead. - ap2-receiver.py: SETPEERS / SETPEERSX now spawn / re-spawn the slave with the iPhone's IP list and ClockID; the shared array is allocated once per AP2Server and threaded through Stream into Audio. - ap2/connections/audio.py: msec_to_playout, samples_elapsed_since_anchor, and the two anchorMonotonicNanosLocal capture sites now read from the disciplined clock; _wait_for_ptp_sync() blocks up to 1.5s on first playback so the anchor lands in master time domain. - ptp_selftest.py: mock-master loopback test that does not need an AirPlay sender to validate the slave end-to-end. Audio pipeline / dependency updates ----------------------------------- - requirements.txt: drop av==8.1.0 pin (no longer builds on Python >= 3.10 because distutils.msvccompiler was removed); add numpy (required by av's to_ndarray) and platform-conditional pycaw on Windows. - ap2/connections/audio.py: PyAV 14+ made AudioCodecContext.channels read-only — set channel count via layout instead. AudioResampler may return planar frames even when asked for packed, so use to_ndarray() and interleave explicitly; previously bytes(frame.planes[0]) only picked up channel 0 and produced pitched-down crackling. - ap2/connections/audio.py: PyAudio frames_per_buffer raised from 4 to 1024 (~23 ms at 44.1 kHz). Four-frame buffers on Windows WASAPI underrun constantly and produce zipper noise. Other ----- - ap2/connections/ptp.py spawn() uses a module-level entry function rather than a nested closure so Windows' multiprocessing 'spawn' start-method can pickle it. Tested on Windows 11 + Python 3.11 + iPhone 17 Pro iOS 26.4.2: pairing, fp-setup, audio playback, and volume control all work; PTP servo residual reported in the debug log stays under 2ms.
Two pre-existing-and-new playback stability issues found while testing the PTP integration on a real Wi-Fi link with an iPhone 17 Pro: 1) Audio scheduler should not consume PTP steps directly. Using the disciplined clock in msec_to_playout was destabilizing single-receiver playback: on Wi-Fi the PTP servo occasionally steps (>100 ms) when it sees outlier Sync samples, and that step propagates straight into the audio anchor — every subsequent packet appears far in the past, the scheduler skips/blocks until session restart. For a single receiver, multi-room sync isn't required and stable playback matters more than absolute master-domain alignment. The audio scheduler now uses local monotonic time again. The PTP slave keeps running and stays available via self._ensure_ptp_clock() for diagnostics and for future multi-room work, where re-introducing PTP into the audio path should also include outlier rejection + slow-slew so that step jumps never reach the scheduler. Also turn _wait_for_ptp_sync() into a non-blocking status logger — it was adding up to 1.5 s of startup latency to every playback start for no benefit now that the anchor isn't in master domain. 2) Suppress resend-request storm in AudioRealtime.serve(). This was a pre-existing bug — every RTCP TIME_ANNOUNCE wake re-issues REXMIT_REQUEST for every missing seq still in the buffer, so on a lossy Wi-Fi link the sender's control port gets flooded with duplicate requests for the same packet and audio hiccups every ~2 s. Track per-seq monotonic_ns of the last request and only re-issue after 250 ms cooldown. GC the tracking dict at 2 s to bound memory.
There was a problem hiding this comment.
Pull request overview
This PR restores compatibility with modern iOS AirPlay 2 senders, introduces a PTPv2 slave implementation, and updates the audio pipeline/dependencies to improve playback stability (especially on Windows).
Changes:
- Fixes RTSP/feature negotiation and device/timing peer metadata to prevent early session teardown on iOS 17+/26.
- Adds a new PTPv2 ordinary-clock slave (plus shared disciplined-clock state and a loopback self-test).
- Updates the audio decode/resample path for modern PyAV behavior and improves real-time playback stability (buffer sizing, resend dedupe).
Reviewed changes
Copilot reviewed 8 out of 9 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| requirements.txt | Updates deps for modern PyAV + adds NumPy and Windows-only pycaw. |
| ptp_selftest.py | Adds loopback PTP master/slave convergence self-test harness. |
| ap2/connections/stream.py | Threads shared PTP clock array into audio connection processes. |
| ap2/connections/ptp.py | Implements PTPv2 slave process + spawn/shared-array helpers. |
| ap2/connections/ptp_messages.py | Adds IEEE 1588-2008 message codec subset. |
| ap2/connections/ptp_clock.py | Adds shared disciplined-clock state used across processes. |
| ap2/connections/audio.py | Updates PyAudio buffer sizing, PyAV channel/layout handling, resample/packing, and resend dedupe. |
| ap2/bitflags.py | Adjusts default AirPlay 2 feature bitmask to a known-working value. |
| ap2-receiver.py | Fixes OPTIONS Public header, improves timing peer enumeration/ID, spawns PTP slave on SETPEERS/SETPEERSX, and corrects Windows volume PID targeting. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+651
to
+655
| arr = out.to_ndarray() | ||
| if arr.ndim == 2 and arr.shape[0] > 1: | ||
| # planar (channels, samples) -> interleaved | ||
| arr = arr.T.reshape(-1) | ||
| return arr.tobytes() |
Comment on lines
+516
to
+519
| if hasattr(self.codecContext, "layout"): | ||
| self.codecContext.layout = "mono" if self.channel_count == 1 else "stereo" | ||
| else: | ||
| self.codecContext.channels = self.channel_count |
Comment on lines
+676
to
+680
| msec until intended playout of RTP packet with timestamp rtp_ts. | ||
|
|
||
| The "now" reading comes from the PTP-disciplined clock (master time | ||
| domain) when locked, falling back to local perf_counter when not. | ||
| anchorMonotonicNanosLocal is captured in the same domain so the |
Comment on lines
+841
to
+843
| if isinstance(plist, list): | ||
| ips = [str(x) for x in plist if isinstance(x, (str, bytes))] | ||
| self._maybe_start_ptp_slave(ips, master_clock_identity=None) |
Comment on lines
+904
to
+912
| def _maybe_start_ptp_slave(self, ips, master_clock_identity): | ||
| """Start (or restart) the PTP slave when the peer list changes.""" | ||
| if not ips: | ||
| return | ||
| key = (tuple(ips), master_clock_identity) | ||
| if self.server.ptp_master_key == key and self.server.ptp_proc and self.server.ptp_proc.is_alive(): | ||
| self.logger.debug("PTP slave already locked to this peer set; reuse") | ||
| return | ||
| # Tear down any old slave |
Comment on lines
+109
to
+115
| def pack(self) -> bytes: | ||
| b0 = ((self.transport_specific & 0x0F) << 4) | (self.message_type & 0x0F) | ||
| b1 = self.version & 0x0F | ||
| correction_scaled = (self.correction_ns & 0xFFFFFFFFFFFFFFFF) << 16 | ||
| # In real PTP correction is a 64-bit signed scaled ns; for tx we just | ||
| # send zero. For rx we decode to ns by dropping the low 16b. | ||
| return struct.pack( |
Comment on lines
+108
to
+113
| offset = self._arr[0] | ||
| freq_ppb = self._arr[1] | ||
| sync_local = self._arr[2] | ||
| if offset == 0.0 and freq_ppb == 0.0: | ||
| return local_ns | ||
| elapsed = local_ns - sync_local |
Seven targeted fixes from the automated reviewer on openairplay#99: 1. audio.py: Use frame.format.is_planar instead of an arr.shape[0] > 1 heuristic when deciding whether to interleave PyAV output. The shape heuristic happens to work for current PyAV (packed audio comes back as (1, N*ch), planar as (ch, N)), but it's an implementation detail we shouldn't rely on. 2. audio.py: AudioResampler layout was hard-coded to 'stereo' even when the codec context was already set to mono and PyAudio's sink was opened with channels=1; resampling would up-mix to 2 channels and the write would mismatch the sink. Match resampler layout to channel_count. 3. audio.py: Rewrite _ptp_now_ns() docstring to lead with "returns local monotonic ns — *not* PTP-disciplined" so the next reader/debugger isn't misled by the historical narrative. 4. ap2-receiver.py: do_SETPEERS now filters its peer list to IPv4 only. PTPSlave binds AF_INET sockets and sendto()'s the first address — an IPv6 entry (fe80::… / fdda::…) would raise immediately. The SETPEERSX path already had this filter; SETPEERS did not. 5. ap2-receiver.py: TEARDOWN with no remaining streams now also tears down the PTP slave. Previously the slave kept UDP/319+320 bound and kept sending Delay_Req to a sender that had already disconnected; subsequent SETPEERS for a different peer set could also leak slave processes. 6. ptp_messages.py: Remove the broken PtpHeader.pack() method. It produced an incorrect wire layout (control_field before sourcePortIdentity/sequenceId) and was only kept as a placeholder pointing at pack_header_bytes(); leaving it in the public surface is a footgun for anyone writing future TX paths. 7. ptp_clock.py: now_master_ns() now checks the status field instead of treating (offset == 0 and freq_ppb == 0) as "unsynced". A legitimate sync (loopback self-test, or two clocks genuinely in agreement) can land on those exact values, in which case discipline was being silently bypassed even though state was SYNCED. Selftest still converges. Receiver still boots and announces correctly.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 9 changed files in this pull request and generated 4 comments.
Comments suppressed due to low confidence (3)
ap2/connections/ptp.py:314
Delay_RespcorrectionField handling looks inconsistent withSync/Follow_Up: correctionField is applied additively elsewhere, but here it’s subtracted. This will skew the computed reverse path timing and therefore mean path delay / offset estimates when transparent-clock corrections are present.
pending = self._pending_delay_reqs.pop(msg.header.sequence_id, None)
if not pending:
return
t4_master_ns = msg.body.receive_timestamp_ns - msg.header.correction_ns
self._record_delay_sample(t3_local_ns=pending.t3_local_ns, t4_master_ns=t4_master_ns)
ap2/connections/ptp.py:175
- The bind failure log doesn’t mention that UDP/319 and UDP/320 are privileged ports on many OSes. Without that hint, this can be hard to diagnose when running unprivileged (PTP will silently stay UNSYNCED).
self._event_sock = _make_event_socket()
self._general_sock = _make_general_socket()
except OSError as e:
self._log(f"failed to bind PTP ports 319/320: {e!r} — slave aborting", logging.ERROR)
self.clock.set_status(PTPDisciplinedClock.STATUS_UNSYNCED)
return
ap2/connections/ptp.py:41
INITIAL_STEP_THRESHOLD_NSis defined but never used, which makes the servo behavior harder to reason about (and suggests the initial lock logic may have drifted from the intended design).
# Tunables
DELAY_REQ_INTERVAL_S = 1.0 # how often we initiate Delay_Req
MAX_SYNC_HISTORY = 32 # samples kept for slope/jitter detection
INITIAL_STEP_THRESHOLD_NS = 1_000_000 # 1 ms: above this on first lock, step
RUNNING_STEP_THRESHOLD_NS = 100_000_000 # 100 ms: re-step instead of slewing
OUTLIER_REJECT_FACTOR = 4.0 # reject samples whose path delay >> median
Comment on lines
+18
to
+27
| import logging | ||
| import multiprocessing | ||
| import os | ||
| import random | ||
| import select | ||
| import socket | ||
| import struct | ||
| import threading | ||
| import time | ||
| from collections import deque |
Comment on lines
+16
to
+18
| import multiprocessing | ||
| import struct | ||
| import time |
Comment on lines
676
to
+683
| def msec_to_playout(self, rtp_ts): | ||
| """ | ||
| msec until intended playout of RTP packet with timestamp rtp_ts | ||
| msec until intended playout of RTP packet with timestamp rtp_ts. | ||
|
|
||
| The "now" reading comes from the PTP-disciplined clock (master time | ||
| domain) when locked, falling back to local perf_counter when not. | ||
| anchorMonotonicNanosLocal is captured in the same domain so the | ||
| subtraction stays consistent across re-locks. |
Comment on lines
+845
to
+852
| if isinstance(plist, list): | ||
| # PTPSlave binds AF_INET sockets and sendto()s the master's | ||
| # IP — feeding an IPv6 (e.g. fe80::...) into that call | ||
| # raises immediately. Filter to dotted-quad IPv4 only. | ||
| ips = [ | ||
| str(x) for x in plist | ||
| if isinstance(x, (str, bytes)) and str(x).count(".") == 3 | ||
| ] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Tested against iPhone 17 Pro on iOS 26.4.2 + Windows 11 + Python 3.11.
The project was previously unusable on iOS 17+ (SpringBoard would respring
and the session was torn down before audio could start). This PR makes
the receiver compatible again, adds a working PTPv2 slave, modernizes the
PyAV / NumPy / Windows volume integration, and fixes a couple of
pre-existing playback-stability bugs surfaced during testing.
Closes the same symptom tracked in
shairport-sync #1942
(still open upstream).
What's included
iOS 17+/26 compatibility (the blocker fixes)
device_info['name']wasNonebecause the argparse default neverpersisted — iOS 17+ tears down the session immediately when name is null.
do_OPTIONSPublicheader had a Python implicit-string-concatenationbug producing
FLUSHFLUSHBUFFERED/PUTSETPEERSXSETMAGICCOOKIE, andwas missing
SETPEERSandSETRATEANCHORTIME.timingPeerInfo.Addressesnow enumerates every non-loopback IPv4 + IPv6on the host (was one IP);
IDis now an IP string, matching shairport-sync.(
0x1c340405c4a00).set_volume_pidnow uses the data process PID so pycaw can find thePyAudio session — previously volume was silently no-op on Windows.
PTPv2 slave (sub-ms accuracy on Wi-Fi)
ap2/connections/ptp_messages.py— IEEE 1588-2008 codec.ap2/connections/ptp.py— ordinary-clock slave + PI servo on UDP319/320. Handles one-step and two-step Sync.
ap2/connections/ptp_clock.py—PTPDisciplinedClocksharedacross processes via
multiprocessing.Array.ap2-receiver.py: SETPEERS / SETPEERSX now spawn/respawn the slavewith the sender's IP list and ClockID; the shared array is allocated
once per AP2Server and threaded through Stream into Audio.
ptp_selftest.py— mock-master loopback test that convergesend-to-end without an AirPlay sender.
The audio scheduler intentionally does not consume the disciplined
clock — see the playback-stability section for why.
Audio pipeline / dep updates
requirements.txt: dropav==8.1.0(no longer builds on Python ≥ 3.10because
distutils.msvccompilerwas removed). Addnumpy(required byPyAV
to_ndarray()) and platform-conditionalpycawon Windows.AudioCodecContext.channelsread-only — uselayout.AudioResamplermay return planar frames even when asked for packed —use
to_ndarray()+ explicit interleave;bytes(frame.planes[0])waspicking up channel 0 only and producing pitch-shifted crackling.
frames_per_bufferraised from4to1024(the 4-framedefault underruns WASAPI continuously and produces zipper noise).
Playback stability
AudioRealtime.serve()resend-storm fix (pre-existing bug): everyRTCP TIME_ANNOUNCE wake was re-issuing REXMIT_REQUEST for every missing
seq still in the buffer, flooding the sender's control port and
producing audible hiccups every ~2 s on Wi-Fi. Added a 250 ms per-seq
cooldown + GC.
_ptp_now_ns()in the audio path reverts to local monotonic time. PTPre-steps on Wi-Fi were leaking into
msec_to_playoutand stallingplayback. The PTP slave stays running and remains the right primitive
for future multi-room work — but the audio scheduler shouldn't see
step jumps. Documented inline.
Other
ptp.pyspawn()uses a module-level entry function rather than anested closure so Windows'
multiprocessingspawnstart-method canpickle it.
Test plan
python ptp_selftest.py— PTP slave reachesSYNCED, residual stable < 2 ms after ~5 s.
pair → fp-setup → second SETUP with streams → audio playback →
volume sync all work.
steady-state; no longer affects playback after the decoupling fix.
patch; previously had a 2 s glitch cycle on Wi-Fi.
Linux / macOS, older iOS.
Notes for the maintainer
has been effectively broken for any modern iPhone for two years.
audio integration is intentionally minimal (one helper + non-blocking
wait) so it can be reverted/refined independently.
git add -fdue to the root.gitignore'swhitelist pattern — kept as-is to match existing convention rather
than reworking the ignore policy.
iOS-compat / PTP / audio-deps separately.