Skip to content

Releases: SteliyanH/kadr-audio

v0.7.0 — adopts kadr 1.0

Choose a tag to compare

@SteliyanH SteliyanH released this 28 Aug 11:22
7ca92e4

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

Choose a tag to compare

@SteliyanH SteliyanH released this 28 Aug 09:02
51f0d40

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 allowBluetoothallowBluetoothHFP, 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

Choose a tag to compare

@SteliyanH SteliyanH released this 27 Aug 11:35
5622858

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

Choose a tag to compare

@SteliyanH SteliyanH released this 27 Aug 11:35
a66f37f

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

Choose a tag to compare

@SteliyanH SteliyanH released this 27 Aug 11:35
2ff548b

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

Choose a tag to compare

@SteliyanH SteliyanH released this 27 Aug 11:35
7c009ca

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 resume

Design

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

Choose a tag to compare

@SteliyanH SteliyanH released this 27 Aug 08:32

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 by assetURL, 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 a Bool, because .denied and .restricted need different interfaces: one is fixable in Settings, the other is not fixable by the user at all.
  • MusicLibraryError conforming to LocalizedError. 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.