Repository: postlund/pyatv
Pull request #2059 (2023-06-17 05:02:45)

raop: Send audio as raw PCM
Audio is now sent as raw PCM samples instead of being encapsulated into ALAC frames. This gives a big performance boost since data is no longer unaligned. Also made some other optimizations, so it should be less stressful on low end systems.

Relates to #2057 

<a href="https://gitpod.io/#https://github.com/postlund/pyatv/pull/2059"><img src="https://gitpod.io/button/open-in-gitpod.svg"/></a>



codecov[bot]: ## [Codecov](https://app.codecov.io/gh/postlund/pyatv/pull/2059?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl) Report
Patch coverage: **`75.86`**% and project coverage change: **`-0.19`** :warning:
> Comparison is base [(`74b3fc4`)](https://app.codecov.io/gh/postlund/pyatv/commit/74b3fc449cc4025151fb2f4786008125acf74777?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl) 88.84% compared to head [(`b18fcd2`)](https://app.codecov.io/gh/postlund/pyatv/pull/2059?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl) 88.66%.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #2059      +/-   ##
==========================================
- Coverage   88.84%   88.66%   -0.19%     
==========================================
  Files         163      163              
  Lines       10825    10839      +14     
==========================================
- Hits         9618     9610       -8     
- Misses       1207     1229      +22     
```


| [Impacted Files](https://app.codecov.io/gh/postlund/pyatv/pull/2059?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl) | Coverage Δ | |
|---|---|---|
| [pyatv/protocols/raop/alac.py](https://app.codecov.io/gh/postlund/pyatv/pull/2059?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl#diff-cHlhdHYvcHJvdG9jb2xzL3Jhb3AvYWxhYy5weQ==) | `100.00% <ø> (ø)` | |
| [pyatv/protocols/raop/protocols/airplayv2.py](https://app.codecov.io/gh/postlund/pyatv/pull/2059?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl#diff-cHlhdHYvcHJvdG9jb2xzL3Jhb3AvcHJvdG9jb2xzL2FpcnBsYXl2Mi5weQ==) | `29.87% <ø> (ø)` | |
| [pyatv/support/rtsp.py](https://app.codecov.io/gh/postlund/pyatv/pull/2059?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl#diff-cHlhdHYvc3VwcG9ydC9ydHNwLnB5) | `94.73% <ø> (ø)` | |
| [pyatv/protocols/raop/audio\_source.py](https://app.codecov.io/gh/postlund/pyatv/pull/2059?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl#diff-cHlhdHYvcHJvdG9jb2xzL3Jhb3AvYXVkaW9fc291cmNlLnB5) | `76.53% <63.15%> (-0.39%)` | :arrow_down: |
| [pyatv/const.py](https://app.codecov.io/gh/postlund/pyatv/pull/2059?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl#diff-cHlhdHYvY29uc3QucHk=) | `100.00% <100.00%> (ø)` | |
| [pyatv/protocols/raop/stream\_client.py](https://app.codecov.io/gh/postlund/pyatv/pull/2059?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl#diff-cHlhdHYvcHJvdG9jb2xzL3Jhb3Avc3RyZWFtX2NsaWVudC5weQ==) | `86.10% <100.00%> (-4.23%)` | :arrow_down: |
| [pyatv/support/buffer.py](https://app.codecov.io/gh/postlund/pyatv/pull/2059?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl#diff-cHlhdHYvc3VwcG9ydC9idWZmZXIucHk=) | `100.00% <100.00%> (ø)` | |

... and [3 files with indirect coverage changes](https://app.codecov.io/gh/postlund/pyatv/pull/2059/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl)


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/postlund/pyatv/pull/2059?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Pierre+St%C3%A5hl).


Patch:
diff --git a/pyatv/protocols/raop/alac.py b/pyatv/protocols/raop/alac.py
index a9d94a91..3ba0e6da 100644
--- a/pyatv/protocols/raop/alac.py
+++ b/pyatv/protocols/raop/alac.py
@@ -10,6 +10,8 @@ Some assumptions here:
 * Samples are 16 bit/2 bytes
 * Number of samples in a frame is derived from frame size
 * End tag (111) is present at the end of a frame
+
+NB: This module is currently not used as raw samples are sent instead of ALAC.
 """
 from bitarray import bitarray
 
diff --git a/pyatv/protocols/raop/audio_source.py b/pyatv/protocols/raop/audio_source.py
index 9a5f93a9..935d443f 100644
--- a/pyatv/protocols/raop/audio_source.py
+++ b/pyatv/protocols/raop/audio_source.py
@@ -8,6 +8,7 @@ import io
 import logging
 import math
 import re
+import sys
 import threading
 import time
 from typing import Generator, Optional, Union
@@ -31,6 +32,22 @@ BUFFER_SIZE = 64 * 1024
 HEADROOM_SIZE = 32 * 1024
 
 
+def _to_audio_samples(data: Union[bytes, array.array]) -> bytes:
+    output: array.array
+    if isinstance(data, bytes):
+        # TODO: This assumes s16 samples!
+        output = array.array("h", data)
+    else:
+        output = data
+
+    # TODO: According to my investigation in #2057, this should happen if system
+    # byteorder is "big". So not sure why this works...
+    if sys.byteorder == "little":
+        output.byteswap()
+
+    return output.tobytes()
+
+
 def _int2sf(sample_size: int) -> SampleFormat:
     if sample_size == 1:
         return SampleFormat.UNSIGNED8
@@ -53,7 +70,10 @@ class AudioSource(ABC):
 
     @abstractmethod
     async def readframes(self, nframes: int) -> bytes:
-        """Read number of frames and advance in stream."""
+        """Read number of frames and advance in stream.
+
+        Frames are returned in little endian to match what AirPlay expects.
+        """
 
     @abstractmethod
     async def get_metadata(self) -> MediaMetadata:
@@ -115,18 +135,20 @@ class BufferedIOBaseWrapper(io.BufferedIOBase):
         if size == 0:
             return b""
 
-        self.buffer.add(self.reader.read(size))
+        # If space left in buffer, read from source and add it there. Don't do it if
+        # there's enough data in the buffer already though.
+        left_in_buffer = self.buffer.remaining
+        if left_in_buffer > 0 and size != -1 and size > self.buffer.size:
+            self.buffer.add(self.reader.read(min(size, left_in_buffer)))
 
-        # Read all data (if -1), otherwise as much as request OR space left in buffer
         to_read = self.buffer.size if size == -1 else min(size, self.buffer.size)
-
         return self.buffer.get(to_read)
 
     def seek(self, pos, origin=io.SEEK_SET):
         """Seek to position in stream."""
         if origin == io.SEEK_SET:
             self.buffer.seek(pos)
-        return self.buffer.tell()
+        return self.buffer.position
 
     def tell(self):
         """Return current position in stream."""
@@ -301,11 +323,11 @@ class BufferedIOBaseSource(AudioSource):
         # Use correct wrapper when streaming from buffer to ensure we have some kind
         # of seek support
         if isinstance(source, io.BufferedIOBase):
-            streamable_source = StreamableIOBaseWrapper(source)
             if source.seekable():
                 metadata_source = source
             else:
                 metadata_source = BufferedIOBaseWrapper(source, buffer)
+            streamable_source = StreamableIOBaseWrapper(metadata_source)
         else:
             streamable_source = StreamReaderWrapper(source, buffer)
             metadata_source = StreamableSourceWrapper(streamable_source, buffer)
@@ -370,7 +392,7 @@ class BufferedIOBaseSource(AudioSource):
         if len(self._audio_buffer) < 0.5 * self._buffer_size:
             self._buffer_needs_refilling.set()
 
-        return data
+        return _to_audio_samples(data)
 
     async def get_metadata(self) -> MediaMetadata:
         """Return media metadata if available and possible."""
@@ -598,11 +620,10 @@ class InternetSource(AudioSource):
 
     async def readframes(self, nframes: int) -> bytes:
         """Read number of frames and advance in stream."""
-        frames: Optional[array.array] = await self.loop.run_in_executor(
-            None, next, self.stream_generator, None
-        )
-        if frames:
-            return frames.tobytes()
+        with suppress(StopIteration):
+            frames: Optional[array.array] = next(self.stream_generator)
+            if frames:
+                return _to_audio_samples(frames)
         return AudioSource.NO_FRAMES
 
     async def get_metadata(self) -> MediaMetadata:
@@ -665,7 +686,7 @@ class FileSource(AudioSource):
         bytes_to_read = (self.sample_size * self.channels) * nframes
         data = self.samples[self.pos : min(len(self.samples), self.pos + bytes_to_read)]
         self.pos += bytes_to_read
-        return data
+        return _to_audio_samples(data)
 
     async def get_metadata(self) -> MediaMetadata:
         """Return media metadata if available and possible."""
diff --git a/pyatv/protocols/raop/protocols/airplayv2.py b/pyatv/protocols/raop/protocols/airplayv2.py
index 38009a25..d9c129e3 100644
--- a/pyatv/protocols/raop/protocols/airplayv2.py
+++ b/pyatv/protocols/raop/protocols/airplayv2.py
@@ -45,7 +45,17 @@ class AirPlayV2(StreamProtocol):
                 "sessionUUID": str(uuid4()).upper(),
                 "timingPort": timing_client_port,
                 "timingProtocol": "NTP",
-                "sourceVersion": "409.16",
+                "isMultiSelectAirPlay": True,
+                "groupContainsGroupLeader": False,
+                "macAddress": "AA:BB:CC:DD:EE:FF",
+                "model": "iPhone14,3",
+                "name": "pyatv",
+                "osBuildVersion": "20F66",
+                "osName": "iPhone OS",
+                "osVersion": "16.5",
+                "senderSupportsRelay": False,
+                "sourceVersion": "690.7.1",
+                "statsCollectionEnabled": False,
             }
         )
         resp = decode_bplist_from_body(setup_resp)
@@ -94,10 +104,10 @@ class AirPlayV2(StreamProtocol):
             body={
                 "streams": [
                     {
-                        "audioFormat": 0x40000,
+                        "audioFormat": 0x800,
                         "audioMode": "default",
                         "controlPort": control_client_port,
-                        "ct": 2,  # Channels?
+                        "ct": 1,  # Raw PCM
                         "isMedia": True,
                         "latencyMax": 88200,
                         "latencyMin": 11025,
diff --git a/pyatv/protocols/raop/stream_client.py b/pyatv/protocols/raop/stream_client.py
index 57378053..bbd6ba50 100644
--- a/pyatv/protocols/raop/stream_client.py
+++ b/pyatv/protocols/raop/stream_client.py
@@ -7,13 +7,13 @@ AirPlay v1 and/or v2. This is mainly for code re-use purposes.
 from abc import ABC, abstractmethod
 import asyncio
 import logging
-from time import perf_counter
+from time import monotonic, perf_counter
 from typing import Any, Dict, Mapping, NamedTuple, Optional, Tuple, cast
 import weakref
 
 from pyatv import exceptions
 from pyatv.protocols.airplay.utils import pct_to_dbfs
-from pyatv.protocols.raop import alac, timing
+from pyatv.protocols.raop import timing
 from pyatv.protocols.raop.audio_source import AudioSource
 from pyatv.protocols.raop.fifo import PacketFifo
 from pyatv.protocols.raop.packets import (
@@ -502,7 +502,7 @@ class StreamClient:
     ):
         stats = Statistics(self.context.sample_rate)
 
-        initial_time = perf_counter()
+        initial_time = monotonic()
         self._is_playing = True
         prev_slow_seqno = None
         number_slow_seqno = 0
@@ -551,7 +551,7 @@ class StreamClient:
             # are (from when we initially stared to stream). The diff is the time we
             # need to sleep until next lap.
             abs_time_stream = stats.total_frames / self.context.sample_rate
-            rel_to_start = perf_counter() - initial_time
+            rel_to_start = monotonic() - initial_time
             diff = abs_time_stream - rel_to_start
             if diff > 0:
                 number_slow_seqno = 0
@@ -610,7 +610,7 @@ class StreamClient:
             self.rtsp.session_id,
         )
 
-        audio = alac.encode(frames)
+        audio = frames
 
         if transport.is_closing():
             _LOGGER.warning("Connection closed while streaming audio")
diff --git a/pyatv/support/buffer.py b/pyatv/support/buffer.py
index 6d282a05..bf695900 100644
--- a/pyatv/support/buffer.py
+++ b/pyatv/support/buffer.py
@@ -77,6 +77,11 @@ class SemiSeekableBuffer:
         """Return number of bytes in buffer."""
         return len(self._buffer) - (self._position if self._has_headroom_data else 0)
 
+    @property
+    def remaining(self) -> int:
+        """Return remaining bytes in buffer."""
+        return self._buffer_size - self.size
+
     @property
     def position(self) -> int:
         """Return absolute position of bytes read from buffer.
diff --git a/pyatv/support/rtsp.py b/pyatv/support/rtsp.py
index d769da0d..3ff68f69 100644
--- a/pyatv/support/rtsp.py
+++ b/pyatv/support/rtsp.py
@@ -29,7 +29,7 @@ ANNOUNCE_PAYLOAD = (
     + "c=IN IP4 {remote_ip}\r\n"
     + "t=0 0\r\n"
     + "m=audio 0 RTP/AVP 96\r\n"
-    + "a=rtpmap:96 AppleLossless\r\n"
+    + "a=rtpmap:96 L16/44100/2\r\n"
     + f"a=fmtp:96 {FRAMES_PER_PACKET} 0 "
     + "{bits_per_channel} 40 10 14 {channels} 255 0 0 {sample_rate}\r\n"
 )


Test patch:
diff --git a/tests/fake_device/raop.py b/tests/fake_device/raop.py
index f3325c57..89514928 100644
--- a/tests/fake_device/raop.py
+++ b/tests/fake_device/raop.py
@@ -12,7 +12,6 @@ from typing import Dict, Optional, cast
 
 from pyatv.protocols.dmap import parser
 from pyatv.protocols.dmap.tag_definitions import lookup_tag
-from pyatv.protocols.raop import alac
 from pyatv.protocols.raop.packets import RetransmitReqeust, RtpHeader, SyncPacket
 from pyatv.protocols.raop.protocols.airplayv1 import parse_transport
 from pyatv.support.http import (
@@ -235,10 +234,10 @@ class AudioReceiver(asyncio.Protocol):
                         data, (self.state.remote_address, self.state.control_port)
                     )
             else:
-                self.state.add_audio_packet(header.seqno, alac.decode(data[12:]))
+                self.state.add_audio_packet(header.seqno, data[12:])
         elif packet_type == 0x56:  # Retransmission
             original_packet = data[4:]  # Remove retransmission header
-            self.state.add_audio_packet(header.seqno, alac.decode(original_packet[12:]))
+            self.state.add_audio_packet(header.seqno, original_packet[12:])
         else:
             _LOGGER.debug("Unhandled packet type: %d", packet_type)
 
diff --git a/tests/support/test_buffer.py b/tests/support/test_buffer.py
index 170575c4..d4974b23 100644
--- a/tests/support/test_buffer.py
+++ b/tests/support/test_buffer.py
@@ -71,6 +71,16 @@ def test_buffer_emptyness(buffer):
     assert buffer.empty()
 
 
+def test_buffer_remaining(buffer):
+    assert buffer.remaining == BUFFER_SIZE
+
+    buffer.add(b"a")
+    assert buffer.remaining == BUFFER_SIZE - 1
+
+    buffer.add(b"a" * BUFFER_SIZE)
+    assert buffer.remaining == 0
+
+
 def test_position_no_seeking(buffer):
     assert buffer.position == 0