From 3fd5025e8726bcf467351e911201ed70d606658d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 11 May 2026 14:34:52 +0000 Subject: [PATCH 01/10] Add pantam drum Android app with Oboe low-latency audio Full Android project under pantam-drum/: - C++ audio engine using Oboe (LowLatency + Exclusive mode) with lock-free atomic pending-trigger design so the audio callback is never blocked by the UI thread - SynthVoice: 3-partial handpan synthesis (fundamental, 2x octave, 3.5x inharmonic) with ADSR envelope; attack 5ms, decay 300ms - Touch pressure (MotionEvent.getPressure) drives note velocity; hold duration extends release time from 1s up to 4s - PantamView: 9 circular tap zones (1 Ding center + 8-note ring), dark steel visual style with radial gradients and active highlights - 9 notes in E major / Fb scale: E3 164.81 Hz through B4 493.88 Hz - Multi-touch support; stream auto-restarts on Bluetooth disconnect https://claude.ai/code/session_01H8bbxnAJEE71XduJ7QAPgu --- pantam-drum/app/build.gradle.kts | 60 +++++ pantam-drum/app/src/main/AndroidManifest.xml | 29 +++ pantam-drum/app/src/main/cpp/AudioEngine.cpp | 112 +++++++++ pantam-drum/app/src/main/cpp/AudioEngine.h | 53 ++++ pantam-drum/app/src/main/cpp/CMakeLists.txt | 17 ++ pantam-drum/app/src/main/cpp/PantamJNI.cpp | 32 +++ pantam-drum/app/src/main/cpp/SynthVoice.cpp | 107 ++++++++ pantam-drum/app/src/main/cpp/SynthVoice.h | 33 +++ .../kotlin/com/pantam/drum/MainActivity.kt | 27 +++ .../main/kotlin/com/pantam/drum/PantamView.kt | 228 ++++++++++++++++++ .../app/src/main/res/layout/activity_main.xml | 12 + .../app/src/main/res/values/strings.xml | 4 + pantam-drum/build.gradle.kts | 4 + pantam-drum/gradle.properties | 5 + pantam-drum/gradle/libs.versions.toml | 16 ++ .../gradle/wrapper/gradle-wrapper.properties | 7 + pantam-drum/settings.gradle.kts | 17 ++ 17 files changed, 763 insertions(+) create mode 100644 pantam-drum/app/build.gradle.kts create mode 100644 pantam-drum/app/src/main/AndroidManifest.xml create mode 100644 pantam-drum/app/src/main/cpp/AudioEngine.cpp create mode 100644 pantam-drum/app/src/main/cpp/AudioEngine.h create mode 100644 pantam-drum/app/src/main/cpp/CMakeLists.txt create mode 100644 pantam-drum/app/src/main/cpp/PantamJNI.cpp create mode 100644 pantam-drum/app/src/main/cpp/SynthVoice.cpp create mode 100644 pantam-drum/app/src/main/cpp/SynthVoice.h create mode 100644 pantam-drum/app/src/main/kotlin/com/pantam/drum/MainActivity.kt create mode 100644 pantam-drum/app/src/main/kotlin/com/pantam/drum/PantamView.kt create mode 100644 pantam-drum/app/src/main/res/layout/activity_main.xml create mode 100644 pantam-drum/app/src/main/res/values/strings.xml create mode 100644 pantam-drum/build.gradle.kts create mode 100644 pantam-drum/gradle.properties create mode 100644 pantam-drum/gradle/libs.versions.toml create mode 100644 pantam-drum/gradle/wrapper/gradle-wrapper.properties create mode 100644 pantam-drum/settings.gradle.kts diff --git a/pantam-drum/app/build.gradle.kts b/pantam-drum/app/build.gradle.kts new file mode 100644 index 0000000..e691a7a --- /dev/null +++ b/pantam-drum/app/build.gradle.kts @@ -0,0 +1,60 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "com.pantam.drum" + compileSdk = 34 + + defaultConfig { + applicationId = "com.pantam.drum" + minSdk = 23 + targetSdk = 34 + 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 = "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) +} diff --git a/pantam-drum/app/src/main/AndroidManifest.xml b/pantam-drum/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..57f797e --- /dev/null +++ b/pantam-drum/app/src/main/AndroidManifest.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/pantam-drum/app/src/main/cpp/AudioEngine.cpp b/pantam-drum/app/src/main/cpp/AudioEngine.cpp new file mode 100644 index 0000000..00d8227 --- /dev/null +++ b/pantam-drum/app/src/main/cpp/AudioEngine.cpp @@ -0,0 +1,112 @@ +#include "AudioEngine.h" +#include +#include +#include + +#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()); + + // Double-buffering for minimum stable latency + mStream->setBufferSizeInFrames(mStream->getFramesPerBurst() * 2); + + 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(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) { + // Soft clip with tanh to prevent harsh digital clipping + for (int i = 0; i < numFrames; ++i) { + buf[i] = std::tanh(buf[i]); + } +} + +void AudioEngine::onErrorAfterClose(oboe::AudioStream* /*stream*/, oboe::Result result) { + LOGW("Stream error: %s — restarting", oboe::convertToText(result)); + start(); +} diff --git a/pantam-drum/app/src/main/cpp/AudioEngine.h b/pantam-drum/app/src/main/cpp/AudioEngine.h new file mode 100644 index 0000000..faeec9b --- /dev/null +++ b/pantam-drum/app/src/main/cpp/AudioEngine.h @@ -0,0 +1,53 @@ +#pragma once + +#include +#include "SynthVoice.h" +#include +#include + +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 mStream; + std::array 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 hasTrigger{false}; + std::atomic velocity{1.0f}; + std::atomic hasRelease{false}; + std::atomic releaseTime{1.0f}; + }; + std::array mPending; + + void consumePendingNotes(); + void mixAndClip(float* buf, int numFrames); +}; diff --git a/pantam-drum/app/src/main/cpp/CMakeLists.txt b/pantam-drum/app/src/main/cpp/CMakeLists.txt new file mode 100644 index 0000000..bf9851f --- /dev/null +++ b/pantam-drum/app/src/main/cpp/CMakeLists.txt @@ -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) diff --git a/pantam-drum/app/src/main/cpp/PantamJNI.cpp b/pantam-drum/app/src/main/cpp/PantamJNI.cpp new file mode 100644 index 0000000..0dd86ac --- /dev/null +++ b/pantam-drum/app/src/main/cpp/PantamJNI.cpp @@ -0,0 +1,32 @@ +#include +#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(noteIndex), static_cast(velocity)); +} + +JNIEXPORT void JNICALL +Java_com_pantam_drum_PantamView_nativeReleaseNote( + JNIEnv* /*env*/, jobject /*thiz*/, + jint noteIndex, jfloat holdDurationSecs) { + gEngine.releaseNote(static_cast(noteIndex), static_cast(holdDurationSecs)); +} + +JNIEXPORT void JNICALL +Java_com_pantam_drum_PantamView_nativeStopEngine(JNIEnv* /*env*/, jobject /*thiz*/) { + gEngine.stop(); +} + +} // extern "C" diff --git a/pantam-drum/app/src/main/cpp/SynthVoice.cpp b/pantam-drum/app/src/main/cpp/SynthVoice.cpp new file mode 100644 index 0000000..fe2ff8b --- /dev/null +++ b/pantam-drum/app/src/main/cpp/SynthVoice.cpp @@ -0,0 +1,107 @@ +#include "SynthVoice.h" +#include +#include + +static constexpr double kTwoPi = 6.283185307179586; + +void SynthVoice::trigger(float frequency, float velocity, int sampleRate) { + mSampleRate = sampleRate; + mVelocity = velocity; + mStage = Stage::Attack; + mSampleCount = 0; + + mPhaseInc1 = kTwoPi * frequency / sampleRate; + mPhaseInc2 = kTwoPi * (frequency * 2.0) / sampleRate; + mPhaseInc3 = kTwoPi * (frequency * 3.5) / sampleRate; + + // Reset phases on re-trigger for clean attack + mPhase1 = 0.0; + mPhase2 = 0.0; + mPhase3 = 0.0; + + mAttackSamples = static_cast(sampleRate * 0.005f); // 5ms + mDecaySamples = static_cast(sampleRate * 0.300f); // 300ms + mSustainLevel = 0.4f; + + // Default release: 1 second + float releaseTime = 1.0f; + mReleaseCoeff = std::exp(-1.0f / (sampleRate * releaseTime)); +} + +void SynthVoice::release(float releaseTimeSecs) { + if (mStage == Stage::Idle) return; + mStage = Stage::Release; + mSampleCount = 0; + releaseTimeSecs = std::max(0.01f, releaseTimeSecs); + mReleaseCoeff = std::exp(-1.0f / (mSampleRate * releaseTimeSecs)); +} + +void SynthVoice::render(float* outputBuffer, int numFrames) { + if (mStage == Stage::Idle) return; + + for (int i = 0; i < numFrames; ++i) { + // Advance ADSR envelope + switch (mStage) { + case Stage::Attack: { + if (mAttackSamples <= 0) { + mEnvelope = 1.0f; + mStage = Stage::Decay; + mSampleCount = 0; + } else { + mEnvelope += 1.0f / mAttackSamples; + if (++mSampleCount >= mAttackSamples) { + mEnvelope = 1.0f; + mStage = Stage::Decay; + mSampleCount = 0; + } + } + break; + } + case Stage::Decay: { + float decayCoeff = std::exp(-1.0f / mDecaySamples); + mEnvelope = mSustainLevel + (mEnvelope - mSustainLevel) * decayCoeff; + if (++mSampleCount >= mDecaySamples) { + mEnvelope = mSustainLevel; + mStage = Stage::Sustain; + mSampleCount = 0; + } + break; + } + case Stage::Sustain: + // Held at sustain level; transitions to Release via release() + break; + case Stage::Release: + mEnvelope *= mReleaseCoeff; + if (mEnvelope < 0.0001f) { + mEnvelope = 0.0f; + mStage = Stage::Idle; + return; + } + break; + case Stage::Idle: + return; + } + + // Synthesize handpan timbre: fundamental + octave + 3.5x inharmonic partial + float sample = static_cast( + std::sin(mPhase1) * 0.70 + + std::sin(mPhase2) * 0.25 + + std::sin(mPhase3) * 0.12 + ); + + outputBuffer[i] += sample * mEnvelope * mVelocity; + + mPhase1 += mPhaseInc1; + mPhase2 += mPhaseInc2; + mPhase3 += mPhaseInc3; + + // Wrap phases to avoid precision loss over time + if (mPhase1 >= kTwoPi) mPhase1 -= kTwoPi; + if (mPhase2 >= kTwoPi) mPhase2 -= kTwoPi; + if (mPhase3 >= kTwoPi) mPhase3 -= kTwoPi; + } +} + +bool SynthVoice::isActive() const { + return mStage != Stage::Idle; +} diff --git a/pantam-drum/app/src/main/cpp/SynthVoice.h b/pantam-drum/app/src/main/cpp/SynthVoice.h new file mode 100644 index 0000000..6a09694 --- /dev/null +++ b/pantam-drum/app/src/main/cpp/SynthVoice.h @@ -0,0 +1,33 @@ +#pragma once + +class SynthVoice { +public: + void trigger(float frequency, float velocity, int sampleRate); + void release(float releaseTimeSecs); + void render(float* outputBuffer, int numFrames); + bool isActive() const; + +private: + enum class Stage { Idle, Attack, Decay, Sustain, Release }; + + Stage mStage = Stage::Idle; + int mSampleRate = 48000; + float mVelocity = 1.0f; + float mEnvelope = 0.0f; + + // Three independent phase accumulators for handpan partials + double mPhase1 = 0.0; + double mPhase2 = 0.0; + double mPhase3 = 0.0; + double mPhaseInc1 = 0.0; // fundamental + double mPhaseInc2 = 0.0; // 2x (octave) + double mPhaseInc3 = 0.0; // 3.5x (inharmonic partial — handpan character) + + // ADSR in samples + int mAttackSamples = 0; + int mDecaySamples = 0; + float mSustainLevel = 0.4f; + float mReleaseCoeff = 0.0f; // exponential decay coefficient per sample + + int mSampleCount = 0; // counter within current stage +}; diff --git a/pantam-drum/app/src/main/kotlin/com/pantam/drum/MainActivity.kt b/pantam-drum/app/src/main/kotlin/com/pantam/drum/MainActivity.kt new file mode 100644 index 0000000..d1ecb09 --- /dev/null +++ b/pantam-drum/app/src/main/kotlin/com/pantam/drum/MainActivity.kt @@ -0,0 +1,27 @@ +package com.pantam.drum + +import android.os.Bundle +import android.view.WindowManager +import androidx.appcompat.app.AppCompatActivity + +class MainActivity : AppCompatActivity() { + + private lateinit var pantamView: PantamView + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + setContentView(R.layout.activity_main) + pantamView = findViewById(R.id.pantamView) + } + + override fun onStart() { + super.onStart() + pantamView.startEngine() + } + + override fun onStop() { + super.onStop() + pantamView.stopEngine() + } +} diff --git a/pantam-drum/app/src/main/kotlin/com/pantam/drum/PantamView.kt b/pantam-drum/app/src/main/kotlin/com/pantam/drum/PantamView.kt new file mode 100644 index 0000000..13299a9 --- /dev/null +++ b/pantam-drum/app/src/main/kotlin/com/pantam/drum/PantamView.kt @@ -0,0 +1,228 @@ +package com.pantam.drum + +import android.content.Context +import android.graphics.* +import android.os.SystemClock +import android.util.AttributeSet +import android.util.SparseArray +import android.view.MotionEvent +import android.view.View +import kotlin.math.* + +class PantamView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : View(context, attrs) { + + // ── JNI ────────────────────────────────────────────────────────────────── + + companion object { + init { System.loadLibrary("pantam_drum") } + + private val NOTE_NAMES = arrayOf( + "Ding", "B3", "C#4", "D#4", "E4", "F#4", "G#4", "A4", "B4" + ) + // Outer-ring zones: angle in degrees from top (0° = 12 o'clock), clockwise + // Arranged ascending from top-left going clockwise: B3, B4 at top, ascending around + // Layout: top=B4, top-right=C#4, right=D#4, bottom-right=E4, + // bottom=F#4, bottom-left=G#4, left=A4, top-left=B3 + private val RING_ANGLES_DEG = floatArrayOf( + 315f, // index 1 → B3 (top-left) + 45f, // index 2 → C#4 (top-right) + 90f, // index 3 → D#4 (right) + 135f, // index 4 → E4 (bottom-right) + 180f, // index 5 → F#4 (bottom) + 225f, // index 6 → G#4 (bottom-left) + 270f, // index 7 → A4 (left) + 0f // index 8 → B4 (top) + ) + } + + external fun nativeStartEngine() + external fun nativeTriggerNote(noteIndex: Int, velocity: Float) + external fun nativeReleaseNote(noteIndex: Int, holdDurationSecs: Float) + external fun nativeStopEngine() + + fun startEngine() = nativeStartEngine() + fun stopEngine() = nativeStopEngine() + + // ── Zone geometry ───────────────────────────────────────────────────────── + + private data class Zone( + val cx: Float, + val cy: Float, + val radius: Float, + val noteIndex: Int + ) + + private val zones = ArrayList(9) + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + buildZones(w, h) + buildPaints() + } + + private fun buildZones(w: Int, h: Int) { + zones.clear() + val min = minOf(w, h).toFloat() + val cx = w / 2f + val cy = h / 2f + val dingRadius = min * 0.12f + val ringRadius = min * 0.35f + val zoneRadius = min * 0.11f + + // Index 0: Ding (center) + zones.add(Zone(cx, cy, dingRadius, 0)) + + // Indices 1–8: outer ring + for (i in 0 until 8) { + val angleDeg = RING_ANGLES_DEG[i] + val angleRad = Math.toRadians(angleDeg.toDouble() - 90.0).toFloat() + val zx = cx + ringRadius * cos(angleRad) + val zy = cy + ringRadius * sin(angleRad) + zones.add(Zone(zx, zy, zoneRadius, i + 1)) + } + } + + // ── Painting ────────────────────────────────────────────────────────────── + + private val zonePaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val activeZonePaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val outerRingPaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val bgPaint = Paint() + + private val activeZoneSet = mutableSetOf() + + private fun buildPaints() { + bgPaint.color = 0xFF1A1A2E.toInt() + + borderPaint.apply { + color = 0xFF8899AA.toInt() + style = Paint.Style.STROKE + strokeWidth = 2f * resources.displayMetrics.density + } + + outerRingPaint.apply { + color = 0xFF445566.toInt() + style = Paint.Style.STROKE + strokeWidth = 3f * resources.displayMetrics.density + } + + textPaint.apply { + color = Color.WHITE + textSize = 13f * resources.displayMetrics.scaledDensity + textAlign = Paint.Align.CENTER + typeface = Typeface.DEFAULT_BOLD + } + } + + override fun onDraw(canvas: Canvas) { + val w = width.toFloat() + val h = height.toFloat() + + // Background + canvas.drawRect(0f, 0f, w, h, bgPaint) + + // Decorative outer instrument ring + val min = minOf(w, h) + canvas.drawCircle(w / 2f, h / 2f, min * 0.48f, outerRingPaint) + + // Draw zones + for (zone in zones) { + val isActive = zone.noteIndex in activeZoneSet + drawZone(canvas, zone, isActive) + } + } + + private fun drawZone(canvas: Canvas, zone: Zone, isActive: Boolean) { + val r = zone.radius + + // Radial gradient: dark outer → blue-steel center + val innerColor = if (isActive) 0xFF7FB3E8.toInt() else 0xFF4A90D9.toInt() + val outerColor = if (isActive) 0xFF3A6090.toInt() else 0xFF2C3E50.toInt() + + val gradient = RadialGradient( + zone.cx, zone.cy, r, + innerColor, outerColor, + Shader.TileMode.CLAMP + ) + zonePaint.shader = gradient + + canvas.drawCircle(zone.cx, zone.cy, r, zonePaint) + canvas.drawCircle(zone.cx, zone.cy, r, borderPaint) + + // Note label + val textY = zone.cy + textPaint.textSize * 0.38f + canvas.drawText(NOTE_NAMES[zone.noteIndex], zone.cx, textY, textPaint) + } + + // ── Touch handling ──────────────────────────────────────────────────────── + + // Maps pointer ID → Triple(noteIndex, downTimeMs, downPressure) + private val activePointers = SparseArray>() + + override fun onTouchEvent(event: MotionEvent): Boolean { + val action = event.actionMasked + val ptrIdx = event.actionIndex + val pointerId = event.getPointerId(ptrIdx) + + when (action) { + MotionEvent.ACTION_DOWN, + MotionEvent.ACTION_POINTER_DOWN -> { + val x = event.getX(ptrIdx) + val y = event.getY(ptrIdx) + val pressure = event.getPressure(ptrIdx).coerceIn(0.1f, 1.0f) + val noteIdx = hitTest(x, y) ?: return true + + activePointers.put(pointerId, Triple(noteIdx, SystemClock.uptimeMillis(), pressure)) + activeZoneSet.add(noteIdx) + nativeTriggerNote(noteIdx, pressure) + invalidate() + } + + MotionEvent.ACTION_UP, + MotionEvent.ACTION_POINTER_UP, + MotionEvent.ACTION_CANCEL -> { + val info = activePointers[pointerId] ?: return true + val (noteIdx, downTime, _) = info + val holdSecs = ((SystemClock.uptimeMillis() - downTime) / 1000f).coerceIn(0f, 5f) + + activePointers.remove(pointerId) + // Only clear the visual highlight if no other pointer is on this zone + if (activePointers.none { it.first == noteIdx }) { + activeZoneSet.remove(noteIdx) + } + nativeReleaseNote(noteIdx, holdSecs) + invalidate() + } + } + return true + } + + private fun hitTest(x: Float, y: Float): Int? { + // Check Ding (center, index 0) last so outer zones aren't blocked + var closest: Int? = null + var closestDist = Float.MAX_VALUE + for (zone in zones) { + val dx = x - zone.cx + val dy = y - zone.cy + val dist = sqrt(dx * dx + dy * dy) + if (dist < zone.radius && dist < closestDist) { + closest = zone.noteIndex + closestDist = dist + } + } + return closest + } +} + +// Inline helper: SparseArray forEach +private fun SparseArray.none(predicate: (T) -> Boolean): Boolean { + for (i in 0 until size()) { + if (predicate(valueAt(i))) return false + } + return true +} diff --git a/pantam-drum/app/src/main/res/layout/activity_main.xml b/pantam-drum/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..ea22c5d --- /dev/null +++ b/pantam-drum/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/pantam-drum/app/src/main/res/values/strings.xml b/pantam-drum/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..8cd763f --- /dev/null +++ b/pantam-drum/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Pantam Drum + diff --git a/pantam-drum/build.gradle.kts b/pantam-drum/build.gradle.kts new file mode 100644 index 0000000..7629126 --- /dev/null +++ b/pantam-drum/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.android) apply false +} diff --git a/pantam-drum/gradle.properties b/pantam-drum/gradle.properties new file mode 100644 index 0000000..26dbc3b --- /dev/null +++ b/pantam-drum/gradle.properties @@ -0,0 +1,5 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +android.enableJetifier=false +android.prefabVersion=2.0.0 +kotlin.code.style=official diff --git a/pantam-drum/gradle/libs.versions.toml b/pantam-drum/gradle/libs.versions.toml new file mode 100644 index 0000000..5db58ea --- /dev/null +++ b/pantam-drum/gradle/libs.versions.toml @@ -0,0 +1,16 @@ +[versions] +agp = "8.3.2" +kotlin = "1.9.23" +compileSdk = "34" +minSdk = "23" +targetSdk = "34" +oboe = "1.9.0" +appcompat = "1.6.1" + +[libraries] +oboe = { group = "com.google.oboe", name = "oboe", version.ref = "oboe" } +appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } diff --git a/pantam-drum/gradle/wrapper/gradle-wrapper.properties b/pantam-drum/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a80b22c --- /dev/null +++ b/pantam-drum/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/pantam-drum/settings.gradle.kts b/pantam-drum/settings.gradle.kts new file mode 100644 index 0000000..5066258 --- /dev/null +++ b/pantam-drum/settings.gradle.kts @@ -0,0 +1,17 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "pantam-drum" +include(":app") From d331926e5b00428d47a3ecb469c95e7bbb6ca715 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 21:46:41 +0000 Subject: [PATCH 02/10] Pin Gradle JVM to Java 21 via org.gradle.java.home Gradle 8.6 is incompatible with JDK 25 (Android Studio's bundled JDK). Pinning to the system Java 21 install makes the build work regardless of which IDE or shell environment invokes it. https://claude.ai/code/session_01H8bbxnAJEE71XduJ7QAPgu --- pantam-drum/gradle.properties | 1 + 1 file changed, 1 insertion(+) diff --git a/pantam-drum/gradle.properties b/pantam-drum/gradle.properties index 26dbc3b..c2a90d4 100644 --- a/pantam-drum/gradle.properties +++ b/pantam-drum/gradle.properties @@ -1,4 +1,5 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +org.gradle.java.home=/usr/lib/jvm/java-21-openjdk-amd64 android.useAndroidX=true android.enableJetifier=false android.prefabVersion=2.0.0 From 35702d442624a864ec72fa9f5e98efd49f4244f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 20 May 2026 21:06:39 +0000 Subject: [PATCH 03/10] Fix AGP version detection by using explicit plugin version in root build file Android Studio's tooling can't determine the AGP version when it's declared via a version catalog alias. Using a direct id/version string in the root build.gradle.kts makes the version parseable by all tools. https://claude.ai/code/session_01H8bbxnAJEE71XduJ7QAPgu --- pantam-drum/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pantam-drum/build.gradle.kts b/pantam-drum/build.gradle.kts index 7629126..26fad3c 100644 --- a/pantam-drum/build.gradle.kts +++ b/pantam-drum/build.gradle.kts @@ -1,4 +1,4 @@ plugins { - alias(libs.plugins.android.application) apply false - alias(libs.plugins.kotlin.android) apply false + id("com.android.application") version "8.3.2" apply false + id("org.jetbrains.kotlin.android") version "1.9.23" apply false } From 3454ef107c64e1d5baca5d9128a5009f3ab8a755 Mon Sep 17 00:00:00 2001 From: Sheix Date: Sun, 24 May 2026 10:26:51 +0300 Subject: [PATCH 04/10] fix build bugs --- pantam-drum/app/build.gradle.kts | 2 +- pantam-drum/app/src/main/cpp/AudioEngine.cpp | 12 ++++++------ pantam-drum/gradle/wrapper/gradle-wrapper.properties | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pantam-drum/app/build.gradle.kts b/pantam-drum/app/build.gradle.kts index e691a7a..ceddffb 100644 --- a/pantam-drum/app/build.gradle.kts +++ b/pantam-drum/app/build.gradle.kts @@ -32,7 +32,7 @@ android { externalNativeBuild { cmake { - path = "src/main/cpp/CMakeLists.txt" + path = file("src/main/cpp/CMakeLists.txt") version = "3.22.1" } } diff --git a/pantam-drum/app/src/main/cpp/AudioEngine.cpp b/pantam-drum/app/src/main/cpp/AudioEngine.cpp index 00d8227..a2039b9 100644 --- a/pantam-drum/app/src/main/cpp/AudioEngine.cpp +++ b/pantam-drum/app/src/main/cpp/AudioEngine.cpp @@ -12,12 +12,12 @@ 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); + ->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); diff --git a/pantam-drum/gradle/wrapper/gradle-wrapper.properties b/pantam-drum/gradle/wrapper/gradle-wrapper.properties index a80b22c..cea7a79 100644 --- a/pantam-drum/gradle/wrapper/gradle-wrapper.properties +++ b/pantam-drum/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From 0cc2ab441075ee7c1b843cb18993cf145fedcd35 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 07:28:22 +0000 Subject: [PATCH 05/10] Fix multi-note crackling: exp() in audio loop, phase clicks, amplitude, GC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three root causes of crackling when tapping multiple notes: 1. std::exp() was called once per sample inside the Decay stage render loop — at 48kHz with 4 voices decaying simultaneously that was ~192k expensive transcendental calls/sec, reliably overrunning the audio callback budget. Pre-compute mDecayCoeff in trigger() instead. 2. Resetting oscillator phases to 0 on every trigger caused a waveform discontinuity click when re-tapping an already-playing note. Now phases only reset when the voice was idle. 3. Per-voice amplitude (0.70+0.25+0.12 = 1.07 max) meant 2 simultaneous notes already exceeded 1.0 and tanh produced heavy saturation. Scaled weights down so 4 voices at full pressure sum to ~1.07. Also cache RadialGradient objects in onSizeChanged() instead of allocating 9 new ones per draw frame, eliminating GC pressure that caused occasional audio thread jitter. https://claude.ai/code/session_01H8bbxnAJEE71XduJ7QAPgu --- pantam-drum/app/src/main/cpp/SynthVoice.cpp | 34 +++++++++--------- pantam-drum/app/src/main/cpp/SynthVoice.h | 3 +- .../main/kotlin/com/pantam/drum/PantamView.kt | 35 ++++++++++++------- 3 files changed, 42 insertions(+), 30 deletions(-) diff --git a/pantam-drum/app/src/main/cpp/SynthVoice.cpp b/pantam-drum/app/src/main/cpp/SynthVoice.cpp index fe2ff8b..89ce634 100644 --- a/pantam-drum/app/src/main/cpp/SynthVoice.cpp +++ b/pantam-drum/app/src/main/cpp/SynthVoice.cpp @@ -14,18 +14,20 @@ void SynthVoice::trigger(float frequency, float velocity, int sampleRate) { mPhaseInc2 = kTwoPi * (frequency * 2.0) / sampleRate; mPhaseInc3 = kTwoPi * (frequency * 3.5) / sampleRate; - // Reset phases on re-trigger for clean attack - mPhase1 = 0.0; - mPhase2 = 0.0; - mPhase3 = 0.0; + // Only reset phases when voice was idle — avoids discontinuity click on re-trigger + if (mStage == Stage::Idle) { + mPhase1 = 0.0; + mPhase2 = 0.0; + mPhase3 = 0.0; + } - mAttackSamples = static_cast(sampleRate * 0.005f); // 5ms - mDecaySamples = static_cast(sampleRate * 0.300f); // 300ms - mSustainLevel = 0.4f; + mAttackSamples = static_cast(sampleRate * 0.005f); // 5ms + mDecaySamples = static_cast(sampleRate * 0.300f); // 300ms + mSustainLevel = 0.4f; - // Default release: 1 second - float releaseTime = 1.0f; - mReleaseCoeff = std::exp(-1.0f / (sampleRate * releaseTime)); + // Pre-compute all coefficients here — exp() must never run inside render() + mDecayCoeff = std::exp(-1.0f / mDecaySamples); + mReleaseCoeff = std::exp(-1.0f / (sampleRate * 1.0f)); } void SynthVoice::release(float releaseTimeSecs) { @@ -58,8 +60,7 @@ void SynthVoice::render(float* outputBuffer, int numFrames) { break; } case Stage::Decay: { - float decayCoeff = std::exp(-1.0f / mDecaySamples); - mEnvelope = mSustainLevel + (mEnvelope - mSustainLevel) * decayCoeff; + mEnvelope = mSustainLevel + (mEnvelope - mSustainLevel) * mDecayCoeff; if (++mSampleCount >= mDecaySamples) { mEnvelope = mSustainLevel; mStage = Stage::Sustain; @@ -82,11 +83,12 @@ void SynthVoice::render(float* outputBuffer, int numFrames) { return; } - // Synthesize handpan timbre: fundamental + octave + 3.5x inharmonic partial + // Handpan timbre: fundamental + octave + 3.5x inharmonic partial + // Weights scaled so 4 simultaneous voices at max don't clip after mixing float sample = static_cast( - std::sin(mPhase1) * 0.70 + - std::sin(mPhase2) * 0.25 + - std::sin(mPhase3) * 0.12 + std::sin(mPhase1) * 0.175 + + std::sin(mPhase2) * 0.063 + + std::sin(mPhase3) * 0.030 ); outputBuffer[i] += sample * mEnvelope * mVelocity; diff --git a/pantam-drum/app/src/main/cpp/SynthVoice.h b/pantam-drum/app/src/main/cpp/SynthVoice.h index 6a09694..76c4695 100644 --- a/pantam-drum/app/src/main/cpp/SynthVoice.h +++ b/pantam-drum/app/src/main/cpp/SynthVoice.h @@ -27,7 +27,8 @@ class SynthVoice { int mAttackSamples = 0; int mDecaySamples = 0; float mSustainLevel = 0.4f; - float mReleaseCoeff = 0.0f; // exponential decay coefficient per sample + float mDecayCoeff = 0.0f; // pre-computed; never call exp() in the audio loop + float mReleaseCoeff = 0.0f; int mSampleCount = 0; // counter within current stage }; diff --git a/pantam-drum/app/src/main/kotlin/com/pantam/drum/PantamView.kt b/pantam-drum/app/src/main/kotlin/com/pantam/drum/PantamView.kt index 13299a9..ba318c9 100644 --- a/pantam-drum/app/src/main/kotlin/com/pantam/drum/PantamView.kt +++ b/pantam-drum/app/src/main/kotlin/com/pantam/drum/PantamView.kt @@ -57,6 +57,10 @@ class PantamView @JvmOverloads constructor( private val zones = ArrayList(9) + // Cached gradients — built once in onSizeChanged, never allocated during draw + private val normalGradients = arrayOfNulls(9) + private val activeGradients = arrayOfNulls(9) + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { super.onSizeChanged(w, h, oldw, oldh) buildZones(w, h) @@ -83,6 +87,21 @@ class PantamView @JvmOverloads constructor( val zy = cy + ringRadius * sin(angleRad) zones.add(Zone(zx, zy, zoneRadius, i + 1)) } + + // Pre-build all gradients so onDraw allocates nothing + for (zone in zones) { + val ni = zone.noteIndex + normalGradients[ni] = RadialGradient( + zone.cx, zone.cy, zone.radius, + 0xFF4A90D9.toInt(), 0xFF2C3E50.toInt(), + Shader.TileMode.CLAMP + ) + activeGradients[ni] = RadialGradient( + zone.cx, zone.cy, zone.radius, + 0xFF7FB3E8.toInt(), 0xFF3A6090.toInt(), + Shader.TileMode.CLAMP + ) + } } // ── Painting ────────────────────────────────────────────────────────────── @@ -138,20 +157,10 @@ class PantamView @JvmOverloads constructor( } private fun drawZone(canvas: Canvas, zone: Zone, isActive: Boolean) { - val r = zone.radius - - // Radial gradient: dark outer → blue-steel center - val innerColor = if (isActive) 0xFF7FB3E8.toInt() else 0xFF4A90D9.toInt() - val outerColor = if (isActive) 0xFF3A6090.toInt() else 0xFF2C3E50.toInt() - - val gradient = RadialGradient( - zone.cx, zone.cy, r, - innerColor, outerColor, - Shader.TileMode.CLAMP - ) - zonePaint.shader = gradient + val ni = zone.noteIndex + zonePaint.shader = if (isActive) activeGradients[ni] else normalGradients[ni] - canvas.drawCircle(zone.cx, zone.cy, r, zonePaint) + canvas.drawCircle(zone.cx, zone.cy, zone.radius, zonePaint) canvas.drawCircle(zone.cx, zone.cy, r, borderPaint) // Note label From 021607942f26fb10e9cf72bc962151bd07e3e949 Mon Sep 17 00:00:00 2001 From: Sheix Date: Sun, 24 May 2026 10:35:23 +0300 Subject: [PATCH 06/10] fix local build --- pantam-drum/gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pantam-drum/gradle.properties b/pantam-drum/gradle.properties index c2a90d4..2756250 100644 --- a/pantam-drum/gradle.properties +++ b/pantam-drum/gradle.properties @@ -1,5 +1,5 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -org.gradle.java.home=/usr/lib/jvm/java-21-openjdk-amd64 +#org.gradle.java.home=/usr/lib/jvm/java-21-openjdk-amd64 android.useAndroidX=true android.enableJetifier=false android.prefabVersion=2.0.0 From 791b19568ad31760a7dfb7dc9137aca0e0b9819c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 07:40:27 +0000 Subject: [PATCH 07/10] =?UTF-8?q?Bump=20AGP=208.3.2=E2=86=928.7.3=20and=20?= =?UTF-8?q?Kotlin=201.9.23=E2=86=922.0.21=20for=20Gradle=208.12=20compat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGP 8.3.2 was designed for Gradle 8.4–8.6. After the Gradle wrapper was updated to 8.12, the version mismatch caused Android Studio to fail project modelling and report "Unable to determine AGP version". AGP 8.7.x officially supports Gradle 8.9+ so works correctly with 8.12. Kotlin bumped to 2.0.21 (stable with AGP 8.7). compileSdk/targetSdk raised to 35 as required by AGP 8.7. https://claude.ai/code/session_01H8bbxnAJEE71XduJ7QAPgu --- pantam-drum/app/build.gradle.kts | 4 ++-- pantam-drum/build.gradle.kts | 4 ++-- pantam-drum/gradle/libs.versions.toml | 10 +++++----- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/pantam-drum/app/build.gradle.kts b/pantam-drum/app/build.gradle.kts index ceddffb..25e719d 100644 --- a/pantam-drum/app/build.gradle.kts +++ b/pantam-drum/app/build.gradle.kts @@ -5,12 +5,12 @@ plugins { android { namespace = "com.pantam.drum" - compileSdk = 34 + compileSdk = 35 defaultConfig { applicationId = "com.pantam.drum" minSdk = 23 - targetSdk = 34 + targetSdk = 35 versionCode = 1 versionName = "1.0" diff --git a/pantam-drum/build.gradle.kts b/pantam-drum/build.gradle.kts index 26fad3c..c7ad754 100644 --- a/pantam-drum/build.gradle.kts +++ b/pantam-drum/build.gradle.kts @@ -1,4 +1,4 @@ plugins { - id("com.android.application") version "8.3.2" apply false - id("org.jetbrains.kotlin.android") version "1.9.23" apply false + id("com.android.application") version "8.7.3" apply false + id("org.jetbrains.kotlin.android") version "2.0.21" apply false } diff --git a/pantam-drum/gradle/libs.versions.toml b/pantam-drum/gradle/libs.versions.toml index 5db58ea..caa6e7a 100644 --- a/pantam-drum/gradle/libs.versions.toml +++ b/pantam-drum/gradle/libs.versions.toml @@ -1,11 +1,11 @@ [versions] -agp = "8.3.2" -kotlin = "1.9.23" -compileSdk = "34" +agp = "8.7.3" +kotlin = "2.0.21" +compileSdk = "35" minSdk = "23" -targetSdk = "34" +targetSdk = "35" oboe = "1.9.0" -appcompat = "1.6.1" +appcompat = "1.7.0" [libraries] oboe = { group = "com.google.oboe", name = "oboe", version.ref = "oboe" } From 8e7f2a3b740cca4947ea5f5423861c8dee56f8c4 Mon Sep 17 00:00:00 2001 From: Sheix Date: Sun, 24 May 2026 10:43:33 +0300 Subject: [PATCH 08/10] fix buid error --- pantam-drum/app/src/main/kotlin/com/pantam/drum/PantamView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pantam-drum/app/src/main/kotlin/com/pantam/drum/PantamView.kt b/pantam-drum/app/src/main/kotlin/com/pantam/drum/PantamView.kt index ba318c9..1d04ebd 100644 --- a/pantam-drum/app/src/main/kotlin/com/pantam/drum/PantamView.kt +++ b/pantam-drum/app/src/main/kotlin/com/pantam/drum/PantamView.kt @@ -161,7 +161,7 @@ class PantamView @JvmOverloads constructor( zonePaint.shader = if (isActive) activeGradients[ni] else normalGradients[ni] canvas.drawCircle(zone.cx, zone.cy, zone.radius, zonePaint) - canvas.drawCircle(zone.cx, zone.cy, r, borderPaint) + canvas.drawCircle(zone.cx, zone.cy, zone.radius+2, borderPaint) // Note label val textY = zone.cy + textPaint.textSize * 0.38f From 117f3bdf8ffa1f772a34d24ea9fa5ad7f43adeaa Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 07:47:04 +0000 Subject: [PATCH 09/10] Point org.gradle.java.home to Android Studio JBR on Windows The Linux path was commented out since it doesn't exist on Windows. Using the JBR bundled with Android Studio instead, which is Java 21 and compatible with Gradle 8.12 + AGP 8.7.3. https://claude.ai/code/session_01H8bbxnAJEE71XduJ7QAPgu --- pantam-drum/gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pantam-drum/gradle.properties b/pantam-drum/gradle.properties index 2756250..b70fcf1 100644 --- a/pantam-drum/gradle.properties +++ b/pantam-drum/gradle.properties @@ -1,5 +1,5 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -#org.gradle.java.home=/usr/lib/jvm/java-21-openjdk-amd64 +org.gradle.java.home=C:/Program Files/Android/Android Studio/jbr android.useAndroidX=true android.enableJetifier=false android.prefabVersion=2.0.0 From 635740eefff1ea50ecf8707290a38336ef26cfb8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 08:36:43 +0000 Subject: [PATCH 10/10] Fix remaining crack on simultaneous notes: smoothing, phase reset, buffer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three more click sources eliminated: 1. Phase reset condition was checked AFTER mStage was already changed to Attack, so the if (mStage == Stage::Idle) was always false — phases were never reset for truly fresh voices. Fixed by checking stage before modifying it, and zeroing mSmoothedAmp on fresh voice init. 2. Output amplitude (mEnvelope * mVelocity) was applied directly to audio samples with no smoothing. Any discontinuity at ADSR stage boundaries, re-triggers with different pressure, or velocity steps became audible clicks. Added a one-pole LP filter (mSmoothedAmp, coeff 0.05) that spreads any amplitude change over ~20 samples — fully inaudible. 3. Buffer size raised from 2 to 4 bursts to survive brief CPU spikes when multiple notes trigger simultaneously with a UI redraw. tanh() replaced with a hard clamp (cheaper, sufficient given controlled amplitude). Also switched invalidate() to postInvalidateOnAnimation() so simultaneous touch events don't schedule multiple redraws per vsync frame. https://claude.ai/code/session_01H8bbxnAJEE71XduJ7QAPgu --- pantam-drum/app/src/main/cpp/AudioEngine.cpp | 11 +++++++---- pantam-drum/app/src/main/cpp/SynthVoice.cpp | 15 +++++++++++---- pantam-drum/app/src/main/cpp/SynthVoice.h | 1 + .../src/main/kotlin/com/pantam/drum/PantamView.kt | 4 ++-- 4 files changed, 21 insertions(+), 10 deletions(-) diff --git a/pantam-drum/app/src/main/cpp/AudioEngine.cpp b/pantam-drum/app/src/main/cpp/AudioEngine.cpp index a2039b9..bb5fc89 100644 --- a/pantam-drum/app/src/main/cpp/AudioEngine.cpp +++ b/pantam-drum/app/src/main/cpp/AudioEngine.cpp @@ -32,8 +32,9 @@ bool AudioEngine::start() { mStream->getSharingMode() == oboe::SharingMode::Exclusive ? "Exclusive" : "Shared", mStream->getFramesPerBurst()); - // Double-buffering for minimum stable latency - mStream->setBufferSizeInFrames(mStream->getFramesPerBurst() * 2); + // 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) { @@ -100,9 +101,11 @@ void AudioEngine::consumePendingNotes() { } void AudioEngine::mixAndClip(float* buf, int numFrames) { - // Soft clip with tanh to prevent harsh digital clipping + // 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) { - buf[i] = std::tanh(buf[i]); + if (buf[i] > 1.0f) buf[i] = 1.0f; + else if (buf[i] < -1.0f) buf[i] = -1.0f; } } diff --git a/pantam-drum/app/src/main/cpp/SynthVoice.cpp b/pantam-drum/app/src/main/cpp/SynthVoice.cpp index 89ce634..ac150fe 100644 --- a/pantam-drum/app/src/main/cpp/SynthVoice.cpp +++ b/pantam-drum/app/src/main/cpp/SynthVoice.cpp @@ -7,20 +7,23 @@ static constexpr double kTwoPi = 6.283185307179586; void SynthVoice::trigger(float frequency, float velocity, int sampleRate) { mSampleRate = sampleRate; mVelocity = velocity; - mStage = Stage::Attack; mSampleCount = 0; mPhaseInc1 = kTwoPi * frequency / sampleRate; mPhaseInc2 = kTwoPi * (frequency * 2.0) / sampleRate; mPhaseInc3 = kTwoPi * (frequency * 3.5) / sampleRate; - // Only reset phases when voice was idle — avoids discontinuity click on re-trigger + // Check stage BEFORE changing it — reset phases only for truly silent voices + // so re-triggers of a ringing note stay phase-continuous (no click) if (mStage == Stage::Idle) { mPhase1 = 0.0; mPhase2 = 0.0; mPhase3 = 0.0; + mSmoothedAmp = 0.0f; // fresh voice: amplitude smoother starts from silence } + mStage = Stage::Attack; + mAttackSamples = static_cast(sampleRate * 0.005f); // 5ms mDecaySamples = static_cast(sampleRate * 0.300f); // 300ms mSustainLevel = 0.4f; @@ -83,15 +86,19 @@ void SynthVoice::render(float* outputBuffer, int numFrames) { return; } + // Smooth the amplitude with a one-pole LP filter so any envelope or + // velocity discontinuity is spread over ~20 samples — inaudible as a click + float targetAmp = mEnvelope * mVelocity; + mSmoothedAmp += (targetAmp - mSmoothedAmp) * 0.05f; + // Handpan timbre: fundamental + octave + 3.5x inharmonic partial - // Weights scaled so 4 simultaneous voices at max don't clip after mixing float sample = static_cast( std::sin(mPhase1) * 0.175 + std::sin(mPhase2) * 0.063 + std::sin(mPhase3) * 0.030 ); - outputBuffer[i] += sample * mEnvelope * mVelocity; + outputBuffer[i] += sample * mSmoothedAmp; mPhase1 += mPhaseInc1; mPhase2 += mPhaseInc2; diff --git a/pantam-drum/app/src/main/cpp/SynthVoice.h b/pantam-drum/app/src/main/cpp/SynthVoice.h index 76c4695..ef97b79 100644 --- a/pantam-drum/app/src/main/cpp/SynthVoice.h +++ b/pantam-drum/app/src/main/cpp/SynthVoice.h @@ -31,4 +31,5 @@ class SynthVoice { float mReleaseCoeff = 0.0f; int mSampleCount = 0; // counter within current stage + float mSmoothedAmp = 0.0f; // LP-filtered output amplitude — eliminates all click sources }; diff --git a/pantam-drum/app/src/main/kotlin/com/pantam/drum/PantamView.kt b/pantam-drum/app/src/main/kotlin/com/pantam/drum/PantamView.kt index 1d04ebd..eee5f72 100644 --- a/pantam-drum/app/src/main/kotlin/com/pantam/drum/PantamView.kt +++ b/pantam-drum/app/src/main/kotlin/com/pantam/drum/PantamView.kt @@ -189,7 +189,7 @@ class PantamView @JvmOverloads constructor( activePointers.put(pointerId, Triple(noteIdx, SystemClock.uptimeMillis(), pressure)) activeZoneSet.add(noteIdx) nativeTriggerNote(noteIdx, pressure) - invalidate() + postInvalidateOnAnimation() } MotionEvent.ACTION_UP, @@ -205,7 +205,7 @@ class PantamView @JvmOverloads constructor( activeZoneSet.remove(noteIdx) } nativeReleaseNote(noteIdx, holdSecs) - invalidate() + postInvalidateOnAnimation() } } return true