diff options
| author | 2021-03-01 17:59:26 +0000 | |
|---|---|---|
| committer | 2021-03-26 00:10:44 -0700 | |
| commit | baceb11e71e2249338c84103487ab0218f7db1d4 (patch) | |
| tree | b869ef862384d540bbed6ae67a5f0d8162e990de | |
| parent | ae6711b2a78edb30fdec09a083480df002dde0d1 (diff) | |
Introduce the concept of software and external hotword.
Follow-up: implement circular buffer and integrate with the flows.
Bug: 182788844
Bug: 168305377
CTS-Coverage-Bug: 183425641
Test: atest CtsVoiceInteractionTestCases
Change-Id: I654a06afb27bb961bfb547a6636e08eb72a031fc
14 files changed, 1081 insertions, 64 deletions
diff --git a/core/api/system-current.txt b/core/api/system-current.txt index 33690094b2c3..49b8a901bcbb 100644 --- a/core/api/system-current.txt +++ b/core/api/system-current.txt @@ -10338,7 +10338,7 @@ package android.service.trust { package android.service.voice { - public class AlwaysOnHotwordDetector { + public class AlwaysOnHotwordDetector implements android.service.voice.HotwordDetector { method @Nullable public android.content.Intent createEnrollIntent(); method @Nullable public android.content.Intent createReEnrollIntent(); method @Nullable public android.content.Intent createUnEnrollIntent(); @@ -10348,6 +10348,8 @@ package android.service.voice { method @Nullable @RequiresPermission(allOf={android.Manifest.permission.RECORD_AUDIO, android.Manifest.permission.CAPTURE_AUDIO_HOTWORD}) public android.service.voice.AlwaysOnHotwordDetector.ModelParamRange queryParameter(int); method @RequiresPermission(allOf={android.Manifest.permission.RECORD_AUDIO, android.Manifest.permission.CAPTURE_AUDIO_HOTWORD}) public int setParameter(int, int); method @RequiresPermission(allOf={android.Manifest.permission.RECORD_AUDIO, android.Manifest.permission.CAPTURE_AUDIO_HOTWORD}) public boolean startRecognition(int); + method @RequiresPermission(allOf={android.Manifest.permission.RECORD_AUDIO, android.Manifest.permission.CAPTURE_AUDIO_HOTWORD}) public boolean startRecognition(); + method @Nullable public boolean startRecognition(@NonNull android.os.ParcelFileDescriptor, @NonNull android.media.AudioFormat, @Nullable android.os.PersistableBundle); method @RequiresPermission(allOf={android.Manifest.permission.RECORD_AUDIO, android.Manifest.permission.CAPTURE_AUDIO_HOTWORD}) public boolean stopRecognition(); method public final void updateState(@Nullable android.os.PersistableBundle, @Nullable android.os.SharedMemory); field public static final int AUDIO_CAPABILITY_ECHO_CANCELLATION = 1; // 0x1 @@ -10367,13 +10369,9 @@ package android.service.voice { field @Deprecated public static final int STATE_KEYPHRASE_UNSUPPORTED = -1; // 0xffffffff } - public abstract static class AlwaysOnHotwordDetector.Callback { + public abstract static class AlwaysOnHotwordDetector.Callback implements android.service.voice.HotwordDetector.Callback { ctor public AlwaysOnHotwordDetector.Callback(); method public abstract void onAvailabilityChanged(int); - method public abstract void onDetected(@NonNull android.service.voice.AlwaysOnHotwordDetector.EventPayload); - method public abstract void onError(); - method public abstract void onRecognitionPaused(); - method public abstract void onRecognitionResumed(); method public void onRejected(@Nullable android.service.voice.HotwordRejectedResult); } @@ -10418,23 +10416,36 @@ package android.service.voice { public abstract class HotwordDetectionService extends android.app.Service { ctor public HotwordDetectionService(); method @Nullable public final android.os.IBinder onBind(@NonNull android.content.Intent); - method public void onDetectFromDspSource(@NonNull android.os.ParcelFileDescriptor, @NonNull android.media.AudioFormat, long, @NonNull android.service.voice.HotwordDetectionService.DspHotwordDetectionCallback); + method public void onDetect(@NonNull android.os.ParcelFileDescriptor, @NonNull android.media.AudioFormat, long, @NonNull android.service.voice.HotwordDetectionService.Callback); + method public void onDetect(@NonNull android.os.ParcelFileDescriptor, @NonNull android.media.AudioFormat, @NonNull android.service.voice.HotwordDetectionService.Callback); + method public void onDetect(@NonNull android.os.ParcelFileDescriptor, @NonNull android.media.AudioFormat, @Nullable android.os.PersistableBundle, @NonNull android.service.voice.HotwordDetectionService.Callback); method public void onUpdateState(@Nullable android.os.PersistableBundle, @Nullable android.os.SharedMemory); field public static final String SERVICE_INTERFACE = "android.service.voice.HotwordDetectionService"; } - public static final class HotwordDetectionService.DspHotwordDetectionCallback { - method public void onDetected(); + public static final class HotwordDetectionService.Callback { + method public void onDetected(@Nullable android.service.voice.HotwordDetectedResult); method public void onRejected(@Nullable android.service.voice.HotwordRejectedResult); } public interface HotwordDetector { + method @RequiresPermission(allOf={android.Manifest.permission.RECORD_AUDIO, android.Manifest.permission.CAPTURE_AUDIO_HOTWORD}) public boolean startRecognition(); + method public boolean startRecognition(@NonNull android.os.ParcelFileDescriptor, @NonNull android.media.AudioFormat, @Nullable android.os.PersistableBundle); + method public boolean stopRecognition(); field public static final int CONFIDENCE_LEVEL_HIGH = 3; // 0x3 field public static final int CONFIDENCE_LEVEL_LOW = 1; // 0x1 field public static final int CONFIDENCE_LEVEL_MEDIUM = 2; // 0x2 field public static final int CONFIDENCE_LEVEL_NONE = 0; // 0x0 } + public static interface HotwordDetector.Callback { + method public void onDetected(@NonNull android.service.voice.AlwaysOnHotwordDetector.EventPayload); + method public void onError(); + method public void onRecognitionPaused(); + method public void onRecognitionResumed(); + method public void onRejected(@Nullable android.service.voice.HotwordRejectedResult); + } + public final class HotwordRejectedResult implements android.os.Parcelable { method public int describeContents(); method public int getConfidenceLevel(); @@ -10445,6 +10456,7 @@ package android.service.voice { public class VoiceInteractionService extends android.app.Service { method @NonNull public final android.service.voice.AlwaysOnHotwordDetector createAlwaysOnHotwordDetector(String, java.util.Locale, android.service.voice.AlwaysOnHotwordDetector.Callback); method @NonNull @RequiresPermission(android.Manifest.permission.MANAGE_HOTWORD_DETECTION) public final android.service.voice.AlwaysOnHotwordDetector createAlwaysOnHotwordDetector(String, java.util.Locale, @Nullable android.os.PersistableBundle, @Nullable android.os.SharedMemory, android.service.voice.AlwaysOnHotwordDetector.Callback); + method @NonNull @RequiresPermission(android.Manifest.permission.MANAGE_HOTWORD_DETECTION) public final android.service.voice.HotwordDetector createHotwordDetector(@NonNull android.media.AudioFormat, @Nullable android.os.PersistableBundle, @Nullable android.os.SharedMemory, @NonNull android.service.voice.HotwordDetector.Callback); method @NonNull @RequiresPermission("android.permission.MANAGE_VOICE_KEYPHRASES") public final android.media.voice.KeyphraseModelManager createKeyphraseModelManager(); } diff --git a/core/java/android/service/voice/AbstractHotwordDetector.java b/core/java/android/service/voice/AbstractHotwordDetector.java new file mode 100644 index 000000000000..e4eefc4e3a81 --- /dev/null +++ b/core/java/android/service/voice/AbstractHotwordDetector.java @@ -0,0 +1,105 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.service.voice; + +import static com.android.internal.util.function.pooled.PooledLambda.obtainMessage; + +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.media.AudioFormat; +import android.os.Handler; +import android.os.Looper; +import android.os.ParcelFileDescriptor; +import android.os.PersistableBundle; +import android.os.RemoteException; +import android.util.Slog; + +import com.android.internal.app.IVoiceInteractionManagerService; + +/** Base implementation of {@link HotwordDetector}. */ +abstract class AbstractHotwordDetector implements HotwordDetector { + private static final String TAG = AbstractHotwordDetector.class.getSimpleName(); + private static final boolean DEBUG = false; + + private final IVoiceInteractionManagerService mManagerService; + private final Handler mHandler; + private final HotwordDetector.Callback mCallback; + + AbstractHotwordDetector( + IVoiceInteractionManagerService managerService, + HotwordDetector.Callback callback) { + mManagerService = managerService; + // TODO: this needs to be supplied from above + mHandler = new Handler(Looper.getMainLooper()); + mCallback = callback; + } + + /** + * Detect hotword from an externally supplied stream of data. + * + * @return a writeable file descriptor that clients can start writing data in the given format. + * In order to stop detection, clients can close the given stream. + */ + @Nullable + @Override + public boolean startRecognition( + @NonNull ParcelFileDescriptor audioStream, + @NonNull AudioFormat audioFormat, + @Nullable PersistableBundle options) { + if (DEBUG) { + Slog.i(TAG, "#recognizeHotword"); + } + + // TODO: consider closing existing session. + + try { + mManagerService.startListeningFromExternalSource( + audioStream, + audioFormat, + options, + new BinderCallback(mHandler, mCallback)); + } catch (RemoteException e) { + e.rethrowFromSystemServer(); + } + + return true; + } + + private static class BinderCallback + extends IMicrophoneHotwordDetectionVoiceInteractionCallback.Stub { + private final Handler mHandler; + // TODO: these need to be weak references. + private final HotwordDetector.Callback mCallback; + + BinderCallback(Handler handler, HotwordDetector.Callback callback) { + this.mHandler = handler; + this.mCallback = callback; + } + + /** TODO: onDetected */ + @Override + public void onDetected( + @Nullable HotwordDetectedResult hotwordDetectedResult, + @Nullable AudioFormat audioFormat, + @Nullable ParcelFileDescriptor audioStreamIgnored) { + mHandler.sendMessage(obtainMessage( + HotwordDetector.Callback::onDetected, + mCallback, + new AlwaysOnHotwordDetector.EventPayload(audioFormat, hotwordDetectedResult))); + } + } +} diff --git a/core/java/android/service/voice/AlwaysOnHotwordDetector.java b/core/java/android/service/voice/AlwaysOnHotwordDetector.java index 94ca68ff6af8..73e0da16e049 100644 --- a/core/java/android/service/voice/AlwaysOnHotwordDetector.java +++ b/core/java/android/service/voice/AlwaysOnHotwordDetector.java @@ -68,7 +68,7 @@ import java.util.Locale; * mark and track it as such. */ @SystemApi -public class AlwaysOnHotwordDetector { +public class AlwaysOnHotwordDetector extends AbstractHotwordDetector { //---- States of Keyphrase availability. Return codes for onAvailabilityChanged() ----// /** * Indicates that this hotword detector is no longer valid for any recognition @@ -459,7 +459,7 @@ public class AlwaysOnHotwordDetector { /** * Callbacks for always-on hotword detection. */ - public static abstract class Callback { + public abstract static class Callback implements HotwordDetector.Callback { /** * Updates the availability state of the active keyphrase and locale on every keyphrase @@ -547,11 +547,13 @@ public class AlwaysOnHotwordDetector { IVoiceInteractionManagerService modelManagementService, int targetSdkVersion, boolean supportHotwordDetectionService, @Nullable PersistableBundle options, @Nullable SharedMemory sharedMemory) { + super(modelManagementService, callback); + + mHandler = new MyHandler(); mText = text; mLocale = locale; mKeyphraseEnrollmentInfo = keyphraseEnrollmentInfo; mExternalCallback = callback; - mHandler = new MyHandler(); mInternalCallback = new SoundTriggerListener(mHandler); mModelManagementService = modelManagementService; mTargetSdkVersion = targetSdkVersion; @@ -705,6 +707,17 @@ public class AlwaysOnHotwordDetector { } /** + * Starts recognition for the associated keyphrase. + * + * @see #startRecognition(int) + */ + @RequiresPermission(allOf = {RECORD_AUDIO, CAPTURE_AUDIO_HOTWORD}) + @Override + public boolean startRecognition() { + return startRecognition(0); + } + + /** * Stops recognition for the associated keyphrase. * Caller must be the active voice interaction service via * Settings.Secure.VOICE_INTERACTION_SERVICE. @@ -718,6 +731,7 @@ public class AlwaysOnHotwordDetector { * {@link VoiceInteractionService} hosting this detector has been shut down. */ @RequiresPermission(allOf = {RECORD_AUDIO, CAPTURE_AUDIO_HOTWORD}) + @Override public boolean stopRecognition() { if (DBG) Slog.d(TAG, "stopRecognition()"); synchronized (mLock) { diff --git a/core/java/android/service/voice/HotwordDetectionService.java b/core/java/android/service/voice/HotwordDetectionService.java index db984c246b2f..fb731a094f90 100644 --- a/core/java/android/service/voice/HotwordDetectionService.java +++ b/core/java/android/service/voice/HotwordDetectionService.java @@ -20,6 +20,7 @@ import static com.android.internal.util.function.pooled.PooledLambda.obtainMessa import android.annotation.CallSuper; import android.annotation.DurationMillisLong; +import android.annotation.IntDef; import android.annotation.NonNull; import android.annotation.Nullable; import android.annotation.SdkConstant; @@ -36,6 +37,9 @@ import android.os.RemoteException; import android.os.SharedMemory; import android.util.Log; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; import java.util.Locale; /** @@ -51,6 +55,24 @@ public abstract class HotwordDetectionService extends Service { private static final boolean DBG = true; /** + * Source for the given audio stream. + * + * @hide + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + AUDIO_SOURCE_MICROPHONE, + AUDIO_SOURCE_EXTERNAL + }) + @interface AudioSource {} + + /** @hide */ + public static final int AUDIO_SOURCE_MICROPHONE = 1; + /** @hide */ + public static final int AUDIO_SOURCE_EXTERNAL = 2; + + /** * The {@link Intent} that must be declared as handled by the service. * To be supported, the service must also require the * {@link android.Manifest.permission#BIND_HOTWORD_DETECTION_SERVICE} permission so @@ -73,12 +95,12 @@ public abstract class HotwordDetectionService extends Service { if (DBG) { Log.d(TAG, "#detectFromDspSource"); } - mHandler.sendMessage(obtainMessage(HotwordDetectionService::onDetectFromDspSource, + mHandler.sendMessage(obtainMessage(HotwordDetectionService::onDetect, HotwordDetectionService.this, audioStream, audioFormat, timeoutMillis, - new DspHotwordDetectionCallback(callback))); + new Callback(callback))); } @Override @@ -92,6 +114,40 @@ public abstract class HotwordDetectionService extends Service { options, sharedMemory)); } + + @Override + public void detectFromMicrophoneSource( + ParcelFileDescriptor audioStream, + @AudioSource int audioSource, + AudioFormat audioFormat, + PersistableBundle options, + IDspHotwordDetectionCallback callback) + throws RemoteException { + if (DBG) { + Log.d(TAG, "#detectFromMicrophoneSource"); + } + switch (audioSource) { + case AUDIO_SOURCE_MICROPHONE: + mHandler.sendMessage(obtainMessage( + HotwordDetectionService::onDetect, + HotwordDetectionService.this, + audioStream, + audioFormat, + new Callback(callback))); + break; + case AUDIO_SOURCE_EXTERNAL: + mHandler.sendMessage(obtainMessage( + HotwordDetectionService::onDetect, + HotwordDetectionService.this, + audioStream, + audioFormat, + options, + new Callback(callback))); + break; + default: + Log.i(TAG, "Unsupported audio source " + audioSource); + } + } }; @CallSuper @@ -113,27 +169,30 @@ public abstract class HotwordDetectionService extends Service { } /** - * Detect the audio data generated from Dsp. - * - * <p>Note: the clients are supposed to call {@code close} on the input stream when they are - * done with the operation in order to free up resources. + * Called when the device hardware (such as a DSP) detected the hotword, to request second stage + * validation before handing over the audio to the {@link AlwaysOnHotwordDetector}. + * <p> + * After {@code callback} is invoked or {@code timeoutMillis} has passed, the system closes + * {@code audioStream} and invokes the appropriate {@link AlwaysOnHotwordDetector.Callback + * callback}. * * @param audioStream Stream containing audio bytes returned from DSP * @param audioFormat Format of the supplied audio * @param timeoutMillis Timeout in milliseconds for the operation to invoke the callback. If * the application fails to abide by the timeout, system will close the * microphone and cancel the operation. - * @param callback Use {@link HotwordDetectionService#DspHotwordDetectionCallback} to return - * the detected result. + * @param callback The callback to use for responding to the detection request. * * @hide */ @SystemApi - public void onDetectFromDspSource( + public void onDetect( @NonNull ParcelFileDescriptor audioStream, @NonNull AudioFormat audioFormat, @DurationMillisLong long timeoutMillis, - @NonNull DspHotwordDetectionCallback callback) { + @NonNull Callback callback) { + // TODO: Add a helpful error message. + throw new UnsupportedOperationException(); } /** @@ -154,38 +213,94 @@ public abstract class HotwordDetectionService extends Service { @SystemApi public void onUpdateState(@Nullable PersistableBundle options, @Nullable SharedMemory sharedMemory) { + // TODO: Handle the unimplemented case by throwing? + } + + /** + * Called when the {@link VoiceInteractionService} requests that this service + * {@link HotwordDetector#startRecognition() start} hotword recognition on audio coming directly + * from the device microphone. + * <p> + * On such a request, the system streams mic audio to this service through {@code audioStream}. + * Audio is streamed until {@link HotwordDetector#stopRecognition()} is called, at which point + * the system closes {code audioStream}. + * <p> + * On successful detection of a hotword within {@code audioStream}, call + * {@link Callback#onDetected(HotwordDetectedResult)}. The system continues to stream audio + * through {@code audioStream}; {@code callback} is reusable. + * + * @param audioStream Stream containing audio bytes returned from a microphone + * @param audioFormat Format of the supplied audio + * @param callback The callback to use for responding to the detection request. + * {@link Callback#onRejected(HotwordRejectedResult) callback.onRejected} cannot be used here. + */ + public void onDetect( + @NonNull ParcelFileDescriptor audioStream, + @NonNull AudioFormat audioFormat, + @NonNull Callback callback) { + // TODO: Add a helpful error message. + throw new UnsupportedOperationException(); + } + + /** + * Called when the {@link VoiceInteractionService} requests that this service + * {@link HotwordDetector#startRecognition(ParcelFileDescriptor, AudioFormat, + * PersistableBundle)} run} hotword recognition on audio coming from an external connected + * microphone. + * <p> + * Upon invoking the {@code callback}, the system closes {@code audioStream} and sends the + * detection result to the {@link HotwordDetector.Callback hotword detector}. + * + * @param audioStream Stream containing audio bytes returned from a microphone + * @param audioFormat Format of the supplied audio + * @param options Options supporting detection, such as configuration specific to the source of + * the audio, provided through + * {@link HotwordDetector#startRecognition(ParcelFileDescriptor, AudioFormat, + * PersistableBundle)}. + * @param callback The callback to use for responding to the detection request. + */ + public void onDetect( + @NonNull ParcelFileDescriptor audioStream, + @NonNull AudioFormat audioFormat, + @Nullable PersistableBundle options, + @NonNull Callback callback) { + // TODO: Add a helpful error message. + throw new UnsupportedOperationException(); } /** - * Callback for returning the detected result. + * Callback for returning the detection result. * * @hide */ @SystemApi - public static final class DspHotwordDetectionCallback { + public static final class Callback { // TODO: need to make sure we don't store remote references, but not a high priority. private final IDspHotwordDetectionCallback mRemoteCallback; - private DspHotwordDetectionCallback(IDspHotwordDetectionCallback remoteCallback) { + private Callback(IDspHotwordDetectionCallback remoteCallback) { mRemoteCallback = remoteCallback; } /** * Called when the detected result is valid. */ - public void onDetected() { + public void onDetected(@Nullable HotwordDetectedResult hotwordDetectedResult) { try { - mRemoteCallback.onDetected(); + mRemoteCallback.onDetected(hotwordDetectedResult); } catch (RemoteException e) { throw e.rethrowFromSystemServer(); } } /** - * Informs the {@link AlwaysOnHotwordDetector} that the keyphrase was not detected. + * Informs the {@link HotwordDetector} that the keyphrase was not detected. + * <p> + * This cannot not be used when recognition is done through + * {@link #onDetect(ParcelFileDescriptor, AudioFormat, Callback)}. * * @param result Info about the second stage detection result. This is provided to - * the {@link AlwaysOnHotwordDetector}. + * the {@link HotwordDetector}. */ public void onRejected(@Nullable HotwordRejectedResult result) { try { diff --git a/core/java/android/service/voice/HotwordDetector.java b/core/java/android/service/voice/HotwordDetector.java index abf49b797da4..26491245914f 100644 --- a/core/java/android/service/voice/HotwordDetector.java +++ b/core/java/android/service/voice/HotwordDetector.java @@ -16,8 +16,17 @@ package android.service.voice; +import static android.Manifest.permission.CAPTURE_AUDIO_HOTWORD; +import static android.Manifest.permission.RECORD_AUDIO; + import android.annotation.IntDef; +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.annotation.RequiresPermission; import android.annotation.SystemApi; +import android.media.AudioFormat; +import android.os.ParcelFileDescriptor; +import android.os.PersistableBundle; /** * Basic functionality for hotword detectors. @@ -40,11 +49,100 @@ public interface HotwordDetector { int CONFIDENCE_LEVEL_HIGH = 3; /** @hide */ - @IntDef(prefix = { "CONFIDENCE_LEVEL_" }, value = { + @IntDef(prefix = {"CONFIDENCE_LEVEL_"}, value = { CONFIDENCE_LEVEL_NONE, CONFIDENCE_LEVEL_LOW, CONFIDENCE_LEVEL_MEDIUM, CONFIDENCE_LEVEL_HIGH }) - @interface HotwordConfidenceLevelValue {} + @interface HotwordConfidenceLevelValue { + } + + /** + * Starts hotword recognition. + * <p> + * On calling this, the system streams audio from the device microphone to this application's + * {@link HotwordDetectionService}. Audio is streamed until {@link #stopRecognition()} is + * called. + * <p> + * On detection of a hotword, + * {@link AlwaysOnHotwordDetector.Callback#onDetected(AlwaysOnHotwordDetector.EventPayload)} + * is called on the callback provided when creating this {@link HotwordDetector}. + * <p> + * There is a noticeable impact on battery while recognition is active, so make sure to call + * {@link #stopRecognition()} when detection isn't needed. + * <p> + * Calling this again while recognition is active does nothing. + * + * @return true if the request to start recognition succeeded + */ + @RequiresPermission(allOf = {RECORD_AUDIO, CAPTURE_AUDIO_HOTWORD}) + boolean startRecognition(); + + /** + * Stops hotword recognition. + * + * @return true if the request to stop recognition succeeded + */ + boolean stopRecognition(); + + /** + * Starts hotword recognition on audio coming from an external connected microphone. + * <p> + * {@link #stopRecognition()} must be called before {@code audioStream} is closed. + * + * @param audioStream stream containing the audio bytes to run detection on + * @param audioFormat format of the encoded audio + * @param options options supporting detection, such as configuration specific to the + * source of the audio. This will be provided to the {@link HotwordDetectionService}. + * PersistableBundle does not allow any remotable objects or other contents that can be + * used to communicate with other processes. + * @return true if the request to start recognition succeeded + */ + boolean startRecognition( + @NonNull ParcelFileDescriptor audioStream, + @NonNull AudioFormat audioFormat, + @Nullable PersistableBundle options); + + /** + * The callback to notify of detection events. + */ + interface Callback { + + /** + * Called when the keyphrase is spoken. + * + * @param eventPayload Payload data for the detection event. + */ + // TODO: Consider creating a new EventPayload that the AOHD one subclasses. + void onDetected(@NonNull AlwaysOnHotwordDetector.EventPayload eventPayload); + + /** + * Called when the detection fails due to an error. + */ + void onError(); + + /** + * Called when the recognition is paused temporarily for some reason. + * This is an informational callback, and the clients shouldn't be doing anything here + * except showing an indication on their UI if they have to. + */ + void onRecognitionPaused(); + + /** + * Called when the recognition is resumed after it was temporarily paused. + * This is an informational callback, and the clients shouldn't be doing anything here + * except showing an indication on their UI if they have to. + */ + void onRecognitionResumed(); + + /** + * Called when the {@link HotwordDetectionService second stage detection} did not detect the + * keyphrase. + * + * @param result Info about the second stage detection result, provided by the + * {@link HotwordDetectionService}. + */ + void onRejected(@Nullable HotwordRejectedResult result); + } } diff --git a/core/java/android/service/voice/IDspHotwordDetectionCallback.aidl b/core/java/android/service/voice/IDspHotwordDetectionCallback.aidl index 6f641e1cd1e7..c6b10ff05b08 100644 --- a/core/java/android/service/voice/IDspHotwordDetectionCallback.aidl +++ b/core/java/android/service/voice/IDspHotwordDetectionCallback.aidl @@ -16,6 +16,7 @@ package android.service.voice; +import android.service.voice.HotwordDetectedResult; import android.service.voice.HotwordRejectedResult; /** @@ -23,11 +24,14 @@ import android.service.voice.HotwordRejectedResult; * * @hide */ +// TODO: Rename this. oneway interface IDspHotwordDetectionCallback { + /** * Called when the detected result is valid. */ - void onDetected(); + void onDetected( + in HotwordDetectedResult hotwordDetectedResult); /** * Sends {@code result} to the HotwordDetector. diff --git a/core/java/android/service/voice/IHotwordDetectionService.aidl b/core/java/android/service/voice/IHotwordDetectionService.aidl index 0791f1ca49eb..cb140f9346fa 100644 --- a/core/java/android/service/voice/IHotwordDetectionService.aidl +++ b/core/java/android/service/voice/IHotwordDetectionService.aidl @@ -29,10 +29,17 @@ import android.service.voice.IDspHotwordDetectionCallback; */ oneway interface IHotwordDetectionService { void detectFromDspSource( - in ParcelFileDescriptor audioStream, - in AudioFormat audioFormat, - long timeoutMillis, - in IDspHotwordDetectionCallback callback); + in ParcelFileDescriptor audioStream, + in AudioFormat audioFormat, + long timeoutMillis, + in IDspHotwordDetectionCallback callback); + + void detectFromMicrophoneSource( + in ParcelFileDescriptor audioStream, + int audioSource, + in AudioFormat audioFormat, + in PersistableBundle options, + in IDspHotwordDetectionCallback callback); void updateState(in PersistableBundle options, in SharedMemory sharedMemory); } diff --git a/core/java/android/service/voice/IMicrophoneHotwordDetectionVoiceInteractionCallback.aidl b/core/java/android/service/voice/IMicrophoneHotwordDetectionVoiceInteractionCallback.aidl new file mode 100644 index 000000000000..80f20fe405b1 --- /dev/null +++ b/core/java/android/service/voice/IMicrophoneHotwordDetectionVoiceInteractionCallback.aidl @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.service.voice; + +import android.media.AudioFormat; +import android.service.voice.HotwordDetectedResult; + +/** + * Callback for returning the detected result from the HotwordDetectionService. + * + * @hide + */ +oneway interface IMicrophoneHotwordDetectionVoiceInteractionCallback { + + /** + * Called when the detected result is valid. + */ + void onDetected( + in HotwordDetectedResult hotwordDetectedResult, + in AudioFormat audioFormat, + in ParcelFileDescriptor audioStream); +} diff --git a/core/java/android/service/voice/SoftwareHotwordDetector.java b/core/java/android/service/voice/SoftwareHotwordDetector.java new file mode 100644 index 000000000000..f49a9d45ae06 --- /dev/null +++ b/core/java/android/service/voice/SoftwareHotwordDetector.java @@ -0,0 +1,146 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.service.voice; + +import static android.Manifest.permission.RECORD_AUDIO; + +import static com.android.internal.util.function.pooled.PooledLambda.obtainMessage; + +import android.annotation.Nullable; +import android.annotation.RequiresPermission; +import android.media.AudioFormat; +import android.os.Handler; +import android.os.Looper; +import android.os.ParcelFileDescriptor; +import android.os.PersistableBundle; +import android.os.RemoteException; +import android.os.SharedMemory; +import android.util.Slog; + +import com.android.internal.app.IVoiceInteractionManagerService; + +import java.io.PrintWriter; + +/** + * Manages hotword detection not relying on a specific hardware. + * + * <p>On devices where DSP is available it's strongly recommended to use + * {@link AlwaysOnHotwordDetector}. + * + * @hide + **/ +class SoftwareHotwordDetector extends AbstractHotwordDetector { + private static final String TAG = SoftwareHotwordDetector.class.getSimpleName(); + private static final boolean DEBUG = true; + + private final IVoiceInteractionManagerService mManagerService; + private final HotwordDetector.Callback mCallback; + private final AudioFormat mAudioFormat; + private final Handler mHandler; + private final Object mLock = new Object(); + + SoftwareHotwordDetector( + IVoiceInteractionManagerService managerService, + AudioFormat audioFormat, + PersistableBundle options, + SharedMemory sharedMemory, + HotwordDetector.Callback callback) { + super(managerService, callback); + + mManagerService = managerService; + mAudioFormat = audioFormat; + mCallback = callback; + mHandler = new Handler(Looper.getMainLooper()); + + try { + mManagerService.updateState(options, sharedMemory); + } catch (RemoteException e) { + throw e.rethrowFromSystemServer(); + } + } + + @RequiresPermission(RECORD_AUDIO) + @Override + public boolean startRecognition() { + if (DEBUG) { + Slog.i(TAG, "#startRecognition"); + } + + maybeCloseExistingSession(); + + try { + mManagerService.startListeningFromMic( + mAudioFormat, new BinderCallback(mHandler, mCallback)); + } catch (RemoteException e) { + e.rethrowFromSystemServer(); + } + + return true; + } + + /** TODO: stopRecognition */ + @RequiresPermission(RECORD_AUDIO) + @Override + public boolean stopRecognition() { + if (DEBUG) { + Slog.i(TAG, "#stopRecognition"); + } + + try { + mManagerService.stopListeningFromMic(); + } catch (RemoteException e) { + e.rethrowFromSystemServer(); + } + + return true; + } + + private void maybeCloseExistingSession() { + // TODO: needs to be synchronized. + // TODO: implement this + } + + private static class BinderCallback + extends IMicrophoneHotwordDetectionVoiceInteractionCallback.Stub { + private final Handler mHandler; + // TODO: this needs to be a weak reference. + private final HotwordDetector.Callback mCallback; + + BinderCallback(Handler handler, HotwordDetector.Callback callback) { + this.mHandler = handler; + this.mCallback = callback; + } + + /** TODO: onDetected */ + @Override + public void onDetected( + @Nullable HotwordDetectedResult hotwordDetectedResult, + @Nullable AudioFormat audioFormat, + @Nullable ParcelFileDescriptor audioStream) { + mHandler.sendMessage(obtainMessage( + HotwordDetector.Callback::onDetected, + mCallback, + new AlwaysOnHotwordDetector.EventPayload( + audioFormat, hotwordDetectedResult, audioStream))); + } + } + + /** @hide */ + public void dump(String prefix, PrintWriter pw) { + // TODO: implement this + } +} diff --git a/core/java/android/service/voice/VoiceInteractionService.java b/core/java/android/service/voice/VoiceInteractionService.java index cb3791d9986a..2a2522741955 100644 --- a/core/java/android/service/voice/VoiceInteractionService.java +++ b/core/java/android/service/voice/VoiceInteractionService.java @@ -29,6 +29,7 @@ import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.hardware.soundtrigger.KeyphraseEnrollmentInfo; +import android.media.AudioFormat; import android.media.voice.KeyphraseModelManager; import android.os.Bundle; import android.os.Handler; @@ -134,6 +135,7 @@ public class VoiceInteractionService extends Service { private KeyphraseEnrollmentInfo mKeyphraseEnrollmentInfo; private AlwaysOnHotwordDetector mHotwordDetector; + private SoftwareHotwordDetector mSoftwareHotwordDetector; /** * Called when a user has activated an affordance to launch voice assist from the Keyguard. @@ -389,6 +391,60 @@ public class VoiceInteractionService extends Service { } /** + * Creates a {@link HotwordDetector} and initializes the application's + * {@link HotwordDetectionService} using {@code options} and {code sharedMemory}. + * + * <p>To be able to call this, you need to set android:hotwordDetectionService in the + * android.voice_interaction metadata file to a valid hotword detection service, and set + * android:isolatedProcess="true" in the hotword detection service's declaration. Otherwise, + * this throws an {@link IllegalStateException}. + * + * <p>This instance must be retained and used by the client. + * Calling this a second time invalidates the previously created hotword detector + * which can no longer be used to manage recognition. + * + * <p>Using this has a noticeable impact on battery, since the microphone is kept open + * for the lifetime of the recognition {@link HotwordDetector#startRecognition() session}. On + * devices where hardware filtering is available (such as through a DSP), it's highly + * recommended to use {@link #createAlwaysOnHotwordDetector} instead. + * + * @param audioFormat Format of the audio to be passed to {@link HotwordDetectionService}. + * @param options Application configuration data to be provided to the + * {@link HotwordDetectionService}. PersistableBundle does not allow any remotable objects or + * other contents that can be used to communicate with other processes. + * @param sharedMemory The unrestricted data blob to be provided to the + * {@link HotwordDetectionService}. Use this to provide hotword models or other such data to the + * sandboxed process. + * @param callback The callback to notify of detection events. + * @return A hotword detector for the given audio format. + * + * @see #createAlwaysOnHotwordDetector(String, Locale, PersistableBundle, SharedMemory, + * AlwaysOnHotwordDetector.Callback) + * + * @hide + */ + @SystemApi + @RequiresPermission(Manifest.permission.MANAGE_HOTWORD_DETECTION) + @NonNull + public final HotwordDetector createHotwordDetector( + @NonNull AudioFormat audioFormat, + @Nullable PersistableBundle options, + @Nullable SharedMemory sharedMemory, + @NonNull HotwordDetector.Callback callback) { + if (mSystemService == null) { + throw new IllegalStateException("Not available until onReady() is called"); + } + synchronized (mLock) { + // Allow only one concurrent recognition via the APIs. + safelyShutdownHotwordDetector(); + mSoftwareHotwordDetector = + new SoftwareHotwordDetector( + mSystemService, audioFormat, options, sharedMemory, callback); + } + return mSoftwareHotwordDetector; + } + + /** * Creates an {@link KeyphraseModelManager} to use for enrolling voice models outside of the * pre-bundled system voice models. * @hide @@ -431,24 +487,43 @@ public class VoiceInteractionService extends Service { private void safelyShutdownHotwordDetector() { synchronized (mLock) { - if (mHotwordDetector == null) { - return; - } + shutdownDspHotwordDetectorLocked(); + shutdownMicrophoneHotwordDetectorLocked(); + } + } - try { - mHotwordDetector.stopRecognition(); - } catch (Exception ex) { - // Ignore. - } + private void shutdownDspHotwordDetectorLocked() { + if (mHotwordDetector == null) { + return; + } - try { - mHotwordDetector.invalidate(); - } catch (Exception ex) { - // Ignore. - } + try { + mHotwordDetector.stopRecognition(); + } catch (Exception ex) { + // Ignore. + } - mHotwordDetector = null; + try { + mHotwordDetector.invalidate(); + } catch (Exception ex) { + // Ignore. } + + mHotwordDetector = null; + } + + private void shutdownMicrophoneHotwordDetectorLocked() { + if (mSoftwareHotwordDetector == null) { + return; + } + + try { + mSoftwareHotwordDetector.stopRecognition(); + } catch (Exception ex) { + // Ignore. + } + + mSoftwareHotwordDetector = null; } /** @@ -478,6 +553,13 @@ public class VoiceInteractionService extends Service { } else { mHotwordDetector.dump(" ", pw); } + + pw.println(" MicrophoneHotwordDetector"); + if (mSoftwareHotwordDetector == null) { + pw.println(" NULL"); + } else { + mSoftwareHotwordDetector.dump(" ", pw); + } } } } diff --git a/core/java/com/android/internal/app/IVoiceInteractionManagerService.aidl b/core/java/com/android/internal/app/IVoiceInteractionManagerService.aidl index e2732867d195..bb6233bc772d 100644 --- a/core/java/com/android/internal/app/IVoiceInteractionManagerService.aidl +++ b/core/java/com/android/internal/app/IVoiceInteractionManagerService.aidl @@ -18,8 +18,10 @@ package com.android.internal.app; import android.content.ComponentName; import android.content.Intent; +import android.media.AudioFormat; import android.media.permission.Identity; import android.os.Bundle; +import android.os.ParcelFileDescriptor; import android.os.PersistableBundle; import android.os.RemoteCallback; import android.os.SharedMemory; @@ -33,6 +35,7 @@ import android.hardware.soundtrigger.KeyphraseMetadata; import android.hardware.soundtrigger.SoundTrigger; import android.service.voice.IVoiceInteractionService; import android.service.voice.IVoiceInteractionSession; +import android.service.voice.IMicrophoneHotwordDetectionVoiceInteractionCallback; interface IVoiceInteractionManagerService { void showSession(in Bundle sessionArgs, int flags); @@ -242,4 +245,16 @@ interface IVoiceInteractionManagerService { * Requests to shutdown hotword detection service. */ void shutdownHotwordDetectionService(); + + void startListeningFromMic( + in AudioFormat audioFormat, + in IMicrophoneHotwordDetectionVoiceInteractionCallback callback); + + void stopListeningFromMic(); + + void startListeningFromExternalSource( + in ParcelFileDescriptor audioStream, + in AudioFormat audioFormat, + in PersistableBundle options, + in IMicrophoneHotwordDetectionVoiceInteractionCallback callback); } diff --git a/services/voiceinteraction/java/com/android/server/voiceinteraction/HotwordDetectionConnection.java b/services/voiceinteraction/java/com/android/server/voiceinteraction/HotwordDetectionConnection.java index 11ccfd88c100..bbfb0d6a392b 100644 --- a/services/voiceinteraction/java/com/android/server/voiceinteraction/HotwordDetectionConnection.java +++ b/services/voiceinteraction/java/com/android/server/voiceinteraction/HotwordDetectionConnection.java @@ -16,6 +16,9 @@ package com.android.server.voiceinteraction; +import static android.service.voice.HotwordDetectionService.AUDIO_SOURCE_EXTERNAL; +import static android.service.voice.HotwordDetectionService.AUDIO_SOURCE_MICROPHONE; + import android.annotation.NonNull; import android.annotation.Nullable; import android.content.ComponentName; @@ -24,23 +27,30 @@ import android.content.Intent; import android.hardware.soundtrigger.IRecognitionStatusCallback; import android.hardware.soundtrigger.SoundTrigger; import android.media.AudioAttributes; +import android.media.AudioFormat; +import android.media.AudioManager; import android.media.AudioRecord; import android.media.MediaRecorder; import android.os.ParcelFileDescriptor; import android.os.PersistableBundle; import android.os.RemoteException; import android.os.SharedMemory; +import android.service.voice.HotwordDetectedResult; import android.service.voice.HotwordDetectionService; import android.service.voice.HotwordRejectedResult; import android.service.voice.IDspHotwordDetectionCallback; import android.service.voice.IHotwordDetectionService; +import android.service.voice.IMicrophoneHotwordDetectionVoiceInteractionCallback; import android.util.Pair; import android.util.Slog; +import com.android.internal.annotations.GuardedBy; import com.android.internal.app.IHotwordRecognitionStatusCallback; import com.android.internal.infra.ServiceConnector; +import java.io.Closeable; import java.io.IOException; +import java.io.InputStream; import java.io.OutputStream; import java.io.PrintWriter; import java.util.concurrent.Executor; @@ -63,6 +73,8 @@ final class HotwordDetectionConnection { private static final long VALIDATION_TIMEOUT_MILLIS = 3000; private static final long VOICE_INTERACTION_TIMEOUT_TO_OPEN_MIC_MILLIS = 2000; private static final int MAX_STREAMING_SECONDS = 10; + private static final int MICROPHONE_BUFFER_LENGTH_SECONDS = 8; + private static final int HOTWORD_AUDIO_LENGTH_SECONDS = 3; private final Executor mAudioCopyExecutor = Executors.newCachedThreadPool(); // TODO: This may need to be a Handler(looper) @@ -76,6 +88,9 @@ final class HotwordDetectionConnection { final @NonNull ServiceConnector<IHotwordDetectionService> mRemoteHotwordDetectionService; boolean mBound; + @GuardedBy("mLock") + private ParcelFileDescriptor mCurrentAudioSink; + HotwordDetectionConnection(Object lock, Context context, ComponentName serviceName, int userId, boolean bindInstantServiceAllowed, @Nullable PersistableBundle options, @Nullable SharedMemory sharedMemory) { @@ -134,6 +149,62 @@ final class HotwordDetectionConnection { service -> service.updateState(options, sharedMemory)); } + void startListeningFromMic( + AudioFormat audioFormat, + IMicrophoneHotwordDetectionVoiceInteractionCallback callback) { + if (DEBUG) { + Slog.d(TAG, "startListeningFromMic"); + } + + AudioRecord audioRecord = createMicAudioRecord(audioFormat); + if (audioRecord == null) { + // TODO: Callback.onError(); + return; + } + + handleSoftwareHotwordDetection( + audioFormat, + AudioReader.createFromAudioRecord(audioRecord), + AUDIO_SOURCE_MICROPHONE, + // TODO: handle bundles better. + new PersistableBundle(), + callback); + } + + public void startListeningFromExternalSource( + ParcelFileDescriptor audioStream, + AudioFormat audioFormat, + @Nullable PersistableBundle options, + IMicrophoneHotwordDetectionVoiceInteractionCallback callback) { + if (DEBUG) { + Slog.d(TAG, "startListeningFromExternalSource"); + } + + ParcelFileDescriptor.AutoCloseInputStream audioReader = + new ParcelFileDescriptor.AutoCloseInputStream(audioStream); + + handleSoftwareHotwordDetection( + audioFormat, + AudioReader.createFromInputStream(audioReader), + AUDIO_SOURCE_EXTERNAL, + options, + callback); + } + + void stopListening() { + if (DEBUG) { + Slog.d(TAG, "stopListening"); + } + + synchronized (mLock) { + if (mCurrentAudioSink != null) { + Slog.i(TAG, "Closing audio stream to hotword detector: stopping requested"); + bestEffortClose(mCurrentAudioSink); + } + mCurrentAudioSink = null; + } + } + private void detectFromDspSource(SoundTrigger.KeyphraseRecognitionEvent recognitionEvent, IHotwordRecognitionStatusCallback externalCallback) { if (DEBUG) { @@ -175,7 +246,7 @@ final class HotwordDetectionConnection { Runnable cancellingJob = () -> { record.stop(); - bestEffortCloseFileDescriptor(audioSink); + bestEffortClose(audioSink); // TODO: consider calling externalCallback.onRejected(ERROR_TIMEOUT). }; @@ -186,11 +257,11 @@ final class HotwordDetectionConnection { // TODO: consider making this a non-anonymous class. IDspHotwordDetectionCallback internalCallback = new IDspHotwordDetectionCallback.Stub() { @Override - public void onDetected() throws RemoteException { + public void onDetected(HotwordDetectedResult result) throws RemoteException { if (DEBUG) { Slog.d(TAG, "onDetected"); } - bestEffortCloseFileDescriptor(audioSink); + bestEffortClose(audioSink); cancelingFuture.cancel(true); // Give 2 more seconds for the interactor to start consuming the mic. If it fails to @@ -202,6 +273,7 @@ final class HotwordDetectionConnection { VOICE_INTERACTION_TIMEOUT_TO_OPEN_MIC_MILLIS, TimeUnit.MILLISECONDS); + // TODO: Propagate the HotwordDetectedResult. externalCallback.onKeyphraseDetected(recognitionEvent); } @@ -221,7 +293,7 @@ final class HotwordDetectionConnection { recognitionEvent.getCaptureFormat(), VALIDATION_TIMEOUT_MILLIS, internalCallback)); - bestEffortCloseFileDescriptor(clientRead); + bestEffortClose(clientRead); } static final class SoundTriggerCallback extends IRecognitionStatusCallback.Stub { @@ -283,16 +355,48 @@ final class HotwordDetectionConnection { new AudioAttributes.Builder() .setInternalCapturePreset(MediaRecorder.AudioSource.HOTWORD).build(), recognitionEvent.getCaptureFormat(), - getBufferSizeInBytes(sampleRate, MAX_STREAMING_SECONDS), + getBufferSizeInBytes( + sampleRate, + MAX_STREAMING_SECONDS, + recognitionEvent.getCaptureFormat().getChannelCount()), recognitionEvent.getCaptureSession()); } + @Nullable + private AudioRecord createMicAudioRecord(AudioFormat audioFormat) { + if (DEBUG) { + Slog.i(TAG, "#createAudioRecord"); + } + try { + AudioRecord audioRecord = new AudioRecord( + new AudioAttributes.Builder().setHotwordMode().build(), + audioFormat, + getBufferSizeInBytes( + audioFormat.getSampleRate(), + MICROPHONE_BUFFER_LENGTH_SECONDS, + audioFormat.getChannelCount()), + AudioManager.AUDIO_SESSION_ID_GENERATE); + + if (audioRecord.getState() != AudioRecord.STATE_INITIALIZED) { + Slog.w(TAG, "Failed to initialize AudioRecord"); + audioRecord.release(); + return null; + } + + return audioRecord; + } catch (IllegalArgumentException e) { + Slog.e(TAG, "Failed to create AudioRecord", e); + return null; + } + } + /** * Returns the number of bytes required to store {@code bufferLengthSeconds} of audio sampled at * {@code sampleRate} Hz, using the format returned by DSP audio capture. */ - private static int getBufferSizeInBytes(int sampleRate, int bufferLengthSeconds) { - return BYTES_PER_SAMPLE * sampleRate * bufferLengthSeconds; + private static int getBufferSizeInBytes( + int sampleRate, int bufferLengthSeconds, int intChannelCount) { + return BYTES_PER_SAMPLE * sampleRate * bufferLengthSeconds * intChannelCount; } private static Pair<ParcelFileDescriptor, ParcelFileDescriptor> createPipe() { @@ -307,17 +411,176 @@ final class HotwordDetectionConnection { return Pair.create(fileDescriptors[0], fileDescriptors[1]); } - private static void bestEffortCloseFileDescriptor(ParcelFileDescriptor fd) { + public void dump(String prefix, PrintWriter pw) { + pw.print(prefix); pw.print("mBound="); pw.println(mBound); + } + + private interface AudioReader extends Closeable { + int read(byte[] dest, int offset, int length) throws IOException; + + static AudioReader createFromInputStream(InputStream is) { + return new AudioReader() { + @Override + public int read(byte[] dest, int offset, int length) throws IOException { + return is.read(dest, offset, length); + } + + @Override + public void close() throws IOException { + is.close(); + } + }; + } + + static AudioReader createFromAudioRecord(AudioRecord record) { + record.startRecording(); + + return new AudioReader() { + @Override + public int read(byte[] dest, int offset, int length) throws IOException { + return record.read(dest, offset, length); + } + + @Override + public void close() throws IOException { + record.stop(); + record.release(); + } + }; + } + } + + private void handleSoftwareHotwordDetection( + AudioFormat audioFormat, + AudioReader audioSource, + int audioSourceValue, + @Nullable PersistableBundle options, + IMicrophoneHotwordDetectionVoiceInteractionCallback callback) { + Pair<ParcelFileDescriptor, ParcelFileDescriptor> clientPipe = createPipe(); + + if (clientPipe == null) { + // TODO: Need to propagate as unknown error or something? + return; + } + ParcelFileDescriptor serviceAudioSink = clientPipe.second; + ParcelFileDescriptor serviceAudioSource = clientPipe.first; + + synchronized (mLock) { + mCurrentAudioSink = serviceAudioSink; + } + + mAudioCopyExecutor.execute(() -> { + try (AudioReader source = audioSource; + OutputStream fos = + new ParcelFileDescriptor.AutoCloseOutputStream(serviceAudioSink)) { + byte[] buffer = new byte[1024]; + + while (true) { + int bytesRead = source.read(buffer, 0, 1024); + + if (bytesRead < 0) { + break; + } + + // TODO: First write to ring buffer to make sure we don't lose data if the next + // statement fails. + // ringBuffer.append(buffer, bytesRead); + fos.write(buffer, 0, bytesRead); + } + } catch (IOException e) { + Slog.w(TAG, "Failed supplying audio data to validator", e); + } finally { + synchronized (mLock) { + mCurrentAudioSink = null; + } + } + }); + + // TODO: handle cancellations well + // TODO: what if we cancelled and started a new one? + mRemoteHotwordDetectionService.run( + service -> service.detectFromMicrophoneSource( + serviceAudioSource, + // TODO: consider making a proxy callback + copy of audio format + audioSourceValue, audioFormat, options, + new IDspHotwordDetectionCallback.Stub() { + @Override + public void onRejected(@Nullable HotwordRejectedResult result) + throws RemoteException { + // TODO: Propagate the HotwordRejectedResult. + } + + @Override + public void onDetected(@Nullable HotwordDetectedResult triggerResult) + throws RemoteException { + // Stop + bestEffortClose(serviceAudioSink); + + if (audioSourceValue == AUDIO_SOURCE_EXTERNAL) { + callback.onDetected(triggerResult, null, null); + // TODO: Add a delay before closing. + bestEffortClose(audioSource); + } + + Pair<ParcelFileDescriptor, ParcelFileDescriptor> clientPipe = + createPipe(); + + if (clientPipe == null) { + // Error. + // Need to propagate as unknown error or something? + return; + } + ParcelFileDescriptor callbackAudioSink = clientPipe.second; + ParcelFileDescriptor callbackClientRead = clientPipe.first; + + mAudioCopyExecutor.execute(() -> { + try (AudioReader source = audioSource; + OutputStream fos = + new ParcelFileDescriptor.AutoCloseOutputStream( + callbackAudioSink)) { + + // TODO: get ring buffer suffix here + // fos.write(lastSeveralSeconds); + + byte[] buffer = new byte[1024]; + while (true) { + int bytesRead = source.read(buffer, 0, 1024); + + if (bytesRead < 0) { + break; + } + + fos.write(buffer, 0, bytesRead); + } + } catch (IOException e) { + Slog.w(TAG, "Failed supplying audio data to validator", e); + } finally { + synchronized (mLock) { + mCurrentAudioSink = null; + } + } + }); + + // TODO: noteOp here. + // Remove hotword offset from trigger result + // TODO: consider propagating only capture session. + callback.onDetected(triggerResult, null, callbackClientRead); + // TODO: Add a delay before closing. + bestEffortClose(callbackClientRead); + } + })); + + // TODO: verify this is the right thing to do here. + bestEffortClose(serviceAudioSource); + } + + private static void bestEffortClose(Closeable closeable) { try { - fd.close(); + closeable.close(); } catch (IOException e) { if (DEBUG) { - Slog.w(TAG, "Failed closing file descriptor", e); + Slog.w(TAG, "Failed closing", e); } } } - - public void dump(String prefix, PrintWriter pw) { - pw.print(prefix); pw.print("mBound="); pw.println(mBound); - } }; diff --git a/services/voiceinteraction/java/com/android/server/voiceinteraction/VoiceInteractionManagerService.java b/services/voiceinteraction/java/com/android/server/voiceinteraction/VoiceInteractionManagerService.java index c110b231bd0e..108649d04681 100644 --- a/services/voiceinteraction/java/com/android/server/voiceinteraction/VoiceInteractionManagerService.java +++ b/services/voiceinteraction/java/com/android/server/voiceinteraction/VoiceInteractionManagerService.java @@ -48,6 +48,7 @@ import android.hardware.soundtrigger.SoundTrigger.KeyphraseSoundModel; import android.hardware.soundtrigger.SoundTrigger.ModelParamRange; import android.hardware.soundtrigger.SoundTrigger.ModuleProperties; import android.hardware.soundtrigger.SoundTrigger.RecognitionConfig; +import android.media.AudioFormat; import android.media.permission.Identity; import android.media.permission.PermissionUtil; import android.media.permission.SafeCloseable; @@ -56,6 +57,7 @@ import android.os.Bundle; import android.os.Handler; import android.os.IBinder; import android.os.Parcel; +import android.os.ParcelFileDescriptor; import android.os.PersistableBundle; import android.os.RemoteCallback; import android.os.RemoteCallbackList; @@ -66,6 +68,7 @@ import android.os.ShellCallback; import android.os.Trace; import android.os.UserHandle; import android.provider.Settings; +import android.service.voice.IMicrophoneHotwordDetectionVoiceInteractionCallback; import android.service.voice.IVoiceInteractionSession; import android.service.voice.VoiceInteractionManagerInternal; import android.service.voice.VoiceInteractionService; @@ -983,6 +986,8 @@ public class VoiceInteractionManagerService extends SystemService { } } + //----------------- Hotword Detection/Validation APIs --------------------------------// + @Override public void updateState(@Nullable PersistableBundle options, @Nullable SharedMemory sharedMemory) { @@ -1023,6 +1028,72 @@ public class VoiceInteractionManagerService extends SystemService { } } + @Override + public void startListeningFromMic( + AudioFormat audioFormat, + IMicrophoneHotwordDetectionVoiceInteractionCallback callback) + throws RemoteException { + enforceCallingPermission(Manifest.permission.RECORD_AUDIO); + enforceCallingPermission(Manifest.permission.CAPTURE_AUDIO_HOTWORD); + synchronized (this) { + enforceIsCurrentVoiceInteractionService(); + + if (mImpl == null) { + Slog.w(TAG, "startListeningFromMic without running voice interaction service"); + return; + } + final long caller = Binder.clearCallingIdentity(); + try { + mImpl.startListeningFromMicLocked(audioFormat, callback); + } finally { + Binder.restoreCallingIdentity(caller); + } + } + } + + @Override + public void startListeningFromExternalSource( + ParcelFileDescriptor audioStream, + AudioFormat audioFormat, + PersistableBundle options, + IMicrophoneHotwordDetectionVoiceInteractionCallback callback) + throws RemoteException { + synchronized (this) { + enforceIsCurrentVoiceInteractionService(); + + if (mImpl == null) { + Slog.w(TAG, "startListeningFromExternalSource without running voice" + + " interaction service"); + return; + } + final long caller = Binder.clearCallingIdentity(); + try { + mImpl.startListeningFromExternalSourceLocked( + audioStream, audioFormat, options, callback); + } finally { + Binder.restoreCallingIdentity(caller); + } + } + } + + @Override + public void stopListeningFromMic() throws RemoteException { + synchronized (this) { + enforceIsCurrentVoiceInteractionService(); + + if (mImpl == null) { + Slog.w(TAG, "stopListeningFromMic without running voice interaction service"); + return; + } + final long caller = Binder.clearCallingIdentity(); + try { + mImpl.stopListeningFromMicLocked(); + } finally { + Binder.restoreCallingIdentity(caller); + } + } + } + //----------------- Model management APIs --------------------------------// @Override diff --git a/services/voiceinteraction/java/com/android/server/voiceinteraction/VoiceInteractionManagerServiceImpl.java b/services/voiceinteraction/java/com/android/server/voiceinteraction/VoiceInteractionManagerServiceImpl.java index 0742cb4c0c84..efa7f78e22aa 100644 --- a/services/voiceinteraction/java/com/android/server/voiceinteraction/VoiceInteractionManagerServiceImpl.java +++ b/services/voiceinteraction/java/com/android/server/voiceinteraction/VoiceInteractionManagerServiceImpl.java @@ -40,15 +40,18 @@ import android.content.pm.IPackageManager; import android.content.pm.PackageManager; import android.content.pm.ServiceInfo; import android.hardware.soundtrigger.IRecognitionStatusCallback; +import android.media.AudioFormat; import android.os.Bundle; import android.os.Handler; import android.os.IBinder; +import android.os.ParcelFileDescriptor; import android.os.PersistableBundle; import android.os.RemoteCallback; import android.os.RemoteException; import android.os.ServiceManager; import android.os.SharedMemory; import android.os.UserHandle; +import android.service.voice.IMicrophoneHotwordDetectionVoiceInteractionCallback; import android.service.voice.IVoiceInteractionService; import android.service.voice.IVoiceInteractionSession; import android.service.voice.VoiceInteractionService; @@ -444,6 +447,52 @@ class VoiceInteractionManagerServiceImpl implements VoiceInteractionSessionConne mHotwordDetectionConnection = null; } + public void startListeningFromMicLocked( + AudioFormat audioFormat, + IMicrophoneHotwordDetectionVoiceInteractionCallback callback) { + if (DEBUG) { + Slog.d(TAG, "startListeningFromMic"); + } + + if (mHotwordDetectionConnection == null) { + // TODO: callback.onError(); + return; + } + + mHotwordDetectionConnection.startListeningFromMic(audioFormat, callback); + } + + public void startListeningFromExternalSourceLocked( + ParcelFileDescriptor audioStream, + AudioFormat audioFormat, + @Nullable PersistableBundle options, + IMicrophoneHotwordDetectionVoiceInteractionCallback callback) { + if (DEBUG) { + Slog.d(TAG, "startListeningFromExternalSource"); + } + + if (mHotwordDetectionConnection == null) { + // TODO: callback.onError(); + return; + } + + mHotwordDetectionConnection + .startListeningFromExternalSource(audioStream, audioFormat, options, callback); + } + + public void stopListeningFromMicLocked() { + if (DEBUG) { + Slog.d(TAG, "stopListeningFromMic"); + } + + if (mHotwordDetectionConnection == null) { + Slog.w(TAG, "stopListeningFromMic() called but connection isn't established"); + return; + } + + mHotwordDetectionConnection.stopListening(); + } + public IRecognitionStatusCallback createSoundTriggerCallbackLocked( IHotwordRecognitionStatusCallback callback) { if (DEBUG) { |