STRecorder.java
// SPDX-License-Identifier: Apache-2.0
package com.stkouyu;
import android.content.Context;
import android.os.AsyncTask;
import android.os.Handler;
import android.os.Message;
import com.shengzhiai.yugu.stcompat.internal.AudioInput;
import com.shengzhiai.yugu.stcompat.internal.Capture;
import com.shengzhiai.yugu.stcompat.internal.CompatConfig;
import com.shengzhiai.yugu.stcompat.internal.MainThread;
import com.shengzhiai.yugu.stcompat.internal.Player;
import com.shengzhiai.yugu.stcompat.internal.Wav;
import com.shengzhiai.yugu.stcompat.internal.YLog;
import com.stkouyu.listener.OnSTRecorderListener;
import com.stkouyu.util.AiUtil;
import java.io.File;
import java.io.IOException;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
/**
* Stand-alone recorder with the API of the Shengtong SDK, backed by the compat capture engine:
* 16 kHz mono PCM16 written as WAV (also when the path ends with .mp3), PCM chunks delivered to
* {@link Callback} on the capture thread, events sent to the {@link #setHandler(Handler) handler}
* as SkEgnManager.CODE_* messages and to {@link OnSTRecorderListener} on the main thread.
*/
public class STRecorder {
/** Receives each captured PCM chunk on the capture thread. */
public interface Callback {
void run(byte[] data, int size);
}
/** Plays a file in the background: {@code execute(path)}. */
public class PlayBackTask extends AsyncTask<String, Integer, Void> {
public PlayBackTask() {
}
@Override
protected Void doInBackground(String... paths) {
if (paths == null || paths.length == 0) {
return null;
}
return playBackFile(paths[0]);
}
@Override
protected void onCancelled() {
stopPlayer(false);
}
@Override
protected void onPostExecute(Void result) {
if (mPlayBackTask == this) {
mPlayBackTask = null;
}
}
}
private static volatile STRecorder instance;
public volatile boolean isRecording;
public volatile boolean mIsRecordPaused;
public volatile boolean mIsReStartRecord;
public PlayBackTask mPlayBackTask;
private final Object lock = new Object();
private final Player player = new Player();
private volatile Context context;
private volatile String recordDir;
private volatile Integer audioSource;
private volatile Handler handler;
private volatile OnSTRecorderListener listener;
private volatile String mp3Path;
private volatile String lastPath;
private volatile boolean mute;
private Capture capture;
private Wav.Writer writer;
private AudioInput prepared;
private CountDownLatch playDone;
private STRecorder() {
}
/** Recordings without an explicit path go to this file (WAV data, see the class comment). */
public void setMp3Path(String path) {
this.mp3Path = path;
}
/** Receives SkEgnManager.CODE_RECORDER_* and CODE_PLAY_* messages. */
public void setHandler(Handler handler) {
this.handler = handler;
}
public void setOnSTRecorderListener(OnSTRecorderListener listener) {
this.listener = listener;
}
public static STRecorder getInstance() {
return getInstance(null, null, null, null);
}
public static STRecorder getInstance(Context context) {
return getInstance(context, null, null, null);
}
/** @param recordDir directory for recordings started without a path */
public static STRecorder getInstance(Context context, String recordDir) {
return getInstance(context, recordDir, null, null);
}
/** @param sampleRate only 16000 is supported; other values are logged at WARN */
public static STRecorder getInstance(Context context, String recordDir, Integer sampleRate) {
return getInstance(context, recordDir, sampleRate, null);
}
/** @param audioSource {@code MediaRecorder.AudioSource} value, null for MIC */
public static STRecorder getInstance(Context context, String recordDir, Integer sampleRate, Integer audioSource) {
STRecorder r = instance;
if (r == null) {
synchronized (STRecorder.class) {
r = instance;
if (r == null) {
r = new STRecorder();
instance = r;
}
}
}
if (context != null) {
Context app = context.getApplicationContext();
r.context = app != null ? app : context;
}
if (recordDir != null) {
r.recordDir = recordDir;
}
if (sampleRate != null && sampleRate != Wav.SAMPLE_RATE) {
YLog.w("STRecorder records at 16000 Hz; sampleRate " + sampleRate + " is ignored");
}
if (audioSource != null) {
r.audioSource = audioSource;
}
return r;
}
/** Releases the capture device and a microphone opened ahead of time. */
protected boolean releaseRecorder() {
Capture c;
AudioInput p;
synchronized (lock) {
c = capture;
capture = null;
p = prepared;
prepared = null;
}
if (c != null) {
c.stop();
c.await(1000);
}
if (p != null) {
p.release();
}
closeWriter();
isRecording = false;
return true;
}
/** Opens the microphone ahead of the next {@link #start(String, Callback)}. */
protected boolean activeRecorder() {
synchronized (lock) {
if (prepared != null || capture != null) {
return true;
}
}
try {
AudioInput in = CompatConfig.audioInputFactory().open(context, audioSource);
synchronized (lock) {
if (prepared == null) {
prepared = in;
return true;
}
}
in.release();
return true;
} catch (AudioInput.AudioInputException e) {
YLog.w("STRecorder: microphone unavailable: " + e.getMessage());
return false;
}
}
protected void releasePlayer() {
stopPlayer(false);
}
/** Stops everything and forgets the instance; getInstance creates a new one. */
public void delete() {
releaseRecorder();
stopPlay();
synchronized (STRecorder.class) {
if (instance == this) {
instance = null;
}
}
}
@Override
public void finalize() {
try {
releaseRecorder();
} catch (RuntimeException ignored) {
// nothing to report from a finalizer
}
}
/**
* Starts recording into {@code path} (WAV data). A null path uses the mp3Path, then
* {@code <recordDir or externalFilesDir/record>/<time>.wav}.
*/
public void start(String path, final Callback callback) {
synchronized (lock) {
if (capture != null) {
YLog.w("STRecorder.start ignored: already recording");
send(SkEgnManager.CODE_RECORDER_ONSTARTRECORDFAIL, "recorder is busy");
return;
}
}
File file = target(path);
final Wav.Writer w;
try {
w = new Wav.Writer(file, Wav.SAMPLE_RATE, Wav.CHANNELS);
} catch (IOException e) {
send(SkEgnManager.CODE_RECORDER_ONSTARTRECORDFAIL, "cannot create " + file + ": " + e.getMessage());
return;
}
AudioInput in;
synchronized (lock) {
in = prepared;
prepared = null;
}
Capture c;
try {
if (in == null) {
in = CompatConfig.audioInputFactory().open(context, audioSource);
}
c = new Capture(in, new Capture.Sink() {
@Override
public void onPcm(byte[] data, int length) {
try {
w.write(data, 0, length);
} catch (IOException e) {
YLog.w("STRecorder: cannot write: " + e.getMessage());
}
if (callback != null) {
callback.run(data, length);
}
}
@Override
public void onCaptureError(String reason) {
send(SkEgnManager.CODE_RECORDER_ONSTARTRECORDFAIL, reason);
}
}, "yugu-strecorder");
c.setMute(mute);
c.start();
} catch (AudioInput.AudioInputException e) {
if (in != null) {
in.release();
}
closeQuietly(w);
send(SkEgnManager.CODE_RECORDER_ONSTARTRECORDFAIL, "microphone unavailable: " + e.getMessage());
return;
}
synchronized (lock) {
capture = c;
writer = w;
}
lastPath = file.getAbsolutePath();
isRecording = true;
mIsRecordPaused = false;
mIsReStartRecord = false;
send(SkEgnManager.CODE_RECORDER_START, lastPath);
MainThread.post(new Runnable() {
@Override
public void run() {
OnSTRecorderListener l = listener;
if (l != null) {
l.onStart();
}
}
});
}
private File target(String path) {
if (path != null && !path.trim().isEmpty()) {
return new File(path.trim());
}
if (mp3Path != null && !mp3Path.trim().isEmpty()) {
return new File(mp3Path.trim());
}
File dir = recordDir != null ? new File(recordDir) : new File(AiUtil.externalFilesDir(context), "record");
return new File(dir, System.currentTimeMillis() + ".wav");
}
/** Stops recording and completes the WAV file before returning. */
public void stop() {
Capture c;
synchronized (lock) {
c = capture;
capture = null;
}
if (c != null) {
c.stop();
if (!c.await(2000)) {
YLog.w("STRecorder: capture thread did not stop in time");
}
}
closeWriter();
boolean was = isRecording;
isRecording = false;
mIsRecordPaused = false;
if (was) {
send(SkEgnManager.CODE_RECORDER_END, lastPath);
}
}
public void pause() {
Capture c;
synchronized (lock) {
c = capture;
}
if (c != null && !mIsRecordPaused) {
c.pause();
mIsRecordPaused = true;
mIsReStartRecord = false;
send(SkEgnManager.CODE_RECORDER_PAUSE, null);
}
}
public void restart() {
Capture c;
synchronized (lock) {
c = capture;
}
if (c != null && mIsRecordPaused) {
if (c.resume()) {
mIsRecordPaused = false;
mIsReStartRecord = true;
} else {
send(SkEgnManager.CODE_RECORDER_ONSTARTRECORDFAIL, "microphone could not be restarted");
}
}
}
/** Stops recording and deletes the file. */
public void cancel() {
boolean was = isRecording;
stop();
String p = lastPath;
if (was && p != null) {
//noinspection ResultOfMethodCallIgnored
new File(p).delete();
}
}
/** Plays the last recording; events go to {@code handler}. */
public void playback(Handler handler) {
playWithPath(lastPath, handler);
}
/** Plays a file in a {@link PlayBackTask}; events go to {@code handler}. */
public void playWithPath(final String path, Handler handler) {
if (handler != null) {
this.handler = handler;
}
stopPlay();
MainThread.run(new Runnable() {
@Override
public void run() {
PlayBackTask t = new PlayBackTask();
mPlayBackTask = t;
t.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, path);
}
});
}
public void stopPlay() {
PlayBackTask t = mPlayBackTask;
mPlayBackTask = null;
if (t != null) {
t.cancel(false);
}
stopPlayer(true);
}
/** Mutes the captured audio (silence is recorded and delivered). */
public void setbMute(boolean mute) {
this.mute = mute;
Capture c;
synchronized (lock) {
c = capture;
}
if (c != null) {
c.setMute(mute);
}
}
/** Plays {@code path} and returns when playback ends (immediately when called on the main thread). */
public Void playBackFile(final String path) {
if (MainThread.isMainThread()) {
startPlayer(path, null);
return null;
}
final CountDownLatch done = new CountDownLatch(1);
MainThread.post(new Runnable() {
@Override
public void run() {
startPlayer(path, done);
}
});
try {
done.await(30, TimeUnit.MINUTES);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return null;
}
private void startPlayer(final String path, final CountDownLatch done) {
synchronized (lock) {
playDone = done;
}
player.play(path, new Player.Events() {
@Override
public void onStart() {
send(SkEgnManager.CODE_PLAY_START, path);
}
@Override
public void onFail(String reason) {
send(SkEgnManager.CODE_PLAY_START_FAIL, reason);
release(done);
}
@Override
public void onEnd() {
send(SkEgnManager.CODE_PLAY_END, path);
release(done);
}
});
}
private void release(CountDownLatch done) {
if (done != null) {
done.countDown();
}
synchronized (lock) {
if (playDone == done) {
playDone = null;
}
}
}
private void stopPlayer(final boolean notify) {
MainThread.run(new Runnable() {
@Override
public void run() {
player.stop(notify);
CountDownLatch d;
synchronized (lock) {
d = playDone;
playDone = null;
}
if (d != null) {
d.countDown();
}
}
});
}
private void closeWriter() {
Wav.Writer w;
synchronized (lock) {
w = writer;
writer = null;
}
closeQuietly(w);
}
private static void closeQuietly(Wav.Writer w) {
if (w != null) {
try {
w.close();
} catch (IOException e) {
YLog.w("STRecorder: cannot finish file: " + e.getMessage());
}
}
}
private void send(int what, Object obj) {
Handler h = handler;
if (h != null) {
Message m = h.obtainMessage(what, obj);
h.sendMessage(m);
}
}
}