Clarify capture timeline and microphone permission contracts

This commit is contained in:
evgeny committed 2026-10-28 11:36:13 +01:00
1 parent c0c282ca3a
commit 69bc182ee9
3 files changed
+34 -6

No files matched your search

@@ -43,7 +43,7 @@ class AudioRecorderManager {
PackageManager.PERMISSION_GRANTED
}
/** target — канал либо dm:conv_id; reply/request относятся к каналу, generation — к PM.
/** channelId — канал либо dm:conv_id; replyTo/requestId относятся к каналу, generation — к PM.
* true — Android/native захват запущен; false — отказ с диагностикой и откатом принадлежащих ресурсов. */
@Synchronized fun startRecording(channelId: String, replyTo: String = "", requestId: Long = 0, generation: Long = 0): Boolean =
startCapture(CaptureMode.RECORD, channelId, replyTo, requestId, generation)
@@ -6,9 +6,16 @@ import kotlin.math.floor
import kotlin.math.roundToInt
import kotlin.math.roundToLong
/** Общая сетка PCM 48k stereo. Внутри поколения отсчёты идут по sequence, без перекладки
* отдельных блоков по шумным timestamps. Медленный дрейф часов компенсируется непрерывной
* интерполяцией; только реальные пропуски дают тишину. Все методы — под lock владельца. */
/**
* Непрерывная сетка PCM16 48 кГц stereo для InputBridge общего media-входа.
* originNs и timestamps используют System.nanoTime; один pull выдаёт 960 stereo-фреймов (1920 Short).
* Владелец сериализует все обращения своим lock и ждёт deadlineNs перед pull: конец блока плюс резерв 200 мс.
* reset переключает поколение аппаратного входа, сохраняя общую выходную позицию/время.
* До привязки, перед началом входа и при пропусках выдаётся тишина; поздний PCM не заменяет уже выданную тишину.
* Внутри поколения sequence определяет непрерывность, шумные timestamps не переставляют блоки.
* Медленный дрейф компенсируется интерполяцией до ±2000 ppm; скачок времени либо компенсируется bias,
* либо заново привязывает свежий звук после остановки reader. Здесь нет I/O, ожиданий или фонового worker.
*/
internal class MediaCaptureTimeline(val originNs: Long, private val log: (String, String) -> Unit = { _, _ -> }) {
companion object {
const val FRAMES = 960
@@ -30,10 +37,13 @@ internal class MediaCaptureTimeline(val originNs: Long, private val log: (String
private var timestampBiasNs = 0L
var generation = 0L
private set
/** Число отвергнутых 20мс блоков: поздние или очередь уже содержит 24 блока; накопление за жизнь объекта. */
var lateFrames = 0L
private set
/** Число выданных stereo-фреймов тишины, включая ожидание привязки; reset не обнуляет. */
var silentFrames = 0L
private set
/** Часть silentFrames после начала привязанного входа, когда PCM отсутствует по нужному sequence. */
var missingFrames = 0L
private set
var timestampJitterMaxNs = 0L
@@ -45,11 +55,16 @@ internal class MediaCaptureTimeline(val originNs: Long, private val log: (String
val nextTimeNs: Long get() = originNs + position * 1_000_000_000 / 48000
val deadlineNs: Long get() = nextTimeNs + PERIOD_NS + RESERVE_NS
/** Очистить очередь, привязку и оценку дрейфа/jitter для epoch; выходная position и накопленные счётчики PCM сохраняются. */
fun reset(epoch: Long) {
generation = epoch; pending.clear(); lastInputNs = -1; lastSequence = -1; lastCreatedNs = 0
anchored = false; cursor = Double.NaN; clockOffset = 0.0; ratePpm = 0; timestampJitterMaxNs = 0; timestampBiasNs = 0; timestampJumps = 0
}
/** Принять 1920 Short с sequence в stereo-фреймах и временем первого фрейма; PCM заимствуется до pull/reset.
* false — чужое поколение, неповышающийся sequence, поздний PCM или полная очередь.
* Неверная длина PCM либо отрицательные sequence/timeNs бросают исключение.
* Вызывающий не должен менять массив принятого Frame. Необычные пропуски/скачки логируются через log владельца. */
fun offer(frame: MicrophoneCapture.Frame): Boolean {
require(frame.pcm.size == FRAMES * 2 && frame.sequence >= 0 && frame.timeNs >= 0)
if (frame.generation != generation) return false
@@ -99,6 +114,8 @@ internal class MediaCaptureTimeline(val originNs: Long, private val log: (String
return true
}
/** Требует nowNs >= deadlineNs. Вернуть новый Frame общей выходной сетки с текущим generation и продвинуть её на 20 мс.
* timeNs/sequence относятся к сетке, createdNs=nowNs; массив принадлежит получателю. */
fun pull(nowNs: Long): MicrophoneCapture.Frame {
check(nowNs >= deadlineNs)
val start = nextTimeNs
@@ -8,7 +8,11 @@ import androidx.core.content.ContextCompat
import com.utun.chat.ChatApplication
import com.utun.chat.MainActivity
/* Запросы только из main thread; диалогом разрешений владеет видимая Activity. */
/** App-scoped вход в запрос RECORD_AUDIO; сам диалог и pending callback принадлежат видимой Activity.
* Activity устанавливает requester при onStart и снимает только свой обработчик при onStop.
* Без Activity открывается MainActivity с EXTRA_REQUEST: исходное действие/onGranted не сохраняются,
* после выдачи разрешения пользователь повторяет команду (например, PTT).
*/
object MicrophonePermission {
const val EXTRA_REQUEST = "request_microphone_permission"
var requester: ((String, () -> Unit) -> Unit)? = null
@@ -16,8 +20,15 @@ object MicrophonePermission {
fun granted(): Boolean = ContextCompat.checkSelfPermission(ChatApplication.instance, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
/** Main thread: при готовом разрешении вызвать onGranted синхронно, иначе передать запрос Activity.
* Обработчик Activity решает, принять ли параллельный запрос; отказ разрешения не вызывает onGranted.
* Ошибка открытия Activity логируется; исключения пользовательского requester/onGranted передаются caller. */
fun request(reason: String, onGranted: () -> Unit = {}) {
check(Looper.myLooper() == Looper.getMainLooper())
check(Looper.myLooper() == Looper.getMainLooper()) {
val message = "MicrophonePermission requires main thread: caller=${Thread.currentThread().name}"
LogManager.addLog("ERROR", "PERMISSION", message)
message
}
if (granted()) { onGranted(); return }
LogManager.addLog("INFO", "PERMISSION", "Microphone required: $reason")
val handler = requester