diff options
author | 2025-03-21 10:18:05 -0700 | |
---|---|---|
committer | 2025-03-21 10:18:05 -0700 | |
commit | 2aaf2069ef3ffabd09841b0d5464bdc092246f56 (patch) | |
tree | 0fc54f038e3b7e40c50f326b5e315d119a2122cc | |
parent | 728504ce72974b0d1f2058bd772c6ed9f0a573b0 (diff) | |
parent | f0d5530b3b6937884105198385d851274ef2aa13 (diff) |
Merge "Pandora: prepare for LE Audio streaming" into main
-rw-r--r-- | android/pandora/mmi2grpc/mmi2grpc/_audio.py | 2 | ||||
-rw-r--r-- | android/pandora/mmi2grpc/mmi2grpc/hap.py | 7 | ||||
-rw-r--r-- | android/pandora/server/configs/PtsBotTest.xml | 1 | ||||
-rw-r--r-- | android/pandora/server/src/Hap.kt | 66 | ||||
-rw-r--r-- | android/pandora/server/src/LeAudio.kt | 96 | ||||
-rw-r--r-- | pandora/interfaces/pandora_experimental/hap.proto | 12 | ||||
-rw-r--r-- | pandora/interfaces/pandora_experimental/le_audio.proto | 25 |
7 files changed, 126 insertions, 83 deletions
diff --git a/android/pandora/mmi2grpc/mmi2grpc/_audio.py b/android/pandora/mmi2grpc/mmi2grpc/_audio.py index 7bc97167a8..6ab5b148ff 100644 --- a/android/pandora/mmi2grpc/mmi2grpc/_audio.py +++ b/android/pandora/mmi2grpc/mmi2grpc/_audio.py @@ -18,7 +18,7 @@ import math import os from threading import Thread -# import numpy as np +import numpy as np # from scipy.io import wavfile SINE_FREQUENCY = 440 diff --git a/android/pandora/mmi2grpc/mmi2grpc/hap.py b/android/pandora/mmi2grpc/mmi2grpc/hap.py index bd2fd83643..99202e9f7f 100644 --- a/android/pandora/mmi2grpc/mmi2grpc/hap.py +++ b/android/pandora/mmi2grpc/mmi2grpc/hap.py @@ -28,7 +28,8 @@ from pandora.security_grpc import Security from pandora.security_pb2 import LE_LEVEL3, PairingEventAnswer from pandora_experimental.gatt_grpc import GATT from pandora_experimental.hap_grpc import HAP -from pandora_experimental.hap_pb2 import HaPlaybackAudioRequest +from pandora_experimental.le_audio_pb2 import LeAudioPlaybackAudioRequest +from pandora_experimental.le_audio_grpc import LeAudio BASE_UUID = uuid.UUID("00000000-0000-1000-8000-00805F9B34FB") SINK_ASE_UUID = 0x2BC4 @@ -60,9 +61,9 @@ class HAPProxy(ProfileProxy): self.connection = None def convert_frame(data): - return HaPlaybackAudioRequest(data=data, source=self.source) + return LeAudioPlaybackAudioRequest(data=data) - self.audio = AudioSignal(lambda frames: self.hap.HaPlaybackAudio(map(convert_frame, frames)), + self.audio = AudioSignal(lambda frames: self.le_audio.LeAudioPlaybackAudio(map(convert_frame, frames)), AUDIO_SIGNAL_AMPLITUDE, AUDIO_SIGNAL_SAMPLING_RATE) def test_started(self, test: str, **kwargs): diff --git a/android/pandora/server/configs/PtsBotTest.xml b/android/pandora/server/configs/PtsBotTest.xml index 2acbd64521..60a609cc56 100644 --- a/android/pandora/server/configs/PtsBotTest.xml +++ b/android/pandora/server/configs/PtsBotTest.xml @@ -35,6 +35,7 @@ <target_preparer class="com.android.tradefed.targetprep.PythonVirtualenvPreparer"> <option name="dep-module" value="grpcio" /> <option name="dep-module" value="protobuf==3.20.1" /> + <option name="dep-module" value="numpy" /> <!-- Re-enable when A2DP audio streaming tests are active, disabling to speed up atest runtime (installation takes roughly 30s each time, never cached) --> diff --git a/android/pandora/server/src/Hap.kt b/android/pandora/server/src/Hap.kt index ddecaeb773..a242f4bcf2 100644 --- a/android/pandora/server/src/Hap.kt +++ b/android/pandora/server/src/Hap.kt @@ -29,8 +29,6 @@ import android.bluetooth.BluetoothProfile.CONNECTION_POLICY_ALLOWED import android.bluetooth.BluetoothProfile.STATE_CONNECTED import android.content.Context import android.content.IntentFilter -import android.media.AudioManager -import android.media.AudioTrack import android.util.Log import com.google.protobuf.Empty import io.grpc.Status @@ -61,7 +59,6 @@ class Hap(val context: Context) : HAPImplBase(), Closeable { private val bluetoothManager = context.getSystemService(BluetoothManager::class.java)!! private val bluetoothAdapter = bluetoothManager.adapter - private val audioManager = context.getSystemService(AudioManager::class.java)!! private val bluetoothHapClient = getProfileProxy<BluetoothHapClient>(context, BluetoothProfile.HAP_CLIENT) @@ -79,8 +76,6 @@ class Hap(val context: Context) : HAPImplBase(), Closeable { ) .shareIn(scope, SharingStarted.Eagerly) - private var audioTrack: AudioTrack? = null - private class PresetInfoChanged( var connection: Connection, var presetInfoList: List<BluetoothHapPresetInfo>, @@ -287,67 +282,6 @@ class Hap(val context: Context) : HAPImplBase(), Closeable { } } - override fun haPlaybackAudio( - responseObserver: StreamObserver<Empty> - ): StreamObserver<HaPlaybackAudioRequest> { - Log.i(TAG, "haPlaybackAudio") - - if (audioTrack == null) { - audioTrack = buildAudioTrack() - } - - // Play an audio track. - audioTrack!!.play() - - if (audioTrack!!.getPlayState() != AudioTrack.PLAYSTATE_PLAYING) { - responseObserver.onError( - Status.UNKNOWN.withDescription("AudioTrack is not started").asException() - ) - } - - // Volume is maxed out to avoid any amplitude modification of the provided audio data, - // enabling the test runner to do comparisons between input and output audio signal. - // Any volume modification should be done before providing the audio data. - if (audioManager.isVolumeFixed) { - Log.w(TAG, "Volume is fixed, cannot max out the volume") - } else { - val maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) - if (audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) < maxVolume) { - audioManager.setStreamVolume( - AudioManager.STREAM_MUSIC, - maxVolume, - AudioManager.FLAG_SHOW_UI, - ) - } - } - - return object : StreamObserver<HaPlaybackAudioRequest> { - override fun onNext(request: HaPlaybackAudioRequest) { - val data = request.data.toByteArray() - val written = synchronized(audioTrack!!) { audioTrack!!.write(data, 0, data.size) } - if (written != data.size) { - responseObserver.onError( - Status.UNKNOWN.withDescription("AudioTrack write failed").asException() - ) - } - } - - override fun onError(t: Throwable) { - t.printStackTrace() - val sw = StringWriter() - t.printStackTrace(PrintWriter(sw)) - responseObserver.onError( - Status.UNKNOWN.withCause(t).withDescription(sw.toString()).asException() - ) - } - - override fun onCompleted() { - responseObserver.onNext(Empty.getDefaultInstance()) - responseObserver.onCompleted() - } - } - } - override fun waitPresetChanged( request: Empty, responseObserver: StreamObserver<WaitPresetChangedResponse>, diff --git a/android/pandora/server/src/LeAudio.kt b/android/pandora/server/src/LeAudio.kt index 7e7e7e6147..7677f9ea71 100644 --- a/android/pandora/server/src/LeAudio.kt +++ b/android/pandora/server/src/LeAudio.kt @@ -25,9 +25,11 @@ import android.bluetooth.BluetoothProfile.STATE_DISCONNECTED import android.content.Context import android.content.Intent import android.content.IntentFilter -import android.media.* +import android.media.AudioTrack +import android.media.AudioManager import android.util.Log import com.google.protobuf.Empty +import io.grpc.Status import io.grpc.stub.StreamObserver import java.io.Closeable import kotlinx.coroutines.CoroutineScope @@ -41,6 +43,8 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.shareIn import pandora.LeAudioGrpc.LeAudioImplBase import pandora.LeAudioProto.* +import java.io.PrintWriter +import java.io.StringWriter @kotlinx.coroutines.ExperimentalCoroutinesApi class LeAudio(val context: Context) : LeAudioImplBase(), Closeable { @@ -57,6 +61,8 @@ class LeAudio(val context: Context) : LeAudioImplBase(), Closeable { private val bluetoothLeAudio = getProfileProxy<BluetoothLeAudio>(context, BluetoothProfile.LE_AUDIO) + private var audioTrack: AudioTrack? = null + init { scope = CoroutineScope(Dispatchers.Default) val intentFilter = IntentFilter() @@ -98,4 +104,92 @@ class LeAudio(val context: Context) : LeAudioImplBase(), Closeable { Empty.getDefaultInstance() } } + + override fun leAudioStart(request: LeAudioStartRequest, responseObserver: StreamObserver<Empty>) { + grpcUnary<Empty>(scope, responseObserver) { + if (audioTrack == null) { + audioTrack = buildAudioTrack() + } + val device = request.connection.toBluetoothDevice(bluetoothAdapter) + Log.i(TAG, "start: device=$device") + + if (bluetoothLeAudio.getConnectionState(device) != BluetoothLeAudio.STATE_CONNECTED) { + throw RuntimeException("Device is not connected, cannot start") + } + + // Configure the selected device as active device if it is not + // already. + bluetoothLeAudio.setActiveDevice(device) + + // Play an audio track. + audioTrack!!.play() + + Empty.getDefaultInstance() + } + } + + override fun leAudioStop(request: LeAudioStopRequest, responseObserver: StreamObserver<Empty>) { + grpcUnary<Empty>(scope, responseObserver) { + checkNotNull(audioTrack) { "No track to pause!" } + + // Play an audio track. + audioTrack!!.pause() + + Empty.getDefaultInstance() + } + } + + override fun leAudioPlaybackAudio( + responseObserver: StreamObserver<LeAudioPlaybackAudioResponse> + ): StreamObserver<LeAudioPlaybackAudioRequest> { + Log.i(TAG, "leAudioPlaybackAudio") + + if (audioTrack!!.getPlayState() != AudioTrack.PLAYSTATE_PLAYING) { + responseObserver.onError( + Status.UNKNOWN.withDescription("AudioTrack is not started").asException() + ) + } + + // Volume is maxed out to avoid any amplitude modification of the provided audio data, + // enabling the test runner to do comparisons between input and output audio signal. + // Any volume modification should be done before providing the audio data. + if (audioManager.isVolumeFixed) { + Log.w(TAG, "Volume is fixed, cannot max out the volume") + } else { + val maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + if (audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) < maxVolume) { + audioManager.setStreamVolume( + AudioManager.STREAM_MUSIC, + maxVolume, + AudioManager.FLAG_SHOW_UI, + ) + } + } + + return object : StreamObserver<LeAudioPlaybackAudioRequest> { + override fun onNext(request: LeAudioPlaybackAudioRequest) { + val data = request.data.toByteArray() + val written = synchronized(audioTrack!!) { audioTrack!!.write(data, 0, data.size) } + if (written != data.size) { + responseObserver.onError( + Status.UNKNOWN.withDescription("AudioTrack write failed").asException() + ) + } + } + + override fun onError(t: Throwable) { + t.printStackTrace() + val sw = StringWriter() + t.printStackTrace(PrintWriter(sw)) + responseObserver.onError( + Status.UNKNOWN.withCause(t).withDescription(sw.toString()).asException() + ) + } + + override fun onCompleted() { + responseObserver.onNext(LeAudioPlaybackAudioResponse.getDefaultInstance()) + responseObserver.onCompleted() + } + } + } } diff --git a/pandora/interfaces/pandora_experimental/hap.proto b/pandora/interfaces/pandora_experimental/hap.proto index 5847887529..d56fdb515d 100644 --- a/pandora/interfaces/pandora_experimental/hap.proto +++ b/pandora/interfaces/pandora_experimental/hap.proto @@ -28,8 +28,6 @@ service HAP { rpc SetNextPreset(SetNextPresetRequest) returns (google.protobuf.Empty); // Set next preset rpc SetPreviousPreset(SetPreviousPresetRequest) returns (google.protobuf.Empty); - // Playback audio - rpc HaPlaybackAudio(stream HaPlaybackAudioRequest) returns (google.protobuf.Empty); // Set preset name rpc WritePresetName(WritePresetNameRequest) returns (google.protobuf.Empty); // Get preset record @@ -50,16 +48,6 @@ message GetFeaturesResponse{ int32 features = 1; } -// Request of the `PlaybackAudio` method. -message HaPlaybackAudioRequest { - // Low Energy connection. - Connection connection = 1; - // Audio data to playback. - // `data` should be interleaved stereo frames with 16-bit signed little-endian - // linear PCM samples at 44100Hz sample rate - bytes data = 2; -} - // Request of the `SetActivePreset` method. message SetActivePresetRequest { // Connection crafted by grpc server diff --git a/pandora/interfaces/pandora_experimental/le_audio.proto b/pandora/interfaces/pandora_experimental/le_audio.proto index 4500564991..4628ac9a2e 100644 --- a/pandora/interfaces/pandora_experimental/le_audio.proto +++ b/pandora/interfaces/pandora_experimental/le_audio.proto @@ -10,8 +10,33 @@ package pandora; // Service to trigger LE Audio procedures. service LeAudio { rpc Open(OpenRequest) returns (google.protobuf.Empty); + // Playback audio + rpc LeAudioPlaybackAudio(stream LeAudioPlaybackAudioRequest) returns (LeAudioPlaybackAudioResponse); + // Start an opened stream. + rpc LeAudioStart(LeAudioStartRequest) returns (google.protobuf.Empty); + // Stop an opened stream. + rpc LeAudioStop(LeAudioStopRequest) returns (google.protobuf.Empty); } message OpenRequest { Connection connection = 1; } + +// Request for the `LeAudioStart` method. +message LeAudioStartRequest { + Connection connection = 1; +} + +// Request of the `PlaybackAudio` method. +message LeAudioPlaybackAudioRequest { + // Audio data to playback. + bytes data = 1; +} + +// Response of the `LeAudioPlaybackAudio` method. +message LeAudioPlaybackAudioResponse {} + +// Request of the `LeAudioStop` method. +message LeAudioStopRequest { + Connection connection = 1; +} |