Skip to content

Fix AVAudioRecorder Android parity with AVFoundation - #29

Open
iyungui wants to merge 7 commits into
skiptools:mainfrom
iyungui:fix/avaudiorecorder-parity
Open

Fix AVAudioRecorder Android parity with AVFoundation#29
iyungui wants to merge 7 commits into
skiptools:mainfrom
iyungui:fix/avaudiorecorder-parity

Conversation

@iyungui

@iyungui iyungui commented Jul 28, 2026

Copy link
Copy Markdown

Fixes #28.

Brings the Android AVAudioRecorder in Sources/SkipAV/AVAudioRecorder.swift in line with the AVFoundation API it stands in for. Only the #elseif SKIP branch is touched — iOS re-exports AVKit and is unaffected.

Each fix is a separate commit, so anything you'd rather not take can be dropped without unpicking the rest.

Commit Fix
cff6cf5 currentTime returned a negative elapsed time, and restarted from zero after pause()
906417b averagePower(forChannel:) returned Double; the header declares float
50517a3 Power was a 0...1 linear ratio; AVFoundation reports decibels (-160.0 ... 0.0)
3c4cdbd isMeteringEnabled and updateMeters() were missing, so metering was unusable
d195af0 record() restarted rather than resumed after pause(), and returned Void
2e1ae9b AVNumberOfChannelsKey was ignored; numeric settings only matched Int
b91f9ab Test for the metering conversion, README corrections

Behavior changes

Two of these change what existing Android code observes. Flagging them for a breaking-change label if you agree they warrant one.

Metering now returns decibels. Code written against the old 0...1 range needs converting:

// before: level was 0.0...1.0
let level = recorder.averagePower(forChannel: 0)

// after: powers are in dB, and metering must be enabled and refreshed
recorder.isMeteringEnabled = true
recorder.updateMeters()
let level = pow(10.0, recorder.averagePower(forChannel: 0) / 20.0)   // back to 0.0...1.0

record() after pause() now resumes. Code that relied on the old behavior to restart a take should call stop() and then record().

record() gaining a Bool return is source-compatible via @discardableResult.

Notes on the implementation

  • updateMeters() samples the amplitude once and caches it, rather than each accessor reading MediaRecorder.getMaxAmplitude() directly. That call resets on every read, so reading it from both accessors made whichever ran second observe silence. The AVFoundation shape happens to be exactly the right fit for this.
  • The decibel conversion is computed in Double and narrowed to Float at the end: SkipLib exposes log10(Double) and log10f(Float) but no log10(Float) overload.
  • amplitudeToDecibels(_:) is public static and Android-only so the test can reach it from the test module — following the existing convention in this file for init(platformValue:url:) and kotlin(nocopy:). Happy to make it internal and drop the test if you'd rather not add API surface.
  • AVFormatIDKey is still ignored, since MediaRecorder has no linear PCM output format. Rather than partially mapping it, the README now states that recordings are always AAC/MPEG-4.

Verification

  • swift test passes locally: 5/5 Swift-side tests, including the new one. The XCSkipTests gradle harness did not run — no Gradle on this machine — so the transpiled Kotlin tests are down to CI.
  • I did inspect the generated Kotlin under .build/plugins/outputs/.../skip/av/AVAudioRecorder.kt and confirmed the conversions resolve against skip.lib (Int(Number), Float(Number), log10(Double)).
  • The Android runtime behavior is not verified by me. In particular the pause() / resume() change and the metering values need a device or emulator to confirm. I'd appreciate a check there, or point me at how you'd like it exercised and I'll do it.

Marked as draft for that reason. Happy to reshape the scope — the first two commits are the uncontroversial ones if you'd prefer to start there.

iyungui added 7 commits July 28, 2026 22:43
`currentTime` computed `startTime.timeIntervalSinceNow`, which measures from
the start date *to now* and is therefore negative for a recording that has
already begun. AVFoundation documents the property as "Get the current time
of the recording", i.e. a positive elapsed duration.

Also accumulate the duration of completed segments so that the elapsed time
survives a `pause()`/`record()` cycle instead of restarting from zero, and
reset the accumulator in `stop()`.
AVFoundation declares both power accessors as returning `float`:

    - (float)peakPowerForChannel:(NSUInteger)channelNumber;
    - (float)averagePowerForChannel:(NSUInteger)channelNumber;

Returning `Double` on Android meant that cross-platform code compiling
against the iOS signature failed to compile once transpiled to Kotlin.
AVFoundation's peakPowerForChannel:/averagePowerForChannel: return decibels
relative to full scale, ranging from -160 (silence) to 0 (full scale).
SkipAV returned MediaRecorder's raw amplitude scaled into 0...1, so a level
meter written against the iOS API rendered incorrectly on Android.

The conversion is extracted into `amplitudeToDecibels(_:)` so that it can be
covered by a test without needing a live MediaRecorder. Note that SkipLib
exposes log10(Double) and log10f(Float) but no log10(Float) overload, so the
math is done in Double and narrowed to Float at the end.
AVFoundation gates metering behind two members that SkipAV was missing:

    @Property(getter=isMeteringEnabled) BOOL meteringEnabled;
    - (void)updateMeters;

SkipAV instead declared `meteringEnabled` — the Objective-C name rather than
the name Swift imports — and marked it `@available(*, unavailable)`, and had
no `updateMeters()` at all. The usual iOS metering sequence therefore did not
compile on Android:

    recorder.isMeteringEnabled = true
    recorder.updateMeters()
    let level = recorder.averagePower(forChannel: 0)

