Pick the Android audio devices, microphone preset and playback usage

Microphone and speaker are chosen separately, so a Bluetooth headset can
play in media quality while the phone's own microphone listens, instead of
the headset dropping into its call profile. Devices are kept by type and
address: Android hands out new ids whenever one reconnects, and a line that
lost its device comes back to it when it returns.

The microphone preset (voice recognition by default, or the platform's
call processing, unprocessed, ...) and whether voices play as media or as
a call are Android's own settings. Changing either reopens the open
streams in place. Communication mode, and the headset's call link that a
Bluetooth microphone needs, are held only while a stream needs them, and
are set up before that stream opens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 18:31:22 +00:00
parent 2281c125fa
commit 62ced89701
9 changed files with 582 additions and 50 deletions

View File

@@ -68,8 +68,9 @@ private:
class AudioLine : public oboe::AudioStreamDataCallback, public oboe::AudioStreamErrorCallback {
public:
AudioLine(bool input, int32_t deviceId, int32_t channels)
: input_(input), deviceId_(deviceId), channels_(channels),
/** {@code preset} is the input preset of a capture line, {@code usage} a playback line's usage. */
AudioLine(bool input, int32_t deviceId, int32_t channels, int32_t preset, int32_t usage)
: input_(input), deviceId_(deviceId), channels_(channels), preset_(preset), usage_(usage),
ring_(static_cast<size_t>(kFrameSize) * channels * (input ? kCaptureFrames : kPlaybackFrames)) {}
int32_t channels() const { return channels_; }
@@ -87,12 +88,12 @@ public:
->setSampleRateConversionQuality(oboe::SampleRateConversionQuality::Medium)
->setDataCallback(this)
->setErrorCallback(this);
if (deviceId_ > 0) builder.setDeviceId(deviceId_);
int32_t deviceId = deviceId_.load();
if (deviceId > 0) builder.setDeviceId(deviceId);
if (input_) {
// The platform's own voice processing stays off: the client runs TS3's.
builder.setInputPreset(oboe::InputPreset::VoiceRecognition);
builder.setInputPreset(static_cast<oboe::InputPreset>(preset_.load()));
} else {
builder.setUsage(oboe::Usage::Media)->setContentType(oboe::ContentType::Speech);
builder.setUsage(static_cast<oboe::Usage>(usage_.load()))->setContentType(oboe::ContentType::Speech);
}
std::shared_ptr<oboe::AudioStream> stream;
oboe::Result result = builder.openStream(stream);
@@ -102,15 +103,35 @@ public:
stream->close();
return oboe::Result::ErrorClosed;
}
if (stream_) stream_->close(); // a concurrent reopen got there first
stream_ = stream;
if (started_) result = stream_->requestStart();
return result;
}
oboe::Result start() {
std::lock_guard<std::mutex> lock(mutex_);
started_ = true;
return stream_ ? stream_->requestStart() : oboe::Result::ErrorClosed;
}
/** Moves the line to another device or configuration; the ring and whoever blocks on it stay. */
oboe::Result reopen(int32_t deviceId, int32_t preset, int32_t usage) {
{
std::lock_guard<std::mutex> lock(mutex_);
if (closing_.load()) return oboe::Result::ErrorClosed;
if (stream_) {
stream_->stop();
stream_->close();
stream_.reset();
}
}
deviceId_.store(deviceId);
preset_.store(preset);
usage_.store(usage);
return open();
}
/** Blocks until {@code count} samples are captured; fewer only once closing. */
size_t read(int16_t *data, size_t count) {
size_t done = 0;
@@ -157,18 +178,21 @@ public:
if (closing_.load()) return;
__android_log_print(ANDROID_LOG_INFO, kTag, "stream closed (%s), reopening",
oboe::convertToText(error));
deviceId_ = 0;
if (open() == oboe::Result::OK) start();
deviceId_.store(0);
open();
}
private:
const bool input_;
int32_t deviceId_;
std::atomic<int32_t> deviceId_;
const int32_t channels_;
std::atomic<int32_t> preset_;
std::atomic<int32_t> usage_;
SampleRing ring_;
std::atomic<bool> closing_{false};
std::mutex mutex_;
std::shared_ptr<oboe::AudioStream> stream_;
bool started_ = false; // guarded by mutex_
};
AudioLine *line(jlong handle) {
@@ -183,8 +207,8 @@ extern "C" {
JNIEXPORT jlong JNICALL
Java_com_ts3client_android_audio_OboeLine_nativeOpen(JNIEnv *env, jclass, jboolean input, jint deviceId,
jint channels) {
auto *created = new AudioLine(input, deviceId, channels);
jint channels, jint preset, jint usage) {
auto *created = new AudioLine(input, deviceId, channels, preset, usage);
oboe::Result result = created->open();
if (result != oboe::Result::OK) {
delete created;
@@ -207,6 +231,15 @@ Java_com_ts3client_android_audio_OboeLine_nativeStart(JNIEnv *env, jclass, jlong
}
}
JNIEXPORT void JNICALL
Java_com_ts3client_android_audio_OboeLine_nativeReopen(JNIEnv *env, jclass, jlong handle, jint deviceId,
jint preset, jint usage) {
oboe::Result result = line(handle)->reopen(deviceId, preset, usage);
if (result != oboe::Result::OK && result != oboe::Result::ErrorClosed) {
env->ThrowNew(env->FindClass("java/lang/IllegalStateException"), oboe::convertToText(result));
}
}
JNIEXPORT jint JNICALL
Java_com_ts3client_android_audio_OboeLine_nativeRead(JNIEnv *env, jclass, jlong handle, jbyteArray buffer,
jint offset, jint length) {