Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
60 changes: 60 additions & 0 deletions pantam-drum/app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
}

android {
namespace = "com.pantam.drum"
compileSdk = 35

defaultConfig {
applicationId = "com.pantam.drum"
minSdk = 23
targetSdk = 35
versionCode = 1
versionName = "1.0"

ndk {
abiFilters += listOf("arm64-v8a", "x86_64")
}

externalNativeBuild {
cmake {
cppFlags += "-std=c++17"
arguments += "-DANDROID_STL=c++_shared"
}
}
}

buildFeatures {
prefab = true
}

externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
version = "3.22.1"
}
}

ndkVersion = "26.3.11579264"

compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}

buildTypes {
release {
isMinifyEnabled = false
}
}
}

dependencies {
implementation(libs.oboe)
implementation(libs.appcompat)
}
29 changes: 29 additions & 0 deletions pantam-drum/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />

<uses-feature android:name="android.hardware.audio.low_latency" android:required="false" />
<uses-feature android:name="android.hardware.audio.pro" android:required="false" />
<uses-feature android:name="android.hardware.touchscreen.multitouch" android:required="true" />

<application
android:allowBackup="false"
android:label="@string/app_name"
android:theme="@style/Theme.AppCompat.NoActionBar"
android:hardwareAccelerated="true">

<activity
android:name=".MainActivity"
android:exported="true"
android:configChanges="orientation|screenSize|keyboardHidden"
android:screenOrientation="portrait"
android:immersive="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>

</manifest>
115 changes: 115 additions & 0 deletions pantam-drum/app/src/main/cpp/AudioEngine.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
#include "AudioEngine.h"
#include <android/log.h>
#include <cmath>
#include <cstring>

#define TAG "PantamAudio"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__)
#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, TAG, __VA_ARGS__)

constexpr float AudioEngine::kFrequencies[kNumNotes];

bool AudioEngine::start() {
oboe::AudioStreamBuilder builder;
builder.setDirection(oboe::Direction::Output)
->setPerformanceMode(oboe::PerformanceMode::LowLatency)
->setSharingMode(oboe::SharingMode::Exclusive)
->setFormat(oboe::AudioFormat::Float)
->setChannelCount(oboe::ChannelCount::Mono)
->setDataCallback(this)
->setErrorCallback(this);
// No fixed sample rate — let Oboe use the device's native rate

oboe::Result result = builder.openStream(mStream);
if (result != oboe::Result::OK) {
LOGW("Failed to open stream: %s", oboe::convertToText(result));
return false;
}

mSampleRate = mStream->getSampleRate();
LOGI("Stream opened: sampleRate=%d, sharingMode=%s, framesPerBurst=%d",
mSampleRate,
mStream->getSharingMode() == oboe::SharingMode::Exclusive ? "Exclusive" : "Shared",
mStream->getFramesPerBurst());

// 4 bursts: still very low latency (~8ms) but resilient to brief CPU spikes
// when multiple notes trigger simultaneously alongside a UI redraw
mStream->setBufferSizeInFrames(mStream->getFramesPerBurst() * 4);

result = mStream->start();
if (result != oboe::Result::OK) {
LOGW("Failed to start stream: %s", oboe::convertToText(result));
return false;
}
return true;
}

void AudioEngine::stop() {
if (mStream) {
mStream->stop();
mStream->close();
mStream.reset();
}
}

void AudioEngine::triggerNote(int noteIndex, float velocity) {
if (noteIndex < 0 || noteIndex >= kNumNotes) return;
mPending[noteIndex].velocity.store(velocity, std::memory_order_relaxed);
mPending[noteIndex].hasTrigger.store(true, std::memory_order_release);
}

void AudioEngine::releaseNote(int noteIndex, float holdDurationSecs) {
if (noteIndex < 0 || noteIndex >= kNumNotes) return;
// Map hold duration → release time: 1.0s base, up to 4.0s for long holds
float releaseTime = 1.0f + holdDurationSecs * 0.6f;
if (releaseTime > 4.0f) releaseTime = 4.0f;
mPending[noteIndex].releaseTime.store(releaseTime, std::memory_order_relaxed);
mPending[noteIndex].hasRelease.store(true, std::memory_order_release);
}

oboe::DataCallbackResult AudioEngine::onAudioReady(
oboe::AudioStream* /*stream*/,
void* audioData,
int32_t numFrames) {

float* output = static_cast<float*>(audioData);
std::memset(output, 0, sizeof(float) * numFrames);

consumePendingNotes();

for (int i = 0; i < kNumNotes; ++i) {
mVoices[i].render(output, numFrames);
}

mixAndClip(output, numFrames);
return oboe::DataCallbackResult::Continue;
}

