parent
00dded1c1d
commit
b107d8c6a6
|
|
@ -12,8 +12,8 @@ android {
|
|||
applicationId = "org.elseif.yrxtals"
|
||||
minSdk = 28
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
versionCode = 3
|
||||
versionName = "1.0.2"
|
||||
ndk {
|
||||
abiFilters += "arm64-v8a"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import AVFoundation
|
|||
import UIKit
|
||||
|
||||
/// 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 {
|
||||
|
||||
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.
|
||||
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 {
|
||||
if isCapturing { return }
|
||||
|
||||
try configureSession()
|
||||
try startEngine()
|
||||
registerObservers()
|
||||
isCapturing = true
|
||||
registerObservers()
|
||||
try fullStartFromScratch()
|
||||
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() {
|
||||
if !isCapturing { return }
|
||||
unregisterObservers()
|
||||
teardownEngine()
|
||||
isCapturing = false
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation])
|
||||
unregisterObservers()
|
||||
teardownEngineAndSession()
|
||||
print("[YrXtals] CaptureSession stopped")
|
||||
}
|
||||
|
||||
|
|
@ -63,6 +61,13 @@ class CaptureSession {
|
|||
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.
|
||||
private func teardownEngine() {
|
||||
engine?.inputNode.removeTap(onBus: 0)
|
||||
|
|
@ -70,21 +75,29 @@ class CaptureSession {
|
|||
engine = nil
|
||||
}
|
||||
|
||||
/// re-establishes the engine after an interruption or a return to the foreground. keeps the session active.
|
||||
private func bounceEngine() {
|
||||
/// tears down the engine and deactivates the AVAudioSession.
|
||||
private func teardownEngineAndSession() {
|
||||
teardownEngine()
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setActive(true, options: [])
|
||||
try startEngine()
|
||||
print("[YrXtals] CaptureSession bounced")
|
||||
} catch {
|
||||
print("[YrXtals] CaptureSession bounce failed: \(error)")
|
||||
}
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation])
|
||||
}
|
||||
|
||||
/// listens for interruption, foreground activation, and media-services-reset notifications.
|
||||
/// listens for backgrounding, interruption, foreground activation, and media-services-reset notifications.
|
||||
private func registerObservers() {
|
||||
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(
|
||||
forName: AVAudioSession.interruptionNotification,
|
||||
object: nil,
|
||||
|
|
@ -92,24 +105,12 @@ class CaptureSession {
|
|||
) { [weak self] note in
|
||||
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(
|
||||
forName: AVAudioSession.mediaServicesWereResetNotification,
|
||||
object: nil,
|
||||
queue: .main,
|
||||
) { [weak self] _ in
|
||||
guard let self = self, self.isCapturing else { return }
|
||||
do { try self.configureSession() } catch {
|
||||
print("[YrXtals] post-reset session reconfigure failed: \(error)")
|
||||
}
|
||||
self.bounceEngine()
|
||||
self?.handleMediaServicesReset()
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -119,23 +120,57 @@ class CaptureSession {
|
|||
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) {
|
||||
guard let info = note.userInfo,
|
||||
let raw = info[AVAudioSessionInterruptionTypeKey] as? UInt,
|
||||
let type = AVAudioSession.InterruptionType(rawValue: raw) else { return }
|
||||
switch type {
|
||||
case .began:
|
||||
teardownEngine()
|
||||
teardownEngineAndSession()
|
||||
print("[YrXtals] audio interruption began")
|
||||
case .ended:
|
||||
if isCapturing { bounceEngine() }
|
||||
print("[YrXtals] audio interruption ended")
|
||||
guard isCapturing else { return }
|
||||
do {
|
||||
try fullStartFromScratch()
|
||||
print("[YrXtals] CaptureSession restarted after interruption")
|
||||
} catch {
|
||||
print("[YrXtals] CaptureSession restart failed: \(error)")
|
||||
}
|
||||
@unknown default:
|
||||
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.
|
||||
private func push(buffer: AVAudioPCMBuffer, handle: OpaquePointer, sampleRate: UInt32, channels: UInt32) {
|
||||
guard let channelData = buffer.floatChannelData else { return }
|
||||
|
|
|
|||
|
|
@ -41,6 +41,17 @@ export YRXTALS_KEY_ALIAS="${YRXTALS_KEY_ALIAS:-yrxtals}"
|
|||
|
||||
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"
|
||||
mkdir -p "$JNILIBS/arm64-v8a"
|
||||
|
||||
|
|
@ -56,7 +67,24 @@ if [[ ! -f "$AAB" ]]; then
|
|||
exit 1
|
||||
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 "signed aab: $AAB"
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -551,8 +551,8 @@ pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportStartMicCapt
|
|||
let pusher = av.inner.pcm_sender();
|
||||
match MicInput::start(pusher) {
|
||||
Ok(mic) => av.mic_input = Some(mic),
|
||||
Err(e) => {
|
||||
dbg_log!("viewportStartMicCapture failed: {e}");
|
||||
Err(_e) => {
|
||||
dbg_log!("viewportStartMicCapture failed: {_e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,8 +14,11 @@ pub struct MicInput {
|
|||
impl MicInput {
|
||||
/// opens a LOW_LATENCY mic input stream with the Unprocessed preset and starts the data callback.
|
||||
pub fn start(pusher: PcmSender) -> Result<Self, String> {
|
||||
#[cfg(debug_assertions)]
|
||||
let mut frames_since_log: u64 = 0;
|
||||
#[cfg(debug_assertions)]
|
||||
let mut callbacks_since_log: u64 = 0;
|
||||
#[cfg(debug_assertions)]
|
||||
let mut peak_since_log: f32 = 0.0;
|
||||
let callback: ndk::audio::AudioStreamDataCallback = Box::new(
|
||||
move |stream: &AudioStream, audio_data: *mut c_void, num_frames: i32| {
|
||||
|
|
|
|||
Loading…
Reference in New Issue