black and white bed linen

TuneURL SDK Developer Documentation

Easily add audio trigger detection to iOS or Android apps.

Quickstart — iOS

Five steps. Working detector in under ten minutes.

1. Add the framework
Drop TuneURL.xcframework into your Xcode project. The reference app uses a vendored framework in a Dependencies/ folder; Swift Package Manager support is available on request.

2. Bundle the trigger sound
Add trigger_sound.mp3 (provided by TuneURL with your account) to your app target.

3. Initialize the detector
import TuneURL
import AudioStreaming

let triggerURL = Bundle.main.url(
forResource: "trigger_sound", withExtension: "mp3")!
let detector = StreamDetector(triggerURL)

4. Feed PCM from your player
let player = AudioPlayer()

let parse = FilterEntry(name: "detector") { [weak detector] buffer, _ in
detector?.append(buffer)
}
player.frameFiltering.add(entry: parse)

player.play(url: streamURL)

5. Handle matches

detector.matchCallback = { match in
guard match.matchPercentage >= 70 else { return }
DispatchQueue.main.async {
// match.id, match.type, match.name, match.info
self.handleMatch(match)
}
}

That's the full streaming integration. The detector runs continuously on the audio your player decodes. When a trigger is detected and the server returns a match above your threshold, your callback fires on the audio thread — dispatch to main before touching UI.

Quickstart — Android

Android uses a foreground service for OTA capture and the native C++ core for fingerprinting. The reference app wires ExoPlayer's audio sink to the SDK for the streaming path.

1. Add the SDK module
// settings.gradle.kts
include(":tuneurl_sdk")

// app/build.gradle.kts
dependencies {\
implementation(project(":tuneurl_sdk"))
}

2. Configure API endpoints
import com.dekidea.tuneurl.util.TuneURLManager
import com.dekidea.tuneurl.util.Constants

TuneURLManager.updateStringSetting(
context,
Constants.SETTING_SEARCH_FINGERPRINT_URL,
"<your-search-fingerprint-endpoint>"
)
TuneURLManager.updateStringSetting(
context,
Constants.SETTING_INTERESTS_API_URL,
"<your-interests-endpoint>"
)

3. Build OTA detection (microphone)
// Requires RECORD_AUDIO permission

TuneURLManager.startTuneURLService(context)

4. Receive matches

class MatchReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Constants.SEARCH_FINGERPRINT_RESULT_RECEIVED) {
val match = parseMatch(intent)
// match.id, match.type, match.name, match.info, match.matchPercentage
}
}
}

val filter = IntentFilter(Constants.SEARCH_FINGERPRINT_RESULT_RECEIVED)
ContextCompat.registerReceiver(
context, MatchReceiver(), filter, ContextCompat.RECEIVER_NOT_EXPORTED
)

For the streaming path with ExoPlayer, see the reference app's CapturingRenderersFactory and StreamAudioCapture, which tap decoded PCM and forward it to the SDK's fingerprinter.

The two detection paths

