Problem
We had several wireless lavalier mics whose vendor app had been discontinued. The mics still powered on and paired, but the only thing a phone or PC could use was the standard Bluetooth headset profile. The good audio path ran over a private serial protocol that only the vendor app spoke, and the vendor’s update and licence servers were offline.
The goal was concrete: record several of these mics at once, each as its own track, on Windows and Android, with the protocol fully under our control. Later goals added video, other microphones, a build pipeline that ships to stores, and a remote mode where one device starts and collects takes from another.
This post follows the path from a decompiled APK to that result, and covers the failures that shaped each step.
Production Symptoms
- Through the headset profile the mic was a call microphone: 16 kHz wideband at best, and one mic at a time.
- The vendor app no longer installed or worked on current phones. Its servers timed out, refused connections or redirected to a DNS block page.
- No public documentation existed for the serial protocol or the audio codec.
- Each mic has its own crystal. Two mics, a sound card and a camera run on four different clocks, so long takes drift apart.
Step 1: Recover the Protocol Statically
The first plan assumed we would need Bluetooth HCI snoop logs and runtime hooking, because the vendor SDK shipped an “authenticate” native library. Neither was needed.
The path that worked:
- Decompile the APK (jadx). The SDK core was obfuscated into single-letter packages: transport, session logic, parsers, a setting-to-command map, payload builders and codec detection.
- Map the obfuscated classes against a public sample project of an older version of the same SDK, which still had readable names.
- Rewrite framing, commands and parameter encoding as a small Python module.
- Check the encoder against the two session packets that the app hard-codes. Both came out byte-identical, which confirmed the checksum rule before any hardware test.
The link format is a 9-byte header followed by an optional command frame. All integers are big-endian, and every checksum is the two’s complement of the byte sum:
def checksum(data: bytes) -> int:
"""Two's complement of the byte sum: sum(data) + cksum == 0 mod 256."""
return (-sum(data)) & 0xFF
def build_link_header(total_len: int, seq: int, ack: int, ptype: int) -> bytes:
"""9-byte link header: FF 5A len16 40 seq ack type cksum."""
h = SYNC + struct.pack(">HBBBB", total_len, HDR_BYTE4, seq & 0xFF, ack & 0xFF, ptype)
return h + bytes([checksum(h)])
def build_frame(cmd: int, payload: bytes, seq: int = 0, ack: int = 0) -> bytes:
sub = b"\x00\x00" + SUB_MAGIC + struct.pack(">HH", len(payload) + 6, cmd)
total = 9 + len(sub) + len(payload) + 1
body = sub + payload
return build_link_header(total, seq, ack, 2) + body + bytes([checksum(body)])
Settings use a 6-byte parameter block (00 06 | subtype16 | value16). The vendor code divides by 255 instead of 256 when it splits the high byte. The reference encoder keeps that bug on purpose, because the device firmware was written against it.
The static pass also covered authentication. The licence library checks the app’s own SDK licence against a server, not the device. The device-level challenge command exists, but its handler in the app is empty and no response is ever sent. On the firmware we tested, the device never sent the challenge.
Step 2: Identify the Audio Codec
The audio command carries a 4-byte timestamp and then a frame with a 4-byte header. Three findings together identified the codec:
- The decoder library imports
celt_mode_create,celt_decoder_createand a four-argumentcelt_decode. That signature exists only in CELT 0.7 to 0.10; 0.11 added a frame-size argument. - Its internal functions carry a prefix over upstream names. All 19 of them exist in the CELT 0.7.1 source. Pitch prediction is among them, and it was removed in 0.8, which rules out later versions.
- The rate and frame-size tables were read straight from the library’s read-only data section (in a disassembler, after mapping virtual addresses to file offsets).
The 4-byte header is a vendor wrapper that the decoder strips before calling stock libcelt:
def parse_header(b):
if len(b) < 4 or b[0] != 0x9E or (b[1] & 0x24):
return None
rate = RATES[(b[1] >> 6) & 3]
ch_code = (b[1] >> 4) & 3
if ch_code > 1:
return None
return {"rate": rate, "channels": ch_code + 1, "frame_size": FRAME_SIZES[b[1] & 3],
"flags": b[2], "nbytes": b[3]}
The proof was empirical: stock CELT 0.7.1, built from the upstream tarball, decoded the first 443 captured frames with zero errors. The stream is 44.1 kHz stereo with 512 samples and 190 bytes per frame. That is about 86 packets per second and about 131 kbit/s. Left and right are identical, because the mic has a mono capsule.
Step 3: The Proof of Concept on Real Hardware
The proof of concept was a Python client on a Windows ARM64 laptop. On Windows you can connect an RFCOMM socket to a service UUID with port 0, and the stack resolves the channel through SDP. That removes the need to know the channel number:
def open_rfcomm(mac, channel=None, timeout=10.0):
s = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_STREAM, socket.BTPROTO_RFCOMM)
if channel:
s.settimeout(timeout)
s.connect((mac, channel))
elif os.name == "nt":
s.setblocking(True) # raw Winsock connect must not see a non-blocking socket
_connect_by_uuid_windows(s, mac, SPP_UUID)
...
The setblocking(True) line was the first live bug. The first probe failed with Winsock error 10035 (WSAEWOULDBLOCK). A timeout set earlier had made the socket non-blocking, and the raw connect call through ctypes does not handle that.
After the fix the observed sequence was:
- Session open at +0.5 s.
- Device version report at +0.84 s, followed by an unprompted state dump.
- Four rounds of clock sync (about 130 ms each).
- Start-audio command at +1.37 s, first audio at +2.29 s.
A heartbeat every 500 ms keeps the stream alive. Session open and clock sync turned out to be optional; audio streams without them.
The live tests answered the questions static analysis could not:
- Setting writes are never acknowledged, but they persist across reconnects.
- The quality levels request 190/170/140 bytes per frame, and the device actually delivers 190/180/150.
- The standard A2DP headphone output works while the private stream is running, so audio back to the mic needs no private protocol.
- An early assumption from community material was that the headset profile would be narrowband (3.5 kHz). On Windows 11 it negotiates wideband mSBC at 16 kHz. It is still far below the private stream, which is full band to about 20 kHz.
Firmware modification was part of the original question. It was dropped: no firmware image newer than the device’s own was obtainable, and nothing was ever sent to or flashed onto a mic.
The clock problem shows up
A bridge script played the decoded stream to a Windows output device. The first run used a plain FIFO. The buffer grew to 174 ms and 232 ms of audio was dropped. The mic’s sample clock and the sound card’s clock do not run at the same rate, and a FIFO between them either fills up or runs dry.
The fix was a jitter buffer that resamples by a ratio steered by its own fill level, clamped to ±0.5 % so the correction is inaudible. The same controller ended up in the app:
var err = (_count - _frac - TargetSamples) / Math.Max(TargetSamples, 1);
Ratio = 1.0 + Math.Clamp(Gain * err, -MaxRatioDeviation, MaxRatioDeviation);
var lastIdx = _frac + (frames - 1) * Ratio;
if (lastIdx + 1 >= _count)
{
Underruns++;
_started = false;
output.Clear();
return false;
}
With it, a 40 s live run decoded 3,489 frames with 0 decode errors and 0 underruns, and buffer latency stayed between 46 and 91 ms.
Step 4: From Script to Windows App
Requirements for the app: several mics at once, one start/stop for all of them, native on Windows ARM64 and x64, and nothing that blocks a later macOS or Linux port.
.NET MAUI was the first candidate. It was dropped because it has no Linux target, and on macOS (through Mac Catalyst) it cannot use the classic Bluetooth API that RFCOMM needs. The stack became .NET 10 with Avalonia.
The architecture has three layers:
- Core, with no OS APIs: framing, session handling, the CELT binding, jitter buffer and recorder. It is unit-tested against real captures.
- Platform layers per OS: RFCOMM, device discovery, audio output, and later camera and encoders.
- App heads: thin per-platform entry points. They hand the shared UI an
IAppHostwith the hub factory, recording folder, permission prompts and share/open actions.
The first Windows build took minutes, not days, because Core was a direct port of the Python module that was already verified against hardware. The first CLI run on the real mic recorded 440 frames with 0 decode errors.
Native code was the hard part
CELT is C, so every target needs its own binary. Two problems came out of that.
The first was loading. A Zig-built ARM64 Windows DLL failed to load with “incorrect format”, although its headers looked correct. Rebuilding with llvm-mingw fixed it, and Zig was dropped. Loading is now one resolver that knows each platform’s layout:
private static IntPtr Resolve(string name, Assembly assembly, DllImportSearchPath? path)
{
if (!Libs.TryGetValue(name, out var f))
{
return IntPtr.Zero;
}
if (OperatingSystem.IsAndroid())
{
return NativeLibrary.TryLoad(f.Unix, out var lib) ? lib : IntPtr.Zero;
}
if (OperatingSystem.IsIOS())
{
var framework = Path.Combine(AppContext.BaseDirectory, "Frameworks", $"{name}.framework", name);
return NativeLibrary.TryLoad(framework, out var lib) ? lib : IntPtr.Zero;
}
var os = OperatingSystem.IsWindows() ? "win" : OperatingSystem.IsMacOS() ? "osx" : "linux";
// ... map ProcessArchitecture to arm64/x64/x86 ...
var candidate = Path.Combine(AppContext.BaseDirectory, "runtimes", $"{os}-{arch}", "native", file);
if (NativeLibrary.TryLoad(candidate, out var handle))
{
return handle;
}
return NativeLibrary.TryLoad(name, assembly, path, out handle) ? handle : IntPtr.Zero;
}
The second was thread safety, and it only showed up with several mics. Rendering store screenshots with several simulated mics crashed the process inside celt_decode. Without -DVAR_ARRAYS, CELT 0.7.1 falls back to one process-wide scratch stack, so two decoders on two threads corrupt each other. Every build script now passes the flag. A regression test that decodes the same capture on several threads at once crashed the test host on the old binary and passes on the new one.
Step 5: Android
The Android head went in after the shared UI was moved into a UserControl that both heads host. Bluetooth maps directly: BluetoothSocket to the same service UUID, with a fallback for stacks that refuse an authenticated link to an already-bonded device. CELT is built with NDK r29, aligned to 16 KB pages for Android 15+.
Audio-only recording worked on the first install. Video did not.
Eight frames, all at timestamp 0
The first Android video take contained 8 video frames, all stamped 0 ms, next to 7.6 s of correct audio. The first guesses were a wrong clock mapping and a stalled encoder drain. A self-test that opened the camera on the phone and counted frames showed something else: the preview stopped after about five frames, and the encoder received none.
The cause was one line. The preview ImageReader image was wrapped in a C# using. In .NET for Android, Dispose() releases the managed peer but does not call the Java Image.close(). With maxImages=2 the reader’s buffers were never returned, the camera HAL timed out dequeuing new ones, and the whole repeating capture request stalled. That included the encoder’s stream.
private void OnImage(ImageReader reader)
{
var image = reader.AcquireLatestImage();
if (image is null)
{
return;
}
try { ConvertPreview(image); }
finally { image.Close(); } // Dispose() does not close the Java Image; unclosed images stall the whole capture session.
}
After the fix the same self-test produced 89 frames in 3 s, 3 key frames, and monotonic timestamps.
Rotation hints are not reliable
Portrait video played sideways. The recorder wrote the rotation as a container hint, and players handle the Matroska hint inconsistently. Our sign was also wrong. Fixing the sign was not enough, so the phone now draws each camera frame upright through OpenGL into the encoder’s input surface, and the container hint is 0. One more trap: on the test phone, SurfaceTexture.GetTransformMatrix() already contained the sensor rotation, so applying it as well rotated the frames twice.
Step 6: Build and Release Pipeline
The pipeline runs on a GitHub release. The tag version (v1.2.3) gives every store its build number: the Android version code, the Apple build number and the MSIX version.
Native libraries are cross-compiled on a single Linux runner:
- CELT and LAME are downloaded from pinned URLs and checked against pinned SHA-256 hashes.
- llvm-mingw produces the Windows DLLs and the NDK produces the Android
.sofiles. - The results are uploaded as artifacts and consumed by the platform jobs.
There is no build matrix; the per-target loop lives in the same scripts developers run locally:
for pair in aarch64:android-arm64 x86_64:android-x64; do
arch="${pair%%:*}"
rid="${pair#*:}"
out="$here/runtimes/$rid/native"
mkdir -p "$out"
(cd "$src" && "$clang" --target="$arch-linux-android$api" -shared -fPIC -O2 -DHAVE_LRINTF -DVAR_ARRAYS \
-Wl,-z,max-page-size=16384 -Wl,-soname,libcelt071.so -Wno-everything \
-o "$out/libcelt071.so" $files -lm)
echo "built $out/libcelt071.so"
done
A screenshot job renders the UI headlessly with simulated mics that replay a real captured session. A Python step frames those screenshots and builds every store asset. It also validates the listing text against store limits.
What the first real releases broke
- iOS: the newest .NET iOS workload required an Xcode version the default macOS runner did not have. The fix was to move the job to a newer runner image and add an explicit Xcode selection step.
- Android: the release asked for an AAB, but only an APK came out. The project file set
AndroidPackageFormat(singular) and CI passedAndroidPackageFormats(plural). The singular property won, so the CI override had no effect. The fix was renaming one property. A second fix was needed because the signed APK lands one directory abovepublish/, while the AAB lands inside it. - Microsoft Store: the submission API has no image type for the portrait poster we generated, and the submission failed. The poster is still generated, but it is no longer uploaded.
- Signing: a missing keystore had produced a debug-signed build instead of an error. It is now a hard failure, and keystore passwords are passed as
file:paths instead of command-line values.
Google Play publishing uses no stored key. GitHub’s OIDC token is exchanged through Google Workload Identity Federation for a short-lived service-account identity:
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@v3
with:
workload_identity_provider: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ vars.GCP_PLAY_SERVICE_ACCOUNT }}
- name: Submit to Google Play
run: >
python Tools/store/google_play.py --assets in/store-assets/android
--package "in/android/app-$VERSION-android.aab" --build-number "$BUILD"
--version "$VERSION" --track "$TRACK" --status "$STATUS"
The submission script does everything in one Play “edit”: upload, check that the version code matches, update listings and images, set the track, validate and commit. If any step fails it deletes the edit, so a broken run never leaves a half-updated listing.
Step 7: Remote Mode
The requirement: any instance can control any other over Wi-Fi or Bluetooth, each device records its own audio and video locally, and when the take stops the controller collects everything into one package. Finding another device should be a simple search, regardless of transport.
Discovery
On Wi-Fi the searcher broadcasts a short query to the global broadcast address, a multicast group and each interface’s subnet broadcast. It repeats the query, because single datagrams get lost on busy Wi-Fi:
async Task SendLoop()
{
// Datagrams get lost on busy Wi-Fi: ask a few times.
foreach (var delay in new[] { 0, 700, 1500, 3000 })
{
await Task.Delay(delay, ct).ConfigureAwait(false);
foreach (var t in targets)
{
try { await udp.SendAsync(query, t, ct).ConfigureAwait(false); }
catch (SocketException) { /* unreachable network */ }
}
}
}
On Bluetooth each instance runs an RFCOMM service with its own UUID, and the searcher probes paired phones and computers. Results from both transports are merged by a per-install ID, and Wi-Fi is preferred. The link itself is a minimal frame format over any duplex stream: a 1-byte kind, a 4-byte length, and a JSON payload (raw bytes for file chunks). Header and payload go out in one write, because on Bluetooth each write can become its own packet. Control requires a consent prompt on the remote device.
Starting at the same instant
The controller estimates each remote’s clock offset with an NTP-style exchange, and keeps the sample with the lowest round-trip time:
var offset = p.T2 - (p.T1 + t3) / 2;
lock (_lock)
{
_samples.Add((rtt, offset));
// Keep ~2 minutes: during a file sync pings wait behind file data, and only the old, fast samples are accurate.
if (_samples.Count > 120)
{
_samples.RemoveAt(0);
}
var best = _samples.MinBy(s => s.Rtt);
ClockOffset = best.Offset;
ClockRtt = TimeSpan.FromTicks(best.Rtt);
}
To start a take, the controller picks a start time 700 ms ahead (1,200 ms if any remote is on Bluetooth). It translates that time into each remote’s clock and sends it. Each device sleeps until shortly before the instant and then spins until its own clock reaches it:
var wait = TimeSpan.FromTicks(startAt - MediaClock.Now);
if (wait > TimeSpan.FromMilliseconds(20))
{
await Task.Delay(wait - TimeSpan.FromMilliseconds(15));
}
while (MediaClock.Now < startAt)
{
Thread.Yield();
}
_hub.StartRecordingAll(RecordingRoot);
Each remote reports its actual start, and the controller stores the residual offset and its uncertainty in the take manifest. Every audio track is already locked to the device’s host clock, so an editor only has to shift each device by one constant.
The bug behind the comment
The comment about pings waiting behind file data came from a real failure. After a long Bluetooth file sync, the manifest recorded an offset of 1.9 s ± 1.9 s. The pings sent during the transfer had queued behind 64 KiB file chunks on a link that moved only 55–80 KB/s. Their slow samples replaced the good one. Two changes fixed it: keep the best sample over about two minutes, and store the offset measured at Start instead of a later one. The retest gave 25 ms ± 4 ms.
Two other fixes came out of cross-platform testing:
- A mic name containing a colon was valid on Android and invalid as a Windows file name, so the transfer to Windows failed. All received and generated file names now go through one sanitiser.
- A protocol level bump was nearly shipped in a way that would make older releases refuse the handshake. The wire version stays 1, and a separate
levelfield announces newer messages such as pause. Old peers ignore the field and are flagged as not supporting pause.
Runtime Impact
- Decoding: 0 decode errors across every live capture, from the first 443 frames to multi-minute takes.
- Tests: 10 tests on day one, about 350 later, run on win-arm64 and win-x64. They include bit-exact decode of a real capture and the multi-threaded CELT regression test.
- Windows video: 1080p30 on the hardware H.264 encoder, about 10.5 Mbit/s against a 12 Mbit/s target. Recording starts in 24–40 ms with a pre-warmed encoder.
- Remote over Wi-Fi: the peer is found in 100–161 ms, remotes start 3–19 ms from the scheduled instant, and a 5 s WAV transfers in under 0.4 s.
- Remote over Bluetooth only: discovery takes 0.5–2.4 s, starts land within 12–25 ms (±4–6 ms clock uncertainty), and transfer is the bottleneck, at 55–80 KB/s.
- Failure recovery: a Windows MP4 recorder killed at 8.0 s kept 8.05 s of video and 7.96 s of audio, and the file played.
Final Takeaway
The protocol work was mostly static. Matching the decompiled code against a public sample, and checking the encoder against packets the app hard-codes, gave a verified spec before the first Bluetooth connection. The expensive bugs came later and were all about state that crosses a boundary:
- clocks between devices;
- buffers between Java and .NET;
- a global scratch stack between threads;
- an MSBuild property between a project file and CI;
- a ping queue behind file data.
Each one is now pinned by a clock-locked track, an explicit Close(), a compiler flag with a regression test, a single property name, and a best-sample offset estimate. Those are the class of failures this setup now prevents: silent drift, frozen capture, cross-thread corruption, missing release artifacts, and wrong alignment in multi-device takes.