diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AudioRecorderManager.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AudioRecorderManager.kt index 41035d7a..771c03da 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AudioRecorderManager.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AudioRecorderManager.kt @@ -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) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/MediaCaptureTimeline.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/MediaCaptureTimeline.kt index 62853dab..9940c1d4 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/MediaCaptureTimeline.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/MediaCaptureTimeline.kt @@ -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 diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/MicrophonePermission.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/MicrophonePermission.kt index 9448fccf..1c7dda3d 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/MicrophonePermission.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/MicrophonePermission.kt @@ -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