The SDK supports two ways to get audio into the fingerprinter. You can use one or both in the same app.gOTA (microphon

iOS API reference

StreamDetector

Detector for the streaming path. Initialize once per audio session.
public class StreamDetector {
public init(_ triggerURL: URL)
public var matchCallback: ((Match) -> Void)?
public func append(_ buffer: AVAudioPCMBuffer)
public func reset()
}

Listener

Detector for the OTA (microphone) path. Static API; one listener per app.

public class Listener {
public static func setTrigger(_ url: URL)
public static func startListening(
onMatch: @escaping (Match) -> Void
)
public static func stopListening()
public static var audioBufferDelegate: ((AVAudioPCMBuffer) -> Void)?
}

Detector

Shared configuration for both paths. Call once at app launch.

public class Detector {
public static func setTrigger(_ url: URL)
}

Match

The object is delivered to your callback when the server confirms a trigger.
public struct Match {
public let id: Int
public let type: String // see EngagementType
public let name: String?
public let description: String?
public let info: String? // type-specific payload (URL, phone, etc.)
public let matchPercentage: Int // 0-100, server confidence
public let fingerprintVersion: String?

public func prettyDescription() -> String
}

Android API reference

TuneURLSDK (Kotlin)

Top-level Kotlin wrapper around the native fingerprinter. Loads native-lib on init.
object TuneURLSDK {
const val FORMAT_VERSION_V1: Int
const val FORMAT_VERSION_V2: Int // default

fun isInitialized(): Boolean
fun setFormatVersion(version: Int)
fun getFormatVersion(): Int

fun extractFingerprintFromFile(audioFilePath: String): ByteArray?
fun extractFingerprintFromBuffer(
audioBuffer: ByteBuffer, waveLength: Int
): ByteArray?
fun calculateSimilarity(
buffer1: ByteBuffer, length1: Int,
buffer2: ByteBuffer, length2: Int
): Float
fun fingerprintToHexString(fingerprint: ByteArray): String
}

TuneURLManager (Java)

Service lifecycle and configuration. Use for OTA detection and to point the SDK at your backend.

public class TuneURLManager {
public static void updateStringSetting(
Context ctx, String key, String value);
public static void startTuneURLService(Context ctx);
public static void stopTuneURLService(Context ctx);
public static void startScanning(
Context ctx, String path, long positionUs);
public static void stopScanning(Context ctx);
}

Broadcast intents

Matches and other SDK results are delivered via broadcast intents. Register a receiver for the actions you care about:

Match types and CTAs

The match.type string tells you how to act on it. Eight built-in types:

Reporting engagement

Once a match fires, your app reports back what the user did with it. These reports drive the attribution telemetry on the TuneURL dashboard.

Five report actions:

  • heard — emit immediately on match (the trigger was detected)

  • interested — user tapped the prompt

  • uninterested — user dismissed the prompt

  • acted — user completed the CTA (visited URL, saved coupon, called number, etc.)

  • shared — user shared the engagement externally

Wire format

Reports are batched and posted as JSON to your configured interests endpoint:

POST /interests

Content-Type: text/plain
[
{
"UserID": "<32-char hashed user id>",
"Date": "2026-06-02T1430",
"TuneURL_ID": 12345,
"Interest_action": "interested"
}
]

The iOS reference app batches reports in groups of 5 and flushes on app background or terminates. Failed posts are retained in a file cache and retried on the next opportunity.

Lifecycle and audio session

iOS

The streaming detector requires the audio session to be active in playback mode with a short IO buffer. The reference app uses 100ms:

try AVAudioSession.sharedInstance().setCategory(
.playback, mode: .default, options: [])
try AVAudioSession.sharedInstance().setPreferredIOBufferDuration(0.1)
try AVAudioSession.sharedInstance().setActive(true)

For the OTA path, you need the record category and the appropriate options for mixing with other audio:

try AVAudioSession.sharedInstance().setCategory(
.playAndRecord,
mode: .default,
options: [.mixWithOthers, .defaultToSpeaker,
.allowBluetooth, .allowBluetoothA2DP])

Stop the detector when playback stops and reset its internal buffer:

func stop() {
player.stop()
streamDetector.reset()
}

Android

OTA detection runs in a foreground service (TuneURLService). The SDK manages this lifecycle for you — you only call startTuneURLService and stopTuneURLService. Required runtime permissions:

RECORD_AUDIO — for OTA detection

POST_NOTIFICATIONS — Android 13+ for engagement notifications

FOREGROUND_SERVICE_MICROPHONE — Android 14+ for OTA service

Match handling pattern

A complete iOS match handler. This is the shape the reference app uses — dispatch to main, dedupe within a short window, report to telemetry, then present UI based on type.
private var lastMatch: Match?
private var lastMatchTime: Date?

detector.matchCallback = { [weak self] match in
guard let self else { return }
guard match.matchPercentage >= 70 else { return }

DispatchQueue.main.async {
// Dedupe: ignore same match within 10s window
if let last = self.lastMatch,
last.id == match.id,
let lastTime = self.lastMatchTime,
abs(lastTime.timeIntervalSinceNow) < 10 {
return
}
self.lastMatch = match
self.lastMatchTime = Date.now

// Report "heard" to telemetry
ReportAction.heard(Engagement(match: match)).report()

// Present based on type
switch match.type {
case "open_page", "save_page":
self.presentEngagement(match)
case "coupon":
self.saveCoupon(match)
case "phone":
self.promptCall(match.info)
case "sms":
self.composeSMS(match.info)
default:
log.write("Unknown engagement type: \(match.type)")
}
}
}

Configuration reference

Performance and resource use

Privacy and data handling

For app store review and your own privacy policy:

What gets sent to TuneURL servers

On match: the 5-second post-trigger audio fingerprint (binary blob, not raw audio) plus metadata.

On engagement report: hashed user ID, timestamp, match ID, action (heard/interested/uninterested/acted/shared).

What does NOT get sent

No raw microphone audio is uploaded — only fingerprints derived from short post-trigger windows.

No background or always-on listening. Detection runs only while your audio session is active.

No PII unless your app explicitly attaches it. The default user ID is a generated UUID stored in app preferences.

iOS PrivacyInfo.xcprivacy

The reference app ships a PrivacyInfo.xcprivacy manifest. If you embed the SDK in your own app, declare microphone usage (NSMicrophoneUsageDescription) if you use the OTA path, and the relevant tracking domains if you use TuneURL's hosted backend.

Troubleshooting

“The detector runs, but no callback fires.”

Check three things in order: (1) the audio session is active and the player is producing PCM frames, (2) you actually played a known trigger sound through that stream, (3) your match threshold isn't too high. Start at 50 to confirm the path works, then raise to 70+ once you're seeing matches.

“I get the same match firing repeatedly.”

Add the dedupe pattern from the Match handling pattern section above. Without it, the same trigger heard multiple times in a stream will produce multiple callbacks.

“Matches fire, but my UI doesn’t update.”

The callback runs on the audio thread. Dispatch to the main queue (iOS) or main dispatcher (Android) before touching UI.

“The Android service crashes on Android 14+.”

Add the FOREGROUND_SERVICE_MICROPHONE permission to your manifest and request RECORD_AUDIO at runtime before starting the service.

“I need to verify a new SDK build to be deployed.”

Add a distinctive Log/NSLog with a unique marker string in code you can trigger from your app. If the marker doesn’t appear in logs, the build didn’t deploy — chase that before debugging detection.

“Fingerprint version mismatch.”

Android only. If you generated reference fingerprints in v1 and your runtime is on v2 (the default), the matcher silently returns 0.0. Set TuneURLSDK.setFormatVersion() consistently across fingerprint generation and matching.

FAQ

Q: What audio format does the SDK expect?

10240 Hz, mono, 16-bit PCM. The SDK will resample if your source differs, but staying close to the native rate is cheaper.

Q: Can the SDK process podcast files instead of live streams?

Yes. Anything that produces PCM (decoded podcast, video soundtrack, microphone) works. The detector doesn’t care about the source.

Q: Does the streaming path work offline?

Local detection works offline. The server match step requires a network connection; if it fails, the match isn’t delivered. There’s no built-in offline queue for matches in the current SDK.

Q: What happens if multiple triggers fire close together?

Each fires its own match callback. The dedupe is per match.id, not per detection, so different campaigns don’t suppress each other.

Q: How do I test without a live broadcast?

Play the reference trigger_sound.mp3 file through any audio source your app listens to. The detector treats it the same way as a live trigger.

©Copyright 2026. All rights reserved.