Skip to content

PTPv2 slave + playback stability fixes#99

Open
AsBrings wants to merge 3 commits into
openairplay:masterfrom
AsBrings:feature/ios26-and-ptp
Open

PTPv2 slave + playback stability fixes#99
AsBrings wants to merge 3 commits into
openairplay:masterfrom
AsBrings:feature/ios26-and-ptp

Conversation

@AsBrings

Copy link
Copy Markdown

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'] was None because the argparse default never
    persisted — iOS 17+ tears down the session immediately when name is null.
  • do_OPTIONS Public header had a Python implicit-string-concatenation
    bug producing FLUSHFLUSHBUFFERED / PUTSETPEERSXSETMAGICCOOKIE, and
    was missing SETPEERS and SETRATEANCHORTIME.
  • timingPeerInfo.Addresses now enumerates every non-loopback IPv4 + IPv6
    on the host (was one IP); ID is now an IP string, matching shairport-sync.
  • Default feature bitmask aligned with shairport-sync's known-working value
    (0x1c340405c4a00).
  • set_volume_pid now uses the data process PID so pycaw can find the
    PyAudio session — previously volume was silently no-op on Windows.

PTPv2 slave (sub-ms accuracy on Wi-Fi)

  • New ap2/connections/ptp_messages.py — IEEE 1588-2008 codec.
  • New ap2/connections/ptp.py — ordinary-clock slave + PI servo on UDP
    319/320. Handles one-step and two-step Sync.
  • New ap2/connections/ptp_clock.pyPTPDisciplinedClock shared
    across processes via multiprocessing.Array.
  • ap2-receiver.py: SETPEERS / SETPEERSX now spawn/respawn the slave
    with 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 converges
    end-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: drop av==8.1.0 (no longer builds on Python ≥ 3.10
    because distutils.msvccompiler was removed). Add numpy (required by
    PyAV to_ndarray()) and platform-conditional pycaw on Windows.
  • PyAV 14+ made AudioCodecContext.channels read-only — use layout.
  • AudioResampler may return planar frames even when asked for packed —
    use to_ndarray() + explicit interleave; bytes(frame.planes[0]) was
    picking up channel 0 only and producing pitch-shifted crackling.
  • PyAudio frames_per_buffer raised from 4 to 1024 (the 4-frame
    default underruns WASAPI continuously and produces zipper noise).

Playback stability

  • AudioRealtime.serve() resend-storm fix (pre-existing bug): every
    RTCP 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. PTP
    re-steps on Wi-Fi were leaking into msec_to_playout and stalling
    playback. 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.py spawn() uses a module-level entry function rather than a
    nested closure so Windows' multiprocessing spawn start-method can
    pickle it.

Test plan

  • Loopback self-test: python ptp_selftest.py — PTP slave reaches
    SYNCED, residual stable < 2 ms after ~5 s.
  • End-to-end on Windows 11 + Python 3.11 + iPhone 17 Pro iOS 26.4.2:
    pair → fp-setup → second SETUP with streams → audio playback →
    volume sync all work.
  • PTP debug log on real iPhone shows servo residual < 2 ms
    steady-state; no longer affects playback after the decoupling fix.
  • Playback stays smooth across the resend-storm bug after the dedupe
    patch; previously had a 2 s glitch cycle on Wi-Fi.
  • Not tested: BUFFERED stream (type 103), AAC / Opus, multi-room,
    Linux / macOS, older iOS.

Notes for the maintainer

  • The iOS 17+/26 compat fixes are the highest-impact piece — the project
    has been effectively broken for any modern iPhone for two years.
  • The PTP work is self-contained in three new modules + a self-test;
    audio integration is intentionally minimal (one helper + non-blocking
    wait) so it can be reverted/refined independently.
  • Several new files needed git add -f due to the root .gitignore's
    whitelist pattern — kept as-is to match existing convention rather
    than reworking the ignore policy.
  • Happy to split into multiple PRs if you'd prefer reviewing
    iOS-compat / PTP / audio-deps separately.

AsBrings 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.
Copilot AI review requested due to automatic review settings May 22, 2026 19:15

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 thread ap2/connections/audio.py
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 thread ap2/connections/audio.py
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 thread ap2/connections/audio.py
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 thread ap2-receiver.py
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 thread ap2-receiver.py
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 thread ap2/connections/ptp_messages.py Outdated
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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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_Resp correctionField handling looks inconsistent with Sync/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_NS is 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 thread ap2/connections/ptp.py
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 thread ap2/connections/audio.py
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 thread ap2-receiver.py
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
]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants