Releases: SteliyanH/kadr-audio
Release list
v0.7.0 — adopts kadr 1.0
Adopts kadr 1.0.
No API change here — kadr 1.0 is a stability commitment with no code in it. The substantive part is the pin style: the kadr dependency moves from .upToNextMinor to from: "1.0.0".
While kadr was pre-1.0 every adapter had to pin .upToNextMinor, because SwiftPM's from: means .upToNextMajor and does not special-case 0.x — it would have accepted breaking minors, and kadr's minors really did break. But that meant each adapter accepted exactly one kadr minor, so the whole family had to move in lockstep. When it drifted, no single kadr version satisfied them all and an app depending on the family had no resolvable dependency graph at all.
from: "1.0.0" ends that: every adapter now accepts all of 1.x.
58 tests pass unchanged.
v0.6.1 — The iOS slice compiles
The iOS slice did not compile, and CI never built it. Found while checking whether anything else was needed before freezing the package.
error: capture of 'observer' with non-Sendable type 'any NSObjectProtocol'
in a '@Sendable' closure
AudioSession.interruptions captured the observer token from addObserver(forName:) so it could remove it later. That token is any NSObjectProtocol, which is not Sendable. Rewritten on NotificationCenter.notifications(named:), which owns its own lifetime.
Also allowBluetooth → allowBluetoothHFP, deprecated since iOS 8 — HFP is the profile carrying a microphone, which is the whole reason a recording session allows Bluetooth.
Why six green releases said nothing
swift test runs on macOS, where every #if os(iOS) body is excluded — 295 of this package's 1084 source lines, including all of MusicLibrary, AudioSession and VoiceoverRecorder.
So the platform this package exists for went unbuilt through six releases. Fifty-eight passing tests said nothing about it, because not one could reach the code.
CI now builds for iOS, here and across the four sibling packages. A package whose primary platform is never compiled by CI is not tested, whatever the test count says.
v0.5.0 — File inspection, attribution, route awareness
Three smaller additions, closing kadr-audio's planned surface.
AudioFile — check before it reaches a composition
A picker returns a URL and a content-type filter is a guess: .audio admits files with no decodable audio track, and a user can rename anything. Handing such a URL to AudioTrack produces a composition that exports silently wrong, or fails deep inside AVFoundation with an error naming neither the file nor the problem.
let track = try await AudioFile.audioTrack(for: pickedURL)
// "“holiday.mov” doesn't contain any audio."
// "Pick a music or voice file — a video file won't work here."One asset load turns a silent failure at export into a sentence at import.
MusicAttribution — a credit line that degrades properly
Returns nil rather than a dangling " — " when both fields are missing, and treats whitespace-only metadata as missing. Library items with neither title nor artist are common enough that a credit made of spaces is a real outcome.
AudioSession.recordingWouldCapturePlayback
On the built-in speaker the microphone hears the playback too, so a voiceover recorded there arrives with the backing track already in it. That is bleed, not timing — none of v0.3's latency work touches it. Better to suggest headphones before the take than after.
41 → 56 tests.
v0.4.0 — Loudness normalisation
Every social platform normalises on upload — Instagram, TikTok and YouTube all target roughly −14 LUFS. A composition mixed by ear is re-levelled after publishing, usually downward and unevenly across clips mixed at different times.
Measuring first is the only way to control that rather than discover it.
let measured = Loudness.integrated(samples: samples, sampleRate: 48_000, channels: 2)
let track = AudioTrack(url: musicURL).normalized(from: measured, to: .social)Implemented per ITU-R BS.1770-4: K-weighting, 400 ms blocks at 75% overlap, absolute gate at −70 LUFS, relative gate 10 LU below what survives it.
Tested by properties, not reference values
"A 1 kHz sine at −20 dBFS measures −21.3 LUFS" would pin the code to one particular filter and break on any legitimate refinement. These hold for every correct implementation and fail for most incorrect ones:
- doubling amplitude → +6.02 dB
- tenfold → +20 dB
- length does not change loudness — it is a rate, not a total
- a silent half is gated out, not averaged in
- measure → gain → measure lands within 0.3 dB of target
Three limits, documented rather than hidden
A gain above 1.0 raises peaks as well as loudness and nothing here limits them, so a very quiet source pushed to −14 LUFS may clip. The caller may know their source has headroom, so this returns the number rather than deciding.
Silence returns a gain of 1.0. Multiplying silence by anything is still silence.
The K-weighting coefficients are specified at 48 kHz and applied directly — exact there, an approximation at 44.1 kHz. What most implementations do, but an approximation, and the source says so.
26 → 41 tests.
v0.3.0 — Voiceover recording
AVAudioRecorder is a handful of lines and free from Apple. What this packages is the part that decides whether a take lands on the picture or a fifth of a second behind it.
let take = try recorder.stop()
video = video.audio { take.audioTrack(startingAt: previewTime) }Why latency compensation is the feature
A voiceover is performed against playback. The performer reacts to audio that already left the device late, and their voice arrives at the input late again — so the take is behind the picture by the sum of both.
| Connection | Round trip |
|---|---|
| Wired | a couple of ms — nobody notices |
| Bluetooth | 150–200 ms — several frames at 30 fps, unmistakable on a lip-sync |
Measured at start(), not at construction: plugging in AirPods between the two changes the answer by two orders of magnitude.
An honest limit
Compensation shifts a recording into alignment; it cannot undo what the performer heard while performing. A take recorded against 200 ms-late playback is correctly placed and still performed against late audio. RecordingLatency.isPerceptible(_:) exists so a host can suggest a wired connection rather than silently papering over it.
16 → 26 tests. Ten of them are the latency arithmetic and the placement it drives — pure, so they run on the macOS host CI uses.
v0.2.0 — Audio session management
Closes a defect that was live in every consumer of the kadr family.
The bug
Nothing in the family touched AVAudioSession — not kadr, not kadr-ui, not the reference app. So a host got whatever session it happened to be in, which for a fresh app is .soloAmbient.
That category obeys the ring/silent switch. A user mutes their phone, opens a video editor, and the preview is silent with nothing on screen explaining why. Video playback is expected to ignore that switch, which is why every video app you have used does.
Two more consequences of having no session at all: an interruption stopped playback and nothing resumed it, and starting a preview stopped the user's music without anyone deciding it should.
try AudioSession.configure(.preview) // audible with the phone muted
try AudioSession.activate() // when the preview appears
try AudioSession.deactivate() // lets their music resumeDesign
AudioSessionPolicy is a separate type from AudioSession — the policy carries the decisions, the session applies them. That split is the only reason this has coverage: AVAudioSession cannot be exercised off a device, and CI runs on macOS where the framework does not exist.
Configuration and activation are separate calls. Activation takes audio focus, so a host that activates at launch silences the user's music for as long as the app is open.
deactivate notifies others by default, so a paused music app resumes.
interruptions carries the system's shouldResume flag rather than swallowing it — ignoring it is how an app talks over a phone call.
8 → 16 tests.
v0.1.0 — Music-library resolution
First release. MPMediaItem → kadr AudioTrack.
Why it is a separate package
kadr core is AVFoundation-only by policy. MediaPlayer is a different framework with its own privacy prompt and its own platform limits, so it lives here — the same reason Photos lives in kadr-photos. A package is justified by a dependency, not by a topic.
Per-clip volume, waveform extraction and audio-only export are kadr core's job and stay there. If this package ever holds only AVFoundation work, it has stopped being a package and become a fragmentation of core — that is written into the ROADMAP as an explicit non-goal.
The thing worth knowing first
Most of a typical music library cannot be exported into a video. Apple Music tracks are DRM-protected: MPMediaItem.assetURL is nil for anything from a subscription, and no API turns one into a file. Only music the user owns — purchased, or imported themselves — exposes a URL.
A consumer that does not know this ships a picker where nearly everything the user taps fails, with nothing useful said. So the surface makes it explicit twice:
// Offer only what will actually work
let usable = try MusicLibrary.usableSongs()
// Or resolve and handle the refusal properly
let track = try MusicLibrary.audioTrack(for: picked)
video = video.music(track.volume(0.4).ducking(0.2))Added
MusicLibrary.audioTrack(for:)— resolves an item, or throws an error explaining why it cannot.MusicLibrary.usableSongs()— filtered byassetURL, so the result is what a picker should offer rather than what the library contains.MusicLibrary.isAuthorized/requestAuthorization()— the latter returns the status rather than aBool, because.deniedand.restrictedneed different interfaces: one is fixable in Settings, the other is not fixable by the user at all.MusicLibraryErrorconforming toLocalizedError. The recovery text names Apple Music as the reason rather than suggesting a retry, because retrying with another subscription track fails identically.
Platforms
iOS 17+ and visionOS 1+. macOS is declared in the manifest so the package resolves against kadr and can be tested in CI, but MediaPlayer's types are unavailable there — canImport(MediaPlayer) is true on macOS while every type is marked unavailable, so the guards are #if os(iOS) || os(visionOS). tvOS is excluded outright.
Requires
NSAppleMusicUsageDescription in your app's Info.plist. Without it, requesting authorization terminates the app.