Design Rationale¶
This page explains why the load-bearing engineering choices were made. Each decision is tied to the constraint that forced it, so the design reads as a chain of "we had to do X because Y", not a list of preferences. For what was built, see Architecture; for the platform obstacles that shape all of this, see Why It's Hard.
Why Zygisk + LSPosed (root injection, not an official API)¶
Constraint: Android exposes no supported API to intercept or transform
another app's microphone capture. AAudio, OpenSL ES, AudioRecord, and tinyalsa
may appear in a target process; AudioFlinger and vendor HAL objects live across
the audioserver boundary. No public mechanism lets one app rewrite those buffers.
Decision: run inside the target process. Zygisk (a Magisk feature) loads
a native module into every specialized app process at fork time, which is exactly
where the capture callbacks live — so the DSP can process a block in-place with
no cross-process copy. LSPosed provides the parallel Java-side path for apps that
capture through AudioRecord, using a dedicated JNI bridge and DSP library, and
lets policy be resolved per process. Both
require root because there is no other way to place code in a foreign process.
The trade-off is explicit: Echidna needs Magisk + Zygisk and cannot function as
an unrooted app for the native path. That cost buys the only viable interception
point.
Why capture routes have explicit support states¶
Constraint: transforming an untyped byte buffer is unsafe. A route must have trustworthy sample rate, channel count, sample format, stream direction, and lifecycle ownership. A successful symbol patch without that contract can corrupt audio or memory. Zygisk also specializes app processes; it does not inject and remain resident in audioserver, where AudioFlinger and vendor HAL stream objects live.
Decision: make route reachability a code-owned matrix. AAudio, OpenSL, tinyalsa, and LSPosed
Java AudioRecord are operational candidates because their normal APIs supply PCM metadata. Native
AudioRecord and libc reads are developer-contract-only until a normal-flow metadata producer
exists. Audio HAL and AudioFlinger return unsupported_injection_boundary until a separate,
proven service-side injection design owns a stable stream ABI. Telemetry reports each route's
support state and exact reason; unsupported routes are not presented as installed hooks.
Why a native C++ DSP engine¶
Constraint: the DSP runs synchronously inside the audio capture callback. Capture callbacks are real-time: they must return within a block period (single- digit to low-tens of milliseconds) or the audio stack under-runs (xruns, glitches, dropped frames). Per-sample pitch/formant/auto-tune work is CPU-heavy and cannot tolerate a garbage-collector pause or JNI marshalling on the hot path.
Decision: implement the engine as native C++ (libech_dsp.so) with a
synchronous in-callback path and an optional hybrid worker path for heavier,
higher-quality transforms. Native code gives deterministic latency, direct
in-place buffer processing, and room for NEON/AVX per-ABI optimization. The C ABI
(echidna_process_block(in, out, frames, sr, channels)) is a single-block
synchronous call so it drops straight into any capture callback the hook chain
intercepts. A managed-language engine on the callback thread would risk
non-deterministic pauses precisely where determinism matters most.
The engine offers two paths off the same C ABI. The low-latency path runs the whole chain in-place inside the capture callback; the hybrid path hands heavier transforms to a worker across a lock-free ring buffer, with an overrun watchdog that trips to pass-through rather than glitch. Either way the DSP flow through the effect chain is the same block of synchronous work:
flowchart LR
IN["Captured PCM block<br/>(sr · channels · frames)"] --> MODE{"Latency mode"}
MODE -->|"Low-Latency"| CB["Process in capture callback<br/>(in place)"]
MODE -->|"Balanced / High-Quality"| RING["Lock-free ring buffer<br/>→ worker thread"]
subgraph CHAIN["echidna_process_block → dsp.process() — the effect chain"]
direction LR
NG["Noise gate"] --> EQ["EQ"] --> COMP["Compressor / AGC"] --> PITCH["Pitch shift"] --> FORMANT["Formant shift"] --> AT["Auto-Tune"] --> REV["Reverb"] --> MIX["Dry/Wet mix"]
end
CB --> CHAIN
RING --> CHAIN
CHAIN --> WD{"Overrun / xrun<br/>watchdog"}
WD -->|"within block deadline"| OUT["Processed PCM written in place"]
WD -->|"risk of overrun"| BYP["Fail-safe pass-through<br/>(auto-bypass on repeat)"]
classDef safe fill:#12492f,stroke:#1f8f5f,color:#eafff4;
class OUT,BYP safe;
Why this shape
The chain is a single synchronous call so it drops into any capture callback unchanged. The watchdog exit on the right is the real-time contract: a voice-interception tool must never glitch a foreign app's audio, so overrun resolves to clean pass-through, and a process that repeatedly overruns has its hook auto-bypassed. See Why It's Hard §3.
Why the in-app service topology (single APK)¶
Constraint: the original design bound the companion app to a phantom
com.echidna.control package — a control service packaged as an Android
library (.aar), which is not independently installable, with no app module
hosting applicationId com.echidna.control. At runtime bindService()
resolved to nothing, bound stayed false, and every control/telemetry call was a
silently-caught no-op. The whole app→service→native control plane was inert.
Decision: host EchidnaControlService inside the companion app and ship
one installable APK. The app's Gradle build folds the :service module in
directly (include(":service") with a projectDir redirect), bundling the
service, the single canonical AIDL, and the JNI library into the one APK. The
bind target became an in-package ComponentName(context, EchidnaControlService::class.java),
and the service is exported="false". This removes the unresolvable cross-package
bind entirely, collapses two divergent AIDL copies into one contract (killing
drift risk), and means a user installs a single APK rather than hunting for a
second package that never existed. A signature-level permission is unnecessary
because the caller and callee are now the same app.
Why memfd over shm_open¶
Constraint: the profile/telemetry shared regions originally used POSIX
shm_open / shm_unlink. bionic (Android's libc) does not provide these —
they simply do not link. The obvious Android replacement, ASharedMemory_create,
lives in libandroid, but the Zygisk target links only liblog (adding
libandroid is undesirable inside an injected module and caused an undefined-
symbol link failure).
Decision: use the same header-only shared-region helper everywhere, backed by
file mappings under /data/local/tmp/echidna when available and a private
memfd_create fallback when they are not. memfd_create is a plain libc syscall
— no extra library to link, works from the injected module — and keeps degraded
processes from crashing when the shared runtime files are unavailable. The
policy path no longer depends on fd handoff. The companion serves strict v2 through an
authenticated, UID-scoped abstract socket for Zygisk and a caller/process-scoped read-only Binder
provider for LSPosed.
Why per-ABI NDK builds¶
Constraint: the code originally built host libraries — an x86_64 Linux
ELF from cmake on the CI runner — and dropped them into the Magisk zip. Those
cannot load on an arm64 Android device. Android ships native code per ABI, and
Zygisk selects a per-ABI zygisk/<abi>.so per process. The spec targets
arm64-v8a, armeabi-v7a, and x86_64; zero were being produced correctly.
Decision: cross-compile per ABI with the NDK toolchain
(tools/build_native_ndk.sh loops the ABI set into build/<abi>/lib/). This
produces four Android targets per ABI. Release delivery transports all four families. The Magisk
module consumes engine/DSP pairs plus inert per-ABI libechidna_preproc.so staging, while the
LSPosed APK consumes dedicated JNI/DSP pairs. Eligible legacy-HIDL system/vendor devices can stage
an exact next-boot registry overlay. No automatic application is added; the separate default-off
companion permission allows only authorized LSPosed per-session attachment.
Per-ABI builds also enable ABI-specific SIMD and are the only
way the trampoline code (which is inherently architecture-specific — see
Architecture) can be compiled and shipped for
each target. arm64-v8a is the locked primary; x86_64 is fully supported; armv7
now has a host-proven ARM32/Thumb-2 prologue relocator, with on-device execution device-gated.
Why fail-closed security¶
The security posture is default-deny at every layer, because the failure mode of a voice-interception tool is severe: a hook that runs when it shouldn't silently rewrites a user's microphone in an app they didn't authorize.
Every ambiguous input resolves toward the non-acting branch. The diagram below traces one
AudioRecord.read (or native capture block) through the gates: any missing, stale, or unverifiable
signal ends at preserve original bytes, never at "transform and hope".
flowchart TD
READ(["Capture block / AudioRecord.read"]) --> SNAP{"Policy snapshot<br/>readable & parseable?"}
SNAP -->|"No snapshot / unparseable / not yet bound"| DENY
SNAP -->|"Yes"| GATES{"Global on · outside panic ·<br/>whitelisted true · owner matches?"}
GATES -->|"Any false / absent"| DENY
GATES -->|"All true"| GEN{"Generation permit<br/>still current?"}
GEN -->|"Revoked mid-read"| DENY
GEN -->|"Current"| PLUG{"Plugin signature<br/>(Ed25519) verifies?"}
PLUG -->|"No key / unverifiable / BoringSSL absent"| DENY
PLUG -->|"Verified"| COMMIT["Commit transformed bytes"]
DENY["Preserve original bytes<br/>+ original read result"]
classDef deny fill:#5b1f1f,stroke:#b5473f,color:#ffecec;
classDef ok fill:#12492f,stroke:#1f8f5f,color:#eafff4;
class DENY deny;
class COMMIT ok;
- Whitelist default-deny. The default profile snapshot is
empty()— empty whitelist, global off. Processing is admitted only when the global gates pass, the specific package/process is explicitly whitelistedtrue, and its capture owner matches the consumer. Absent orfalseentries leave installed hooks inert. Constraint: before the first policy fetch, on an unreadable/unparseable snapshot, or if the receiver never binds, the system must not process — so "no information" has to resolve to "deny", not "allow". - Transactional pass-through when not allowed. LSPosed installs inert hooks so late policy can
activate. Denial, parse/transform failure, exception, or a concurrent revoke preserves the
original bytes and original
AudioRecord.readresult; transformed bytes commit only while the generation permit remains current. - Ed25519 plugin signing. Third-party DSP plugins are verified with Ed25519
(BoringSSL/OpenSSL raw-key verify,
.sigsidecar) and verification is fail-closed: when BoringSSL is not linked,VerifySignaturereturnsfalse, so an unverifiable plugin never loads. The trusted key is build- provisioned (ECHIDNA_TRUSTED_PLUGIN_PUBKEYcompile-def) with an all-zero fail-closed placeholder. Constraint: plugins load into a real-time audio engine running in a foreign process; an unsigned or unverifiable plugin is a code-execution risk, so the safe default when signing is unavailable is to refuse to load anything.
Taken together, every ambiguous state — no snapshot, no signature, no verified key, no whitelist entry — resolves to the non-acting, non-leaking outcome. That is the deliberate stance for a tool with this much reach into other apps' audio.
Why BoringSSL (and why it is layered)¶
Constraint: Ed25519 verification needs a crypto library, but the NDK sysroot
ships no libcrypto, and vendoring a large binary into git is undesirable.
Decision: a layered, first-hit-wins strategy — a prebuilt install
(-DECHIDNA_BORINGSSL_ROOT), then a system find_library(crypto), then
FetchContent BoringSSL (Android-only, pinned tag, fetched into the build tree,
nothing vendored). If none resolve, the build emits a loud warning and plugin
verification stays fail-closed. This keeps the repository free of large binary
blobs while still enabling on-device plugin signing when a crypto backend is
available, and lets a reproducible/offline Docker build pin or mirror the tag.