| /* |
| * 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 com.example.android.voiceinteractor; |
| |
| import android.media.AudioAttributes; |
| import android.media.AudioRecord; |
| import android.media.MediaRecorder; |
| import android.os.Handler; |
| import android.os.Looper; |
| import android.os.PersistableBundle; |
| import android.os.SharedMemory; |
| import android.os.Trace; |
| import android.service.voice.AlwaysOnHotwordDetector; |
| import android.service.voice.HotwordDetectedResult; |
| import android.service.voice.HotwordDetectionService; |
| import android.service.voice.HotwordRejectedResult; |
| import android.util.Log; |
| |
| import androidx.annotation.NonNull; |
| import androidx.annotation.Nullable; |
| |
| import java.lang.reflect.InvocationTargetException; |
| import java.lang.reflect.Method; |
| import java.time.Duration; |
| import java.util.function.IntConsumer; |
| |
| public class SampleHotwordDetectionService extends HotwordDetectionService { |
| static final String TAG = "SHotwordDetectionSrvc"; |
| |
| // AudioRecord config |
| private static final Duration AUDIO_RECORD_BUFFER_DURATION = Duration.ofSeconds(5); |
| private static final Duration DSP_AUDIO_READ_DURATION = Duration.ofSeconds(3); |
| private static final Duration AUDIO_RECORD_RELEASE_TIMEOUT = Duration.ofSeconds(10); |
| |
| private static AudioRecord createAudioRecord(AlwaysOnHotwordDetector.EventPayload eventPayload, |
| int bytesPerSecond, |
| int sessionId) { |
| int audioRecordBufferSize = getBufferSizeInBytes(bytesPerSecond, |
| AUDIO_RECORD_BUFFER_DURATION.getSeconds()); |
| Log.d(TAG, "creating AudioRecord: bytes=" + audioRecordBufferSize |
| + ", lengthSeconds=" + (audioRecordBufferSize / bytesPerSecond)); |
| return new AudioRecord.Builder() |
| .setAudioAttributes( |
| new AudioAttributes.Builder() |
| .setInternalCapturePreset(MediaRecorder.AudioSource.HOTWORD) |
| // TODO see what happens if this is too small |
| .build()) |
| .setAudioFormat(eventPayload.getCaptureAudioFormat()) |
| .setBufferSizeInBytes(audioRecordBufferSize) |
| .setSessionId(sessionId) |
| .setMaxSharedAudioHistoryMillis(AudioRecord.getMaxSharedAudioHistoryMillis()) |
| .build(); |
| } |
| |
| private static int getBufferSizeInBytes(int bytesPerSecond, float bufferLengthSeconds) { |
| return (int) (bytesPerSecond * bufferLengthSeconds); |
| } |
| |
| @Override |
| public void onUpdateState(@Nullable PersistableBundle options, |
| @Nullable SharedMemory sharedMemory, long callbackTimeoutMillis, |
| @Nullable IntConsumer statusCallback) { |
| Log.i(TAG, "onUpdateState"); |
| if (statusCallback != null) { |
| statusCallback.accept(0); |
| } |
| } |
| |
| @Override |
| public void onDetect( |
| @NonNull AlwaysOnHotwordDetector.EventPayload eventPayload, |
| long timeoutMillis, |
| @NonNull Callback callback) { |
| Log.d(TAG, "onDetect (Hardware trigger): " + eventPayload); |
| Trace.beginAsyncSection("HDS.onDetected", 0); |
| |
| int sampleRate = eventPayload.getCaptureAudioFormat().getSampleRate(); |
| int bytesPerSecond = |
| eventPayload.getCaptureAudioFormat().getFrameSizeInBytes() * sampleRate; |
| |
| Integer captureSession = 0; |
| try { |
| Method getCaptureSessionMethod = eventPayload.getClass().getMethod("getCaptureSession"); |
| captureSession = (Integer) getCaptureSessionMethod.invoke(eventPayload); |
| } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { |
| e.printStackTrace(); |
| } |
| int sessionId = |
| // generateSessionId ? |
| // AudioManager.AUDIO_SESSION_ID_GENERATE : |
| captureSession; |
| Trace.beginAsyncSection("HDS.createAudioRecord", 1); |
| AudioRecord record = createAudioRecord(eventPayload, bytesPerSecond, sessionId); |
| Trace.endAsyncSection("HDS.createAudioRecord", 1); |
| if (record.getState() != AudioRecord.STATE_INITIALIZED) { |
| Log.e(TAG, "Failed to init first AudioRecord."); |
| callback.onRejected(new HotwordRejectedResult.Builder().build()); |
| return; |
| } |
| |
| byte[] buffer = new byte[bytesPerSecond * (int) DSP_AUDIO_READ_DURATION.getSeconds()]; |
| Log.d(TAG, "starting read: bytesPerSecond=" + bytesPerSecond |
| + ", totalBufferSize=" + buffer.length); |
| Trace.beginAsyncSection("HDS.startRecording", 1); |
| record.startRecording(); |
| Trace.endAsyncSection("HDS.startRecording", 1); |
| Trace.beginAsyncSection("AudioUtils.read", 1); |
| AudioUtils.read(record, bytesPerSecond, DSP_AUDIO_READ_DURATION.getSeconds(), buffer); |
| Trace.endAsyncSection("AudioUtils.read", 1); |
| |
| callback.onDetected( |
| new HotwordDetectedResult.Builder() |
| .setMediaSyncEvent( |
| record.shareAudioHistory("com.example.android.voiceinteractor", 0)) |
| .setHotwordPhraseId(getKeyphraseId(eventPayload)) |
| .build()); |
| new Handler(Looper.getMainLooper()).postDelayed(() -> { |
| Log.i(TAG, "Releasing audio record"); |
| record.stop(); |
| record.release(); |
| }, AUDIO_RECORD_RELEASE_TIMEOUT.toMillis()); |
| Trace.endAsyncSection("HDS.onDetected", 0); |
| } |
| |
| private int getKeyphraseId(AlwaysOnHotwordDetector.EventPayload payload) { |
| return 0; |
| // if (payload.getKeyphraseRecognitionExtras().isEmpty()) { |
| // return 0; |
| // } |
| // return payload.getKeyphraseRecognitionExtras().get(0).getKeyphraseId(); |
| } |
| |
| @Override |
| public void onDetect(@NonNull Callback callback) { |
| Log.w(TAG, "onDetect called for microphone trigger"); |
| } |
| |
| } |