void AudioEngine::consumePendingNotes() {
for (int i = 0; i < kNumNotes; ++i) {
if (mPending[i].hasTrigger.load(std::memory_order_acquire)) {
mPending[i].hasTrigger.store(false, std::memory_order_relaxed);
float vel = mPending[i].velocity.load(std::memory_order_relaxed);
mVoices[i].trigger(kFrequencies[i], vel, mSampleRate);
}
if (mPending[i].hasRelease.load(std::memory_order_acquire)) {
mPending[i].hasRelease.store(false, std::memory_order_relaxed);
float rt = mPending[i].releaseTime.load(std::memory_order_relaxed);
mVoices[i].release(rt);
}
}
}

void AudioEngine::mixAndClip(float* buf, int numFrames) {
// Hard clamp — amplitude smoothing in SynthVoice keeps levels well below ±1.0,
// so this is just a safety net and avoids the cost of tanh() per sample
for (int i = 0; i < numFrames; ++i) {
if (buf[i] > 1.0f) buf[i] = 1.0f;
else if (buf[i] < -1.0f) buf[i] = -1.0f;
}
}

void AudioEngine::onErrorAfterClose(oboe::AudioStream* /*stream*/, oboe::Result result) {
LOGW("Stream error: %s — restarting", oboe::convertToText(result));
start();
}
53 changes: 53 additions & 0 deletions pantam-drum/app/src/main/cpp/AudioEngine.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
#pragma once

#include <oboe/Oboe.h>
#include "SynthVoice.h"
#include <array>
#include <atomic>

static constexpr int kNumNotes = 9;

class AudioEngine
: public oboe::AudioStreamDataCallback
, public oboe::AudioStreamErrorCallback {
public:
bool start();
void stop();

// Called from JNI thread — safe via atomics
void triggerNote(int noteIndex, float velocity);
void releaseNote(int noteIndex, float holdDurationSecs);

// Oboe callbacks — audio thread
oboe::DataCallbackResult onAudioReady(
oboe::AudioStream* stream,
void* audioData,
int32_t numFrames) override;

void onErrorAfterClose(
oboe::AudioStream* stream,
oboe::Result result) override;

private:
std::shared_ptr<oboe::AudioStream> mStream;
std::array<SynthVoice, kNumNotes> mVoices;
int32_t mSampleRate = 48000;

// Note frequencies: E3, B3, C#4, D#4, E4, F#4, G#4, A4, B4
static constexpr float kFrequencies[kNumNotes] = {
164.81f, 246.94f, 277.18f, 311.13f,
329.63f, 369.99f, 415.30f, 440.00f, 493.88f
};

// Lock-free pending trigger/release — JNI thread writes, audio thread reads
struct PendingNote {
std::atomic<bool> hasTrigger{false};
std::atomic<float> velocity{1.0f};
std::atomic<bool> hasRelease{false};
std::atomic<float> releaseTime{1.0f};
};
std::array<PendingNote, kNumNotes> mPending;

void consumePendingNotes();
void mixAndClip(float* buf, int numFrames);
};
17 changes: 17 additions & 0 deletions pantam-drum/app/src/main/cpp/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
cmake_minimum_required(VERSION 3.22.1)
project(pantam_drum)

find_package(oboe REQUIRED CONFIG)

add_library(pantam_drum SHARED
SynthVoice.cpp
AudioEngine.cpp
PantamJNI.cpp)

target_compile_features(pantam_drum PRIVATE cxx_std_17)

target_link_libraries(pantam_drum
oboe::oboe
android
log
atomic)
32 changes: 32 additions & 0 deletions pantam-drum/app/src/main/cpp/PantamJNI.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#include <jni.h>
#include "AudioEngine.h"

static AudioEngine gEngine;

extern "C" {

JNIEXPORT void JNICALL
Java_com_pantam_drum_PantamView_nativeStartEngine(JNIEnv* /*env*/, jobject /*thiz*/) {
gEngine.start();
}

JNIEXPORT void JNICALL
Java_com_pantam_drum_PantamView_nativeTriggerNote(
JNIEnv* /*env*/, jobject /*thiz*/,
jint noteIndex, jfloat velocity) {
gEngine.triggerNote(static_cast<int>(noteIndex), static_cast<float>(velocity));
}

JNIEXPORT void JNICALL
Java_com_pantam_drum_PantamView_nativeReleaseNote(
JNIEnv* /*env*/, jobject /*thiz*/,
jint noteIndex, jfloat holdDurationSecs) {
gEngine.releaseNote(static_cast<int>(noteIndex), static_cast<float>(holdDurationSecs));
}

JNIEXPORT void JNICALL
Java_com_pantam_drum_PantamView_nativeStopEngine(JNIEnv* /*env*/, jobject /*thiz*/) {
gEngine.stop();
}

} // extern "C"
Loading