penguin/loopdeck

A simple player that support A-B looping for FiiO M11S

commit 749016e1318d3acfc2d857022bcfd0f7fbb3f525

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-10-17T13:44:01Z
subjectInitial commit
commit 749016e1318d3acfc2d857022bcfd0f7fbb3f525
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-10-17T13:44:01Z

    Initial commit
---
 .gitignore                                         |   7 +
 LICENSE                                            |  21 +
 README.md                                          | 164 +++++++
 TAK1.md                                            |  70 +++
 THIRD_PARTY_NOTICES.md                             |  44 ++
 VALIDATION.md                                      |  95 ++++
 app/build.gradle                                   |  21 +
 app/src/main/AndroidManifest.xml                   |  14 +
 app/src/main/cpp/CMakeLists.txt                    |  16 +
 app/src/main/cpp/decoder.cpp                       | 238 ++++++++++
 app/src/main/cpp/decoder.h                         |  49 ++
 app/src/main/cpp/jni.cpp                           |  54 +++
 app/src/main/cpp/tak1.cpp                          | 447 ++++++++++++++++++
 app/src/main/cpp/tak1.h                            |  23 +
 .../java/net/astropenguin/loopdeck/LoopMath.java   |  17 +
 .../net/astropenguin/loopdeck/MainActivity.java    | 251 +++++++++++
 .../net/astropenguin/loopdeck/NativeDecoder.java   |  17 +
 .../net/astropenguin/loopdeck/PlaybackService.java | 323 +++++++++++++
 .../main/java/net/astropenguin/loopdeck/Track.java |   9 +
 app/src/main/res/drawable/ic_music.xml             |   3 +
 app/src/main/res/values/styles.xml                 |  10 +
 build.gradle                                       |   1 +
 build.sh                                           |  13 +
 gradle.properties                                  |   3 +
 gradle/wrapper/gradle-wrapper.jar                  | Bin 0 -> 47623 bytes
 gradle/wrapper/gradle-wrapper.properties           |   8 +
 gradlew                                            |   5 +
 scripts/LoopMathTest.java                          |  21 +
 scripts/build-ffmpeg.sh                            |  53 +++
 scripts/ffmpeg.sha256                              |   1 +
 scripts/tak1_reference.py                          | 161 +++++++
 scripts/test-decoder.cpp                           |  44 ++
 scripts/test-tak1-cases.py                         | 120 +++++
 scripts/test-tak1.cpp                              |  41 ++
 settings.gradle                                    |   4 +
 third_party/licenses/FFmpeg-LGPL-2.1.txt           | 502 +++++++++++++++++++++
 third_party/licenses/Gradle-Apache-2.0.txt         | 177 ++++++++
 37 files changed, 3047 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..4ef6bcb
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+.gradle/
+.cxx/
+**/build/
+local.properties
+.deps/
+third_party/ffmpeg/
+*.iml
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..ab3cc9f
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 LoopDeck contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e6375d0
--- /dev/null
+++ b/README.md
@@ -0,0 +1,164 @@
+# LoopDeck 0.1
+
+A small, offline Android music player for the FiiO M11S. Native Java Views,
+FFmpeg file parsing and decoding, a native TAK 1.0 decoder, AudioTrack output,
+and A–B repeat.
+
+## First version
+
+- Browse a selected folder on internal storage or microSD; permission survives app restarts.
+- Play/pause, stop, seek, previous/next, and automatically advance within a folder.
+- Set A and B while listening; a valid pair immediately enables repeat.
+- Toggle repeat without losing markers. Clear removes both markers.
+- Hold **Set A** or **Set B** to enter exact marker times in milliseconds.
+- Background playback, media notification, lock-screen controls and standard media keys.
+- Pause when headphones are disconnected; handle Android audio focus.
+- No full-library scan, artwork extraction, network access, account, or analytics.
+
+The browser shows every file so unusual extensions can still be selected.
+Automatic next/previous includes recognized audio filenames and audio MIME types.
+Selecting a non-audio file reports an error rather than silently skipping it.
+Ordering is by filename, case insensitive; this is a folder player, not a tag library.
+
+## Formats
+
+The pinned FFmpeg build explicitly includes **TTA, TAK, APE, WavPack (.wv),
+WMA / WMA Pro / WMA Lossless**, FLAC, ALAC, MP3, AAC, Vorbis, Opus, PCM,
+Musepack, Shorten and additional legacy audio decoders. The build script checks
+that the specifically requested decoders were enabled. It uses FFmpeg for both
+container parsing and audio decoding, not Android's codec list.
+
+TAK codec type 0 (TAK 1.0) uses LoopDeck's native C++ decoder. Codec types 2 and
+4 use FFmpeg. Type 0 does not need conversion, a Windows DLL, or emulation on
+the device. The format line shows **TAK 1.0 (native)** when this path is active.
+Mono/stereo at 8, 16 and 24 bits have been compared against the original decoder;
+see `TAK1.md` for scope and reference tests. Experimental TAK types 1 and 3 are
+not supported.
+
+Format support does not imply that damaged, encrypted, DRM-protected or every
+historical variant of a file will play. Failure details appear below the controls.
+Separate CUE-sheet navigation, playlists, metadata editing and saved per-track
+loop markers are not implemented in this first version. Whole-album audio files
+can still be played and looped directly.
+
+## CLI prerequisites (macOS)
+
+Use OpenJDK 27 installation with the Android command-line SDK. The app
+still compiles to Java 17 bytecode for Android 10; JDK 27 runs the build tools.
+Install the SDK command-line tools, NDK and CMake if needed:
+
+```bash
+brew install --cask android-commandlinetools
+
+export JAVA_HOME="$(/usr/libexec/java_home -v 27)"
+export PATH="$JAVA_HOME/bin:$PATH"
+java -version
+export ANDROID_HOME="$HOME/Library/Android/sdk"
+export ANDROID_SDK_ROOT="$ANDROID_HOME"
+
+sdkmanager --sdk_root="$ANDROID_HOME" --licenses
+sdkmanager --sdk_root="$ANDROID_HOME" \
+  "platform-tools" \
+  "platforms;android-35" \
+  "build-tools;36.0.0" \
+  "ndk;27.2.12479018" \
+  "cmake;3.22.1"
+
+export PATH="$ANDROID_HOME/platform-tools:$PATH"
+```
+
+If `/usr/libexec/java_home` cannot find your JDK, set `JAVA_HOME` to its
+installation directory directly. Check that `java -version` prints version 27
+before running `./build.sh`.
+
+On Linux use JDK 27, make, curl, tar/xz and the Android command-line tools;
+install the same SDK packages. FFmpeg's assembler comes from the NDK; no separate
+NASM is needed for ARM64. On Apple Silicon the NDK r27 host toolchain may require
+Rosetta (`softwareupdate --install-rosetta`) if it isn't already available.
+
+## Build and install
+
+```bash
+chmod +x build.sh gradlew scripts/build-ffmpeg.sh
+./build.sh
+adb install -r app/build/outputs/apk/debug/app-debug.apk
+```
+
+The first build downloads checksum-pinned FFmpeg 7.1.5, compiles its ARM64 shared
+libraries, downloads the pinned Gradle distribution and resolves the Android
+Gradle plugin. Later builds reuse the FFmpeg output. `JOBS=4 ./build.sh` can limit
+native build parallelism. To clean the app, run `./gradlew clean`; to force a
+FFmpeg rebuild remove `third_party/ffmpeg/arm64-v8a/.build-config`.
+
+`./build.sh assembleDebug` is equivalent to the default. Run the FFmpeg build
+script first if invoking Gradle directly. The app has no Java library dependencies.
+
+If updating from a source copy that failed with `manifest 'build.ninja' still
+dirty after 100 tries`, remove its generated native build directory once, then
+retry. This keeps the already compiled FFmpeg libraries:
+
+```bash
+rm -rf app/.cxx
+./build.sh
+```
+
+Gradle runs CMake configuration when needed. This project disables Ninja's own
+CMake regeneration rule to avoid a timestamp loop on some mounted filesystems.
+When changing `CMakeLists.txt`, build via Gradle so configuration is refreshed.
+
+This version builds **arm64-v8a only** and requires Android 8/API 26 or newer;
+it targets Android 10/API 29 for private sideloading on the M11S. It is not
+configured for Play Store publication. The build runs on OpenJDK 27 using
+AGP 9.4.0 and Gradle 9.8.1. Java source and target compatibility remain at 17
+for Android 10. The wrapper JAR is included.
+
+## Using A–B repeat
+
+1. Choose **Music folder**, grant access, then select a track.
+2. Tap **Set A** at the start of the passage.
+3. Tap **Set B** after A. Playback returns to A and repeat starts.
+4. Hold either marker button for numerical adjustment. **Loop on/off** retains
+   the pair; **Clear** removes it. Seeking outside the loop turns repeat off.
+
+Short loops cache decoded stereo float PCM during their first pass, up to
+32 MiB. Once cached, repeats do not seek or decode again. The limit is roughly
+95 seconds at 44.1 kHz, 87 seconds at 48 kHz, or 22 seconds at 192 kHz.
+Longer loops use bounded-memory seek/decode repeat and can have a gap at B.
+The status line identifies buffering, cached repeat, and seek mode. Seek and
+first-pass behavior depend on the file's index, decoder and storage speed.
+A cut at a nonzero waveform value can produce a click; no crossfade is applied.
+
+## Audio output
+
+FFmpeg converts decoded audio to stereo 32-bit float PCM, preserving the source
+sample rate when Android accepts it. Mono is converted to stereo and multichannel
+audio is downmixed. If AudioTrack rejects the source rate, the app tries 48 kHz.
+The screen shows the source description and **requested** output rate.
+
+This is not a claim of verified bit-perfect output, native DSD, MQA unfolding or
+the physical DAC's actual rate. DSD files, when decoded, take the PCM path. FiiO's
+firmware and audio settings determine the final hardware path. Check those on the
+actual M11S. Bluetooth encoding is managed by Android.
+
+For device problems:
+
+```bash
+adb logcat -c
+adb logcat AndroidRuntime:E AudioTrack:W '*:S'
+```
+
+Copy the on-screen failure message and report the extension, codec if known,
+source sample rate, and whether normal playback or seeking failed. Avoid clearing
+app data unless you intend to re-grant folder permission.
+
+## Source layout
+
+- `MainActivity.java`: folder listing and native controls; file queries use a worker.
+- `PlaybackService.java`: single playback worker, AudioTrack, loop cache and media session.
+- `decoder.cpp`: FFmpeg custom file-descriptor IO, decoding, resampling and seeking.
+- `tak1.cpp`: native TAK 1.0 decoding, selected automatically for codec type 0.
+- `jni.cpp`: small Java/native bridge.
+- `scripts/build-ffmpeg.sh`: pinned FFmpeg download, build, cache and decoder checks.
+
+App source is MIT licensed. FFmpeg and the Gradle wrapper have their own licenses;
+see `THIRD_PARTY_NOTICES.md` before redistributing binaries.
diff --git a/TAK1.md b/TAK1.md
new file mode 100644
index 0000000..a9ec844
--- /dev/null
+++ b/TAK1.md
@@ -0,0 +1,70 @@
+# TAK 1.0 native decoder
+
+LoopDeck handles legacy TAK codec type 0 with `tak1.cpp`. FFmpeg still parses
+the TAK container and frame boundaries, supplies timestamps, and performs
+seeking. Types 2 and 4 continue to use FFmpeg's decoder. Type 0 is selected
+from STREAMINFO, without attempting to decode it through FFmpeg first.
+
+The new decoder reads each frame into planar signed PCM. The playback adapter
+converts it to left-aligned 32-bit PCM for the existing resampler and AudioTrack
+path. It preserves mono/stereo layout, source rate, seeking and A–B repeat.
+Frames are independent; a seek resets the stream information and resampler.
+
+The implementation includes the old residual codebooks and partition tables,
+channel predictors, prefilters, differential integration, and six stereo modes.
+It deliberately uses unsigned arithmetic for 32-bit wrapping and implements
+rounding toward zero where required by the reference. Header and payload CRC-24
+are verified before reconstruction. Bit reads, frame sizes, predictor orders,
+partition indices and coefficient widths are bounded.
+
+## Reference validation
+
+The supplied reference was `tak_deco_lib.dll` 2.3.0 (32-bit Windows x86), SHA-256:
+
+`6b48b47a291aab210d4003caba93b31dfa39a1ca759615d3d042327d65d539de`
+
+`scripts/tak1_reference.py` runs that library's unmodified decoder instructions
+with Unicorn on the development computer, providing only the small Win32 IO
+adapter it needs. This is a test oracle, not part of Android playback. No DLL
+is included, downloaded, or loaded by LoopDeck.
+
+The uploaded example matched exactly for 573 complete frames. Its partial final
+frame is rejected. A separate generated corpus matched 68 cases, covering mono,
+stereo, 8/16/24-bit PCM, 8–192 kHz, all stereo modes and predictor orders, channel
+prefilters, channel partitions and short final frames. See `VALIDATION.md`.
+These checks cannot establish that every historical encoder variant is handled.
+
+## Optional host checks
+
+These commands build host test executables, not an APK. Install a host FFmpeg
+development package with pkg-config metadata, Python 3, `pefile` and `unicorn`.
+
+```bash
+python3 -m pip install pefile unicorn
+c++ -std=c++17 -O2 -Iapp/src/main/cpp \
+  app/src/main/cpp/tak1.cpp scripts/test-tak1.cpp \
+  $(pkg-config --cflags --libs libavformat libavcodec libavutil) \
+  -o /tmp/test-tak1
+
+python3 scripts/tak1_reference.py \
+  --dll /path/to/tak_deco_lib.dll \
+  --input /path/to/legacy-track.tak \
+  --native /tmp/test-tak1
+
+python3 scripts/test-tak1-cases.py \
+  --dll /path/to/tak_deco_lib.dll --native /tmp/test-tak1
+```
+
+For the specific 8 MiB upload, use `--packets 573` to omit the cut final frame.
+For a complete original file, omit that option. The reference runner is scoped
+to the supplied DLL and is not a general Windows compatibility layer.
+
+To check the actual playback adapter and seeks, compile `decoder.cpp`, `tak1.cpp`
+and `scripts/test-decoder.cpp` with the same include flags, adding
+`$(pkg-config --libs libswresample)` to the libraries, then pass audio fixtures.
+
+## Building the app update
+
+Replace the sources in the existing checkout, clear `app/.cxx` once, and run
+`./build.sh` with JDK 27 as before. The compiled FFmpeg libraries can be reused.
+The screen should show **TAK 1.0 (native)** for a codec-0 file.
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
new file mode 100644
index 0000000..b489ed3
--- /dev/null
+++ b/THIRD_PARTY_NOTICES.md
@@ -0,0 +1,44 @@
+# Third-party components
+
+## Native TAK 1.0 support
+
+`tak1.cpp` is a new portable C++ implementation of TAK codec type 0, written
+from bitstream behavior and analysis of a user-provided TAK decoding library.
+It is included under this project's MIT license. It calls no Windows APIs and
+uses no binary loader or CPU emulator at runtime. Original TAK DLLs are not bundled.
+The optional host reference scripts require separately installed Python
+packages `pefile` and `unicorn`, with their respective upstream licenses;
+neither package is part of the Android application.
+
+## FFmpeg 7.1.5
+
+Source: https://ffmpeg.org/releases/ffmpeg-7.1.5.tar.xz
+
+This project builds FFmpeg as separate shared libraries, without enabling GPL
+or nonfree components or external codec libraries. The FFmpeg build is LGPL
+2.1-or-later. Copyright belongs to the FFmpeg contributors. Its license is
+included in `third_party/licenses/FFmpeg-LGPL-2.1.txt`.
+
+The archive checksum is in `scripts/ffmpeg.sha256`. The exact configuration and
+build steps are in `scripts/build-ffmpeg.sh`. Upstream FFmpeg source is unmodified.
+The JNI glue and app source are supplied in this project. Do not treat the MIT
+app license as a license for FFmpeg.
+
+If distributing an APK, supply the corresponding FFmpeg source, these build
+instructions, license notices and the materials required for users to modify
+and relink the LGPL component. Retain the downloaded source archive rather than
+assuming a download link alone satisfies distribution requirements.
+
+## Gradle wrapper 9.8.1
+
+Source: https://github.com/gradle/gradle/tree/v9.8.1
+
+The included `gradle/wrapper/gradle-wrapper.jar` is from the official Gradle
+repository. Gradle is licensed under Apache License 2.0, included in
+`third_party/licenses/Gradle-Apache-2.0.txt`.
+
+Wrapper JAR SHA-256:
+`238e777fcddd7e34f9708186085def2abd6e08e658505b38718d79d74c21abd5`.
+
+Android SDK/NDK tools and the Android Gradle plugin are downloaded separately
+under their respective licenses and are not included in this source archive.
diff --git a/VALIDATION.md b/VALIDATION.md
new file mode 100644
index 0000000..bc861f0
--- /dev/null
+++ b/VALIDATION.md
@@ -0,0 +1,95 @@
+# Validation for 0.1.0
+
+No APK was built. Android Gradle plugin project evaluation, the Android resource
+linker, the NDK cross-build, packaging and actual M11S playback were not executed.
+This is a source delivery for the owner to build and test on the device.
+
+## Checks completed
+
+- All Java app sources type-checked with Eclipse ECJ against the official Android
+  API 35 `android.jar`, using Java 8 language/bytecode mode. The project runs
+  Gradle on JDK 27 while targeting Java 17 for app compilation; its app source
+  is also compatible with Java 8 syntax.
+- The same sources compiled with OpenJDK 27 `javac --release 17` against Android
+  API 35 stubs. Seven Android API deprecation warnings appeared; there were no errors.
+- JNI C++ source passed the host C++17 compiler's syntax/type checks using official
+  OpenJDK JNI headers and FFmpeg 7.1.5 headers.
+- The actual `decoder.cpp` compiled and linked against a host build of unmodified
+  FFmpeg 7.1.5. This checks its real FFmpeg API usage, not a mock decoder.
+- Sequential decoding, repeated EOF reads and seeks at 0, 1/3 second, 1 second,
+  1 second + 137 frames, and 3 seconds + 137 frames passed for all fixtures below.
+  Each was tested at 44,100 Hz and resampled to 48,000 Hz.
+- Frame arithmetic tests passed for A/B boundaries, repeated wrapping, positions
+  beyond a 32-bit frame counter, marker validation, sample/time conversion and
+  the 32 MiB loop-cache limit.
+- Shell scripts passed `bash -n`. Manifest, theme and vector XML parsed successfully.
+- For the Ninja regeneration failure on a mounted macOS volume, the top-level
+  CMake project sets `CMAKE_SUPPRESS_REGENERATION=ON`; CMake documents that this
+  omits Ninja's build-system regeneration rule. The changed native build has not
+  been run on the owner's machine. `README.md` includes the one-time `.cxx`
+  cleanup needed to replace an existing generated manifest.
+- The Gradle wrapper downloaded the checksum-pinned Gradle 9.8.1 archive from a
+  local test path and successfully ran `--version` on OpenJDK 27. The project
+  retains the official remote distribution URL. The Android app was not built.
+
+| Fixture | Result |
+| --- | --- |
+| Generated 24-bit stereo WAV | Decode / EOF / seeks passed at both rates |
+| FLAC encoded from the WAV | Passed |
+| TTA encoded from the WAV | Passed |
+| WavPack encoded from the WAV | Passed |
+| WMA v2 encoded from the WAV | Passed |
+| MP3 encoded from the WAV | Passed |
+| FFmpeg sample `monkeyaudio/sh3.ape` | Passed |
+| FFmpeg sample `A-codecs/tak/lostchord-tak_2_2_0.tak` | Passed |
+
+### Native TAK 1.0 / codec type 0
+
+The user-supplied 8 MiB TAK prefix declares codec type 0, which the bundled
+FFmpeg cannot decode. LoopDeck now selects a separate native C++ decoder for it.
+
+- The native output matched the supplied TAK decoder DLL exactly for all 573
+  complete frames: 3,158,376 stereo PCM frames / 12,633,504 bytes at 44,100 Hz,
+  about 71.6 seconds. Both PCM outputs have SHA-256
+  `ff2b6067cecf25d2957d2ff7a454bb533d7cdcd49420deaa36cd93961e2b7b2c`.
+- The upload cuts frame 574 short. That incomplete frame is excluded from the
+  comparison and is rejected by the native decoder; it is not counted as a pass.
+- 68 generated fixtures matched the original DLL: mono/stereo, 8/16/24-bit PCM,
+  source rates from 8 to 192 kHz, all six stereo modes, all 16 predictor orders
+  (4 through 256), short final frames, prefilters and channel partitions.
+- Through the actual `decoder.cpp`, sequential decode, EOF and seek comparisons
+  passed for the complete uploaded frames at 44,100 and 48,000 Hz. Generated
+  8-bit and 24-bit mono tracks passed at 8,000 and 48,000 Hz.
+- AddressSanitizer and UndefinedBehaviorSanitizer found no errors across 3,000
+  truncated or modified packet inputs, including modifications with recalculated
+  payload checksums. LeakSanitizer was disabled because the host runtime cannot
+  inspect its process threads.
+- The JNI bridge type-checks with JDK 27 JNI headers after the native integration.
+
+The Windows DLL was executed only by a host reference test harness. It and the
+user's music are not included in this source archive or required by the app.
+The rest of the original track, other real codec-0 albums, ARM64 performance,
+Android integration and actual device playback remain unverified. No APK was built.
+
+The seek test compares 1,024 output frames with sequential decoding. For resampled
+output it excludes the first 64 frames from the comparison to allow filter warm-up.
+The maximum allowed absolute sample difference is 0.003 on the float PCM scale.
+ASF/WMA's demuxer prints seek-probing warnings for the generated WMA fixture,
+but its compared output passes.
+
+One additional legacy FFmpeg sample, `A-codecs/lossless/luckynight.ape`, failed
+decoding in both this decoder and the system FFmpeg CLI. It was not counted as a
+pass or silently ignored. Another APE sample passed. No audio fixtures are included
+in this archive, and these checks do not cover all versions of every format.
+
+## First device checks
+
+1. Build with `./build.sh`, install, grant the actual microSD music folder.
+2. Open examples from the real collection: TTA, TAK, APE, WV and WMA.
+3. Seek well into a long album; test A/B both while playing and while paused.
+4. Confirm cached short repeats and seek-mode long repeats, pause/resume and clearing.
+5. Lock the screen, test physical buttons and headphone disconnect.
+6. Confirm the source rate / requested output rate and check FiiO's audio settings.
+
+AudioTrack timing, final DAC format, actual gaplessness and responsiveness require
+the device. They are not established by host decoder tests.
diff --git a/app/build.gradle b/app/build.gradle
new file mode 100644
index 0000000..715df7c
--- /dev/null
+++ b/app/build.gradle
@@ -0,0 +1,21 @@
+plugins { id 'com.android.application' }
+
+android {
+    namespace 'net.astropenguin.loopdeck'
+    compileSdk 35
+    ndkVersion '27.2.12479018'
+    defaultConfig {
+        applicationId 'net.astropenguin.loopdeck'
+        minSdk 26
+        // This private, sideloaded player targets the M11S's Android 10.
+        targetSdk 29
+        versionCode 1
+        versionName '0.1.0'
+        ndk { abiFilters 'arm64-v8a' }
+        externalNativeBuild { cmake { arguments '-DANDROID_STL=c++_static' } }
+    }
+    externalNativeBuild { cmake { path 'src/main/cpp/CMakeLists.txt'; version '3.22.1' } }
+    compileOptions { sourceCompatibility JavaVersion.VERSION_17; targetCompatibility JavaVersion.VERSION_17 }
+    buildTypes { release { minifyEnabled false } }
+    packaging { jniLibs { useLegacyPackaging true } }
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..67a23d0
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
+    <uses-permission android:name="android.permission.WAKE_LOCK" />
+    <application android:label="LoopDeck" android:theme="@style/AppTheme" android:allowBackup="false" android:icon="@drawable/ic_music">
+        <activity android:name=".MainActivity" android:exported="true">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
+        <service android:name=".PlaybackService" android:exported="false" android:foregroundServiceType="mediaPlayback" />
+    </application>
+</manifest>
diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt
new file mode 100644
index 0000000..a37e026
--- /dev/null
+++ b/app/src/main/cpp/CMakeLists.txt
@@ -0,0 +1,16 @@
+cmake_minimum_required(VERSION 3.22.1)
+# Gradle configures this project. Avoid Ninja repeatedly regenerating build.ninja
+# when source/build trees have skewed timestamps (e.g. on an external volume).
+set(CMAKE_SUPPRESS_REGENERATION ON)
+project(loopdeck LANGUAGES C CXX)
+set(CMAKE_CXX_STANDARD 17)
+set(FFMPEG_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../../../../third_party/ffmpeg/${ANDROID_ABI}")
+foreach(component avformat avcodec swresample avutil)
+    add_library(${component} SHARED IMPORTED)
+    set_target_properties(${component} PROPERTIES IMPORTED_LOCATION "${FFMPEG_ROOT}/lib/lib${component}.so")
+endforeach()
+add_library(loopdeck SHARED decoder.cpp tak1.cpp jni.cpp)
+target_include_directories(loopdeck PRIVATE "${FFMPEG_ROOT}/include")
+target_compile_options(loopdeck PRIVATE -Wall -Wextra -Werror=return-type)
+target_link_options(loopdeck PRIVATE -Wl,-z,max-page-size=16384)
+target_link_libraries(loopdeck avformat avcodec swresample avutil log)
diff --git a/app/src/main/cpp/decoder.cpp b/app/src/main/cpp/decoder.cpp
new file mode 100644
index 0000000..cd061ac
--- /dev/null
+++ b/app/src/main/cpp/decoder.cpp
@@ -0,0 +1,238 @@
+#include "decoder.h"
+#include <algorithm>
+#include <cerrno>
+#include <cstring>
+#include <stdexcept>
+#include <numeric>
+#include <sys/stat.h>
+#include <unistd.h>
+extern "C" {
+#include <libavutil/opt.h>
+}
+
+Decoder::Decoder(int inputFd) {
+    fd = dup(inputFd);
+    if (fd < 0) throw std::runtime_error("Cannot duplicate file descriptor");
+}
+Decoder::~Decoder() {
+    av_packet_free(&packet);
+    av_frame_free(&frame);
+    swr_free(&swr);
+    avcodec_free_context(&codec);
+    avformat_close_input(&format);
+    if (io) { av_freep(&io->buffer); avio_context_free(&io); }
+    if (fd >= 0) close(fd);
+}
+void Decoder::check(int result, const char* operation) {
+    if (result >= 0) return;
+    if (result == AVERROR_PATCHWELCOME && codec && codec->codec_id == AV_CODEC_ID_TAK)
+        throw std::runtime_error("This TAK codec type is not supported by the available decoders.");
+    char detail[AV_ERROR_MAX_STRING_SIZE];
+    av_strerror(result, detail, sizeof(detail));
+    throw std::runtime_error(std::string(operation) + ": " + detail);
+}
+int Decoder::readFd(void* opaque, uint8_t* data, int size) {
+    auto* d = static_cast<Decoder*>(opaque);
+    ssize_t count;
+    do { count = ::read(d->fd, data, size); } while (count < 0 && errno == EINTR);
+    return count > 0 ? static_cast<int>(count) : count == 0 ? AVERROR_EOF : AVERROR(errno);
+}
+int64_t Decoder::seekFd(void* opaque, int64_t offset, int whence) {
+    auto* d = static_cast<Decoder*>(opaque);
+    if (whence & AVSEEK_SIZE) {
+        struct stat info {};
+        return fstat(d->fd, &info) == 0 && S_ISREG(info.st_mode) ? info.st_size : AVERROR(ENOSYS);
+    }
+    const off_t result = lseek(d->fd, offset, whence & ~AVSEEK_FORCE);
+    return result < 0 ? AVERROR(errno) : result;
+}
+void Decoder::open() {
+    format = avformat_alloc_context();
+    if (!format) throw std::bad_alloc();
+    auto* buffer = static_cast<unsigned char*>(av_malloc(65536));
+    if (!buffer) throw std::bad_alloc();
+    io = avio_alloc_context(buffer, 65536, 0, this, readFd, nullptr, seekFd);
+    if (!io) { av_free(buffer); throw std::bad_alloc(); }
+    format->pb = io;
+    format->flags |= AVFMT_FLAG_CUSTOM_IO;
+    check(avformat_open_input(&format, nullptr, nullptr, nullptr), "Open file");
+    // TAK's demuxer exposes STREAMINFO directly. Avoid probing codec type 0
+    // through FFmpeg's unsupported decoder before selecting our native path.
+    const auto* first = format->nb_streams == 1 ? format->streams[0]->codecpar : nullptr;
+    const bool legacyTak = first && first->codec_id == AV_CODEC_ID_TAK && first->extradata_size >= 10
+        && (first->extradata[0] & 63) == 0;
+    if (!legacyTak) check(avformat_find_stream_info(format, nullptr), "Read stream information");
+    const AVCodec* decoder = nullptr;
+    streamIndex = av_find_best_stream(format, AVMEDIA_TYPE_AUDIO, -1, -1, &decoder, 0);
+    check(streamIndex, "Find supported audio stream");
+    stream = format->streams[streamIndex];
+    codec = avcodec_alloc_context3(decoder);
+    if (!codec) throw std::bad_alloc();
+    check(avcodec_parameters_to_context(codec, stream->codecpar), "Read codec parameters");
+    codec->pkt_timebase=stream->time_base;
+    codec->thread_count = 2;
+    if (legacyTak) {
+        tak1 = std::make_unique<Tak1Decoder>(stream->codecpar->extradata, stream->codecpar->extradata_size);
+        const auto& info = tak1->info();
+        codec->sample_rate = info.rate;
+        codec->sample_fmt = AV_SAMPLE_FMT_S32P;
+        codec->bits_per_raw_sample = info.bits;
+        av_channel_layout_uninit(&codec->ch_layout);
+        av_channel_layout_default(&codec->ch_layout, info.channels);
+    } else check(avcodec_open2(codec, decoder, nullptr), "Open audio decoder");
+    if (codec->sample_rate <= 0) throw std::runtime_error("Invalid audio sample rate");
+    frame = av_frame_alloc();
+    packet = av_packet_alloc();
+    if (!frame || !packet) throw std::bad_alloc();
+    outputRate(codec->sample_rate);
+}
+void Decoder::outputRate(int value) {
+    if (value <= 0) throw std::runtime_error("Invalid output rate");
+    rate = value;
+    swr_free(&swr);
+    AVChannelLayout stereo = AV_CHANNEL_LAYOUT_STEREO;
+    check(swr_alloc_set_opts2(&swr, &stereo, AV_SAMPLE_FMT_FLT, rate,
+        &codec->ch_layout, codec->sample_fmt, codec->sample_rate, 0, nullptr), "Configure PCM conversion");
+    check(swr_init(swr), "Initialize PCM conversion");
+}
+int64_t Decoder::durationMs() const {
+    if (stream->duration != AV_NOPTS_VALUE)
+        return av_rescale_q(stream->duration, stream->time_base, AVRational{1,1000});
+    return format->duration == AV_NOPTS_VALUE ? 0 : format->duration / 1000;
+}
+std::string Decoder::description() const {
+    const auto* desc = avcodec_descriptor_get(codec->codec_id);
+    int bits = tak1 ? tak1->info().bits : stream->codecpar->bits_per_raw_sample;
+    return std::string(tak1 ? "TAK 1.0 (native)" : desc ? desc->name : "audio") + " · " + std::to_string(codec->sample_rate)
+        + " Hz · " + std::to_string(codec->ch_layout.nb_channels) + " ch"
+        + (bits > 0 ? " · " + std::to_string(bits) + " bit" : "");
+}
+int Decoder::receiveFrame() {
+    if (!tak1) return avcodec_receive_frame(codec, frame);
+    while (true) {
+        int result = av_read_frame(format, packet);
+        if (result < 0) return result;
+        if (packet->stream_index != streamIndex) { av_packet_unref(packet); continue; }
+        auto audio = tak1->decode(packet->data, packet->size);
+        frame->format = AV_SAMPLE_FMT_S32P;
+        frame->sample_rate = codec->sample_rate;
+        frame->nb_samples = audio.samples;
+        frame->pts = frame->best_effort_timestamp = packet->pts;
+        check(av_channel_layout_copy(&frame->ch_layout, &codec->ch_layout), "Copy TAK channel layout");
+        check(av_frame_get_buffer(frame, 0), "Allocate TAK PCM frame");
+        const int shift = 32-tak1->info().bits;
+        for (int c=0; c<codec->ch_layout.nb_channels; ++c) {
+            auto* destination = reinterpret_cast<int32_t*>(frame->data[c]);
+            for (int i=0; i<audio.samples; ++i) {
+                uint32_t value = uint32_t(audio.channel[c][i]) << shift;
+                std::memcpy(destination+i, &value, sizeof(value));
+            }
+        }
+        av_packet_unref(packet);
+        return 0;
+    }
+}
+bool Decoder::fill() {
+    pending.clear(); pendingOffset = 0;
+    while (true) {
+        int result = receiveFrame();
+        if (result == AVERROR_EOF) {
+            if (resamplerEnded) return false;
+            resamplerEnded = true;
+            const int capacity = swr_get_out_samples(swr, 0);
+            pending.resize(static_cast<size_t>(std::max(capacity, 1)) * 2);
+            uint8_t* out[] = {reinterpret_cast<uint8_t*>(pending.data())};
+            int n = swr_convert(swr, out, capacity, nullptr, 0);
+            check(n, "Drain audio converter");
+            pending.resize(n * 2);
+            return n > 0;
+        }
+        if (result >= 0) {
+            if (!resamplerStarted) {
+                int64_t sourceStart = av_rescale_q(decodedNext, AVRational{1,rate}, AVRational{1,codec->sample_rate});
+                if (frame->best_effort_timestamp != AV_NOPTS_VALUE) {
+                    int64_t origin = stream->start_time == AV_NOPTS_VALUE ? 0 : stream->start_time;
+                    sourceStart = av_rescale_q(frame->best_effort_timestamp-origin,stream->time_base,AVRational{1,codec->sample_rate});
+                }
+                if (timelineOrigin == AV_NOPTS_VALUE) timelineOrigin=sourceStart;
+                sourceStart-=timelineOrigin;
+                if ((codec->codec_id==AV_CODEC_ID_WMAV1 || codec->codec_id==AV_CODEC_ID_WMAV2) && frame->nb_samples>0) {
+                    // ASF timestamps are milliseconds, while WMA v1/v2 emits
+                    // fixed-sized sample frames. Recover the exact frame boundary.
+                    int64_t size=frame->nb_samples;
+                    sourceStart=((sourceStart+size/2)/size)*size;
+                }
+                // Restore the global rational resampling phase after a seek.
+                // Without this, e.g. 44.1 -> 48 kHz seeks shift by a fractional sample.
+                int period = codec->sample_rate / std::gcd(codec->sample_rate,rate);
+                int prefix = static_cast<int>((sourceStart % period + period) % period);
+                if (prefix) check(swr_inject_silence(swr,prefix),"Align audio converter");
+                decodedNext = av_rescale_q(sourceStart-prefix,AVRational{1,codec->sample_rate},AVRational{1,rate});
+                resamplerStarted=true;
+            }
+            int capacity = swr_get_out_samples(swr, frame->nb_samples);
+            pending.resize(static_cast<size_t>(std::max(capacity, 1)) * 2);
+            uint8_t* out[] = {reinterpret_cast<uint8_t*>(pending.data())};
+            int n = swr_convert(swr, out, capacity, const_cast<const uint8_t**>(frame->extended_data), frame->nb_samples);
+            check(n, "Convert decoded audio");
+            int64_t start = decodedNext;
+            decodedNext = start + n;
+            pending.resize(n * 2);
+            pendingOffset = static_cast<size_t>(std::clamp<int64_t>(skipUntil - start, 0, n)) * 2;
+            av_frame_unref(frame);
+            if (pendingOffset < pending.size()) return true;
+            continue;
+        }
+        if (result != AVERROR(EAGAIN)) check(result, "Decode audio");
+        if (inputEnded) {
+            if (drainSent) throw std::runtime_error("Decoder stalled while draining");
+            check(avcodec_send_packet(codec, nullptr), "Drain decoder");
+            drainSent = true;
+            continue;
+        }
+        while (true) {
+            result = av_read_frame(format, packet);
+            if (result == AVERROR_EOF) { inputEnded = true; break; }
+            check(result, "Read audio packet");
+            if (packet->stream_index == streamIndex) {
+                result = avcodec_send_packet(codec, packet);
+                av_packet_unref(packet);
+                check(result, "Submit audio packet");
+                break;
+            }
+            av_packet_unref(packet);
+        }
+    }
+}
+int Decoder::read(float* output, int frames) {
+    if (frames <= 0) return 0;
+    int copied = 0;
+    while (copied < frames) {
+        if (pendingOffset == pending.size() && !fill()) break;
+        size_t count = std::min(pending.size() - pendingOffset, static_cast<size_t>((frames - copied) * 2));
+        std::memcpy(output + copied * 2, pending.data() + pendingOffset, count * sizeof(float));
+        pendingOffset += count;
+        copied += static_cast<int>(count / 2);
+    }
+    return copied;
+}
+void Decoder::seek(int64_t outputFrame) {
+    // Establish the decoded timeline origin. Some containers (notably ASF/WMA)
+    // start decoded audio later than their stream start timestamp.
+    if (timelineOrigin == AV_NOPTS_VALUE && !fill()) throw std::runtime_error("No decoded audio");
+    int64_t origin = stream->start_time == AV_NOPTS_VALUE ? 0 : stream->start_time;
+    // Give predictive codecs and resampling filters enough history before A.
+    int64_t preroll=std::max<int64_t>(0,outputFrame-int64_t(rate)*2);
+    int64_t target = preroll==0 ? origin : av_rescale_q(preroll, AVRational{1,rate}, stream->time_base)
+        + av_rescale_q(timelineOrigin,AVRational{1,codec->sample_rate},stream->time_base) + origin;
+    check(avformat_seek_file(format, streamIndex, INT64_MIN, target, target, 0), "Seek audio");
+    if (tak1) tak1->reset();
+    else avcodec_flush_buffers(codec);
+    av_packet_unref(packet);
+    av_frame_unref(frame);
+    swr_close(swr);
+    check(swr_init(swr), "Reset audio converter");
+    inputEnded = drainSent = resamplerEnded = resamplerStarted = false;
+    pending.clear(); pendingOffset = 0;
+    skipUntil = decodedNext = outputFrame;
+}
diff --git a/app/src/main/cpp/decoder.h b/app/src/main/cpp/decoder.h
new file mode 100644
index 0000000..31e182c
--- /dev/null
+++ b/app/src/main/cpp/decoder.h
@@ -0,0 +1,49 @@
+#pragma once
+#include <cstdint>
+#include <string>
+#include <vector>
+#include <memory>
+#include "tak1.h"
+extern "C" {
+#include <libavformat/avformat.h>
+#include <libavcodec/avcodec.h>
+#include <libswresample/swresample.h>
+}
+
+// Owned by the playback worker only. The descriptor is duplicated, never copied
+// into a temporary file, so album-sized files on removable storage are supported.
+class Decoder {
+public:
+    explicit Decoder(int inputFd);
+    ~Decoder();
+    Decoder(const Decoder&) = delete;
+    Decoder& operator=(const Decoder&) = delete;
+    void open();
+    void outputRate(int rate);
+    int read(float* output, int frames);
+    void seek(int64_t outputFrame);
+    int sourceRate() const { return codec->sample_rate; }
+    int64_t durationMs() const;
+    std::string description() const;
+private:
+    int fd = -1;
+    AVFormatContext* format = nullptr;
+    AVIOContext* io = nullptr;
+    AVCodecContext* codec = nullptr;
+    SwrContext* swr = nullptr;
+    AVFrame* frame = nullptr;
+    AVPacket* packet = nullptr;
+    AVStream* stream = nullptr;
+    int streamIndex = -1, rate = 0;
+    bool inputEnded = false, drainSent = false, resamplerEnded = false, resamplerStarted = false;
+    std::vector<float> pending;
+    size_t pendingOffset = 0;
+    int64_t skipUntil = 0, decodedNext = 0;
+    int64_t timelineOrigin = AV_NOPTS_VALUE;
+    std::unique_ptr<Tak1Decoder> tak1;
+    int receiveFrame();
+    bool fill();
+    void check(int result, const char* operation);
+    static int readFd(void* opaque, uint8_t* data, int size);
+    static int64_t seekFd(void* opaque, int64_t offset, int whence);
+};
diff --git a/app/src/main/cpp/jni.cpp b/app/src/main/cpp/jni.cpp
new file mode 100644
index 0000000..6cc1536
--- /dev/null
+++ b/app/src/main/cpp/jni.cpp
@@ -0,0 +1,54 @@
+#include "decoder.h"
+#include <jni.h>
+#include <memory>
+#include <stdexcept>
+
+static Decoder* decoder(jlong handle) {
+    if (!handle) throw std::runtime_error("No file is open");
+    return reinterpret_cast<Decoder*>(handle);
+}
+static void report(JNIEnv* env, const std::exception& e) {
+    env->ThrowNew(env->FindClass("java/io/IOException"), e.what());
+}
+extern "C" JNIEXPORT jlong JNICALL
+Java_net_astropenguin_loopdeck_NativeDecoder_open(JNIEnv* env, jclass, jint fd) {
+    try {
+        auto d = std::make_unique<Decoder>(fd); d->open();
+        return reinterpret_cast<jlong>(d.release());
+    } catch (const std::exception& e) { report(env, e); return 0; }
+}
+extern "C" JNIEXPORT void JNICALL
+Java_net_astropenguin_loopdeck_NativeDecoder_close(JNIEnv*, jclass, jlong handle) { delete reinterpret_cast<Decoder*>(handle); }
+extern "C" JNIEXPORT jint JNICALL
+Java_net_astropenguin_loopdeck_NativeDecoder_sourceRate(JNIEnv* env, jclass, jlong handle) {
+    try { return decoder(handle)->sourceRate(); } catch (const std::exception& e) { report(env,e); return 0; }
+}
+extern "C" JNIEXPORT jlong JNICALL
+Java_net_astropenguin_loopdeck_NativeDecoder_duration(JNIEnv* env, jclass, jlong handle) {
+    try { return decoder(handle)->durationMs(); } catch (const std::exception& e) { report(env,e); return 0; }
+}
+extern "C" JNIEXPORT jstring JNICALL
+Java_net_astropenguin_loopdeck_NativeDecoder_description(JNIEnv* env, jclass, jlong handle) {
+    try { return env->NewStringUTF(decoder(handle)->description().c_str()); } catch (const std::exception& e) { report(env,e); return nullptr; }
+}
+extern "C" JNIEXPORT void JNICALL
+Java_net_astropenguin_loopdeck_NativeDecoder_outputRate(JNIEnv* env, jclass, jlong handle, jint rate) {
+    try { decoder(handle)->outputRate(rate); } catch (const std::exception& e) { report(env,e); }
+}
+extern "C" JNIEXPORT void JNICALL
+Java_net_astropenguin_loopdeck_NativeDecoder_seek(JNIEnv* env, jclass, jlong handle, jlong frame) {
+    try { decoder(handle)->seek(frame); } catch (const std::exception& e) { report(env,e); }
+}
+extern "C" JNIEXPORT jint JNICALL
+Java_net_astropenguin_loopdeck_NativeDecoder_read(JNIEnv* env, jclass, jlong handle, jfloatArray array, jint frames) {
+    try {
+        auto* d = decoder(handle);
+        if (frames < 0 || frames > env->GetArrayLength(array) / 2) throw std::runtime_error("Invalid PCM buffer size");
+        // Avoid pinning a Java array across file IO and codec work.
+        thread_local std::vector<float> scratch;
+        scratch.resize(static_cast<size_t>(frames) * 2);
+        int count = d->read(scratch.data(), frames);
+        env->SetFloatArrayRegion(array, 0, count * 2, scratch.data());
+        return count;
+    } catch (const std::exception& e) { report(env,e); return 0; }
+}
diff --git a/app/src/main/cpp/tak1.cpp b/app/src/main/cpp/tak1.cpp
new file mode 100644
index 0000000..78a4699
--- /dev/null
+++ b/app/src/main/cpp/tak1.cpp
@@ -0,0 +1,447 @@
+#include "tak1.h"
+#include <algorithm>
+#include <array>
+#include <cstring>
+#include <stdexcept>
+#include <string>
+
+namespace {
+[[noreturn]] void invalid(const char* reason) {
+    throw std::runtime_error(std::string("TAK 1.0: ") + reason);
+}
+void require(bool ok, const char* reason) { if (!ok) invalid(reason); }
+int32_t wrap(uint32_t x) { int32_t v; std::memcpy(&v, &x, 4); return v; }
+uint32_t magnitude(int32_t x) { return x < 0 ? 0U - uint32_t(x) : uint32_t(x); }
+int32_t truncate(int32_t x, int shift) {
+    uint32_t v = magnitude(x) >> shift;
+    return wrap(x < 0 ? 0U - v : v);
+}
+int32_t predict(uint32_t sum, int shift) {
+    int32_t signedSum = wrap(sum);
+    uint32_t value = (magnitude(signedSum) + (1U << (shift - 1))) >> shift;
+    value = std::min(value, 8191U);
+    return signedSum < 0 ? -int32_t(value) : int32_t(value);
+}
+class Bits {
+public:
+    Bits(const uint8_t* data, size_t size) : data(data), size(size) {}
+    uint32_t get(int n) {
+        require(n >= 0 && n <= 32 && size <= SIZE_MAX / 8 && pos <= size*8 && size_t(n) <= size*8-pos, "truncated or invalid bitstream");
+        uint64_t value = 0;
+        const size_t first = pos / 8, end = (pos + n + 7) / 8;
+        for (size_t i = first; i < end; ++i) value |= uint64_t(data[i]) << ((i-first)*8);
+        value >>= pos & 7;
+        pos += n;
+        return uint32_t(value & (n == 32 ? UINT32_MAX : ((uint64_t(1)<<n)-1)));
+    }
+    int32_t signedValue(int n) {
+        require(n > 0 && n <= 32, "invalid signed value size");
+        uint32_t v = get(n), sign = 1U << (n-1);
+        return wrap((v ^ sign)-sign);
+    }
+    int unary(int limit) { for (int i=0; i<limit; ++i) if (get(1)) return i; return limit; }
+    void align() { require(get(int((0-pos)&7)) == 0, "nonzero frame padding"); }
+    size_t position() const { return pos; }
+private:
+    const uint8_t* data;
+    size_t size, pos = 0;
+};
+Tak1Decoder::Info streamInfo(Bits& b) {
+    require(b.get(6) == 0, "different TAK codec type");
+    b.get(4); // Encoder preset does not change the decoding rules.
+    int frameType = int(b.get(4));
+    b.get(32); b.get(3); // Whole-file sample count belongs to the demuxer.
+    require(b.get(3) == 0, "unsupported sample type");
+    Tak1Decoder::Info i{};
+    i.rate = int(b.get(18)) + 6000;
+    i.bits = int(b.get(5)) + 8;
+    i.channels = int(b.get(4)) + 1;
+    if (b.get(1)) {
+        b.get(5);
+        if (b.get(1)) for (int c=0; c<i.channels; ++c) b.get(6);
+    }
+    static constexpr int sizes[] = {3,4,6,8,4096,8192,16384,512,1024,2048};
+    require(frameType < 10, "invalid frame duration");
+    i.frameSamples = frameType < 4 ? i.rate*sizes[frameType]/32 : sizes[frameType];
+    const int maximum = frameType < 4 ? 16384 : i.rate/4;
+    require(i.frameSamples > 0 && i.frameSamples <= maximum && i.frameSamples <= 16384, "invalid frame sample count");
+    require(i.bits >= 8 && i.bits <= 24 && i.channels <= 2, "unsupported PCM format");
+    return i;
+}
+uint32_t crc24(const uint8_t* data, size_t size) {
+    static const auto table = [] {
+        std::array<uint32_t,256> t{};
+        for (unsigned i=0; i<256; ++i) {
+            uint32_t v = i << 16;
+            for (int j=0; j<8; ++j) v = ((v<<1) ^ ((v & 0x800000U) ? 0x864CFBU : 0)) & 0xFFFFFFU;
+            t[i] = v;
+        }
+        return t;
+    }();
+    uint32_t crc = 0xB704CEU;
+    for (size_t i=0; i<size; ++i) crc = ((crc<<8) ^ table[((crc>>16)^data[i])&255]) & 0xFFFFFFU;
+    return crc;
+}
+void checkCrc(const uint8_t* data, size_t size) {
+    require(size >= 3, "missing checksum");
+    uint32_t stored = uint32_t(data[size-3]) | uint32_t(data[size-2])<<8 | uint32_t(data[size-1])<<16;
+    require(crc24(data,size-3) == stored, "frame checksum mismatch");
+}
+struct Code { int width, outer, inner, extended, step, bias; };
+// Numeric codebook parameters recovered from the TAK 1.0 decoder interface.
+constexpr Code codes[47] = {
+    {3,6,4,8,4,44},
+    {4,14,10,18,4,54},
+    {4,12,4,20,8,92},
+    {5,28,22,34,8,106},
+    {5,26,12,40,12,148},
+    {6,56,44,68,16,212},
+    {6,52,24,80,24,296},
+    {7,112,88,136,32,424},
+    {7,104,48,160,48,592},
+    {8,224,176,272,64,848},
+    {8,208,96,320,96,1184},
+    {9,448,352,544,128,1696},
+    {9,416,192,640,192,2368},
+    {10,896,704,1088,256,3392},
+    {10,832,384,1280,384,4736},
+    {11,1792,1408,2176,512,6784},
+    {11,1664,768,2560,768,9472},
+    {12,3584,2816,4352,1024,13568},
+    {12,3328,1536,5120,1536,18944},
+    {13,7168,5632,8704,2048,27136},
+    {13,6656,3072,10240,3072,37888},
+    {14,14336,11264,17408,4096,54272},
+    {14,13312,6144,20480,6144,75776},
+    {15,28672,22528,34816,8192,108544},
+    {15,26624,12288,40960,12288,151552},
+    {16,57344,45056,69632,16384,217088},
+    {16,53248,24576,81920,24576,303104},
+    {17,114688,90112,139264,32768,434176},
+    {17,106496,49152,163840,49152,606208},
+    {18,229376,180224,278528,65536,868352},
+    {18,212992,98304,327680,98304,1212416},
+    {19,458752,360448,557056,131072,1736704},
+    {19,425984,196608,655360,196608,2424832},
+    {20,917504,720896,1114112,262144,3473408},
+    {20,851968,393216,1310720,393216,4849664},
+    {21,1835008,1441792,2228224,524288,6946816},
+    {21,1703936,786432,2621440,786432,9699328},
+    {22,3670016,2883584,4456448,1048576,13893632},
+    {22,3407872,1572864,5242880,1572864,19398656},
+    {23,7340032,5767168,8912896,2097152,27787264},
+    {23,6815744,3145728,10485760,3145728,38797312},
+    {24,14680064,11534336,17825792,4194304,55574528},
+    {24,13631488,6291456,20971520,6291456,77594624},
+    {25,29360128,23068672,35651584,8388608,111149056},
+    {25,27262976,12582912,41943040,12582912,155189248},
+    {26,58720256,46137344,71303168,16777216,222298112},
+    {26,54525952,25165824,83886080,25165824,310378496},
+};
+constexpr int orders[16] = {4,8,12,16,24,32,48,64,80,96,112,128,160,192,224,256};
+int ceilLog2(uint32_t value) {
+    if (value <= 1) return int(value);
+    int n=0; for (--value; value; value>>=1) ++n; return n;
+}
+std::vector<int> boundaries(int rate, int samples, int alignment, int divider, int maxCount) {
+    std::vector<int> t{0};
+    for (int i=1; i<=maxCount; ++i) {
+        int v = (rate*i/divider+alignment/2) & -alignment;
+        t.push_back(v);
+        if (v >= samples) break;
+    }
+    if (t.back() < samples) t.back() = samples;
+    else if (t.back() > samples) {
+        t.back() = samples;
+        if (t.back()-t[t.size()-2] < alignment) t.erase(t.end()-2);
+    }
+    return t;
+}
+struct Filter { std::vector<int32_t> coefficients; int quant = 0, shift = 0; };
+struct Channel { std::vector<int32_t> data; int shift = 0; Filter prefilter; };
+class Payload {
+public:
+    Payload(const uint8_t* data, size_t bytes, const Tak1Decoder::Info& info, int samples)
+        : bits(data,bytes), info(info), n(samples), channelTable(boundaries(info.rate,samples,8,256,64)),
+          residueTable(boundaries(info.rate,samples,4,512,128)) {}
+    Tak1Decoder::Audio decode() {
+        Tak1Decoder::Audio audio; audio.samples = n;
+        Channel ch[2];
+        if (n < 16) {
+            for (int c=0; c<info.channels; ++c) {
+                ch[c].data.resize(n);
+                for (auto& v : ch[c].data) v = bits.signedValue(info.bits);
+            }
+        } else {
+            int mode = info.channels == 2 ? int(bits.get(3)) : 0;
+            require(mode <= 5, "invalid stereo mode");
+            int first = mode == 2 || mode == 4 ? 1 : 0;
+            ch[first] = channel();
+            Filter jointFilter; bool jointHead=false, jointTail=false;
+            if (mode == 1 || mode == 2) {
+                jointHead = bits.get(1); jointTail = bits.get(1);
+                int shift = int(bits.get(4));
+                require(bits.get(2) == 1, "invalid stereo filter");
+                jointFilter = coefficients(8); jointFilter.shift = shift;
+            }
+            if (info.channels == 2) {
+                ch[1-first] = channel();
+                if (mode >= 3) require(ch[0].prefilter.coefficients.empty() && ch[1].prefilter.coefficients.empty(), "incompatible stereo prefilter");
+                joint(mode,ch,jointFilter,jointHead,jointTail);
+            }
+            for (int c=0; c<info.channels; ++c) restore(ch[c]);
+        }
+        bits.align();
+        for (int c=0; c<info.channels; ++c) audio.channel[c] = std::move(ch[c].data);
+        return audio;
+    }
+    size_t consumedBytes() const { return bits.position()/8; }
+private:
+    Bits bits;
+    const Tak1Decoder::Info& info;
+    int n;
+    std::vector<int> channelTable, residueTable;
+    void segment(int mode, int escapeBits, int32_t* output, int count) {
+        require(mode >= 0 && mode <= 50 && count >= 0 && count <= n, "invalid residual segment");
+        if (!mode) { std::fill(output,output+count,0); return; }
+        for (int i=0; i<count; ++i) {
+            uint32_t v;
+            if (mode < 4) {
+                int base = 1 << (mode-1);
+                v = bits.get(mode);
+                if (v) output[i] = int(v) <= base ? int32_t(v)-1 : -(int32_t(v)-base);
+                else {
+                    int q = bits.unary(4);
+                    if (q < 4) {
+                        int sign = int(bits.get(1));
+                        output[i] = sign ? -(q+base) : q+base;
+                    } else {
+                        require(escapeBits > 0 && escapeBits <= 31, "invalid residual escape");
+                        v = bits.get(escapeBits);
+                        uint32_t half = 1U << (escapeBits-1);
+                        output[i] = wrap(v < half ? v+base+4 : 0U-(v+base+4-half));
+                    }
+                }
+            } else {
+                const auto& code = codes[mode-4];
+                v = bits.get(code.width);
+                if (int(v) < code.inner) output[i] = wrap((v>>1) ^ (0U-(v&1)));
+                else {
+                    uint32_t sign = bits.get(1);
+                    if (int(v) < code.outer) v += code.inner/2-code.inner;
+                    else {
+                        int q = bits.unary(9);
+                        if (q < 9) v += code.extended/2-code.outer + uint32_t(code.step/2)*q;
+                        else {
+                            require(escapeBits <= 42, "invalid residual escape");
+                            uint32_t extra = escapeBits > 10 ? bits.get(escapeBits-10) : 0;
+                            v += code.bias/2-code.outer + uint32_t(code.step/2)*extra;
+                        }
+                    }
+                    output[i] = wrap(v ^ (0U-sign));
+                }
+            }
+        }
+    }
+    void residues(int32_t* output, int count) {
+        require(count >= 0 && count <= n, "invalid residual count");
+        if (!bits.get(1)) {
+            int mode = int(bits.get(6));
+            int escape = mode ? int(bits.get(5)) : 1;
+            require(!mode || (escape > 0 && escape <= 28), "invalid residual escape");
+            segment(mode,escape,output,count); return;
+        }
+        int parts = 0;
+        while (parts+1 < int(residueTable.size()) && residueTable[parts+1] <= count) ++parts;
+        if (!parts || count-residueTable[parts] >= 4) ++parts;
+        require(parts > 1 && parts <= 128 && parts < int(residueTable.size()), "invalid residual partition count");
+        std::array<int,128> modes{};
+        int format = int(bits.get(2));
+        if (!format) {
+            int base = int(bits.get(6)), width = int(bits.get(3))+1;
+            for (int i=0; i<parts; ++i) modes[i] = base+int(bits.get(width));
+        } else {
+            require(format <= 2, "invalid residual mode table");
+            modes[0] = int(bits.get(6));
+            for (int i=1; i<parts; ++i) {
+                int mode = modes[i-1];
+                if (format == 1) {
+                    int q = bits.unary(9);
+                    if (q == 9) mode = int(bits.get(6));
+                    else if (q) mode += bits.get(1) ? -q : q;
+                } else {
+                    int v = int(bits.get(2));
+                    if (!v) {
+                        int q = bits.unary(7);
+                        if (q == 7) mode = int(bits.get(6));
+                        else mode += bits.get(1) ? -(q+2) : q+2;
+                    } else if (v == 2) --mode;
+                    else if (v == 3) ++mode;
+                }
+                modes[i] = mode;
+            }
+        }
+        int maxBits = int(bits.get(5));
+        require(maxBits <= 28, "invalid residual amplitude");
+        uint32_t limit = maxBits ? 1U << maxBits : 0;
+        int i=0;
+        while (i<parts) {
+            int mode = modes[i], start = i;
+            require(mode >= 0 && mode <= 50, "invalid residual coding mode");
+            while (i<parts && modes[i] == mode) ++i;
+            int end = i == parts ? count : residueTable[i];
+            int length = end-residueTable[start], escape = 1;
+            if (mode > 0 && mode < 4) {
+                int base = 1 << (mode-1);
+                if (limit > uint32_t(base+3)) escape = ceilLog2(limit-base-3)+1;
+            } else if (mode >= 4) {
+                const auto& code = codes[mode-4];
+                escape = limit*2 < uint32_t(code.bias+code.step) ? 10 : ceilLog2((limit*2-code.bias)/code.step+1)+10;
+            }
+            segment(mode,escape,output+residueTable[start],length);
+        }
+    }
+    Filter coefficients(int count) {
+        require(count > 0 && count <= 256 && !(count&3), "invalid predictor order");
+        Filter f; f.quant = int(bits.get(3));
+        bool differential = count >= 12 && bits.get(1);
+        f.coefficients.resize(count);
+        int base=0, deltaBits=0;
+        if (count > 16) { base = int(bits.get(4)); deltaBits = int(bits.get(1))+2; }
+        for (int i=0; i<count; i+=4) {
+            int width = count <= 16 ? int(bits.get(4)) : base-int(bits.get(deltaBits));
+            require(width > 0 && width <= 15, "invalid predictor coefficient size");
+            for (int j=0; j<4; ++j) f.coefficients[i+j] = bits.signedValue(width);
+        }
+        if (differential) {
+            for (int i=1; i<count; ++i) f.coefficients[i] = wrap(uint32_t(f.coefficients[i])-uint32_t(f.coefficients[i-1]));
+            for (int32_t v : f.coefficients) require(magnitude(v) <= 8191, "invalid differential predictor");
+        }
+        return f;
+    }
+    void lpc(int32_t* x, int count, int mode) {
+        require(mode <= 2, "invalid residual prediction mode");
+        for (int level=0; level<mode; ++level)
+            for (int i=mode-level; i<count; ++i) x[i] = wrap(uint32_t(x[i])+uint32_t(x[i-1]));
+    }
+    void fir(int32_t* x, int count, const Filter& f) {
+        int order = int(f.coefficients.size());
+        require(count >= order, "predictor exceeds subframe");
+        std::vector<int32_t> history(count);
+        for (int i=0; i<order; ++i) history[i] = truncate(x[i],f.shift);
+        for (int i=order; i<count; ++i) {
+            uint32_t sum = 0;
+            for (int j=0; j<order; ++j) sum += uint32_t(history[i-order+j])*uint32_t(f.coefficients[j]);
+            int32_t prediction = predict(sum,10-f.quant);
+            x[i] = wrap((uint32_t(prediction)<<f.shift)-uint32_t(x[i]));
+            history[i] = truncate(x[i],f.shift);
+        }
+    }
+    void plain(int32_t* x, int count) {
+        int mode = int(bits.get(2)); residues(x,count); lpc(x,count,mode);
+    }
+    void subframe(int32_t* x, int count) {
+        if (!bits.get(1)) { plain(x,count); return; }
+        int shift = int(bits.get(4)), order = orders[bits.get(4)];
+        require(order <= count, "predictor exceeds subframe");
+        plain(x,order);
+        Filter f = coefficients(order); f.shift = shift;
+        residues(x+order,count-order); fir(x,count,f);
+    }
+    Channel channel() {
+        Channel c;
+        c.shift = bits.get(1) ? int(bits.get(4))+1 : 0;
+        require(c.shift < info.bits, "invalid sample shift");
+        c.data.resize(n);
+        c.data[0] = wrap(uint32_t(bits.signedValue(info.bits-c.shift)) << c.shift);
+        if (bits.get(1)) {
+            int shift = int(bits.get(4));
+            require(bits.get(2) == 0, "invalid channel prefilter");
+            c.prefilter = coefficients(4); c.prefilter.shift = shift;
+        }
+        int parts = int(bits.get(3))+1;
+        require(parts <= 5, "invalid channel partition count");
+        int offsets[6]{};
+        for (int i=1; i<parts; ++i) {
+            int index = int(bits.get(6));
+            require(index > 0 && index < int(channelTable.size())-1, "invalid channel partition index");
+            offsets[i] = channelTable[index];
+            require(offsets[i] > offsets[i-1], "unordered channel partitions");
+        }
+        offsets[parts] = n-1;
+        for (int i=0; i<parts; ++i) {
+            int length = offsets[i+1]-offsets[i];
+            require(length > 0, "empty channel partition");
+            subframe(c.data.data()+1+offsets[i],length);
+        }
+        return c;
+    }
+    void restore(Channel& c) {
+        if (!c.prefilter.coefficients.empty()) fir(c.data.data()+1,n-1,c.prefilter);
+        for (int i=1; i<n; ++i) c.data[i] = wrap(uint32_t(c.data[i]) << c.shift);
+        for (int i=1; i<n; ++i) c.data[i] = wrap(uint32_t(c.data[i])+uint32_t(c.data[i-1]));
+    }
+    void joint(int mode, Channel* ch, const Filter& filter, bool head, bool tail) {
+        if (!mode) return;
+        auto& left = ch[0].data; auto& right = ch[1].data;
+        if (mode <= 2) {
+            require(n-1 >= 256, "stereo predictor needs a longer frame");
+            const auto& source = mode == 1 ? left : right;
+            auto& target = mode == 1 ? right : left;
+            const int start=4, body=n-1-7;
+            for (int i=0; i<start; ++i) if (head) target[1+i] = wrap(uint32_t(target[1+i])+uint32_t(source[1+i]));
+            for (int i=start+body; i<n-1; ++i) if (tail) target[1+i] = wrap(uint32_t(target[1+i])+uint32_t(source[1+i]));
+            for (int i=0; i<body; ++i) {
+                uint32_t sum=0;
+                for (int j=0; j<8; ++j) sum += uint32_t(truncate(source[1+i+j],filter.shift))*uint32_t(filter.coefficients[j]);
+                target[1+start+i] = wrap((uint32_t(predict(sum,10))<<filter.shift)-uint32_t(target[1+start+i]));
+            }
+        } else {
+            for (int i=1; i<n; ++i) {
+                if (mode == 3) right[i] = wrap(uint32_t(right[i])+uint32_t(left[i]));
+                else if (mode == 4) left[i] = wrap(uint32_t(right[i])-uint32_t(left[i]));
+                else {
+                    uint32_t mid = uint32_t(left[i])*2+(uint32_t(right[i])&1);
+                    uint32_t difference = mid-uint32_t(right[i]);
+                    uint32_t sum = mid+uint32_t(right[i]);
+                    left[i] = wrap((difference>>1) | (difference & 0x80000000U));
+                    right[i] = wrap((sum>>1) | (sum & 0x80000000U));
+                }
+            }
+        }
+    }
+};
+}
+Tak1Decoder::Tak1Decoder(const uint8_t* data, size_t size) {
+    Bits b(data,size); initial = current = streamInfo(b);
+}
+Tak1Decoder::Audio Tak1Decoder::decode(const uint8_t* packet, size_t size) {
+    Bits b(packet,size);
+    Info frameInfo = current;
+    require(b.get(16) == 0xA0FF, "missing frame sync");
+    uint32_t flags = b.get(3), number = b.get(21);
+    int samples = 0;
+    if (flags&1) { samples = int(b.get(14))+1; require(b.get(2) == 0, "invalid last frame flags"); }
+    if (flags&2) {
+        Info parsed = streamInfo(b);
+        require(parsed.rate == initial.rate && parsed.bits == initial.bits && parsed.channels == initial.channels, "PCM format changes inside the track");
+        frameInfo = parsed;
+        if (b.get(6)) b.get(25);
+        b.align();
+    }
+    require(!(flags&4), "unsupported per-frame metadata");
+    b.get(24);
+    size_t header = b.position()/8;
+    checkCrc(packet,header);
+    // Verify compressed data before allowing it to drive allocations or filters.
+    checkCrc(packet+header,size-header);
+    samples = samples ? samples : frameInfo.frameSamples;
+    require(samples > 0 && samples <= frameInfo.frameSamples, "invalid last frame size");
+    Payload payload(packet+header,size-header,frameInfo,samples);
+    Audio audio = payload.decode();
+    size_t bytes = payload.consumedBytes()+3;
+    require(bytes == size-header, "unexpected frame payload length");
+    audio.frameNumber = number;
+    current = frameInfo;
+    return audio;
+}
diff --git a/app/src/main/cpp/tak1.h b/app/src/main/cpp/tak1.h
new file mode 100644
index 0000000..3acd9fb
--- /dev/null
+++ b/app/src/main/cpp/tak1.h
@@ -0,0 +1,23 @@
+#pragma once
+#include <cstddef>
+#include <cstdint>
+#include <vector>
+
+// Native TAK 1.0 (codec type 0) decoder. No Windows DLL or CPU emulation is used.
+// FFmpeg continues to provide container parsing, packets, timestamps and seeking.
+class Tak1Decoder {
+public:
+    struct Info { int rate, bits, channels, frameSamples; };
+    struct Audio {
+        int samples = 0;
+        uint32_t frameNumber = 0;
+        // Signed PCM in the original bit depth, before AudioTrack conversion.
+        std::vector<int32_t> channel[2];
+    };
+    Tak1Decoder(const uint8_t* streamInfo, size_t size);
+    const Info& info() const { return initial; }
+    Audio decode(const uint8_t* packet, size_t size);
+    void reset() { current = initial; }
+private:
+    Info initial{}, current{};
+};
diff --git a/app/src/main/java/net/astropenguin/loopdeck/LoopMath.java b/app/src/main/java/net/astropenguin/loopdeck/LoopMath.java
new file mode 100644
index 0000000..b70af91
--- /dev/null
+++ b/app/src/main/java/net/astropenguin/loopdeck/LoopMath.java
@@ -0,0 +1,17 @@
+package net.astropenguin.loopdeck;
+
+/** Frame arithmetic is independent of Android and covered by a CLI test. */
+final class LoopMath {
+    static final long CACHE_BYTES = 32L * 1024 * 1024;
+    static boolean valid(long a, long b) { return a >= 0 && b > a; }
+    static long frame(long ms, int rate) { return ms * rate / 1000; }
+    static long millis(long frame, int rate) { return frame * 1000 / rate; }
+    static long position(long base, long played, long a, long b, boolean loop) {
+        long absolute = base + played;
+        return loop && valid(a, b) && absolute >= b ? a + Math.floorMod(absolute - a, b - a) : absolute;
+    }
+    static int cacheSamples(long frames) {
+        return frames > 0 && frames <= CACHE_BYTES / (2 * Float.BYTES) ? (int)(frames * 2) : 0;
+    }
+    private LoopMath() {}
+}
diff --git a/app/src/main/java/net/astropenguin/loopdeck/MainActivity.java b/app/src/main/java/net/astropenguin/loopdeck/MainActivity.java
new file mode 100644
index 0000000..a768783
--- /dev/null
+++ b/app/src/main/java/net/astropenguin/loopdeck/MainActivity.java
@@ -0,0 +1,251 @@
+package net.astropenguin.loopdeck;
+
+import android.app.*;
+import android.content.*;
+import android.database.Cursor;
+import android.graphics.Color;
+import android.net.Uri;
+import android.os.*;
+import android.provider.DocumentsContract;
+import android.text.InputType;
+import android.view.*;
+import android.widget.*;
+import java.util.*;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicInteger;
+
+public final class MainActivity extends Activity {
+    private static final int PICK_FOLDER=10;
+    private static final int TEXT=Color.rgb(232,240,235), MUTED=Color.rgb(159,182,168);
+    private final Handler main=new Handler(Looper.getMainLooper());
+    private final ExecutorService files=Executors.newSingleThreadExecutor();
+    private final AtomicInteger generation=new AtomicInteger();
+    private final ArrayList<Entry> entries=new ArrayList<>();
+    private final ArrayList<String> parents=new ArrayList<>();
+    private PlaybackService player;
+    private boolean bound, dragging, resumed;
+    private Uri tree;
+    private String directory;
+    private TextView folderLabel, trackTitle, trackFormat, times, loopTimes, status;
+    private Button play, setA, setB, loopButton, clear, previous, next, stop;
+    private SeekBar seek;
+    private EntryAdapter adapter;
+
+    private final ServiceConnection connection=new ServiceConnection() {
+        @Override public void onServiceConnected(ComponentName name, IBinder binder) {
+            player=((PlaybackService.LocalBinder)binder).service(); render();
+        }
+        @Override public void onServiceDisconnected(ComponentName name) { player=null; }
+    };
+    private final Runnable tick=new Runnable() {
+        @Override public void run() { if (resumed) { render(); main.postDelayed(this,250); } }
+    };
+    @Override public void onCreate(Bundle saved) {
+        super.onCreate(saved);
+        buildUi();
+        bound=bindService(new Intent(this,PlaybackService.class),connection,BIND_AUTO_CREATE);
+        String stored=getPreferences(MODE_PRIVATE).getString("tree",null);
+        if (saved!=null) stored=saved.getString("tree",stored);
+        if (stored!=null) {
+            tree=Uri.parse(stored);
+            directory=saved==null ? DocumentsContract.getTreeDocumentId(tree) : saved.getString("directory",DocumentsContract.getTreeDocumentId(tree));
+            if (saved!=null && saved.getStringArrayList("parents")!=null) parents.addAll(saved.getStringArrayList("parents"));
+            listFolder();
+        }
+    }
+    private int dp(int value) { return Math.round(getResources().getDisplayMetrics().density*value); }
+    private TextView text(String content, int sp, int color) {
+        TextView view=new TextView(this); view.setText(content); view.setTextSize(sp); view.setTextColor(color); return view;
+    }
+    private LinearLayout row() {
+        LinearLayout view=new LinearLayout(this); view.setOrientation(LinearLayout.HORIZONTAL); view.setGravity(Gravity.CENTER_VERTICAL); return view;
+    }
+    private Button button(LinearLayout parent, String label, View.OnClickListener listener) {
+        Button view=new Button(this); view.setText(label); view.setAllCaps(false); view.setTextSize(14);
+        view.setMinWidth(0); view.setMinimumWidth(0); view.setPadding(dp(4),0,dp(4),0);
+        view.setOnClickListener(listener); parent.addView(view,new LinearLayout.LayoutParams(0,dp(48),1)); return view;
+    }
+    private void buildUi() {
+        LinearLayout root=new LinearLayout(this); root.setOrientation(LinearLayout.VERTICAL);
+        root.setPadding(dp(12),dp(6),dp(12),dp(6)); setContentView(root);
+        LinearLayout top=row();
+        TextView brand=text("LoopDeck",22,TEXT); top.addView(brand,new LinearLayout.LayoutParams(0,dp(44),1));
+        Button choose=new Button(this); choose.setText("Music folder"); choose.setAllCaps(false); choose.setOnClickListener(v -> pickFolder());
+        top.addView(choose); root.addView(top);
+        LinearLayout navigation=row();
+        Button up=new Button(this); up.setText("↑"); up.setContentDescription("Parent folder"); up.setOnClickListener(v -> goUp());
+        navigation.addView(up,new LinearLayout.LayoutParams(dp(48),dp(44)));
+        folderLabel=text("Select a folder on internal storage or microSD",12,MUTED); folderLabel.setMaxLines(2);
+        navigation.addView(folderLabel,new LinearLayout.LayoutParams(0,dp(44),1)); root.addView(navigation);
+        ListView list=new ListView(this); adapter=new EntryAdapter(); list.setAdapter(adapter);
+        list.setOnItemClickListener((parent,view,position,id) -> select(entries.get(position)));
+        root.addView(list,new LinearLayout.LayoutParams(-1,0,1));
+        View divider=new View(this); divider.setBackgroundColor(Color.rgb(54,75,63)); root.addView(divider,new LinearLayout.LayoutParams(-1,dp(1)));
+        trackTitle=text("Choose a track",18,TEXT); trackTitle.setMaxLines(2); trackTitle.setPadding(0,dp(6),0,0); root.addView(trackTitle);
+        trackFormat=text("FFmpeg audio player",11,MUTED); trackFormat.setMaxLines(2); root.addView(trackFormat);
+        times=text("0:00.000 / 0:00.000",13,TEXT); root.addView(times);
+        seek=new SeekBar(this); seek.setMax(10000); root.addView(seek,new LinearLayout.LayoutParams(-1,dp(36)));
+        seek.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
+            @Override public void onStartTrackingTouch(SeekBar bar) { dragging=true; }
+            @Override public void onProgressChanged(SeekBar bar,int value,boolean user) {
+                if (user && player!=null) times.setText(time(player.snapshot.duration*value/10000)+" / "+time(player.snapshot.duration));
+            }
+            @Override public void onStopTrackingTouch(SeekBar bar) {
+                if (player!=null) player.seek(player.snapshot.duration*bar.getProgress()/10000); dragging=false;
+            }
+        });
+        LinearLayout transport=row();
+        previous=button(transport,"Previous",v -> { if (player!=null) { ensureForeground(); player.next(-1); } });
+        play=button(transport,"Play",v -> { if (player!=null) { ensureForeground(); player.toggle(); } });
+        next=button(transport,"Next",v -> { if (player!=null) { ensureForeground(); player.next(1); } });
+        stop=button(transport,"Stop",v -> { if (player!=null) player.stopPlayback(); }); root.addView(transport);
+        LinearLayout markers=row();
+        setA=button(markers,"Set A",v -> { if (player!=null) player.markA(); });
+        setB=button(markers,"Set B",v -> { if (player!=null) player.markB(); });
+        loopButton=button(markers,"Loop off",v -> { if (player!=null) player.toggleLoop(); });
+        clear=button(markers,"Clear",v -> { if (player!=null) player.clearLoop(); }); root.addView(markers);
+        setA.setOnLongClickListener(v -> { editMarkers(); return true; });
+        setB.setOnLongClickListener(v -> { editMarkers(); return true; });
+        loopTimes=text("A —     B —   ·   Hold Set A/B to edit times",11,MUTED); root.addView(loopTimes);
+        status=text("Ready",12,MUTED); status.setMaxLines(3); status.setTextIsSelectable(true); root.addView(status);
+    }
+    private void pickFolder() {
+        Intent intent=new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
+        intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION);
+        startActivityForResult(intent,PICK_FOLDER);
+    }
+    @Override protected void onActivityResult(int request,int result,Intent data) {
+        super.onActivityResult(request,result,data);
+        if (request!=PICK_FOLDER || result!=RESULT_OK || data==null || data.getData()==null) return;
+        Uri chosen=data.getData();
+        try {
+            getContentResolver().takePersistableUriPermission(chosen,data.getFlags() & Intent.FLAG_GRANT_READ_URI_PERMISSION);
+            tree=chosen; directory=DocumentsContract.getTreeDocumentId(tree); parents.clear();
+            getPreferences(MODE_PRIVATE).edit().putString("tree",tree.toString()).apply(); listFolder();
+        } catch (Exception e) { showError("Folder permission failed: "+e.getMessage()); }
+    }
+    private void listFolder() {
+        if (tree==null) return;
+        final Uri selectedTree=tree;
+        final String selectedDirectory=directory;
+        final int request=generation.incrementAndGet();
+        entries.clear(); adapter.notifyDataSetChanged(); folderLabel.setText("Loading folder…");
+        files.submit(() -> {
+            ArrayList<Entry> result=new ArrayList<>();
+            String error=null;
+            try {
+                Uri children=DocumentsContract.buildChildDocumentsUriUsingTree(selectedTree,selectedDirectory);
+                String[] columns={DocumentsContract.Document.COLUMN_DOCUMENT_ID,DocumentsContract.Document.COLUMN_DISPLAY_NAME,
+                    DocumentsContract.Document.COLUMN_MIME_TYPE};
+                try (Cursor c=getContentResolver().query(children,columns,null,null,null)) {
+                    if (c==null) throw new IllegalStateException("Storage provider returned no folder listing");
+                    while (c.moveToNext()) {
+                        if (request!=generation.get()) return;
+                        String id=c.getString(0), name=c.getString(1), mime=c.getString(2);
+                        result.add(new Entry(id,name==null ? id : name,mime,DocumentsContract.buildDocumentUriUsingTree(selectedTree,id)));
+                    }
+                }
+                result.sort((x,y) -> x.folder!=y.folder ? (x.folder ? -1 : 1) : x.name.compareToIgnoreCase(y.name));
+            } catch (Exception e) { error="Cannot read folder: "+e.getMessage()+". Choose the folder again if the card changed."; }
+            final String failure=error;
+            main.post(() -> {
+                if (isDestroyed() || request!=generation.get()) return;
+                entries.addAll(result); adapter.notifyDataSetChanged();
+                folderLabel.setText(selectedDirectory+" · "+result.size()+" items");
+                if (failure!=null) showError(failure);
+            });
+        });
+    }
+    private void select(Entry entry) {
+        if (entry.folder) { parents.add(directory); directory=entry.id; listFolder(); return; }
+        if (player==null) { showError("Player is still starting"); return; }
+        ArrayList<Track> tracks=new ArrayList<>(); int selected=0;
+        for (Entry candidate:entries) {
+            if (!candidate.folder && (candidate==entry || candidate.audio)) {
+                if (candidate==entry) selected=tracks.size();
+                tracks.add(new Track(candidate.uri,candidate.name));
+            }
+        }
+        ensureForeground(); player.play(tracks,selected);
+    }
+    private void ensureForeground() { startForegroundService(new Intent(this,PlaybackService.class)); }
+    private boolean goUp() {
+        if (parents.isEmpty()) return false;
+        directory=parents.remove(parents.size()-1); listFolder(); return true;
+    }
+    @Override public void onBackPressed() { if (!goUp()) super.onBackPressed(); }
+    private void render() {
+        if (player==null) return;
+        PlaybackService.Snapshot s=player.snapshot;
+        replace(trackTitle,s.title); replace(trackFormat,s.format); replace(status,s.status);
+        replace(play,s.playing ? "Pause" : "Play"); replace(loopButton,s.loop ? "Loop on" : "Loop off");
+        if (!dragging) {
+            replace(times,time(s.position)+" / "+time(s.duration));
+            int value=s.duration>0 ? (int)Math.min(10000,s.position*10000/s.duration) : 0;
+            if (seek.getProgress()!=value) seek.setProgress(value);
+        }
+        seek.setEnabled(s.loaded && s.duration>0);
+        play.setEnabled(s.loaded); stop.setEnabled(s.loaded);
+        setA.setEnabled(s.loaded); setB.setEnabled(s.loaded); loopButton.setEnabled(s.loaded); clear.setEnabled(s.loaded);
+        replace(loopTimes,"A "+(s.a<0 ? "—" : time(s.a))+"     B "+(s.b<0 ? "—" : time(s.b))+"   ·   Hold to edit");
+    }
+    private static void replace(TextView view,String value) { if (!view.getText().toString().equals(value)) view.setText(value); }
+    private static String time(long ms) {
+        ms=Math.max(ms,0);
+        return String.format(Locale.ROOT,"%d:%02d.%03d",ms/60000,ms/1000%60,ms%1000);
+    }
+    private void editMarkers() {
+        if (player==null || !player.snapshot.loaded) return;
+        PlaybackService.Snapshot s=player.snapshot;
+        LinearLayout form=new LinearLayout(this); form.setOrientation(LinearLayout.VERTICAL); form.setPadding(dp(20),0,dp(20),0);
+        form.addView(text("Times in milliseconds. Leave a field blank to unset it.",14,MUTED));
+        EditText inputA=new EditText(this), inputB=new EditText(this);
+        inputA.setHint("A (ms)"); inputB.setHint("B (ms)");
+        inputA.setInputType(InputType.TYPE_CLASS_NUMBER); inputB.setInputType(InputType.TYPE_CLASS_NUMBER);
+        if (s.a>=0) inputA.setText(Long.toString(s.a)); if (s.b>=0) inputB.setText(Long.toString(s.b));
+        form.addView(inputA); form.addView(inputB);
+        AlertDialog dialog=new AlertDialog.Builder(this).setTitle("Edit A–B loop").setView(form)
+            .setNegativeButton("Cancel",null).setPositiveButton("Apply",null).create();
+        dialog.setOnShowListener(v -> dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(click -> {
+            try {
+                long newA=inputA.getText().length()==0 ? -1 : Long.parseLong(inputA.getText().toString());
+                long newB=inputB.getText().length()==0 ? -1 : Long.parseLong(inputB.getText().toString());
+                if (newA>Long.MAX_VALUE/384000 || newB>Long.MAX_VALUE/384000) throw new NumberFormatException();
+                if (newA>=0 && newB>=0 && newB<=newA) { inputB.setError("B must be after A"); return; }
+                if (player!=null) player.markers(newA,newB); dialog.dismiss();
+            } catch (NumberFormatException e) { inputA.setError("Enter valid milliseconds"); }
+        })); dialog.show();
+    }
+    private void showError(String message) { new AlertDialog.Builder(this).setTitle("LoopDeck").setMessage(message).setPositiveButton("OK",null).show(); }
+    @Override protected void onResume() { super.onResume(); resumed=true; main.post(tick); }
+    @Override protected void onPause() { resumed=false; main.removeCallbacks(tick); super.onPause(); }
+    @Override protected void onSaveInstanceState(Bundle out) {
+        super.onSaveInstanceState(out);
+        if (tree!=null) { out.putString("tree",tree.toString()); out.putString("directory",directory); out.putStringArrayList("parents",parents); }
+    }
+    @Override protected void onDestroy() {
+        generation.incrementAndGet(); files.shutdownNow(); main.removeCallbacks(tick);
+        if (bound) unbindService(connection); super.onDestroy();
+    }
+    private static final class Entry {
+        final String id,name; final Uri uri; final boolean folder,audio;
+        Entry(String id,String name,String mime,Uri uri) {
+            this.id=id; this.name=name; this.uri=uri;
+            folder=DocumentsContract.Document.MIME_TYPE_DIR.equals(mime);
+            audio=(mime!=null && mime.startsWith("audio/")) || name.toLowerCase(Locale.ROOT).matches(
+                ".*\\.(mp3|mp2|mp1|flac|ape|tak|tta|wv|wavpack|wma|wav|wave|aif|aiff|aifc|m4a|mp4|aac|ogg|oga|opus|mpc|mpp|shn|dsf|dff|ac3|dts|amr|au|snd|caf|ra|rm|spx|mka)$");
+        }
+    }
+    private final class EntryAdapter extends BaseAdapter {
+        @Override public int getCount() { return entries.size(); }
+        @Override public Object getItem(int position) { return entries.get(position); }
+        @Override public long getItemId(int position) { return position; }
+        @Override public View getView(int position,View reusable,ViewGroup parent) {
+            TextView view=reusable instanceof TextView ? (TextView)reusable : text("",15,TEXT);
+            view.setPadding(dp(10),dp(12),dp(6),dp(12)); view.setMinHeight(dp(50)); view.setMaxLines(2);
+            Entry e=entries.get(position); view.setText((e.folder ? "▸  " : "")+e.name);
+            view.setTextColor(e.folder ? Color.rgb(115,218,193) : TEXT); return view;
+        }
+    }
+}
diff --git a/app/src/main/java/net/astropenguin/loopdeck/NativeDecoder.java b/app/src/main/java/net/astropenguin/loopdeck/NativeDecoder.java
new file mode 100644
index 0000000..5b56c42
--- /dev/null
+++ b/app/src/main/java/net/astropenguin/loopdeck/NativeDecoder.java
@@ -0,0 +1,17 @@
+package net.astropenguin.loopdeck;
+
+import java.io.IOException;
+
+final class NativeDecoder {
+    static { System.loadLibrary("avutil"); System.loadLibrary("swresample");
+        System.loadLibrary("avcodec"); System.loadLibrary("avformat"); System.loadLibrary("loopdeck"); }
+    static native long open(int fd) throws IOException;
+    static native void close(long handle);
+    static native int sourceRate(long handle) throws IOException;
+    static native long duration(long handle) throws IOException;
+    static native String description(long handle) throws IOException;
+    static native void outputRate(long handle, int rate) throws IOException;
+    static native int read(long handle, float[] buffer, int frames) throws IOException;
+    static native void seek(long handle, long outputFrame) throws IOException;
+    private NativeDecoder() {}
+}
diff --git a/app/src/main/java/net/astropenguin/loopdeck/PlaybackService.java b/app/src/main/java/net/astropenguin/loopdeck/PlaybackService.java
new file mode 100644
index 0000000..b84c277
--- /dev/null
+++ b/app/src/main/java/net/astropenguin/loopdeck/PlaybackService.java
@@ -0,0 +1,323 @@
+package net.astropenguin.loopdeck;
+
+import android.app.*;
+import android.content.*;
+import android.media.*;
+import android.media.session.MediaSession;
+import android.media.session.PlaybackState;
+import android.os.*;
+import java.io.IOException;
+import java.util.*;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.TimeUnit;
+
+public final class PlaybackService extends Service {
+    private static final String CHANNEL = "playback";
+    private static final int NOTIFICATION = 1;
+    public final class LocalBinder extends Binder { PlaybackService service() { return PlaybackService.this; } }
+    private final LocalBinder binder = new LocalBinder();
+    private interface Command { void run() throws Exception; }
+    private final LinkedBlockingQueue<Command> commands = new LinkedBlockingQueue<>();
+    private volatile boolean running = true;
+    private Thread worker;
+    private AudioManager audioManager;
+    private AudioFocusRequest focus;
+    private PowerManager.WakeLock wake;
+    private MediaSession session;
+    private volatile boolean foreground;
+
+    // Everything below, except the immutable published snapshot, belongs to the worker.
+    private long decoder;
+    private AudioTrack output;
+    private List<Track> queue = Collections.emptyList();
+    private int index = -1, rate;
+    private long duration, a = -1, b = -1, cursor, base, written, headEpoch, lastHead;
+    private boolean playing, loop, eof, resumeOnFocus;
+    private String title = "Choose a music folder", format = "", status = "Ready";
+    private final float[] block = new float[4096]; // 2048 stereo frames
+    private int blockSamples, blockOffset;
+    private float[] cache;
+    private int cacheFilled, cacheRead;
+    private long lastPublish, lastSession;
+
+    static final class Snapshot {
+        final String title, format, status;
+        final long position, duration, a, b;
+        final boolean playing, loop, loaded;
+        Snapshot(String title, String format, String status, long position, long duration,
+                 long a, long b, boolean playing, boolean loop, boolean loaded) {
+            this.title=title; this.format=format; this.status=status; this.position=position;
+            this.duration=duration; this.a=a; this.b=b; this.playing=playing; this.loop=loop; this.loaded=loaded;
+        }
+    }
+    volatile Snapshot snapshot = new Snapshot(title, "", status, 0, 0, -1, -1, false, false, false);
+
+    @Override public void onCreate() {
+        super.onCreate();
+        audioManager = (AudioManager)getSystemService(AUDIO_SERVICE);
+        wake = ((PowerManager)getSystemService(POWER_SERVICE)).newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "LoopDeck:playback");
+        wake.setReferenceCounted(false);
+        AudioAttributes attributes = new AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA)
+            .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC).build();
+        focus = new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN).setAudioAttributes(attributes)
+            .setOnAudioFocusChangeListener(change -> submit(() -> focusChanged(change)), new Handler(getMainLooper())).build();
+        ((NotificationManager)getSystemService(NOTIFICATION_SERVICE)).createNotificationChannel(
+            new NotificationChannel(CHANNEL, "Music playback", NotificationManager.IMPORTANCE_LOW));
+        session = new MediaSession(this, "LoopDeck");
+        session.setCallback(new MediaSession.Callback() {
+            @Override public void onPlay() { submit(() -> setPlaying(true)); }
+            @Override public void onPause() { submit(() -> { resumeOnFocus=false; setPlaying(false); }); }
+            @Override public void onStop() { stopPlayback(); }
+            @Override public void onSkipToNext() { next(1); }
+            @Override public void onSkipToPrevious() { next(-1); }
+            @Override public void onSeekTo(long pos) { seek(pos); }
+        });
+        session.setActive(true);
+        registerReceiver(noisy, new IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY));
+        worker = new Thread(this::work, "LoopDeck playback");
+        worker.start();
+    }
+    private final BroadcastReceiver noisy = new BroadcastReceiver() {
+        @Override public void onReceive(Context c, Intent i) { submit(() -> { resumeOnFocus=false; setPlaying(false); }); }
+    };
+    @Override public IBinder onBind(Intent intent) { return binder; }
+    @Override public int onStartCommand(Intent intent, int flags, int startId) {
+        // Satisfy startForegroundService before doing any file IO.
+        startForeground(NOTIFICATION, notification()); foreground = true;
+        if (intent != null) {
+            String action = intent.getAction();
+            if ("toggle".equals(action)) toggle();
+            if ("next".equals(action)) next(1);
+            if ("previous".equals(action)) next(-1);
+            if ("stop".equals(action)) stopPlayback();
+        }
+        return START_NOT_STICKY;
+    }
+    private void submit(Command command) { if (running) commands.offer(command); }
+    void play(List<Track> tracks, int selected) {
+        List<Track> copy = new ArrayList<>(tracks);
+        submit(() -> { queue=copy; load(selected); });
+    }
+    void toggle() { submit(() -> { resumeOnFocus=false; setPlaying(!playing); }); }
+    void next(int direction) { submit(() -> { if (!queue.isEmpty()) load(Math.floorMod(index + direction, queue.size())); }); }
+    void seek(long ms) { submit(() -> {
+        if (decoder == 0) return;
+        long target = LoopMath.frame(Math.max(0, duration > 0 ? Math.min(ms,duration) : ms), rate);
+        if (loop && (target < a || target >= b)) { loop=false; cache=null; }
+        resetAt(target); status=loop ? "A–B loop" : "Ready";
+    }); }
+    void markA() { submit(() -> { if (decoder!=0) changeMarkers(positionFrames(), b); }); }
+    void markB() { submit(() -> { if (decoder!=0) changeMarkers(a, positionFrames()); }); }
+    void markers(long aMs, long bMs) { submit(() -> {
+        if (decoder!=0) changeMarkers(aMs < 0 ? -1 : LoopMath.frame(aMs,rate), bMs < 0 ? -1 : LoopMath.frame(bMs,rate));
+    }); }
+    void clearLoop() { submit(() -> {
+        if (decoder==0) return;
+        long here=positionFrames(); loop=false; a=b=-1; cache=null; resetAt(here); status="Loop cleared";
+    }); }
+    void toggleLoop() { submit(() -> {
+        if (decoder==0) return;
+        if (!LoopMath.valid(a,b)) { status="Set A, then set B after A"; return; }
+        if (loop) { long here=positionFrames(); loop=false; cache=null; resetAt(here); status="Loop off"; }
+        else activateLoop();
+    }); }
+    void stopPlayback() { submit(() -> {
+        setPlaying(false); releaseAudio(); title="Stopped"; format=""; status="Choose a track";
+        a=b=-1; loop=false; duration=0; updateSession();
+        new Handler(getMainLooper()).post(() -> { stopForeground(true); foreground=false; stopSelf(); });
+    }); }
+    private void changeMarkers(long newA, long newB) throws IOException {
+        long total=duration > 0 ? LoopMath.frame(duration,rate) : Long.MAX_VALUE;
+        if (newA < -1 || newB < -1 || newA > total || newB > total) { status="Marker is outside the track"; return; }
+        // Never leave a previous loop running after an invalid edit.
+        long here=positionFrames(); loop=false; cache=null;
+        a=newA; b=newB;
+        if (LoopMath.valid(a,b)) activateLoop();
+        else { resetAt(here); status=a < 0 ? "Set A first" : "A set; set B after A"; }
+    }
+    private void activateLoop() throws IOException {
+        loop=true;
+        int samples=LoopMath.cacheSamples(b-a);
+        cache=null;
+        if (samples > 0) { try { cache=new float[samples]; } catch (OutOfMemoryError ignored) { /* seek fallback */ } }
+        cacheFilled=cacheRead=0;
+        resetAt(a);
+        status=cache != null ? "A–B loop · buffering first pass" : "A–B loop · seek mode";
+    }
+    private void load(int selected) throws Exception {
+        if (selected < 0 || selected >= queue.size()) return;
+        setPlaying(false); releaseAudio();
+        index=selected; title=queue.get(index).name; format=""; status="Opening…"; resumeOnFocus=false;
+        a=b=-1; loop=false; duration=0; publish(); updateSession();
+        try (ParcelFileDescriptor file=getContentResolver().openFileDescriptor(queue.get(index).uri,"r")) {
+            if (file==null) throw new IOException("Cannot open selected file");
+            decoder=NativeDecoder.open(file.getFd());
+        }
+        int sourceRate=NativeDecoder.sourceRate(decoder);
+        duration=NativeDecoder.duration(decoder);
+        format=NativeDecoder.description(decoder);
+        rate=sourceRate;
+        try { output=createOutput(rate); }
+        catch (IllegalArgumentException | UnsupportedOperationException e) {
+            rate=48000; output=createOutput(rate);
+        }
+        NativeDecoder.outputRate(decoder,rate);
+        format += "\nOutput: stereo float PCM · " + rate + " Hz (requested)";
+        cursor=base=written=headEpoch=lastHead=0; blockSamples=blockOffset=0; eof=false;
+        status=sourceRate==rate ? "Ready" : "Resampling to 48000 Hz";
+        session.setMetadata(new MediaMetadata.Builder().putString(MediaMetadata.METADATA_KEY_TITLE,title)
+            .putLong(MediaMetadata.METADATA_KEY_DURATION,duration).build());
+        setPlaying(true);
+    }
+    private AudioTrack createOutput(int hz) {
+        int minimum=AudioTrack.getMinBufferSize(hz,AudioFormat.CHANNEL_OUT_STEREO,AudioFormat.ENCODING_PCM_FLOAT);
+        if (minimum <= 0) throw new IllegalArgumentException("Unsupported output sample rate: " + hz);
+        AudioTrack result = new AudioTrack.Builder().setAudioAttributes(new AudioAttributes.Builder()
+            .setUsage(AudioAttributes.USAGE_MEDIA).setContentType(AudioAttributes.CONTENT_TYPE_MUSIC).build())
+            .setAudioFormat(new AudioFormat.Builder().setSampleRate(hz).setEncoding(AudioFormat.ENCODING_PCM_FLOAT)
+                .setChannelMask(AudioFormat.CHANNEL_OUT_STEREO).build())
+            .setBufferSizeInBytes(Math.max(minimum,hz / 10 * 8)).setTransferMode(AudioTrack.MODE_STREAM).build();
+        if (result.getState()!=AudioTrack.STATE_INITIALIZED) { result.release(); throw new IllegalArgumentException("Audio output initialization failed"); }
+        return result;
+    }
+    private void setPlaying(boolean value) throws IOException {
+        if (output==null) return;
+        if (value && eof && playedFrames()>=written) resetAt(loop ? a : 0);
+        if (value && !playing) {
+            if (audioManager.requestAudioFocus(focus)!=AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { status="Audio focus denied"; return; }
+            output.play(); playing=true; if (!wake.isHeld()) wake.acquire();
+        } else if (!value) {
+            output.pause(); playing=false; if (wake.isHeld()) wake.release(); audioManager.abandonAudioFocusRequest(focus);
+        }
+        publish(); updateSession();
+    }
+    private void focusChanged(int change) throws IOException {
+        if (change==AudioManager.AUDIOFOCUS_GAIN) {
+            if (output!=null) output.setVolume(1f);
+            if (resumeOnFocus) { resumeOnFocus=false; setPlaying(true); }
+        } else if (change==AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK) {
+            if (output!=null) output.setVolume(0.2f);
+        } else if (change==AudioManager.AUDIOFOCUS_LOSS_TRANSIENT) {
+            // Keep the focus request registered so Android can send the gain event.
+            resumeOnFocus=playing;
+            if (output!=null) output.pause();
+            playing=false; if (wake.isHeld()) wake.release();
+            publish(); updateSession();
+        } else if (change==AudioManager.AUDIOFOCUS_LOSS) {
+            resumeOnFocus=false; setPlaying(false);
+        }
+    }
+    private long playedFrames() {
+        if (output==null) return 0;
+        long head=Integer.toUnsignedLong(output.getPlaybackHeadPosition());
+        if (head<lastHead) headEpoch+=1L<<32;
+        lastHead=head; return headEpoch+head;
+    }
+    private long positionFrames() { return output==null ? 0 : LoopMath.position(base,playedFrames(),a,b,loop); }
+    private void resetAt(long target) throws IOException {
+        // A seek invalidates decoder state as well as queued PCM. One worker owns both.
+        boolean wasPlaying=playing;
+        output.pause(); output.flush(); headEpoch=lastHead=written=0;
+        base=cursor=target; blockSamples=blockOffset=0; eof=false;
+        if (cache!=null && cacheFilled==cache.length && loop) cacheRead=(int)((target-a)*2);
+        else {
+            if (cache!=null) cacheFilled=cacheRead=0;
+            NativeDecoder.seek(decoder,target);
+        }
+        if (wasPlaying) output.play();
+    }
+    private boolean pump() throws Exception {
+        if (blockOffset<blockSamples) {
+            int n=output.write(block,blockOffset,blockSamples-blockOffset,AudioTrack.WRITE_NON_BLOCKING);
+            if (n<0) throw new IOException("Audio output error " + n);
+            if ((n&1)!=0) throw new IOException("Unaligned stereo audio write");
+            blockOffset+=n; written+=n/2;
+            return n>0;
+        }
+        if (eof) {
+            if (playedFrames()>=written) {
+                if (index+1<queue.size()) load(index+1);
+                else { setPlaying(false); status="End of queue"; }
+            }
+            return false;
+        }
+        if (loop && cursor>=b) {
+            cursor=a;
+            if (cache!=null && cacheFilled==cache.length) { cacheRead=0; status="A–B loop · cached"; }
+            else { cache=null; NativeDecoder.seek(decoder,a); status="A–B loop · seek mode"; }
+        }
+        int requested=loop ? (int)Math.min(block.length/2,b-cursor) : block.length/2;
+        int count;
+        if (cache!=null && cacheFilled==cache.length) {
+            count=Math.min(requested,(cache.length-cacheRead)/2);
+            System.arraycopy(cache,cacheRead,block,0,count*2); cacheRead+=count*2;
+        } else {
+            count=NativeDecoder.read(decoder,block,requested);
+            if (cache!=null && count>0) { System.arraycopy(block,0,cache,cacheFilled,count*2); cacheFilled+=count*2; }
+        }
+        if (count==0) {
+            if (loop) throw new IOException("Loop ends beyond decoded audio; clear or adjust B");
+            eof=true; return false;
+        }
+        cursor+=count; blockSamples=count*2; blockOffset=0;
+        return true;
+    }
+    private void work() {
+        android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_AUDIO);
+        try {
+            while (running) {
+                try {
+                    Command command=commands.poll(playing ? 0 : 100,TimeUnit.MILLISECONDS);
+                    if (command!=null) { command.run(); publish(); updateSession(); }
+                    if (playing && output!=null && !pump()) Thread.sleep(5);
+                    long now=SystemClock.elapsedRealtime();
+                    if (now-lastPublish>=200) { publish(); lastPublish=now; }
+                    if (now-lastSession>=1000) { updateSession(false); lastSession=now; }
+                } catch (InterruptedException e) { if (!running) break; }
+                catch (Exception | LinkageError e) {
+                    releaseAudio(); loop=false; status="Playback failed: " + (e.getMessage()==null ? e.toString() : e.getMessage());
+                    publish(); updateSession();
+                }
+            }
+        } finally { releaseAudio(); session.setActive(false); session.release(); }
+    }
+    private void releaseAudio() {
+        playing=false; cache=null; blockSamples=blockOffset=0;
+        if (output!=null) { output.pause(); output.flush(); output.release(); output=null; }
+        if (decoder!=0) { NativeDecoder.close(decoder); decoder=0; }
+        if (wake.isHeld()) wake.release();
+        audioManager.abandonAudioFocusRequest(focus);
+    }
+    private void publish() {
+        snapshot=new Snapshot(title,format,status,output==null ? 0 : LoopMath.millis(positionFrames(),rate),duration,
+            a<0 ? -1 : LoopMath.millis(a,rate), b<0 ? -1 : LoopMath.millis(b,rate),playing,loop,decoder!=0);
+    }
+    private void updateSession() { updateSession(true); }
+    private void updateSession(boolean notify) {
+        session.setPlaybackState(new PlaybackState.Builder().setActions(PlaybackState.ACTION_PLAY | PlaybackState.ACTION_PAUSE
+            | PlaybackState.ACTION_PLAY_PAUSE | PlaybackState.ACTION_STOP | PlaybackState.ACTION_SEEK_TO
+            | PlaybackState.ACTION_SKIP_TO_NEXT | PlaybackState.ACTION_SKIP_TO_PREVIOUS)
+            .setState(playing ? PlaybackState.STATE_PLAYING : PlaybackState.STATE_PAUSED,snapshot.position,playing ? 1f : 0f).build());
+        if (notify && foreground) ((NotificationManager)getSystemService(NOTIFICATION_SERVICE)).notify(NOTIFICATION,notification());
+    }
+    private PendingIntent action(String name) {
+        return PendingIntent.getService(this,name.hashCode(),new Intent(this,PlaybackService.class).setAction(name),PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
+    }
+    private Notification notification() {
+        Snapshot s=snapshot;
+        PendingIntent open=PendingIntent.getActivity(this,0,new Intent(this,MainActivity.class),PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
+        return new Notification.Builder(this,CHANNEL).setSmallIcon(R.drawable.ic_music).setContentTitle(s.title)
+            .setContentText(s.playing ? "Playing" : "Paused").setContentIntent(open).setOnlyAlertOnce(true)
+            .setOngoing(s.playing).setVisibility(Notification.VISIBILITY_PUBLIC)
+            .addAction(new Notification.Action.Builder(android.R.drawable.ic_media_previous,"Previous",action("previous")).build())
+            .addAction(new Notification.Action.Builder(s.playing ? android.R.drawable.ic_media_pause : android.R.drawable.ic_media_play,s.playing ? "Pause" : "Play",action("toggle")).build())
+            .addAction(new Notification.Action.Builder(android.R.drawable.ic_media_next,"Next",action("next")).build())
+            .addAction(new Notification.Action.Builder(android.R.drawable.ic_menu_close_clear_cancel,"Stop",action("stop")).build())
+            .setStyle(new Notification.MediaStyle().setMediaSession(session.getSessionToken()).setShowActionsInCompactView(0,1,2)).build();
+    }
+    @Override public void onDestroy() {
+        unregisterReceiver(noisy); running=false; worker.interrupt();
+        // Native IO may still be unwinding; the worker releases its own resources.
+        super.onDestroy();
+    }
+}
diff --git a/app/src/main/java/net/astropenguin/loopdeck/Track.java b/app/src/main/java/net/astropenguin/loopdeck/Track.java
new file mode 100644
index 0000000..3c79f96
--- /dev/null
+++ b/app/src/main/java/net/astropenguin/loopdeck/Track.java
@@ -0,0 +1,9 @@
+package net.astropenguin.loopdeck;
+
+import android.net.Uri;
+
+final class Track {
+    final Uri uri;
+    final String name;
+    Track(Uri uri, String name) { this.uri = uri; this.name = name; }
+}
diff --git a/app/src/main/res/drawable/ic_music.xml b/app/src/main/res/drawable/ic_music.xml
new file mode 100644
index 0000000..c8c2f3e
--- /dev/null
+++ b/app/src/main/res/drawable/ic_music.xml
@@ -0,0 +1,3 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
+    <path android:fillColor="#FFFFFFFF" android:pathData="M12,3v10.55A4,4 0,1 0,14 17V7h6V3z" />
+</vector>
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..d0f2181
--- /dev/null
+++ b/app/src/main/res/values/styles.xml
@@ -0,0 +1,10 @@
+<resources>
+    <style name="AppTheme" parent="android:style/Theme.Material.NoActionBar">
+        <item name="android:fontFamily">sans</item>
+        <item name="android:colorAccent">#73DAC1</item>
+        <item name="android:windowBackground">#111815</item>
+        <item name="android:navigationBarColor">#111815</item>
+        <item name="android:statusBarColor">#111815</item>
+        <item name="android:windowLightStatusBar">false</item>
+    </style>
+</resources>
diff --git a/build.gradle b/build.gradle
new file mode 100644
index 0000000..3b7a870
--- /dev/null
+++ b/build.gradle
@@ -0,0 +1 @@
+plugins { id 'com.android.application' version '9.4.0' apply false }
diff --git a/build.sh b/build.sh
new file mode 100755
index 0000000..8a27190
--- /dev/null
+++ b/build.sh
@@ -0,0 +1,13 @@
+#!/bin/bash
+
+brew list --versions openjdk
+
+export JAVA_HOME=$(brew --prefix)/opt/openjdk@27
+export ANDROID_HOME="$HOME/.rbash/lib/Android"
+export PATH="$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator:$ANDROID_HOME/cmdline-tools/latest/bin:$PATH"
+
+# ./scripts/build-ffmpeg.sh
+
+./gradlew assembleDebug
+
+adb install -r ./app/build/outputs/apk/debug/app-debug.apk
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..d70b049
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,3 @@
+org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
+org.gradle.parallel=true
+android.useAndroidX=false
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..5097068
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..a4e4467
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,8 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip
+distributionSha256Sum=dce76f55f8e251a3a1f130eb120f30b3d271de2b76c9b0729d316b5a1b6dc01f
+networkTimeout=30000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000..dd14095
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,5 @@
+#!/bin/sh
+set -eu
+APP_HOME=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
+if [ -n "${JAVA_HOME:-}" ]; then JAVA="$JAVA_HOME/bin/java"; else JAVA=java; fi
+exec "$JAVA" -Dorg.gradle.appname=gradlew -classpath "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" org.gradle.wrapper.GradleWrapperMain "$@"
diff --git a/scripts/LoopMathTest.java b/scripts/LoopMathTest.java
new file mode 100644
index 0000000..623a799
--- /dev/null
+++ b/scripts/LoopMathTest.java
@@ -0,0 +1,21 @@
+package net.astropenguin.loopdeck;
+
+public final class LoopMathTest {
+    private static void expect(long actual,long expected) {
+        if (actual!=expected) throw new AssertionError("Expected "+expected+", got "+actual);
+    }
+    public static void main(String[] args) {
+        expect(LoopMath.position(1000,499,1000,1500,true),1499);
+        expect(LoopMath.position(1000,500,1000,1500,true),1000);
+        expect(LoopMath.position(1200,1301,1000,1500,true),1001);
+        expect(LoopMath.position(1000,500,1000,1500,false),1500);
+        expect(LoopMath.position(0,(1L<<32)+17,-1,-1,false),(1L<<32)+17);
+        expect(LoopMath.cacheSamples(LoopMath.CACHE_BYTES/8),LoopMath.CACHE_BYTES/4);
+        expect(LoopMath.cacheSamples(LoopMath.CACHE_BYTES/8+1),0);
+        expect(LoopMath.cacheSamples(0),0);
+        expect(LoopMath.frame(1000,192000),192000);
+        expect(LoopMath.millis(192000,192000),1000);
+        if (LoopMath.valid(100,100) || LoopMath.valid(-1,100) || !LoopMath.valid(100,101)) throw new AssertionError("Invalid loop validation");
+        System.out.println("Loop frame arithmetic passed");
+    }
+}
diff --git a/scripts/build-ffmpeg.sh b/scripts/build-ffmpeg.sh
new file mode 100755
index 0000000..bdb42b0
--- /dev/null
+++ b/scripts/build-ffmpeg.sh
@@ -0,0 +1,53 @@
+#!/usr/bin/env bash
+set -euo pipefail
+cd "$(dirname "$0")/.."
+ROOT="$PWD"
+VERSION=7.1.5
+SDK="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}"
+NDK="${ANDROID_NDK_HOME:-$SDK/ndk/27.2.12479018}"
+[[ -d "$NDK/toolchains/llvm/prebuilt" ]] || { echo "Missing NDK: $NDK (see README.md)" >&2; exit 1; }
+case "$(uname -s)" in
+    Darwin) HOST_TAG=darwin-x86_64 ;;
+    Linux) HOST_TAG=linux-x86_64 ;;
+    *) echo 'Build on macOS or Linux (or WSL2).' >&2; exit 1 ;;
+esac
+TOOLCHAIN="$NDK/toolchains/llvm/prebuilt/$HOST_TAG"
+PREFIX="$ROOT/third_party/ffmpeg/arm64-v8a"
+STAMP="$PREFIX/.build-config"
+FINGERPRINT="$VERSION|$NDK|$(cat "$0" | cksum)"
+if [[ -f "$STAMP" && "$(cat "$STAMP")" == "$FINGERPRINT" && -f "$PREFIX/lib/libavcodec.so" ]]; then
+    echo 'Using cached FFmpeg build.'; exit 0
+fi
+mkdir -p .deps
+ARCHIVE="$ROOT/.deps/ffmpeg-$VERSION.tar.xz"
+if [[ ! -f "$ARCHIVE" ]]; then
+    curl --fail --location --retry 3 --connect-timeout 20 "https://ffmpeg.org/releases/ffmpeg-$VERSION.tar.xz" -o "$ARCHIVE.part"
+    mv "$ARCHIVE.part" "$ARCHIVE"
+fi
+EXPECTED=$(awk '{print $1}' scripts/ffmpeg.sha256)
+if command -v sha256sum >/dev/null; then ACTUAL=$(sha256sum "$ARCHIVE" | awk '{print $1}'); else ACTUAL=$(shasum -a 256 "$ARCHIVE" | awk '{print $1}'); fi
+[[ "$EXPECTED" == "$ACTUAL" ]] || { echo 'FFmpeg archive checksum mismatch; remove .deps archive and retry.' >&2; exit 1; }
+[[ -d ".deps/ffmpeg-$VERSION" ]] || tar -xf "$ARCHIVE" -C .deps
+mkdir -p .deps/ffmpeg-build-arm64
+cd .deps/ffmpeg-build-arm64
+DECODERS='aac,aac_fixed,aac_latm,ac3,alac,als,amrnb,amrwb,ape,atrac1,atrac3,atrac3p,atrac3pal,atrac3al,cook,dca,dsd_lsbf,dsd_msbf,dsd_lsbf_planar,dsd_msbf_planar,eac3,flac,gsm,mp1,mp1float,mp2,mp2float,mp3,mp3float,mp3adu,mp3adufloat,mp3on4,mp3on4float,mpc7,mpc8,nellymoser,opus,pcm_*,adpcm_*,qcelp,qdm2,qdmc,ra_144,ra_288,ralf,shorten,sipr,speex,tak,truehd,truespeech,tta,twinvq,vorbis,wavpack,wmalossless,wmapro,wmav1,wmav2,wmavoice'
+"$ROOT/.deps/ffmpeg-$VERSION/configure" \
+    --prefix="$PREFIX" --target-os=android --arch=aarch64 --cpu=armv8-a \
+    --enable-cross-compile --sysroot="$TOOLCHAIN/sysroot" \
+    --cc="$TOOLCHAIN/bin/aarch64-linux-android26-clang" \
+    --cxx="$TOOLCHAIN/bin/aarch64-linux-android26-clang++" \
+    --ar="$TOOLCHAIN/bin/llvm-ar" --ranlib="$TOOLCHAIN/bin/llvm-ranlib" \
+    --strip="$TOOLCHAIN/bin/llvm-strip" --nm="$TOOLCHAIN/bin/llvm-nm" \
+    --disable-autodetect --disable-programs --disable-doc --disable-debug \
+    --disable-network --disable-avdevice --disable-avfilter --disable-swscale \
+    --disable-encoders --disable-muxers --disable-decoders --enable-decoder="$DECODERS" \
+    --disable-protocols --enable-protocol=file \
+    --enable-shared --disable-static --enable-pic \
+    --extra-ldflags='-Wl,-z,max-page-size=16384'
+for CODEC in APE TAK TTA WAVPACK WMAV1 WMAV2 WMAPRO WMALOSSLESS; do
+    grep -q "#define CONFIG_${CODEC}_DECODER 1" config_components.h || { echo "Missing required decoder: $CODEC" >&2; exit 1; }
+done
+JOBS="${JOBS:-$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 4)}"
+make -j"$JOBS"
+make install
+printf '%s' "$FINGERPRINT" > "$STAMP"
diff --git a/scripts/ffmpeg.sha256 b/scripts/ffmpeg.sha256
new file mode 100644
index 0000000..d659b20
--- /dev/null
+++ b/scripts/ffmpeg.sha256
@@ -0,0 +1 @@
+de668509caf9e35e3cd162473441fdb29538c6d96ed080292b3cf9e6fc5d558f  ffmpeg-7.1.5.tar.xz
diff --git a/scripts/tak1_reference.py b/scripts/tak1_reference.py
new file mode 100644
index 0000000..de7fb8e
--- /dev/null
+++ b/scripts/tak1_reference.py
@@ -0,0 +1,161 @@
+"""Optional host-only reference runner for the supplied TAK decoder 2.3 DLL.
+
+Install pefile and unicorn with pip. The Android app never uses this script,
+the Windows DLL, or emulation. Only a small Win32 IO adapter is provided here;
+this is not a general Windows runtime.
+"""
+import sys,struct
+from pathlib import Path
+import pefile
+from unicorn import Uc,UC_ARCH_X86,UC_MODE_32,UC_HOOK_CODE,UC_HOOK_MEM_INVALID
+from unicorn.x86_const import *
+class Oracle:
+ def __init__(self,path):
+  self.pe=pefile.PE(path); self.base=self.pe.OPTIONAL_HEADER.ImageBase
+  self.uc=Uc(UC_ARCH_X86,UC_MODE_32); u=self.uc
+  u.mem_map(0,0x10000); u.mem_write(0,struct.pack('<I',0xffffffff))
+  u.mem_map(self.base,0x30000); u.mem_write(self.base,self.pe.get_memory_mapped_image())
+  u.mem_map(0x10000000,0x4000000); self.next=0x10001000
+  u.mem_map(0x60000000,0x100000); self.stack=0x600ff000
+  u.mem_map(0x70000000,0x10000); self.stop=0x70000000
+  self.funcs={};self.exports={s.name.decode():self.base+s.address for s in self.pe.DIRECTORY_ENTRY_EXPORT.symbols}
+  for imp in self.pe.DIRECTORY_ENTRY_IMPORT:
+   for ent in imp.imports:
+    a=0x70001000+len(self.funcs)*16;self.funcs[a]=ent.name.decode();u.mem_write(ent.address,struct.pack('<I',a));u.mem_write(a,b'\xc3')
+  self.files={};self.tls={};self.last=[];self.trace=False
+  u.hook_add(UC_HOOK_CODE,self.hook,begin=0x70000000,end=0x7000ffff)
+  u.hook_add(UC_HOOK_MEM_INVALID,self.invalid)
+ def read(self,a,n=4):return bytes(self.uc.mem_read(a,n))
+ def dword(self,a):return struct.unpack('<I',self.read(a))[0]
+ def write32(self,a,v):self.uc.mem_write(a,struct.pack('<I',v&0xffffffff))
+ def alloc(self,n):
+  a=self.next;self.next+=(max(n,1)+4095)&~4095
+  if self.next>=0x14000000:raise ValueError('heap exhausted')
+  return a
+ def cstr(self,a):
+  if not a:return ''
+  b=bytearray()
+  while (x:=self.read(a+len(b),1))!=b'\0':b+=x
+  return b.decode('latin1')
+ def ret(self,val,n):
+  sp=self.uc.reg_read(UC_X86_REG_ESP);ra=self.dword(sp)
+  self.uc.reg_write(UC_X86_REG_EAX,val&0xffffffff);self.uc.reg_write(UC_X86_REG_ESP,sp+4+n*4);self.uc.reg_write(UC_X86_REG_EIP,ra)
+ def hook(self,u,a,size,data):
+  self.last=(self.last+[a])[-15:]
+  if a==self.stop:u.emu_stop();return
+  if a not in self.funcs:return
+  name=self.funcs[a];sp=u.reg_read(UC_X86_REG_ESP);args=[self.dword(sp+4+i*4) for i in range(10)]
+  if self.trace:print(name,[hex(x) for x in args[:4]],flush=True)
+  if name in ('InitializeCriticalSection','EnterCriticalSection','LeaveCriticalSection','DeleteCriticalSection'):self.ret(1,1)
+  elif name=='VirtualAlloc':self.ret(self.alloc(args[1]),4)
+  elif name=='VirtualFree':self.ret(1,3)
+  elif name=='LocalAlloc':self.ret(self.alloc(args[1]),2)
+  elif name in ('LocalFree','CloseHandle','TlsFree','FreeLibrary'):self.ret(0 if name=='LocalFree' else 1,1)
+  elif name=='TlsAlloc':self.ret(1,0)
+  elif name=='TlsGetValue':self.ret(self.tls.get(args[0],0),1)
+  elif name=='TlsSetValue':self.tls[args[0]]=args[1];self.ret(1,2)
+  elif name=='GetVersionExA':u.mem_write(args[0]+4,struct.pack('<IIII',6,1,7601,2));self.ret(1,1)
+  elif name=='lstrcpynA':u.mem_write(args[0],self.cstr(args[1]).encode('latin1')[:max(0,args[2]-1)]+b'\0');self.ret(args[0],3)
+  elif name=='lstrlenA':self.ret(len(self.cstr(args[0])),1)
+  elif name=='LoadStringA':u.mem_write(args[2],b'Error\0');self.ret(5,4)
+  elif name=='LoadLibraryExA':self.ret(0,3)
+  elif name=='GetProcAddress':self.ret(0,2)
+  elif name=='GetSystemMetrics':self.ret(0,1)
+  elif name=='RegOpenKeyExA':self.ret(2,5)
+  elif name=='GetKeyboardType':self.ret(4 if args[0]==0 else 0,1)
+  elif name=='GetCurrentThreadId':self.ret(1,0)
+  elif name=='GetStartupInfoA':self.ret(0,1)
+  elif name in ('GetThreadLocale','GetACP'):self.ret(0x409 if name=='GetThreadLocale' else 1252,0)
+  elif name=='GetModuleHandleA':self.ret(self.base,1)
+  elif name=='GetCommandLineA':
+   p=self.alloc(100);u.mem_write(p,b'"C:\\tak.exe"\0');self.ret(p,0)
+  elif name=='GetModuleFileNameA':
+   s=b'C:\\tak_deco_lib.dll\0';u.mem_write(args[1],s);self.ret(len(s)-1,3)
+  elif name=='GetStdHandle':self.ret(0,1)
+  elif name=='GetCPInfo':u.mem_write(args[1],struct.pack('<I',1)+b'?\0'+b'\0'*14);self.ret(1,2)
+  elif name=='GetLocaleInfoA':
+   s=b'1252\0';u.mem_write(args[2],s[:args[3]]);self.ret(len(s),4)
+  elif name=='EnumCalendarInfoA':self.ret(1,4)
+  elif name=='GetLastError':self.ret(0,0)
+  elif name=='CreateFileA':
+   fn=self.cstr(args[0]);self.files[0x100]=open(self.input_path,'rb');self.ret(0x100,7)
+  elif name=='GetFileSize':
+   f=self.files[args[0]];p=f.tell();f.seek(0,2);n=f.tell();f.seek(p)
+   if args[1]:self.write32(args[1],n>>32)
+   self.ret(n,2)
+  elif name=='SetFilePointer':
+   f=self.files[args[0]];off=args[1] if args[1]<0x80000000 else args[1]-0x100000000
+   if args[2]:off+=(self.dword(args[2]) if self.dword(args[2])<0x80000000 else self.dword(args[2])-0x100000000)<<32
+   n=f.seek(off,args[3]);self.ret(n,4)
+  elif name=='ReadFile':
+   f=self.files[args[0]];b=f.read(args[2]);u.mem_write(args[1],b)
+   if args[3]:self.write32(args[3],len(b))
+   self.ret(1,5)
+  else:raise RuntimeError(f'Unhandled import {name}: {[hex(x) for x in args[:6]]}')
+ def invalid(self,u,access,a,size,value,data):
+  raise RuntimeError(f'DLL accessed unmapped memory at {a:#x}')
+ def call(self,fn,*args):
+  a=self.exports[fn] if isinstance(fn,str) else fn
+  u=self.uc;u.reg_write(UC_X86_REG_ESP,self.stack-4*(len(args)+1));u.mem_write(self.stack-4*(len(args)+1),struct.pack('<'+'I'*(len(args)+1),self.stop,*args))
+  u.reg_write(UC_X86_REG_EBP,0);u.reg_write(UC_X86_REG_EAX,0);u.reg_write(UC_X86_REG_EDX,0);u.reg_write(UC_X86_REG_ECX,0)
+  u.emu_start(a,self.stop,count=100000000)
+  if u.reg_read(UC_X86_REG_EIP)!=self.stop:raise RuntimeError('Instruction budget exceeded')
+  return u.reg_read(UC_X86_REG_EAX)
+
+def open_reference(dll, source):
+    oracle = Oracle(str(dll))
+    oracle.call(oracle.base+oracle.pe.OPTIONAL_HEADER.AddressOfEntryPoint, oracle.base, 1, 0)
+    oracle.input_path = str(source)
+    filename = oracle.alloc(64)
+    oracle.uc.mem_write(filename, b"reference.tak\0")
+    options = oracle.alloc(64)
+    handle = oracle.call("tak_SSD_Create_FromFile", filename, options, 0, 0)
+    if not oracle.call("tak_SSD_Valid", handle):
+        raise RuntimeError(f"Reference DLL could not open the file; state {oracle.call('tak_SSD_State', handle):#x}")
+    return oracle, handle
+
+def compare(dll, source, native, packets=None):
+    import subprocess, tempfile, hashlib
+    with tempfile.TemporaryDirectory(prefix="loopdeck-tak1-") as temp:
+        output = Path(temp)/"native.pcm"
+        command = [str(native), str(source), str(output)]
+        if packets is not None:
+            command.append(str(packets))
+        subprocess.run(command, check=True)
+        actual = output.read_bytes()
+    oracle, handle = open_reference(dll, source)
+    info = oracle.alloc(128)
+    if oracle.call("tak_SSD_GetStreamInfo", handle, info):
+        raise RuntimeError("Cannot read reference stream information")
+    block = oracle.dword(info+40)
+    if not block or len(actual)%block:
+        raise RuntimeError("Native output is not aligned to reference PCM frames")
+    destination, got = oracle.alloc(16384*block), oracle.alloc(4)
+    compared = 0
+    try:
+        while compared < len(actual):
+            frames = min(16384, (len(actual)-compared)//block)
+            status = oracle.call("tak_SSD_ReadAudio", handle, destination, frames, got)
+            if status or oracle.dword(got) != frames:
+                raise RuntimeError(f"Reference failed after {compared} bytes; status {status:#x}")
+            reference = oracle.read(destination, frames*block)
+            chunk = actual[compared:compared+len(reference)]
+            if chunk != reference:
+                offset = next(i for i,(a,b) in enumerate(zip(chunk,reference)) if a != b)
+                raise RuntimeError(f"PCM mismatch at byte {compared+offset}")
+            compared += len(reference)
+    finally:
+        oracle.call("tak_SSD_Destroy", handle)
+        for file in oracle.files.values():
+            file.close()
+    print(f"Exact PCM match: {compared} bytes; sha256 {hashlib.sha256(actual).hexdigest()}")
+
+if __name__ == "__main__":
+    import argparse
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument("--dll", type=Path, required=True)
+    parser.add_argument("--input", type=Path, required=True)
+    parser.add_argument("--native", type=Path, required=True)
+    parser.add_argument("--packets", type=int, help="Limit both sides to complete packets in a truncated sample")
+    args = parser.parse_args()
+    compare(args.dll.resolve(), args.input.resolve(), args.native.resolve(), args.packets)
diff --git a/scripts/test-decoder.cpp b/scripts/test-decoder.cpp
new file mode 100644
index 0000000..8e66ef6
--- /dev/null
+++ b/scripts/test-decoder.cpp
@@ -0,0 +1,44 @@
+#include "decoder.h"
+#include <algorithm>
+#include <cmath>
+#include <fcntl.h>
+#include <iostream>
+#include <stdexcept>
+#include <unistd.h>
+#include <vector>
+
+// Host-only integration test: run against the same FFmpeg release as the app.
+// Compares seeks with sequential decoding, including decoder/resampler draining.
+int main(int argc,char** argv) {
+    if (argc<2) return 2;
+    try {
+        for (int i=1;i<argc;i++) {
+            int fd=::open(argv[i],O_RDONLY);
+            if (fd<0) throw std::runtime_error("Cannot open fixture");
+            Decoder d(fd); ::close(fd); d.open();
+            for (int rate : {d.sourceRate(),48000}) {
+                d.outputRate(rate); d.seek(0);
+                std::vector<float> all, chunk(2048*2);
+                int count;
+                while ((count=d.read(chunk.data(),2048))>0) all.insert(all.end(),chunk.begin(),chunk.begin()+count*2);
+                if (all.size()<static_cast<size_t>(rate*4)) throw std::runtime_error("Fixture must contain at least 2 seconds");
+                if (d.read(chunk.data(),2048)!=0) throw std::runtime_error("EOF was not stable");
+                for (int64_t target : {int64_t(0),int64_t(rate/3),int64_t(rate),int64_t(rate+137),int64_t(rate)*3+137}) {
+                    d.seek(target);
+                    count=d.read(chunk.data(),1024);
+                    if (count!=1024) throw std::runtime_error("Short seek output");
+                    double maximum=0;
+                    for (int j=0;j<count*2;j++) maximum=std::max(maximum,std::abs(double(chunk[j]-all[target*2+j])));
+                    // Resampling starts a new FIR filter at a seek. Ignore filter warm-up
+                    // only for the first 64 frames when evaluating resampled output.
+                    if (rate!=d.sourceRate()) {
+                        maximum=0;
+                        for (int j=128;j<count*2;j++) maximum=std::max(maximum,std::abs(double(chunk[j]-all[target*2+j])));
+                    }
+                    if (maximum>0.003) throw std::runtime_error(std::string(argv[i])+": seek mismatch "+std::to_string(maximum)+" at "+std::to_string(target)+" rate "+std::to_string(rate));
+                }
+                std::cout<<argv[i]<<" @ "<<rate<<": sequential decode, EOF, seeks passed ("<<all.size()/2<<" frames)\n";
+            }
+        }
+    } catch (const std::exception& e) { std::cerr<<e.what()<<"\n"; return 1; }
+}
diff --git a/scripts/test-tak1-cases.py b/scripts/test-tak1-cases.py
new file mode 100644
index 0000000..c9ff5d5
--- /dev/null
+++ b/scripts/test-tak1-cases.py
@@ -0,0 +1,120 @@
+"""Optional host regression corpus. Requires pefile, unicorn and test-tak1.
+Generated audio contains simple synthetic integer patterns, not music.
+"""
+from pathlib import Path
+import sys,struct,subprocess
+from tak1_reference import Oracle
+ORDERS=[4,8,12,16,24,32,48,64,80,96,112,128,160,192,224,256]
+def boundaries(rate,samples,alignment,divider,maximum):
+ out=[0]
+ for i in range(1,maximum+1):
+  out.append((rate*i//divider+alignment//2)&-alignment)
+  if out[-1]>=samples:break
+ if out[-1]<samples:out[-1]=samples
+ elif out[-1]>samples:
+  out[-1]=samples
+  if out[-1]-out[-2]<alignment:out.pop(-2)
+ return out
+class Writer:
+ def __init__(self):self.a=[]
+ def put(self,v,n):self.a.extend((v>>i)&1 for i in range(n))
+ def align(self):self.a.extend([0]*((-len(self.a))&7))
+ def data(self):
+  self.align();b=bytearray(len(self.a)//8)
+  for i,v in enumerate(self.a):b[i//8]|=v<<(i&7)
+  return bytes(b)
+def crc(b):
+ c=0xb704ce
+ for v in b:
+  c^=v<<16
+  for _ in range(8):c=((c<<1)^(0x864cfb if c&0x800000 else 0))&0xffffff
+ return c.to_bytes(3,'little')
+def checked(b):return b+crc(b)
+def info(w,rate,bps,ch,n):
+ for v,b in ((0,6),(2,4),(7,4),(n,32),(0,3),(0,3),(rate-6000,18),(bps-8,5),(ch-1,4),(0,1)):w.put(v,b)
+def coefficients(w,c,q=0,diff=False):
+ w.put(q,3)
+ if len(c)>=12:w.put(diff,1)
+ if diff:
+  d=[];last=0
+  for v in c:d.append(v+last);last=v
+  c=d
+ if len(c)>16:w.put(14,4);w.put(0,1)
+ for i in range(0,len(c),4):
+  if len(c)<=16:w.put(14,4)
+  else:w.put(0,2)
+  for v in c[i:i+4]:w.put(v,14)
+def residues(w,values):
+ w.put(0,1);w.put(1,6);w.put(24,5)
+ for v in values:
+  if v==0:w.put(1,1)
+  else:
+   w.put(0,1);a=abs(v)
+   if a<=4:w.put(0,a-1);w.put(1,1);w.put(v<0,1)
+   else:w.put(0,4);w.put((a-5)+(1<<23 if v<0 else 0),24)
+def subframe(w,values,order=0,q=0,shift=0,lpc=0):
+ w.put(bool(order),1)
+ if not order:
+  w.put(lpc,2);residues(w,values)
+ else:
+  w.put(shift,4);w.put(ORDERS.index(order),4);w.put(lpc,2);residues(w,values[:order]);c=[0]*order;c[-1]=64;coefficients(w,c,q,order>=12);residues(w,values[order:])
+def channel(w,rate,bps,n,seed,shift=0,pf=False,order=0,q=0,lpc=0,parts=False):
+ w.put(bool(shift),1)
+ if shift:w.put(shift-1,4)
+ w.put(seed,bps-shift)
+ w.put(pf,1)
+ if pf:w.put(2,4);w.put(0,2);coefficients(w,[0,0,0,64],2)
+ indices=[0,1] if parts else [0];w.put(len(indices)-1,3)
+ for i in indices[1:]:w.put(i,6)
+ table=boundaries(rate,n,8,256,64);offsets=[table[i] for i in indices]+[n-1]
+ for a,b in zip(offsets,offsets[1:]):
+  values=[((i+seed)%7)-3 for i in range(b-a)]
+  subframe(w,values,order,q,2,lpc)
+def make(path,rate=44100,bps=16,ch=2,n=512,mode=0,shift=0,pf=False,order=0,q=0,lpc=0,parts=False):
+ si=Writer();info(si,rate,bps,ch,n);stream=si.data();header=Writer();header.put(0xa0ff,16);header.put(3,3);header.put(0,21);header.put(n-1,14);header.put(0,2);info(header,rate,bps,ch,n);header.put(0,6);head=checked(header.data())
+ w=Writer()
+ if n<16:
+  for c in range(ch):
+   for i in range(n):w.put((i-3)*2+c,bps)
+ else:
+  if ch==2:w.put(mode,3)
+  first=1 if mode in(2,4) else 0
+  channel(w,rate,bps,n,first+1,shift,pf,order,q,lpc,parts)
+  if mode in (1,2):
+   w.put(1,1);w.put(1,1);w.put(2,4);w.put(1,2);coefficients(w,[0,0,0,1024,0,0,0,0])
+  if ch==2:channel(w,rate,bps,n,2-first,shift,pf,order,q,lpc,parts)
+ metadata=b'\x01'+(len(stream)+3).to_bytes(3,'little')+checked(stream)+b'\0'*4
+ path.write_bytes(b'tBaK'+metadata+head+checked(w.data()))
+def compare(path,output,n,bps,ch):
+ x=Oracle(str(DLL));x.call(x.base+x.pe.OPTIONAL_HEADER.AddressOfEntryPoint,x.base,1,0);x.input_path=str(path);fn=x.alloc(64);x.uc.mem_write(fn,b'test.tak\0');opts=x.alloc(64);h=x.call('tak_SSD_Create_FromFile',fn,opts,0,0)
+ assert x.call('tak_SSD_Valid',h)==1,('open',path,x.call('tak_SSD_State',h))
+ out=x.alloc(16384*8);got=x.alloc(4);result=x.call('tak_SSD_ReadAudio',h,out,n,got)
+ assert result==0 and x.dword(got)==n,('read',path,result,x.dword(got))
+ reference=x.read(out,n*ch*((bps+7)//8));output.write_bytes(reference)
+ x.call("tak_SSD_Destroy",h)
+ for file in x.files.values():file.close()
+ p=subprocess.run([str(NATIVE),str(path),str(output.with_suffix('.native'))],capture_output=True)
+ assert p.returncode==0,p.stderr.decode()
+ native=output.with_suffix('.native').read_bytes()
+ assert native==reference,('mismatch',path,next((i,a,b)for i,(a,b)in enumerate(zip(native,reference))if a!=b))
+ return len(reference)
+if __name__=='__main__':
+ import argparse,tempfile
+ parser=argparse.ArgumentParser(description='Generate TAK 1.0 fixtures and compare native PCM against the original DLL')
+ parser.add_argument('--dll',type=Path,required=True);parser.add_argument('--native',type=Path,required=True)
+ args=parser.parse_args();DLL=args.dll.resolve();NATIVE=args.native.resolve()
+ temporary=tempfile.TemporaryDirectory(prefix='loopdeck-tak1-cases-');folder=Path(temporary.name);cases=[]
+ for rate in (8000,11025,22050,44100,48000,96000,192000):
+  for bps in (8,16,24):cases.append(dict(rate=rate,bps=bps,ch=1))
+ for bps in (8,16,24):
+  for mode in range(6):cases.append(dict(bps=bps,mode=mode,shift=1,n=128 if bps==8 and mode==5 else 512))
+ for n in (1,2,7,15,16,17,31,256):cases.append(dict(n=n))
+ for order in (4,8,12,16,24,32,48,64,80,96,112,128,160,192,224,256):cases.append(dict(order=order,q=3,lpc=2))
+ cases.extend([dict(pf=True),dict(pf=True,mode=1),dict(pf=True,mode=2),dict(parts=True),dict(parts=True,order=16,lpc=1)])
+ total=0
+ for index,case in enumerate(cases):
+  p=folder/f'case-{index:03}.tak';make(p,**case)
+  total+=compare(p,p.with_suffix('.reference'),case.get('n',512),case.get('bps',16),case.get('ch',2))
+  print(index,'match',case,flush=True)
+ print('All matched',len(cases),'cases',total,'PCM bytes')
+ temporary.cleanup()
diff --git a/scripts/test-tak1.cpp b/scripts/test-tak1.cpp
new file mode 100644
index 0000000..765d2e3
--- /dev/null
+++ b/scripts/test-tak1.cpp
@@ -0,0 +1,41 @@
+// Host-only TAK 1.0 reference comparison helper. Writes original-depth PCM.
+// Usage: test-tak1 input.tak output.pcm [maximum_packets]
+#include "tak1.h"
+#include <iostream>
+#include <fstream>
+#include <stdexcept>
+extern "C" {
+#include <libavformat/avformat.h>
+}
+int main(int argc, char** argv) {
+    if (argc < 3) return 2;
+    AVFormatContext* format=nullptr;
+    AVPacket* packet=av_packet_alloc();
+    int count=0;
+    try {
+        if (avformat_open_input(&format,argv[1],nullptr,nullptr)<0) throw std::runtime_error("Cannot open fixture");
+        auto* par=format->streams[0]->codecpar;
+        Tak1Decoder decoder(par->extradata,par->extradata_size);
+        std::ofstream out(argv[2],std::ios::binary);
+        if (!out) throw std::runtime_error("Cannot write PCM output");
+        const int bytes=(decoder.info().bits+7)/8;
+        int maximum=argc>3 ? std::stoi(argv[3]) : 1000000;
+        while (count<maximum && av_read_frame(format,packet)>=0) {
+            if (packet->stream_index==0) {
+                auto audio=decoder.decode(packet->data,packet->size);
+                for (int i=0; i<audio.samples; ++i) for (int c=0; c<decoder.info().channels; ++c) {
+                    uint32_t value=uint32_t(audio.channel[c][i]);
+                    if (decoder.info().bits==8) value+=128;
+                    for (int b=0; b<bytes; ++b) out.put(char(value>>(8*b)));
+                }
+                ++count;
+            }
+            av_packet_unref(packet);
+        }
+        std::cout<<count<<" packets decoded\n";
+        av_packet_free(&packet); avformat_close_input(&format); return 0;
+    } catch (const std::exception& e) {
+        std::cerr<<"After "<<count<<" packets: "<<e.what()<<"\n";
+        av_packet_free(&packet); avformat_close_input(&format); return 1;
+    }
+}
diff --git a/settings.gradle b/settings.gradle
new file mode 100644
index 0000000..e366e9e
--- /dev/null
+++ b/settings.gradle
@@ -0,0 +1,4 @@
+pluginManagement { repositories { google(); mavenCentral(); gradlePluginPortal() } }
+dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS); repositories { google(); mavenCentral() } }
+rootProject.name = 'LoopDeck'
+include ':app'
diff --git a/third_party/licenses/FFmpeg-LGPL-2.1.txt b/third_party/licenses/FFmpeg-LGPL-2.1.txt
new file mode 100644
index 0000000..40924c2
--- /dev/null
+++ b/third_party/licenses/FFmpeg-LGPL-2.1.txt
@@ -0,0 +1,502 @@
+                  GNU LESSER GENERAL PUBLIC LICENSE
+                       Version 2.1, February 1999
+
+ Copyright (C) 1991, 1999 Free Software Foundation, Inc.
+ 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+[This is the first released version of the Lesser GPL.  It also counts
+ as the successor of the GNU Library Public License, version 2, hence
+ the version number 2.1.]
+
+                            Preamble
+
+  The licenses for most software are designed to take away your
+freedom to share and change it.  By contrast, the GNU General Public
+Licenses are intended to guarantee your freedom to share and change
+free software--to make sure the software is free for all its users.
+
+  This license, the Lesser General Public License, applies to some
+specially designated software packages--typically libraries--of the
+Free Software Foundation and other authors who decide to use it.  You
+can use it too, but we suggest you first think carefully about whether
+this license or the ordinary General Public License is the better
+strategy to use in any particular case, based on the explanations below.
+
+  When we speak of free software, we are referring to freedom of use,
+not price.  Our General Public Licenses are designed to make sure that
+you have the freedom to distribute copies of free software (and charge
+for this service if you wish); that you receive source code or can get
+it if you want it; that you can change the software and use pieces of
+it in new free programs; and that you are informed that you can do
+these things.
+
+  To protect your rights, we need to make restrictions that forbid
+distributors to deny you these rights or to ask you to surrender these
+rights.  These restrictions translate to certain responsibilities for
+you if you distribute copies of the library or if you modify it.
+
+  For example, if you distribute copies of the library, whether gratis
+or for a fee, you must give the recipients all the rights that we gave
+you.  You must make sure that they, too, receive or can get the source
+code.  If you link other code with the library, you must provide
+complete object files to the recipients, so that they can relink them
+with the library after making changes to the library and recompiling
+it.  And you must show them these terms so they know their rights.
+
+  We protect your rights with a two-step method: (1) we copyright the
+library, and (2) we offer you this license, which gives you legal
+permission to copy, distribute and/or modify the library.
+
+  To protect each distributor, we want to make it very clear that
+there is no warranty for the free library.  Also, if the library is
+modified by someone else and passed on, the recipients should know
+that what they have is not the original version, so that the original
+author's reputation will not be affected by problems that might be
+introduced by others.
+
+  Finally, software patents pose a constant threat to the existence of
+any free program.  We wish to make sure that a company cannot
+effectively restrict the users of a free program by obtaining a
+restrictive license from a patent holder.  Therefore, we insist that
+any patent license obtained for a version of the library must be
+consistent with the full freedom of use specified in this license.
+
+  Most GNU software, including some libraries, is covered by the
+ordinary GNU General Public License.  This license, the GNU Lesser
+General Public License, applies to certain designated libraries, and
+is quite different from the ordinary General Public License.  We use
+this license for certain libraries in order to permit linking those
+libraries into non-free programs.
+
+  When a program is linked with a library, whether statically or using
+a shared library, the combination of the two is legally speaking a
+combined work, a derivative of the original library.  The ordinary
+General Public License therefore permits such linking only if the
+entire combination fits its criteria of freedom.  The Lesser General
+Public License permits more lax criteria for linking other code with
+the library.
+
+  We call this license the "Lesser" General Public License because it
+does Less to protect the user's freedom than the ordinary General
+Public License.  It also provides other free software developers Less
+of an advantage over competing non-free programs.  These disadvantages
+are the reason we use the ordinary General Public License for many
+libraries.  However, the Lesser license provides advantages in certain
+special circumstances.
+
+  For example, on rare occasions, there may be a special need to
+encourage the widest possible use of a certain library, so that it becomes
+a de-facto standard.  To achieve this, non-free programs must be
+allowed to use the library.  A more frequent case is that a free
+library does the same job as widely used non-free libraries.  In this
+case, there is little to gain by limiting the free library to free
+software only, so we use the Lesser General Public License.
+
+  In other cases, permission to use a particular library in non-free
+programs enables a greater number of people to use a large body of
+free software.  For example, permission to use the GNU C Library in
+non-free programs enables many more people to use the whole GNU
+operating system, as well as its variant, the GNU/Linux operating
+system.
+
+  Although the Lesser General Public License is Less protective of the
+users' freedom, it does ensure that the user of a program that is
+linked with the Library has the freedom and the wherewithal to run
+that program using a modified version of the Library.
+
+  The precise terms and conditions for copying, distribution and
+modification follow.  Pay close attention to the difference between a
+"work based on the library" and a "work that uses the library".  The
+former contains code derived from the library, whereas the latter must
+be combined with the library in order to run.
+
+                  GNU LESSER GENERAL PUBLIC LICENSE
+   TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+  0. This License Agreement applies to any software library or other
+program which contains a notice placed by the copyright holder or
+other authorized party saying it may be distributed under the terms of
+this Lesser General Public License (also called "this License").
+Each licensee is addressed as "you".
+
+  A "library" means a collection of software functions and/or data
+prepared so as to be conveniently linked with application programs
+(which use some of those functions and data) to form executables.
+
+  The "Library", below, refers to any such software library or work
+which has been distributed under these terms.  A "work based on the
+Library" means either the Library or any derivative work under
+copyright law: that is to say, a work containing the Library or a
+portion of it, either verbatim or with modifications and/or translated
+straightforwardly into another language.  (Hereinafter, translation is
+included without limitation in the term "modification".)
+
+  "Source code" for a work means the preferred form of the work for
+making modifications to it.  For a library, complete source code means
+all the source code for all modules it contains, plus any associated
+interface definition files, plus the scripts used to control compilation
+and installation of the library.
+
+  Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope.  The act of
+running a program using the Library is not restricted, and output from
+such a program is covered only if its contents constitute a work based
+on the Library (independent of the use of the Library in a tool for
+writing it).  Whether that is true depends on what the Library does
+and what the program that uses the Library does.
+
+  1. You may copy and distribute verbatim copies of the Library's
+complete source code as you receive it, in any medium, provided that
+you conspicuously and appropriately publish on each copy an
+appropriate copyright notice and disclaimer of warranty; keep intact
+all the notices that refer to this License and to the absence of any
+warranty; and distribute a copy of this License along with the
+Library.
+
+  You may charge a fee for the physical act of transferring a copy,
+and you may at your option offer warranty protection in exchange for a
+fee.
+
+  2. You may modify your copy or copies of the Library or any portion
+of it, thus forming a work based on the Library, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+    a) The modified work must itself be a software library.
+
+    b) You must cause the files modified to carry prominent notices
+    stating that you changed the files and the date of any change.
+
+    c) You must cause the whole of the work to be licensed at no
+    charge to all third parties under the terms of this License.
+
+    d) If a facility in the modified Library refers to a function or a
+    table of data to be supplied by an application program that uses
+    the facility, other than as an argument passed when the facility
+    is invoked, then you must make a good faith effort to ensure that,
+    in the event an application does not supply such function or
+    table, the facility still operates, and performs whatever part of
+    its purpose remains meaningful.
+
+    (For example, a function in a library to compute square roots has
+    a purpose that is entirely well-defined independent of the
+    application.  Therefore, Subsection 2d requires that any
+    application-supplied function or table used by this function must
+    be optional: if the application does not supply it, the square
+    root function must still compute square roots.)
+
+These requirements apply to the modified work as a whole.  If
+identifiable sections of that work are not derived from the Library,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works.  But when you
+distribute the same sections as part of a whole which is a work based
+on the Library, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote
+it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Library.
+
+In addition, mere aggregation of another work not based on the Library
+with the Library (or with a work based on the Library) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+  3. You may opt to apply the terms of the ordinary GNU General Public
+License instead of this License to a given copy of the Library.  To do
+this, you must alter all the notices that refer to this License, so
+that they refer to the ordinary GNU General Public License, version 2,
+instead of to this License.  (If a newer version than version 2 of the
+ordinary GNU General Public License has appeared, then you can specify
+that version instead if you wish.)  Do not make any other change in
+these notices.
+
+  Once this change is made in a given copy, it is irreversible for
+that copy, so the ordinary GNU General Public License applies to all
+subsequent copies and derivative works made from that copy.
+
+  This option is useful when you wish to copy part of the code of
+the Library into a program that is not a library.
+
+  4. You may copy and distribute the Library (or a portion or
+derivative of it, under Section 2) in object code or executable form
+under the terms of Sections 1 and 2 above provided that you accompany
+it with the complete corresponding machine-readable source code, which
+must be distributed under the terms of Sections 1 and 2 above on a
+medium customarily used for software interchange.
+
+  If distribution of object code is made by offering access to copy
+from a designated place, then offering equivalent access to copy the
+source code from the same place satisfies the requirement to
+distribute the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+  5. A program that contains no derivative of any portion of the
+Library, but is designed to work with the Library by being compiled or
+linked with it, is called a "work that uses the Library".  Such a
+work, in isolation, is not a derivative work of the Library, and
+therefore falls outside the scope of this License.
+
+  However, linking a "work that uses the Library" with the Library
+creates an executable that is a derivative of the Library (because it
+contains portions of the Library), rather than a "work that uses the
+library".  The executable is therefore covered by this License.
+Section 6 states terms for distribution of such executables.
+
+  When a "work that uses the Library" uses material from a header file
+that is part of the Library, the object code for the work may be a
+derivative work of the Library even though the source code is not.
+Whether this is true is especially significant if the work can be
+linked without the Library, or if the work is itself a library.  The
+threshold for this to be true is not precisely defined by law.
+
+  If such an object file uses only numerical parameters, data
+structure layouts and accessors, and small macros and small inline
+functions (ten lines or less in length), then the use of the object
+file is unrestricted, regardless of whether it is legally a derivative
+work.  (Executables containing this object code plus portions of the
+Library will still fall under Section 6.)
+
+  Otherwise, if the work is a derivative of the Library, you may
+distribute the object code for the work under the terms of Section 6.
+Any executables containing that work also fall under Section 6,
+whether or not they are linked directly with the Library itself.
+
+  6. As an exception to the Sections above, you may also combine or
+link a "work that uses the Library" with the Library to produce a
+work containing portions of the Library, and distribute that work
+under terms of your choice, provided that the terms permit
+modification of the work for the customer's own use and reverse
+engineering for debugging such modifications.
+
+  You must give prominent notice with each copy of the work that the
+Library is used in it and that the Library and its use are covered by
+this License.  You must supply a copy of this License.  If the work
+during execution displays copyright notices, you must include the
+copyright notice for the Library among them, as well as a reference
+directing the user to the copy of this License.  Also, you must do one
+of these things:
+
+    a) Accompany the work with the complete corresponding
+    machine-readable source code for the Library including whatever
+    changes were used in the work (which must be distributed under
+    Sections 1 and 2 above); and, if the work is an executable linked
+    with the Library, with the complete machine-readable "work that
+    uses the Library", as object code and/or source code, so that the
+    user can modify the Library and then relink to produce a modified
+    executable containing the modified Library.  (It is understood
+    that the user who changes the contents of definitions files in the
+    Library will not necessarily be able to recompile the application
+    to use the modified definitions.)
+
+    b) Use a suitable shared library mechanism for linking with the
+    Library.  A suitable mechanism is one that (1) uses at run time a
+    copy of the library already present on the user's computer system,
+    rather than copying library functions into the executable, and (2)
+    will operate properly with a modified version of the library, if
+    the user installs one, as long as the modified version is
+    interface-compatible with the version that the work was made with.
+
+    c) Accompany the work with a written offer, valid for at
+    least three years, to give the same user the materials
+    specified in Subsection 6a, above, for a charge no more
+    than the cost of performing this distribution.
+
+    d) If distribution of the work is made by offering access to copy
+    from a designated place, offer equivalent access to copy the above
+    specified materials from the same place.
+
+    e) Verify that the user has already received a copy of these
+    materials or that you have already sent this user a copy.
+
+  For an executable, the required form of the "work that uses the
+Library" must include any data and utility programs needed for
+reproducing the executable from it.  However, as a special exception,
+the materials to be distributed need not include anything that is
+normally distributed (in either source or binary form) with the major
+components (compiler, kernel, and so on) of the operating system on
+which the executable runs, unless that component itself accompanies
+the executable.
+
+  It may happen that this requirement contradicts the license
+restrictions of other proprietary libraries that do not normally
+accompany the operating system.  Such a contradiction means you cannot
+use both them and the Library together in an executable that you
+distribute.
+
+  7. You may place library facilities that are a work based on the
+Library side-by-side in a single library together with other library
+facilities not covered by this License, and distribute such a combined
+library, provided that the separate distribution of the work based on
+the Library and of the other library facilities is otherwise
+permitted, and provided that you do these two things:
+
+    a) Accompany the combined library with a copy of the same work
+    based on the Library, uncombined with any other library
+    facilities.  This must be distributed under the terms of the
+    Sections above.
+
+    b) Give prominent notice with the combined library of the fact
+    that part of it is a work based on the Library, and explaining
+    where to find the accompanying uncombined form of the same work.
+
+  8. You may not copy, modify, sublicense, link with, or distribute
+the Library except as expressly provided under this License.  Any
+attempt otherwise to copy, modify, sublicense, link with, or
+distribute the Library is void, and will automatically terminate your
+rights under this License.  However, parties who have received copies,
+or rights, from you under this License will not have their licenses
+terminated so long as such parties remain in full compliance.
+
+  9. You are not required to accept this License, since you have not
+signed it.  However, nothing else grants you permission to modify or
+distribute the Library or its derivative works.  These actions are
+prohibited by law if you do not accept this License.  Therefore, by
+modifying or distributing the Library (or any work based on the
+Library), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Library or works based on it.
+
+  10. Each time you redistribute the Library (or any work based on the
+Library), the recipient automatically receives a license from the
+original licensor to copy, distribute, link with or modify the Library
+subject to these terms and conditions.  You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties with
+this License.
+
+  11. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License.  If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Library at all.  For example, if a patent
+license would not permit royalty-free redistribution of the Library by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Library.
+
+If any portion of this section is held invalid or unenforceable under any
+particular circumstance, the balance of the section is intended to apply,
+and the section as a whole is intended to apply in other circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system which is
+implemented by public license practices.  Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+  12. If the distribution and/or use of the Library is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Library under this License may add
+an explicit geographical distribution limitation excluding those countries,
+so that distribution is permitted only in or among countries not thus
+excluded.  In such case, this License incorporates the limitation as if
+written in the body of this License.
+
+  13. The Free Software Foundation may publish revised and/or new
+versions of the Lesser General Public License from time to time.
+Such new versions will be similar in spirit to the present version,
+but may differ in detail to address new problems or concerns.
+
+Each version is given a distinguishing version number.  If the Library
+specifies a version number of this License which applies to it and
+"any later version", you have the option of following the terms and
+conditions either of that version or of any later version published by
+the Free Software Foundation.  If the Library does not specify a
+license version number, you may choose any version ever published by
+the Free Software Foundation.
+
+  14. If you wish to incorporate parts of the Library into other free
+programs whose distribution conditions are incompatible with these,
+write to the author to ask for permission.  For software which is
+copyrighted by the Free Software Foundation, write to the Free
+Software Foundation; we sometimes make exceptions for this.  Our
+decision will be guided by the two goals of preserving the free status
+of all derivatives of our free software and of promoting the sharing
+and reuse of software generally.
+
+                            NO WARRANTY
+
+  15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO
+WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
+EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
+OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY
+KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE
+LIBRARY IS WITH YOU.  SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
+THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+  16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN
+WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY
+AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU
+FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR
+CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE
+LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING
+RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A
+FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF
+SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
+DAMAGES.
+
+                     END OF TERMS AND CONDITIONS
+
+           How to Apply These Terms to Your New Libraries
+
+  If you develop a new library, and you want it to be of the greatest
+possible use to the public, we recommend making it free software that
+everyone can redistribute and change.  You can do so by permitting
+redistribution under these terms (or, alternatively, under the terms of the
+ordinary General Public License).
+
+  To apply these terms, attach the following notices to the library.  It is
+safest to attach them to the start of each source file to most effectively
+convey the exclusion of warranty; and each file should have at least the
+"copyright" line and a pointer to where the full notice is found.
+
+    <one line to give the library's name and a brief idea of what it does.>
+    Copyright (C) <year>  <name of author>
+
+    This library is free software; you can redistribute it and/or
+    modify it under the terms of the GNU Lesser General Public
+    License as published by the Free Software Foundation; either
+    version 2.1 of the License, or (at your option) any later version.
+
+    This library is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+    Lesser General Public License for more details.
+
+    You should have received a copy of the GNU Lesser General Public
+    License along with this library; if not, write to the Free Software
+    Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+
+Also add information on how to contact you by electronic and paper mail.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the library, if
+necessary.  Here is a sample; alter the names:
+
+  Yoyodyne, Inc., hereby disclaims all copyright interest in the
+  library `Frob' (a library for tweaking knobs) written by James Random Hacker.
+
+  <signature of Ty Coon>, 1 April 1990
+  Ty Coon, President of Vice
+
+That's all there is to it!
diff --git a/third_party/licenses/Gradle-Apache-2.0.txt b/third_party/licenses/Gradle-Apache-2.0.txt
new file mode 100644
index 0000000..f433b1a
--- /dev/null
+++ b/third_party/licenses/Gradle-Apache-2.0.txt
@@ -0,0 +1,177 @@
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS