diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 6c5bdeac..8a758861 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -7,6 +7,7 @@ on: - "src/**" - "tests/test_amy_unix_socket.c" - "tests/run_amy_unix_socket_test.sh" + - "tests/check_android_audio_capture.py" - ".github/workflows/android.yml" permissions: @@ -84,26 +85,51 @@ jobs: adb logcat -c adb shell am start -W -n org.amy.hello/.MainActivity sleep 10 - adb logcat -d -s AmyAndroid:I AmyService:I AmyHelloWorld:I '*:S' > /tmp/amy-first.log + adb logcat -d -s AmyAndroid:I AmyAudioCapture:I AmyService:I AmyHelloWorld:I '*:S' > /tmp/amy-first.log test "$(grep -c 'AMY/Oboe started' /tmp/amy-first.log)" -eq 1 grep -q 'AMY output route: deviceId=' /tmp/amy-first.log test "$(grep -c 'C scale complete' /tmp/amy-first.log)" -eq 1 ! grep -q 'C scale failed' /tmp/amy-first.log - grep -q 'wire: v0w0V2.0Z' /tmp/amy-first.log + grep -q 'wire: v0w0V10.0Z' /tmp/amy-first.log test "$(grep -Ec 'wire: v0n(60|62|64|65|67|69|71|72)l1Z' /tmp/amy-first.log)" -eq 8 grep -q 'wire: v0n60l1Z' /tmp/amy-first.log grep -q 'wire: v0n72l1Z' /tmp/amy-first.log + grep -q 'Audio capture complete:' /tmp/amy-first.log + adb uninstall org.amy.hello adb install android/hello-world/build/outputs/apk/debug/hello-world-debug.apk adb logcat -c adb shell am start -W -n org.amy.hello/.MainActivity sleep 10 - adb logcat -d -s AmyAndroid:I AmyService:I AmyHelloWorld:I '*:S' > /tmp/amy-second.log + adb logcat -d -s AmyAndroid:I AmyAudioCapture:I AmyService:I AmyHelloWorld:I '*:S' > /tmp/amy-second.log test "$(grep -c 'AMY/Oboe started' /tmp/amy-second.log)" -eq 1 grep -q 'AMY output route: deviceId=' /tmp/amy-second.log test "$(grep -c 'C scale complete' /tmp/amy-second.log)" -eq 1 ! grep -q 'C scale failed' /tmp/amy-second.log - grep -q 'wire: v0w0V2.0Z' /tmp/amy-second.log + grep -q 'wire: v0w0V10.0Z' /tmp/amy-second.log test "$(grep -Ec 'wire: v0n(60|62|64|65|67|69|71|72)l1Z' /tmp/amy-second.log)" -eq 8 grep -q 'wire: v0n60l1Z' /tmp/amy-second.log grep -q 'wire: v0n72l1Z' /tmp/amy-second.log + grep -q 'Audio capture complete:' /tmp/amy-second.log + + mkdir -p android/audio-capture + adb exec-out run-as org.amy.hello cat files/amy-render.wav > android/audio-capture/amy-render.wav + adb exec-out run-as org.amy.hello cat files/amy-oboe.wav > android/audio-capture/amy-oboe.wav + adb exec-out run-as org.amy.hello cat files/amy-audio-levels.txt > android/audio-capture/amy-audio-levels.txt + test -s android/audio-capture/amy-render.wav + test -s android/audio-capture/amy-oboe.wav + test -s android/audio-capture/amy-audio-levels.txt + cat android/audio-capture/amy-audio-levels.txt + + - name: Analyze captured AMY and Oboe audio levels + run: | + python3 tests/check_android_audio_capture.py \ + android/audio-capture/amy-render.wav \ + android/audio-capture/amy-oboe.wav + + - name: Upload Android audio captures + uses: actions/upload-artifact@v4 + with: + name: amy-android-audio-capture + path: android/audio-capture/ + if-no-files-found: error diff --git a/android/amy-service/src/main/cpp/CMakeLists.txt b/android/amy-service/src/main/cpp/CMakeLists.txt index 25c92db2..08fbf51c 100644 --- a/android/amy-service/src/main/cpp/CMakeLists.txt +++ b/android/amy-service/src/main/cpp/CMakeLists.txt @@ -31,6 +31,7 @@ set(AMY_SOURCES add_library(amy_android SHARED amy_android.cpp + amy_android_capture.cpp amy_android_profile.cpp ${AMY_SOURCES} ) diff --git a/android/amy-service/src/main/cpp/amy_android.cpp b/android/amy-service/src/main/cpp/amy_android.cpp index bfddd62f..250538b8 100644 --- a/android/amy-service/src/main/cpp/amy_android.cpp +++ b/android/amy-service/src/main/cpp/amy_android.cpp @@ -12,6 +12,8 @@ #include #include +#include "amy_android_capture.h" + extern "C" { #include "amy.h" #include "amy_unix_socket.h" @@ -73,6 +75,12 @@ class AmyAndroidEngine final : public oboe::AudioStreamDataCallback, amy_start(config); mAmyStarted = true; + // The helper remains dormant unless the hello-world test has created + // its one-shot private capture marker. It captures the exact samples + // returned by AMY and the exact I16 samples handed to Oboe. + mCapture = std::make_unique( + socketPath, AMY_SAMPLE_RATE, AMY_NCHANS); + oboe::AudioStreamBuilder builder; builder.setDirection(oboe::Direction::Output); builder.setPerformanceMode(oboe::PerformanceMode::LowLatency); @@ -188,6 +196,12 @@ class AmyAndroidEngine final : public oboe::AudioStreamDataCallback, mStream.reset(); } + // No callback can touch the capture buffers after the stream closes. + if (mCapture) { + mCapture->stop(); + mCapture.reset(); + } + cleanupSocketAndAmy(); mAudioCallbackSeen.store(false, std::memory_order_release); mBlock = nullptr; @@ -206,6 +220,7 @@ class AmyAndroidEngine final : public oboe::AudioStreamDataCallback, } mAudioCallbackSeen.store(true, std::memory_order_release); + if (mCapture && mCapture->enabled()) mCapture->beginCallback(numFrames); int32_t outputFrame = 0; while (outputFrame < numFrames) { @@ -223,6 +238,10 @@ class AmyAndroidEngine final : public oboe::AudioStreamDataCallback, const int32_t available = AMY_BLOCK_SIZE - mBlockFrame; const int32_t frames = std::min(available, numFrames - outputFrame); + if (mCapture && mCapture->enabled()) { + mCapture->captureAmyChunk( + mBlock + mBlockFrame * AMY_NCHANS, frames, outputFrame); + } std::memcpy( output + outputFrame * AMY_NCHANS, mBlock + mBlockFrame * AMY_NCHANS, @@ -231,6 +250,7 @@ class AmyAndroidEngine final : public oboe::AudioStreamDataCallback, mBlockFrame += frames; } + if (mCapture && mCapture->enabled()) mCapture->finishCallback(output, numFrames); return oboe::DataCallbackResult::Continue; } @@ -281,6 +301,7 @@ class AmyAndroidEngine final : public oboe::AudioStreamDataCallback, bool mAmyStarted = false; std::atomic mSocket{nullptr}; std::shared_ptr mStream; + std::unique_ptr mCapture; int16_t *mBlock = nullptr; int32_t mBlockFrame = AMY_BLOCK_SIZE; }; diff --git a/android/amy-service/src/main/cpp/amy_android_capture.cpp b/android/amy-service/src/main/cpp/amy_android_capture.cpp new file mode 100644 index 00000000..597b75f5 --- /dev/null +++ b/android/amy-service/src/main/cpp/amy_android_capture.cpp @@ -0,0 +1,278 @@ +#include "amy_android_capture.h" + +#include + +#include +#include +#include +#include + +#include + +#define LOG_TAG "AmyAudioCapture" +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) + +namespace { + +constexpr int32_t kCaptureSeconds = 4; +constexpr const char *kEnableMarker = "amy-audio-capture.enable"; +constexpr const char *kAmyWave = "amy-render.wav"; +constexpr const char *kOboeWave = "amy-oboe.wav"; +constexpr const char *kStatsFile = "amy-audio-levels.txt"; + +std::string joinPath(const std::string &directory, const char *name) { + return directory + "/" + name; +} + +void writeLe16(FILE *file, uint16_t value) { + const uint8_t bytes[2] = { + static_cast(value & 0xff), + static_cast((value >> 8) & 0xff), + }; + std::fwrite(bytes, sizeof(bytes), 1, file); +} + +void writeLe32(FILE *file, uint32_t value) { + const uint8_t bytes[4] = { + static_cast(value & 0xff), + static_cast((value >> 8) & 0xff), + static_cast((value >> 16) & 0xff), + static_cast((value >> 24) & 0xff), + }; + std::fwrite(bytes, sizeof(bytes), 1, file); +} + +bool writeWave(const std::string &path, + const std::vector &samples, + int32_t frames, + int32_t sampleRate, + int32_t channels) { + FILE *file = std::fopen(path.c_str(), "wb"); + if (file == nullptr) return false; + + const uint32_t sampleCount = static_cast(frames * channels); + const uint32_t dataBytes = sampleCount * sizeof(int16_t); + const uint32_t byteRate = static_cast(sampleRate * channels * sizeof(int16_t)); + const uint16_t blockAlign = static_cast(channels * sizeof(int16_t)); + + std::fwrite("RIFF", 4, 1, file); + writeLe32(file, 36u + dataBytes); + std::fwrite("WAVE", 4, 1, file); + std::fwrite("fmt ", 4, 1, file); + writeLe32(file, 16); + writeLe16(file, 1); // PCM + writeLe16(file, static_cast(channels)); + writeLe32(file, static_cast(sampleRate)); + writeLe32(file, byteRate); + writeLe16(file, blockAlign); + writeLe16(file, 16); + std::fwrite("data", 4, 1, file); + writeLe32(file, dataBytes); + std::fwrite(samples.data(), sizeof(int16_t), sampleCount, file); + + const bool ok = std::fclose(file) == 0; + return ok; +} + +struct LevelStats { + int32_t peak = 0; + double rms = 0.0; + double peakDbfs = -200.0; + double rmsDbfs = -200.0; +}; + +LevelStats levelStats(const std::vector &samples, int32_t sampleCount) { + LevelStats result; + if (sampleCount <= 0) return result; + + long double sumSquares = 0.0; + for (int32_t i = 0; i < sampleCount; ++i) { + const int32_t value = samples[static_cast(i)]; + const int32_t magnitude = value == -32768 ? 32768 : std::abs(value); + result.peak = std::max(result.peak, magnitude); + const long double sample = static_cast(value); + sumSquares += sample * sample; + } + + result.rms = std::sqrt(static_cast(sumSquares / sampleCount)); + if (result.peak > 0) { + result.peakDbfs = 20.0 * std::log10(static_cast(result.peak) / 32768.0); + } + if (result.rms > 0.0) { + result.rmsDbfs = 20.0 * std::log10(result.rms / 32768.0); + } + return result; +} + +} // namespace + +AmyAndroidAudioCapture::AmyAndroidAudioCapture( + const char *socketPath, int32_t sampleRate, int32_t channels) + : mSampleRate(sampleRate), mChannels(channels) { + if (socketPath == nullptr || sampleRate <= 0 || channels <= 0) return; + + std::string path(socketPath); + const size_t slash = path.find_last_of('/'); + if (slash == std::string::npos) return; + mDirectory = path.substr(0, slash); + + const std::string marker = joinPath(mDirectory, kEnableMarker); + if (access(marker.c_str(), F_OK) != 0) return; + + // The marker is one-shot. The hello-world app recreates it for each clean + // launch; ordinary users of the AAR never pay the capture cost. + unlink(marker.c_str()); + unlink(joinPath(mDirectory, kAmyWave).c_str()); + unlink(joinPath(mDirectory, kOboeWave).c_str()); + unlink(joinPath(mDirectory, kStatsFile).c_str()); + + mTargetFrames = sampleRate * kCaptureSeconds; + const size_t sampleCount = static_cast(mTargetFrames) * channels; + try { + mAmySamples.resize(sampleCount); + mOboeSamples.resize(sampleCount); + } catch (...) { + LOGE("Unable to allocate Android audio capture buffers"); + mAmySamples.clear(); + mOboeSamples.clear(); + return; + } + + mEnabled = true; + mWriter = std::thread(&AmyAndroidAudioCapture::writerLoop, this); + LOGI("Audio capture armed: %d frames, %d Hz, %d channels", + mTargetFrames, mSampleRate, mChannels); +} + +AmyAndroidAudioCapture::~AmyAndroidAudioCapture() { + stop(); +} + +void AmyAndroidAudioCapture::beginCallback(int32_t numFrames) { + if (!mEnabled || mWriterReady.load(std::memory_order_acquire) || numFrames <= 0) { + mCallbackFrames = 0; + return; + } + + const int32_t remaining = mTargetFrames - mFramesCaptured; + mCallbackStartFrame = mFramesCaptured; + mCallbackFrames = std::min(numFrames, std::max(remaining, 0)); +} + +void AmyAndroidAudioCapture::captureAmyChunk( + const int16_t *samples, int32_t frames, int32_t outputFrame) { + if (!mEnabled || samples == nullptr || frames <= 0 || mCallbackFrames <= 0) return; + if (outputFrame < 0 || outputFrame >= mCallbackFrames) return; + + const int32_t copyFrames = std::min(frames, mCallbackFrames - outputFrame); + const size_t destinationSample = + static_cast(mCallbackStartFrame + outputFrame) * mChannels; + const size_t sampleCount = static_cast(copyFrames) * mChannels; + std::memcpy(mAmySamples.data() + destinationSample, + samples, + sampleCount * sizeof(int16_t)); +} + +void AmyAndroidAudioCapture::finishCallback( + const int16_t *oboeOutput, int32_t numFrames) { + if (!mEnabled || oboeOutput == nullptr || numFrames <= 0 || mCallbackFrames <= 0) return; + + const int32_t copyFrames = std::min(numFrames, mCallbackFrames); + const size_t destinationSample = static_cast(mCallbackStartFrame) * mChannels; + const size_t sampleCount = static_cast(copyFrames) * mChannels; + std::memcpy(mOboeSamples.data() + destinationSample, + oboeOutput, + sampleCount * sizeof(int16_t)); + + mFramesCaptured += copyFrames; + mCallbackFrames = 0; + + if (mFramesCaptured >= mTargetFrames) { + mWriterReady.store(true, std::memory_order_release); + mWriterCv.notify_one(); + } +} + +void AmyAndroidAudioCapture::stop() { + if (!mEnabled || mStopped) return; + mStopped = true; + + { + std::lock_guard lock(mWriterMutex); + if (mFramesCaptured > 0) { + mWriterReady.store(true, std::memory_order_release); + } + mWriterStop = true; + } + mWriterCv.notify_one(); + if (mWriter.joinable()) mWriter.join(); +} + +void AmyAndroidAudioCapture::writerLoop() { + std::unique_lock lock(mWriterMutex); + mWriterCv.wait(lock, [this] { + return mWriterReady.load(std::memory_order_acquire) || mWriterStop; + }); + const bool shouldWrite = + mWriterReady.load(std::memory_order_acquire) && mFramesCaptured > 0; + lock.unlock(); + + if (shouldWrite) writeCaptureFiles(); +} + +void AmyAndroidAudioCapture::writeCaptureFiles() { + const int32_t frames = std::min(mFramesCaptured, mTargetFrames); + const int32_t sampleCount = frames * mChannels; + if (frames <= 0 || sampleCount <= 0) return; + + const std::string amyPath = joinPath(mDirectory, kAmyWave); + const std::string oboePath = joinPath(mDirectory, kOboeWave); + const std::string statsPath = joinPath(mDirectory, kStatsFile); + + const bool amyOk = writeWave(amyPath, mAmySamples, frames, mSampleRate, mChannels); + const bool oboeOk = writeWave(oboePath, mOboeSamples, frames, mSampleRate, mChannels); + + const LevelStats amy = levelStats(mAmySamples, sampleCount); + const LevelStats oboe = levelStats(mOboeSamples, sampleCount); + + int32_t maxAbsDiff = 0; + int32_t mismatchSamples = 0; + for (int32_t i = 0; i < sampleCount; ++i) { + const int32_t a = mAmySamples[static_cast(i)]; + const int32_t b = mOboeSamples[static_cast(i)]; + const int32_t difference = std::abs(a - b); + maxAbsDiff = std::max(maxAbsDiff, difference); + if (difference != 0) ++mismatchSamples; + } + + FILE *stats = std::fopen(statsPath.c_str(), "w"); + if (stats != nullptr) { + std::fprintf(stats, "sample_rate=%d\n", mSampleRate); + std::fprintf(stats, "channels=%d\n", mChannels); + std::fprintf(stats, "frames=%d\n", frames); + std::fprintf(stats, "amy_peak=%d\n", amy.peak); + std::fprintf(stats, "amy_peak_dbfs=%.3f\n", amy.peakDbfs); + std::fprintf(stats, "amy_rms=%.3f\n", amy.rms); + std::fprintf(stats, "amy_rms_dbfs=%.3f\n", amy.rmsDbfs); + std::fprintf(stats, "oboe_peak=%d\n", oboe.peak); + std::fprintf(stats, "oboe_peak_dbfs=%.3f\n", oboe.peakDbfs); + std::fprintf(stats, "oboe_rms=%.3f\n", oboe.rms); + std::fprintf(stats, "oboe_rms_dbfs=%.3f\n", oboe.rmsDbfs); + std::fprintf(stats, "max_abs_diff=%d\n", maxAbsDiff); + std::fprintf(stats, "mismatch_samples=%d\n", mismatchSamples); + std::fclose(stats); + } + + if (!amyOk || !oboeOk || stats == nullptr) { + LOGE("Audio capture write failed: amy=%d oboe=%d stats=%d", + amyOk, oboeOk, stats != nullptr); + return; + } + + LOGI("Audio capture complete: frames=%d AMY peak=%d (%.2f dBFS) RMS=%.1f (%.2f dBFS); Oboe peak=%d (%.2f dBFS) RMS=%.1f (%.2f dBFS); mismatches=%d maxdiff=%d", + frames, + amy.peak, amy.peakDbfs, amy.rms, amy.rmsDbfs, + oboe.peak, oboe.peakDbfs, oboe.rms, oboe.rmsDbfs, + mismatchSamples, maxAbsDiff); +} diff --git a/android/amy-service/src/main/cpp/amy_android_capture.h b/android/amy-service/src/main/cpp/amy_android_capture.h new file mode 100644 index 00000000..e77f00c0 --- /dev/null +++ b/android/amy-service/src/main/cpp/amy_android_capture.h @@ -0,0 +1,50 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +class AmyAndroidAudioCapture { +public: + AmyAndroidAudioCapture(const char *socketPath, int32_t sampleRate, int32_t channels); + ~AmyAndroidAudioCapture(); + + bool enabled() const { return mEnabled; } + + // Called only from Oboe's realtime callback. These methods allocate no + // memory, perform no file I/O, and never take the writer mutex. + void beginCallback(int32_t numFrames); + void captureAmyChunk(const int16_t *samples, int32_t frames, int32_t outputFrame); + void finishCallback(const int16_t *oboeOutput, int32_t numFrames); + + // Called after the Oboe stream has stopped. A partial capture is still + // written, which makes diagnostics useful even on early shutdown/error. + void stop(); + +private: + void writerLoop(); + void writeCaptureFiles(); + + bool mEnabled = false; + bool mStopped = false; + int32_t mSampleRate = 0; + int32_t mChannels = 0; + int32_t mTargetFrames = 0; + int32_t mFramesCaptured = 0; + int32_t mCallbackStartFrame = 0; + int32_t mCallbackFrames = 0; + + std::string mDirectory; + std::vector mAmySamples; + std::vector mOboeSamples; + + std::mutex mWriterMutex; + std::condition_variable mWriterCv; + std::atomic mWriterReady{false}; + bool mWriterStop = false; + std::thread mWriter; +}; diff --git a/android/hello-world/README.md b/android/hello-world/README.md index ee44a05a..491abfe2 100644 --- a/android/hello-world/README.md +++ b/android/hello-world/README.md @@ -6,7 +6,7 @@ On launch it: 1. starts `org.amy.audio.AmyService` from the `amy-service` AAR/module; 2. retries a connection to the app-private `/amy.sock` Unix-domain `SOCK_SEQPACKET` socket until the AMY/Oboe service publishes its ready socket; -3. configures raw oscillator 0 as a sine wave and sets AMY global output gain to `V2.0`; +3. configures raw oscillator 0 as a sine wave and sets AMY global output gain to `V10.0`; 4. waits 30 ms so that setup is committed on a fresh AMY instance before the first note-on; 5. sends AMY wire commands for C4, D4, E4, F4, G4, A4, B4, C5; 6. shows `C scale complete` when all packets have been sent. @@ -20,10 +20,10 @@ The generic AMY Android service also logs Oboe's actual output device ID and res Setup: ```text -v0w0V2.0Z +v0w0V10.0Z ``` -`V` is AMY's global output gain. It is intentionally set above unity in this audible hello-world test; it is not an oscillator-local amplitude control. +`V` is AMY's bus/master output-volume control, not an oscillator-local amplitude control. AMY's final mixer scales this 0..10 control by 0.1, so `V10.0` selects full master gain for this audible hello-world test. `V2.0`, used by an earlier version of this example, was only 20% linear master gain (about -14 dB relative to `V10.0`). Notes use MIDI note numbers and velocity, e.g. middle C: @@ -48,4 +48,4 @@ APK: hello-world/build/outputs/apk/debug/hello-world-debug.apk ``` -The CI Android emulator smoke test builds the AAR/APK and performs two clean install/launch cycles. Each cycle must show exactly one AMY/Oboe startup, an output-route diagnostic, exactly one completed C scale, all eight note-on packets, and no socket failure. +The CI Android emulator smoke test builds the AAR/APK and performs two clean install/launch cycles. Each cycle must show exactly one AMY/Oboe startup, an output-route diagnostic, exactly one completed C scale, all eight note-on packets, and no socket failure. The Android audio-level regression also captures the raw AMY signed-16-bit render stream and the exact signed-16-bit callback buffer handed to Oboe, verifies that they are sample-for-sample identical, and checks their measured peak/RMS level. diff --git a/android/hello-world/src/main/cpp/amy_hello_client.cpp b/android/hello-world/src/main/cpp/amy_hello_client.cpp index df83a9e6..8a4ab011 100644 --- a/android/hello-world/src/main/cpp/amy_hello_client.cpp +++ b/android/hello-world/src/main/cpp/amy_hello_client.cpp @@ -54,10 +54,11 @@ int play_c_scale(const char *path) { int fd = connect_with_retry(path); if (fd < 0) return fd; - // Raw oscillator 0, sine wave. V is AMY's global output gain, not an - // oscillator-local amplitude; use 2.0 here so the hello-world is easy to hear. + // Raw oscillator 0, sine wave. AMY's V control is a 0..10 bus/master + // volume scale; the final mixer multiplies V by 0.1. Use V10.0 so this + // audible hello-world exercises the full AMY output level. // Every packet is an ordinary AMY wire command sent through amy.sock. - int rc = send_wire(fd, "v0w0V2.0Z"); + int rc = send_wire(fd, "v0w0V10.0Z"); if (rc < 0) { close(fd); return rc; diff --git a/android/hello-world/src/main/java/org/amy/hello/MainActivity.java b/android/hello-world/src/main/java/org/amy/hello/MainActivity.java index e1b5eb96..044d530f 100644 --- a/android/hello-world/src/main/java/org/amy/hello/MainActivity.java +++ b/android/hello-world/src/main/java/org/amy/hello/MainActivity.java @@ -2,6 +2,7 @@ import android.app.Activity; import android.os.Bundle; +import android.util.Log; import android.view.Gravity; import android.view.ViewGroup; import android.widget.Button; @@ -11,10 +12,13 @@ import org.amy.audio.AmyService; import java.io.File; +import java.io.IOException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public final class MainActivity extends Activity { + private static final String TAG = "AmyHelloWorld"; + private static final String AUDIO_CAPTURE_MARKER = "amy-audio-capture.enable"; private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor(); private TextView status; @@ -62,6 +66,18 @@ protected void onCreate(Bundle state) { setContentView(root); + // The hello-world app is also the Android integration test client. Arm + // one diagnostic capture before starting the service. The generic AAR + // does not capture anything unless this private marker exists. + try { + File marker = new File(getFilesDir(), AUDIO_CAPTURE_MARKER); + if (!marker.createNewFile() && !marker.isFile()) { + Log.e(TAG, "Unable to arm AMY audio capture: " + marker); + } + } catch (IOException ex) { + Log.e(TAG, "Unable to arm AMY audio capture", ex); + } + AmyService.start(this); if (state == null) { playScale(); diff --git a/tests/check_android_audio_capture.py b/tests/check_android_audio_capture.py new file mode 100644 index 00000000..0e834448 --- /dev/null +++ b/tests/check_android_audio_capture.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 + +import argparse +import array +import math +import sys +import wave + + +def read_wave(path): + with wave.open(path, "rb") as wav: + channels = wav.getnchannels() + sample_width = wav.getsampwidth() + sample_rate = wav.getframerate() + frames = wav.getnframes() + raw = wav.readframes(frames) + + if sample_width != 2: + raise ValueError(f"{path}: expected 16-bit PCM, got {sample_width * 8} bits") + + samples = array.array("h") + samples.frombytes(raw) + if sys.byteorder != "little": + samples.byteswap() + return channels, sample_rate, frames, samples + + +def levels(samples): + if not samples: + return 0, 0.0, -200.0, -200.0, 0 + + peak = max(abs(int(sample)) for sample in samples) + sum_squares = sum(int(sample) * int(sample) for sample in samples) + rms = math.sqrt(sum_squares / len(samples)) + peak_dbfs = 20.0 * math.log10(peak / 32768.0) if peak else -200.0 + rms_dbfs = 20.0 * math.log10(rms / 32768.0) if rms else -200.0 + clipped = sum(sample in (-32768, 32767) for sample in samples) + return peak, rms, peak_dbfs, rms_dbfs, clipped + + +def main(): + parser = argparse.ArgumentParser( + description="Compare AMY renderer samples with the I16 buffer handed to Oboe" + ) + parser.add_argument("amy_wave") + parser.add_argument("oboe_wave") + parser.add_argument( + "--min-peak-dbfs", + type=float, + default=-6.0, + help="fail when either capture peak is below this value (default: -6 dBFS)", + ) + args = parser.parse_args() + + amy_channels, amy_rate, amy_frames, amy = read_wave(args.amy_wave) + oboe_channels, oboe_rate, oboe_frames, oboe = read_wave(args.oboe_wave) + + expected = (2, 48000) + if (amy_channels, amy_rate) != expected: + raise SystemExit( + f"AMY capture format mismatch: {amy_channels} channels @ {amy_rate} Hz" + ) + if (oboe_channels, oboe_rate) != expected: + raise SystemExit( + f"Oboe capture format mismatch: {oboe_channels} channels @ {oboe_rate} Hz" + ) + if amy_frames != oboe_frames or len(amy) != len(oboe): + raise SystemExit( + f"capture length mismatch: AMY={amy_frames} frames Oboe={oboe_frames} frames" + ) + + mismatch_samples = 0 + max_abs_diff = 0 + for source, output in zip(amy, oboe): + difference = abs(int(source) - int(output)) + if difference: + mismatch_samples += 1 + max_abs_diff = max(max_abs_diff, difference) + + amy_peak, amy_rms, amy_peak_dbfs, amy_rms_dbfs, amy_clipped = levels(amy) + oboe_peak, oboe_rms, oboe_peak_dbfs, oboe_rms_dbfs, oboe_clipped = levels(oboe) + + print(f"frames={amy_frames} channels={amy_channels} sample_rate={amy_rate}") + print( + f"AMY : peak={amy_peak:5d} {amy_peak_dbfs:7.2f} dBFS " + f"RMS={amy_rms:9.2f} {amy_rms_dbfs:7.2f} dBFS clipped={amy_clipped}" + ) + print( + f"Oboe: peak={oboe_peak:5d} {oboe_peak_dbfs:7.2f} dBFS " + f"RMS={oboe_rms:9.2f} {oboe_rms_dbfs:7.2f} dBFS clipped={oboe_clipped}" + ) + print(f"sample mismatches={mismatch_samples} max_abs_diff={max_abs_diff}") + + if mismatch_samples != 0: + raise SystemExit( + "Oboe callback buffer is not byte-for-byte identical to the AMY render stream" + ) + if amy_peak_dbfs < args.min_peak_dbfs: + raise SystemExit( + f"AMY peak {amy_peak_dbfs:.2f} dBFS is below minimum {args.min_peak_dbfs:.2f} dBFS" + ) + if oboe_peak_dbfs < args.min_peak_dbfs: + raise SystemExit( + f"Oboe peak {oboe_peak_dbfs:.2f} dBFS is below minimum {args.min_peak_dbfs:.2f} dBFS" + ) + if amy_clipped or oboe_clipped: + raise SystemExit( + f"full-scale clipping detected: AMY={amy_clipped} Oboe={oboe_clipped} samples" + ) + + +if __name__ == "__main__": + main()