Fixed persistence bug on ios

Prep for android launch
This commit is contained in:
jess 2026-05-18 19:10:44 -07:00
parent 00dded1c1d
commit b107d8c6a6
5 changed files with 106 additions and 40 deletions

View File

@ -12,8 +12,8 @@ android {
applicationId = "org.elseif.yrxtals" applicationId = "org.elseif.yrxtals"
minSdk = 28 minSdk = 28
targetSdk = 35 targetSdk = 35
versionCode = 1 versionCode = 3
versionName = "0.1.0" versionName = "1.0.2"
ndk { ndk {
abiFilters += "arm64-v8a" abiFilters += "arm64-v8a"
} }

View File

@ -2,6 +2,7 @@ import AVFoundation
import UIKit import UIKit
/// drives an AVAudioEngine input tap on the device mic and pushes mono float PCM into the rust capture pipeline. /// drives an AVAudioEngine input tap on the device mic and pushes mono float PCM into the rust capture pipeline.
/// stash-and-restart lifecycle: isCapturing remembers user intent; engine is fully torn down on backgrounding and rebuilt from scratch on every return to active state.
class CaptureSession { class CaptureSession {
private var engine: AVAudioEngine? private var engine: AVAudioEngine?
@ -11,24 +12,21 @@ class CaptureSession {
/// supplies the live viewport handle each tap. captured weakly to avoid extending the view's lifetime. /// supplies the live viewport handle each tap. captured weakly to avoid extending the view's lifetime.
var viewportHandleProvider: (() -> OpaquePointer?)? var viewportHandleProvider: (() -> OpaquePointer?)?
/// configures AVAudioSession for low-latency mic capture, installs a tap, and starts the engine. /// stashes the capture-on intent, runs the full configure + start sequence, and registers lifecycle observers.
func start() throws { func start() throws {
if isCapturing { return } if isCapturing { return }
try configureSession()
try startEngine()
registerObservers()
isCapturing = true isCapturing = true
registerObservers()
try fullStartFromScratch()
print("[YrXtals] CaptureSession started") print("[YrXtals] CaptureSession started")
} }
/// removes the tap, stops the engine, and deactivates the audio session. /// clears the capture-on intent and tears down the engine + session.
func stop() { func stop() {
if !isCapturing { return } if !isCapturing { return }
unregisterObservers()
teardownEngine()
isCapturing = false isCapturing = false
try? AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation]) unregisterObservers()
teardownEngineAndSession()
print("[YrXtals] CaptureSession stopped") print("[YrXtals] CaptureSession stopped")
} }
@ -63,6 +61,13 @@ class CaptureSession {
self.engine = engine self.engine = engine
} }
/// fully tears down anything that might be alive, then runs the configure + tap + start sequence from scratch.
private func fullStartFromScratch() throws {
teardownEngine()
try configureSession()
try startEngine()
}
/// removes the tap and stops the AVAudioEngine. safe to call repeatedly. /// removes the tap and stops the AVAudioEngine. safe to call repeatedly.
private func teardownEngine() { private func teardownEngine() {
engine?.inputNode.removeTap(onBus: 0) engine?.inputNode.removeTap(onBus: 0)
@ -70,21 +75,29 @@ class CaptureSession {
engine = nil engine = nil
} }
/// re-establishes the engine after an interruption or a return to the foreground. keeps the session active. /// tears down the engine and deactivates the AVAudioSession.
private func bounceEngine() { private func teardownEngineAndSession() {
teardownEngine() teardownEngine()
do { try? AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation])
try AVAudioSession.sharedInstance().setActive(true, options: [])
try startEngine()
print("[YrXtals] CaptureSession bounced")
} catch {
print("[YrXtals] CaptureSession bounce failed: \(error)")
}
} }
/// listens for interruption, foreground activation, and media-services-reset notifications. /// listens for backgrounding, interruption, foreground activation, and media-services-reset notifications.
private func registerObservers() { private func registerObservers() {
let center = NotificationCenter.default let center = NotificationCenter.default
observers.append(center.addObserver(
forName: UIApplication.willResignActiveNotification,
object: nil,
queue: .main,
) { [weak self] _ in
self?.handleResignActive()
})
observers.append(center.addObserver(
forName: UIApplication.didBecomeActiveNotification,
object: nil,
queue: .main,
) { [weak self] _ in
self?.handleBecomeActive()
})
observers.append(center.addObserver( observers.append(center.addObserver(
forName: AVAudioSession.interruptionNotification, forName: AVAudioSession.interruptionNotification,
object: nil, object: nil,
@ -92,24 +105,12 @@ class CaptureSession {
) { [weak self] note in ) { [weak self] note in
self?.handleInterruption(note) self?.handleInterruption(note)
}) })
observers.append(center.addObserver(
forName: UIApplication.didBecomeActiveNotification,
object: nil,
queue: .main,
) { [weak self] _ in
guard let self = self, self.isCapturing else { return }
self.bounceEngine()
})
observers.append(center.addObserver( observers.append(center.addObserver(
forName: AVAudioSession.mediaServicesWereResetNotification, forName: AVAudioSession.mediaServicesWereResetNotification,
object: nil, object: nil,
queue: .main, queue: .main,
) { [weak self] _ in ) { [weak self] _ in
guard let self = self, self.isCapturing else { return } self?.handleMediaServicesReset()
do { try self.configureSession() } catch {
print("[YrXtals] post-reset session reconfigure failed: \(error)")
}
self.bounceEngine()
}) })
} }
@ -119,23 +120,57 @@ class CaptureSession {
observers.removeAll() observers.removeAll()
} }
/// reacts to AVAudioSession interruptions: stops the engine on .began, restarts it on .ended. /// fully tears down on transition out of active state. preserves isCapturing so the resume path knows to restart.
private func handleResignActive() {
teardownEngineAndSession()
print("[YrXtals] CaptureSession torn down on resignActive")
}
/// runs the full start sequence on return to active state, but only when isCapturing was previously set.
private func handleBecomeActive() {
guard isCapturing else { return }
do {
try fullStartFromScratch()
print("[YrXtals] CaptureSession restarted on becomeActive")
} catch {
print("[YrXtals] CaptureSession restart failed: \(error)")
}
}
/// reacts to AVAudioSession interruptions: tears down on .began, full restart on .ended.
private func handleInterruption(_ note: Notification) { private func handleInterruption(_ note: Notification) {
guard let info = note.userInfo, guard let info = note.userInfo,
let raw = info[AVAudioSessionInterruptionTypeKey] as? UInt, let raw = info[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: raw) else { return } let type = AVAudioSession.InterruptionType(rawValue: raw) else { return }
switch type { switch type {
case .began: case .began:
teardownEngine() teardownEngineAndSession()
print("[YrXtals] audio interruption began") print("[YrXtals] audio interruption began")
case .ended: case .ended:
if isCapturing { bounceEngine() } guard isCapturing else { return }
print("[YrXtals] audio interruption ended") do {
try fullStartFromScratch()
print("[YrXtals] CaptureSession restarted after interruption")
} catch {
print("[YrXtals] CaptureSession restart failed: \(error)")
}
@unknown default: @unknown default:
break break
} }
} }
/// handles iOS resetting the audio HAL: full teardown and full restart when we should be capturing.
private func handleMediaServicesReset() {
teardownEngineAndSession()
guard isCapturing else { return }
do {
try fullStartFromScratch()
print("[YrXtals] CaptureSession restarted after media services reset")
} catch {
print("[YrXtals] CaptureSession restart failed: \(error)")
}
}
/// interleaves the per-channel float buffers and forwards the chunk through the FFI. /// interleaves the per-channel float buffers and forwards the chunk through the FFI.
private func push(buffer: AVAudioPCMBuffer, handle: OpaquePointer, sampleRate: UInt32, channels: UInt32) { private func push(buffer: AVAudioPCMBuffer, handle: OpaquePointer, sampleRate: UInt32, channels: UInt32) {
guard let channelData = buffer.floatChannelData else { return } guard let channelData = buffer.floatChannelData else { return }

View File

@ -41,6 +41,17 @@ export YRXTALS_KEY_ALIAS="${YRXTALS_KEY_ALIAS:-yrxtals}"
bash "$SCRIPT_DIR/generate-icons.sh" bash "$SCRIPT_DIR/generate-icons.sh"
GRADLE_KTS="$REPO_ROOT/android/app/build.gradle.kts"
CURRENT_CODE="$(grep -E '^[[:space:]]*versionCode = [0-9]+' "$GRADLE_KTS" | head -1 | sed -E 's/.*versionCode = ([0-9]+).*/\1/')"
CURRENT_NAME="$(grep -E '^[[:space:]]*versionName = "[^"]+"' "$GRADLE_KTS" | head -1 | sed -E 's/.*versionName = "([^"]+)".*/\1/')"
if [[ -z "$CURRENT_CODE" || -z "$CURRENT_NAME" ]]; then
echo "could not parse versionCode / versionName from $GRADLE_KTS" >&2
exit 1
fi
echo "building versionName=$CURRENT_NAME versionCode=$CURRENT_CODE"
JNILIBS="$REPO_ROOT/android/app/src/main/jniLibs" JNILIBS="$REPO_ROOT/android/app/src/main/jniLibs"
mkdir -p "$JNILIBS/arm64-v8a" mkdir -p "$JNILIBS/arm64-v8a"
@ -56,7 +67,24 @@ if [[ ! -f "$AAB" ]]; then
exit 1 exit 1
fi fi
# patch-bumps versionName and increments versionCode by one after a successful build, so the next invocation tags a fresh version.
NEXT_CODE=$((CURRENT_CODE + 1))
IFS='.' read -r MAJOR MINOR PATCH <<<"$CURRENT_NAME"
if [[ -z "${MAJOR:-}" || -z "${MINOR:-}" || -z "${PATCH:-}" ]]; then
echo "warning: versionName '$CURRENT_NAME' is not semver X.Y.Z; only versionCode will be bumped" >&2
NEXT_NAME="$CURRENT_NAME"
else
NEXT_PATCH=$((PATCH + 1))
NEXT_NAME="${MAJOR}.${MINOR}.${NEXT_PATCH}"
fi
sed -i.bak -E "s/^([[:space:]]*)versionCode = [0-9]+/\\1versionCode = $NEXT_CODE/" "$GRADLE_KTS"
sed -i.bak -E "s/^([[:space:]]*)versionName = \"[^\"]+\"/\\1versionName = \"$NEXT_NAME\"/" "$GRADLE_KTS"
rm -f "$GRADLE_KTS.bak"
echo echo
echo "signed aab: $AAB" echo "signed aab: $AAB"
echo "applicationId: org.elseif.yrxtals" echo "applicationId: org.elseif.yrxtals"
echo "shipped versionName=$CURRENT_NAME versionCode=$CURRENT_CODE"
echo "bumped build.gradle.kts to versionName=$NEXT_NAME versionCode=$NEXT_CODE"
echo "upload at https://play.google.com/console" echo "upload at https://play.google.com/console"

View File

@ -551,8 +551,8 @@ pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportStartMicCapt
let pusher = av.inner.pcm_sender(); let pusher = av.inner.pcm_sender();
match MicInput::start(pusher) { match MicInput::start(pusher) {
Ok(mic) => av.mic_input = Some(mic), Ok(mic) => av.mic_input = Some(mic),
Err(e) => { Err(_e) => {
dbg_log!("viewportStartMicCapture failed: {e}"); dbg_log!("viewportStartMicCapture failed: {_e}");
} }
} }
} }

View File

@ -14,8 +14,11 @@ pub struct MicInput {
impl MicInput { impl MicInput {
/// opens a LOW_LATENCY mic input stream with the Unprocessed preset and starts the data callback. /// opens a LOW_LATENCY mic input stream with the Unprocessed preset and starts the data callback.
pub fn start(pusher: PcmSender) -> Result<Self, String> { pub fn start(pusher: PcmSender) -> Result<Self, String> {
#[cfg(debug_assertions)]
let mut frames_since_log: u64 = 0; let mut frames_since_log: u64 = 0;
#[cfg(debug_assertions)]
let mut callbacks_since_log: u64 = 0; let mut callbacks_since_log: u64 = 0;
#[cfg(debug_assertions)]
let mut peak_since_log: f32 = 0.0; let mut peak_since_log: f32 = 0.0;
let callback: ndk::audio::AudioStreamDataCallback = Box::new( let callback: ndk::audio::AudioStreamDataCallback = Box::new(
move |stream: &AudioStream, audio_data: *mut c_void, num_frames: i32| { move |stream: &AudioStream, audio_data: *mut c_void, num_frames: i32| {