commit 749016e1318d3acfc2d857022bcfd0f7fbb3f525
| author | 斟酌 鵬兄 <tgckpg@gmail.com> |
| date | 2026-10-17T13:44:01Z |
| subject | Initial 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