Sampling the amplitude in `updateMeters()` also fixes a latent problem with
reading it from the accessors: `MediaRecorder.getMaxAmplitude()` resets on
each read, so calling peak and average in the same frame made the second call
report silence.
AVFoundation declares record as:

    - (BOOL)record;    // Start or resume recording to file.

SkipAV returned Void and, more importantly, called `prepareToRecord()` on
every invocation. That builds a fresh MediaRecorder over the same output
file, so `pause()` followed by `record()` discarded everything captured
before the pause instead of resuming. `MediaRecorder.resume()` is the
counterpart to the `pause()` already in use and is available at the same
API level.

`record()` now only prepares when there is no recorder to reuse — the one
created in `init` or by an explicit `prepareToRecord()` is kept, matching
AVFoundation's note that prepareToRecord "is called automatically on record".
`pause()` additionally ignores calls made while not recording, since
MediaRecorder throws IllegalStateException in that state.
The channel count was hardcoded to 2, so a mono recording could not be
requested. The Showcase app's AudioPlayground already passes
`AVNumberOfChannelsKey: 1` and gets stereo on Android.

The remaining numeric settings were matched with `as? Int` only. AVFoundation
documents these as NSNumber values and iOS code commonly writes
`AVSampleRateKey: 44100.0`, which silently fell through to the default.
`intSetting(_:)` now accepts Int, Double, and Float.

`AVFormatIDKey` is still ignored: MediaRecorder has no linear PCM output
format, so only the existing AAC/MPEG-4 combination is available. This is
now called out in the README rather than left implicit.
The AVAudioRecorder API table documented `func record()` and a `Double`
return from `averagePower(forChannel:)`, both of which no longer match the
implementation, and did not mention metering at all. Adds the missing
members and a short note covering the decibel scale, the ignored
`AVFormatIDKey`, and the RECORD_AUDIO requirement.

There were no AVAudioRecorder tests. The new test covers the amplitude to
decibel conversion, which is the part that can be exercised without a live
MediaRecorder or the RECORD_AUDIO permission.
@cla-bot

cla-bot Bot commented Jul 28, 2026

Copy link
Copy Markdown

Thank you for your pull request and welcome to the Skip community. We require contributors to sign our contributor license agreement (CLA), and we don't seem to have the user(s) @iyungui on file. In order for us to review and merge your code, for each noted user please add your GitHub username to Skip's .clabot file

@iyungui

iyungui commented Jul 29, 2026

Copy link
Copy Markdown
Author

recheck

@iyungui

iyungui commented Jul 29, 2026

Copy link
Copy Markdown
Author

Update on verification. I said in the description that I had not exercised this on Android; I've now done that, so here is what I actually observed.

Transpiled Kotlin tests

Installed Gradle and ran the full suite locally, including the XCSkipTests harness that I'd previously had to skip:

JUNIT TEST PASSED SkipAVTests.testAudioRecorderAmplitudeToDecibels (0.021)
JUNIT SUITES 1 TESTS 6 PASSED 6 (100.0%) FAILED 0 SKIPPED 0

Device run

Ran the module's tests against an API 36 emulator (ANDROID_SERIAL) with a throwaway test driving a real MediaRecorder: record 2s → pause() → wait 1s → record() → record 2s → stop(). It needed RECORD_AUDIO in the test manifest, following the pattern in skip-foundation/Tests/SkipFoundationTests/Skip/AndroidManifest.xml. Neither the test nor the manifest is part of this PR — they were only for this check.

VERIFY started=true resumed=true
VERIFY currentTime: afterFirst=2.001 atPause=2.010 afterPauseGap=2.010 afterSecond=4.024
VERIFY power dB: peak=-160.0 average=-160.0
VERIFY file: bytes=40013 durationMillis=3099

What this confirms:

  • currentTime is positive and accurate. 2.001 after the first segment. On main this is negative.
  • The clock holds during a pause. atPause and afterPauseGap are both 2.010 across a one second wait, then 4.024 after the second segment — it accumulates rather than restarting.
  • record() after pause() resumes. The output file is 3.099s. A restart would have re-prepared over the same path and left roughly one 2s segment, so both segments are present.
  • Power is on the decibel scale, not the old 0...1 ratio.

Two caveats I want to be straight about:

  1. The emulator was booted without host audio, so the input is silence and the meters sit at the -160.0 floor. That confirms the scale, the sign, and that updateMeters() feeds the accessors, but it does not exercise level tracking against real signal. Someone with a physical device could confirm that part in a minute.
  2. The file is 3.099s against 4.024s of wall clock. I believe that is the emulator's audio input not producing samples at real time rather than lost audio at the resume boundary, but I can't prove that from here. It doesn't affect the conclusion, since either way the file holds more than a single segment.

One thing worth flagging separately: prepareToRecord() constructs MediaRecorder(Context), which requires API 31, while the CI workflow runs the emulator at API 28. So this code path can't be reached by the current CI configuration — that's why I went to a local API 36 emulator for this.

@iyungui
iyungui marked this pull request as ready for review July 29, 2026 03:28
@iyungui

iyungui commented Aug 13, 2026

Copy link
Copy Markdown
Author

Gentle ping on this one — no rush if it's not a priority right now.

Since opening it I've verified the Android behavior on an API 36 emulator (details in the comment above), and CI is green with no conflicts against main.

If the size of the change is what's holding it up, I'm happy to close this and open a smaller one with just the two non-behavioral commits — the currentTime sign fix and the DoubleFloat return type — and leave the metering and pause/resume changes for a follow-up. Just say the word.

Otherwise I'm content to leave it sitting; I mostly wanted to check it wasn't blocked on something from my end.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AVAudioRecorder on Android diverges from AVFoundation (currentTime sign, metering, record/resume)

1 participant