Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
package com.mentra.asg_client.audio;

import android.media.AudioFormat;
import android.media.AudioRecord;
import android.media.MediaRecorder;
import android.os.Handler;
import android.os.HandlerThread;
import android.util.Log;

import androidx.annotation.Nullable;

import com.mentra.asg_client.io.media.interfaces.AudioChunkCallback;

import java.nio.ByteBuffer;

/**
* Captures raw PCM audio from the device microphone and delivers chunks to a registered callback.
*
* <h3>Configuration</h3>
* Records at 16 kHz, 16-bit mono PCM — the standard for speech processing and on-device ASR.
* The internal read buffer is sized at 2× the platform minimum to reduce the risk of overruns
* on low-end hardware while keeping latency manageable.
*
* <h3>Threading</h3>
* All {@link AudioRecord} reads run on a dedicated {@link HandlerThread} ({@code "AudioRecorder"}).
* {@link #start} and {@link #stop} are safe to call from any thread. The {@link AudioChunkCallback}
* is invoked on the recorder thread, so callers should not block there for long.
*
* <h3>Lifecycle</h3>
* <ol>
* <li>Construct once and reuse — the thread survives across start/stop pairs.
* <li>Call {@link #start} to begin capture; a callback must be set first.
* <li>Call {@link #stop} to halt capture without destroying the thread.
* <li>Call {@link #release} when the recorder is no longer needed.
* </ol>
*/
public class AudioRecorder {

private static final String TAG = "AudioRecorder";

public static final int SAMPLE_RATE_HZ = 16_000;
public static final int CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_MONO;
public static final int AUDIO_ENCODING = AudioFormat.ENCODING_PCM_16BIT;

private final HandlerThread mRecorderThread;
private final Handler mRecorderHandler;

@Nullable private volatile AudioChunkCallback mCallback;
@Nullable private AudioRecord mAudioRecord;
private volatile boolean mRecording = false;
private volatile boolean mVadEnabled = false;

// Bytes per chunk — delivered to the callback on each successful read.
private int mChunkBytes;

public AudioRecorder() {
mRecorderThread = new HandlerThread("AudioRecorder");
mRecorderThread.start();
mRecorderHandler = new Handler(mRecorderThread.getLooper());
}

// ─── Public API ──────────────────────────────────────────────────────────────

/**
* Register the callback that receives audio chunks. May be set or changed at any time;
* changes take effect on the next chunk delivery. Pass {@code null} to detach.
*/
public void setCallback(@Nullable AudioChunkCallback callback) {
mCallback = callback;
}

/**
* Enable or disable Voice Activity Detection mode.
* When enabled, the recorder continues capturing but the caller is expected to gate processing
* on speech presence rather than delivering every chunk downstream. The flag is exposed via
* {@link #isVadEnabled()} so higher layers can inspect it.
*/
public void setVadEnabled(boolean enabled) {
boolean prev = mVadEnabled;
mVadEnabled = enabled;
if (prev != enabled) {
Log.i(TAG, "VAD mode " + (enabled ? "enabled" : "disabled"));
}
}

public boolean isVadEnabled() {
return mVadEnabled;
}

public boolean isRecording() {
return mRecording;
}

/**
* Begin capturing audio. A no-op if already recording.
*
* @return {@code true} if capture started successfully, {@code false} on any error.
*/
public synchronized boolean start() {
if (mRecording) {
Log.w(TAG, "Already recording — ignoring start()");
return true;
}

int minBytes = AudioRecord.getMinBufferSize(SAMPLE_RATE_HZ, CHANNEL_CONFIG, AUDIO_ENCODING);
if (minBytes == AudioRecord.ERROR || minBytes == AudioRecord.ERROR_BAD_VALUE) {
Log.e(TAG, "AudioRecord.getMinBufferSize() failed: " + minBytes);
return false;
}

// Double the minimum to reduce the likelihood of buffer overruns on loaded hardware.
int bufferBytes = minBytes * 2;
mChunkBytes = minBytes; // deliver chunks at the platform-native granularity

AudioRecord record = new AudioRecord(
MediaRecorder.AudioSource.MIC,
SAMPLE_RATE_HZ,
CHANNEL_CONFIG,
AUDIO_ENCODING,
bufferBytes);

if (record.getState() != AudioRecord.STATE_INITIALIZED) {
Log.e(TAG, "AudioRecord failed to initialize (state=" + record.getState() + ")");
record.release();
return false;
}

mAudioRecord = record;
mRecording = true;

record.startRecording();
Log.i(TAG, "Audio capture started — " + SAMPLE_RATE_HZ + " Hz, " + bufferBytes + "B buffer");

mRecorderHandler.post(this::readLoop);
return true;
}

/**
* Stop capturing audio. Safe to call from any thread; blocks until the read loop drains.
* The {@link AudioRecord} instance is released; a subsequent {@link #start} call creates a
* fresh one.
*/
public synchronized void stop() {
if (!mRecording) {
Log.d(TAG, "stop() called while not recording — no-op");
return;
}
mRecording = false;
// AudioRecord.stop() signals the read loop to exit (reads return 0 or error).
if (mAudioRecord != null) {
try {
mAudioRecord.stop();
} catch (IllegalStateException e) {
Log.w(TAG, "Exception stopping AudioRecord", e);
}
mAudioRecord.release();
mAudioRecord = null;
Comment thread
cursor[bot] marked this conversation as resolved.
}
Log.i(TAG, "Audio capture stopped");
}

/**
* Release all resources including the background thread. The recorder cannot be used after
* this call. Call {@link #stop} first if capture is in progress.
*/
public void release() {
stop();
mRecorderThread.quitSafely();
Log.d(TAG, "AudioRecorder released");
}

// ─── Internal ────────────────────────────────────────────────────────────────

/**
* Runs on the recorder thread. Reads chunks from {@link AudioRecord} and forwards them to the
* registered callback until {@link #mRecording} is cleared.
*/
private void readLoop() {
Log.d(TAG, "Read loop started");

while (mRecording) {
AudioRecord record = mAudioRecord;
if (record == null) break;

byte[] buf = new byte[mChunkBytes];
int read = record.read(buf, 0, buf.length);

if (read > 0) {
AudioChunkCallback cb = mCallback;
if (cb != null) {
// Wrap in a read-only view so callers cannot modify the backing array.
ByteBuffer chunk = ByteBuffer.wrap(buf, 0, read).asReadOnlyBuffer();
try {
cb.onSuccess(chunk);
} catch (Exception e) {
Log.e(TAG, "Exception in AudioChunkCallback", e);
}
}
} else if (read == AudioRecord.ERROR_INVALID_OPERATION
|| read == AudioRecord.ERROR_BAD_VALUE) {
Log.e(TAG, "AudioRecord read error: " + read);
break;
}
// read == 0 or ERROR: continue — stop() will clear mRecording.
}

Log.d(TAG, "Read loop exited");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import android.util.Log;
import android.util.Size;
import com.dev.api.DevApi;
import com.mentra.asg_client.audio.AudioRecorder;
import com.mentra.asg_client.camera.UvcStreamingState;
import com.mentra.asg_client.hardware.K900RgbLedController;
import com.mentra.asg_client.io.bes.BesOtaRegistry;
Expand Down Expand Up @@ -202,6 +203,9 @@ public void onCreate() {
// Apply saved camera FOV on start (K900) so last user choice survives reboot
applySavedCameraFovOnStart();

// Schedule a 30-second test recording that starts 10 seconds after service init.
scheduleStartupTestRecording();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Validation recording left in onCreate

Medium Severity

scheduleStartupTestRecording() is invoked from onCreate() for every production boot, starting unsolicited 30-second microphone capture ~10 seconds after startup. The PR test plan says to remove this call before merge, but it is still wired into the main service path.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f510c72. Configure here.


// Initialize WiFi debouncing
Log.d(TAG, "📶 Initializing WiFi debouncing");
initializeWifiDebouncing();
Expand Down Expand Up @@ -1015,6 +1019,39 @@ public void onDataReceived(byte[] data) {
// Helper Methods
// ---------------------------------------------

/**
* Schedules a 30-second test recording that begins 10 seconds after service startup.
* Uses the AudioRecorder already owned by the CommandProcessor so only one AudioRecord
* instance ever holds the microphone at a time.
*/
private void scheduleStartupTestRecording() {
final long START_DELAY_MS = 10_000L;
final long RECORD_DURATION_MS = 30_000L;

new Handler(Looper.getMainLooper()).postDelayed(() -> {
Log.i(TAG, "Startup test recording: starting now");

if (commandProcessor == null) {
Log.w(TAG, "Startup test recording: commandProcessor not ready, aborting");
return;
}

AudioRecorder recorder = commandProcessor.getAudioRecorder();
boolean started = recorder.start();
if (!started) {
Log.e(TAG, "Startup test recording: AudioRecorder.start() failed");
return;
}

Log.i(TAG, "Startup test recording: recording for " + (RECORD_DURATION_MS / 1000) + "s");
new Handler(Looper.getMainLooper()).postDelayed(() -> {
recorder.stop();
Log.i(TAG, "Startup test recording: stopped after " + (RECORD_DURATION_MS / 1000) + "s");
Comment thread
cursor[bot] marked this conversation as resolved.
}, RECORD_DURATION_MS);

}, START_DELAY_MS);
Comment thread
cursor[bot] marked this conversation as resolved.
}

private void onWifiConnected() {
Log.i(TAG, "🌐 Connected to WiFi network");

Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,29 @@
package com.mentra.asg_client.service.core.handlers;

import android.util.Log;

import com.mentra.asg_client.audio.AudioRecorder;
import com.mentra.asg_client.service.legacy.interfaces.ICommandHandler;
import com.mentra.asg_client.service.legacy.managers.AsgClientServiceManager;

import org.json.JSONObject;

import java.util.Set;

/**
* Handler for microphone-related commands.
* Follows Single Responsibility Principle by handling only microphone commands.
*
* <p>Routes {@code set_mic_state} and {@code set_mic_vad_state} BLE commands to the
* {@link AudioRecorder}, which performs the actual PCM capture on a dedicated thread.
*/
public class MicrophoneCommandHandler implements ICommandHandler {

private static final String TAG = "MicrophoneCommandHandler";

private final AsgClientServiceManager serviceManager;

public MicrophoneCommandHandler(AsgClientServiceManager serviceManager) {
this.serviceManager = serviceManager;
private final AudioRecorder audioRecorder;

public MicrophoneCommandHandler(AudioRecorder audioRecorder) {
this.audioRecorder = audioRecorder;
}

@Override
Expand All @@ -43,34 +49,33 @@ public boolean handleCommand(String commandType, JSONObject data) {
}
}

/**
* Handle set microphone state command
*/
// ─── Private helpers ─────────────────────────────────────────────────────────

private boolean handleSetMicState(JSONObject data) {
try {
boolean enabled = data.optBoolean("enabled", false);
Log.d(TAG, "🎤 Setting microphone state: " + enabled);
//TODO: Set microphone state in service manager
// serviceManager.setMicrophoneState(enabled);
return true;
Log.i(TAG, "Setting microphone state: " + (enabled ? "ON" : "OFF"));

if (enabled) {
return audioRecorder.start();
} else {
audioRecorder.stop();
return true;
}
} catch (Exception e) {
Log.e(TAG, "Error handling set microphone state command", e);
Log.e(TAG, "Error handling set_mic_state", e);
return false;
}
}

/**
* Handle set microphone VAD state command
*/
private boolean handleSetMicVadState(JSONObject data) {
try {
boolean enabled = data.optBoolean("enabled", false);
Log.d(TAG, "🎤 Setting microphone VAD state: " + enabled);
//TODO: Set microphone VAD state in service manager
// serviceManager.setMicrophoneVadState(enabled);
Log.i(TAG, "Setting microphone VAD state: " + (enabled ? "ON" : "OFF"));
audioRecorder.setVadEnabled(enabled);
return true;
} catch (Exception e) {
Log.e(TAG, "Error handling set microphone VAD state command", e);
Log.e(TAG, "Error handling set_mic_vad_state", e);
return false;
}
}
Expand Down
Loading
Loading