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.
