From bca1b067e2264bfad3a19603187e40abdf2ee7af Mon Sep 17 00:00:00 2001 From: KimDoHwan Date: Wed, 11 Nov 2026 15:32:41 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=EC=82=AC=EB=9E=8C=20=EC=86=8C?= =?UTF-8?q?=EB=A6=AC=20=EC=A1=B0=EA=B8=B0=20=ED=91=9C=EC=8B=9C=EA=B0=80=20?= =?UTF-8?q?=EB=8B=A4=EC=9D=8C=20=ED=8C=90=EC=A0=95=20=EC=A0=84=EC=97=90=20?= =?UTF-8?q?=EC=82=AC=EB=9D=BC=EC=A7=80=EA=B1=B0=EB=82=98=20=EA=B9=A8?= =?UTF-8?q?=EC=A7=84=20=EA=B0=92=20=ED=95=98=EB=82=98=EB=A1=9C=20=EA=BA=BC?= =?UTF-8?q?=EC=A7=80=EC=A7=80=20=EC=95=8A=EA=B2=8C=20=ED=95=98=EA=B3=A0=20?= =?UTF-8?q?=EA=B0=9C=EB=B0=9C=EC=9E=90=20=EA=B8=B0=EB=A1=9D=EC=97=90=20?= =?UTF-8?q?=EC=A1=B0=EA=B8=B0=20=ED=91=9C=EC=8B=9C=20=EC=97=B4=EC=9D=84=20?= =?UTF-8?q?=EB=8D=94=ED=95=9C=EB=8B=A4=20(#456)=20(#457)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 조기 표시 유지 시간을 275ms 에서 350ms 로 늘린다. 다음 정규 결과는 250ms 뒤에 추론을 시작해 추론이 끝나야 나오므로, 전처리와 추론이 25ms 를 넘는 폰에서는 조기 표시가 먼저 사라져 사람 소리 → 환경음 → 사람 소리로 깜빡일 수 있었다. 받아들임 마감 250ms 는 그대로다. - Silero 가 유한하지 않거나 0~1 밖의 확률을 내면 예외로 스레드를 끝내는 대신 상태를 비우고 이어 간다. 전에는 캡처를 다시 시작할 때까지 조기 표시가 조용히 꺼졌다. - 개발자 기록(CSV) 맨 끝에 speech_preview 열을 더해 조기 표시 줄을 정규 결과와 가른다. - 조기 표시의 개발자 표시 첫 줄이 한 줄(58자)에 드는지 시험으로 지킨다. - docs/ai 의 옮기기 전 저장소 주소를 고치고, 평가 도구와 문서의 유지 시간을 맞춘다. --- .../ai/SileroPreviewInstrumentedTest.kt | 21 ++++++++++++ .../com/example/soundvisualizer/AiDebugCsv.kt | 3 ++ .../soundvisualizer/ai/RealtimeAiPipeline.kt | 19 ++++++++++- .../soundvisualizer/ai/SileroInference.kt | 2 +- .../soundvisualizer/ai/SileroStreamWorker.kt | 11 +++++- .../example/soundvisualizer/AiDebugCsvTest.kt | 14 ++++++++ .../soundvisualizer/AiDebugTextTest.kt | 17 ++++++++++ .../ai/SpeechPreviewTimingTest.kt | 34 +++++++++++++++++++ docs/ARCHITECTURE.md | 2 +- docs/ai/safety-guard-diagnostics-351.md | 2 +- docs/ai/silero-galaxy-422.md | 2 +- tools/ai_reference/speech_verifier_policy.py | 2 +- 12 files changed, 122 insertions(+), 7 deletions(-) create mode 100644 app/src/test/java/com/example/soundvisualizer/ai/SpeechPreviewTimingTest.kt diff --git a/app/src/androidTest/java/com/example/soundvisualizer/ai/SileroPreviewInstrumentedTest.kt b/app/src/androidTest/java/com/example/soundvisualizer/ai/SileroPreviewInstrumentedTest.kt index 8310c31d..90130d18 100644 --- a/app/src/androidTest/java/com/example/soundvisualizer/ai/SileroPreviewInstrumentedTest.kt +++ b/app/src/androidTest/java/com/example/soundvisualizer/ai/SileroPreviewInstrumentedTest.kt @@ -42,6 +42,27 @@ class SileroPreviewInstrumentedTest { } } + @Test fun brokenScoreResetsEvidenceAndKeepsTheOptionalOwnerRunning() { + // 깨진 확률(NaN)이 한 번 나와도 스레드가 끝나지 않고, 상태를 비운 뒤 다음 덩어리를 계속 본다(#456). + // 전에는 예외로 스레드가 끝나 캡처를 다시 시작할 때까지 조기 표시가 조용히 꺼졌다. + val app = InstrumentationRegistry.getInstrumentation().targetContext + RealtimeAiPipeline.create(app, 16000, 1, speechPreviewEnabled = true).use { pipeline -> + val scored = java.util.concurrent.atomic.AtomicInteger() + pipeline.verifierScoreForTest { probability -> if (scored.incrementAndGet() == 2) Float.NaN else probability } + val chunk = FloatArray(512) { kotlin.math.sin(it * .07).toFloat() * .4f } + val deadline = android.os.SystemClock.elapsedRealtime() + 10_000 + // 덩어리를 하나씩 넣어 밀림 버리기(160ms)가 끼지 않게 한다. 넣을 때마다 스레드를 깨운다. + while (pipeline.speechVerifierCallsForTest() < 6 && android.os.SystemClock.elapsedRealtime() < deadline) { + pipeline.ingestInterleavedForTest(chunk, chunk.size) + Thread.sleep(20) + } + assertTrue("깨진 값 뒤에도 덩어리를 계속 봐야 한다: ${pipeline.speechVerifierCallsForTest()}", pipeline.speechVerifierCallsForTest() >= 6) + assertFalse("깨진 값 하나로 조기 표시가 꺼지면 안 된다", pipeline.speechVerifierFailedForTest()) + // 처음 붙을 때 한 번, 깨진 값에서 한 번. + assertTrue("상태를 비워야 한다: ${pipeline.speechVerifierResetsForTest()}", pipeline.speechVerifierResetsForTest() >= 2) + } + } + @Test fun stalledOptionalOwnerCannotBlockRegularDangerOrClose() { val app = InstrumentationRegistry.getInstrumentation().targetContext val entered = java.util.concurrent.CountDownLatch(1) diff --git a/app/src/main/java/com/example/soundvisualizer/AiDebugCsv.kt b/app/src/main/java/com/example/soundvisualizer/AiDebugCsv.kt index 548f0b29..64cb4e4c 100644 --- a/app/src/main/java/com/example/soundvisualizer/AiDebugCsv.kt +++ b/app/src/main/java/com/example/soundvisualizer/AiDebugCsv.kt @@ -41,6 +41,8 @@ object AiDebugCsv { add("total_ms") add("mapping_override_count") add("mapping_signature") + // 화면에만 앞당겨 보인 사람 소리(조기 표시, #422)인지. 정규 결과와 섞어 세지 않게 한다. 새 열은 늘 끝에 더한다(#456). + add("speech_preview") } /** 머리글 한 줄. 파일을 새로 열 때 한 번만 쓴다. */ @@ -88,6 +90,7 @@ object AiDebugCsv { cells += millis(result.totalMs) cells += result.mappingOverrideCount.toString() cells += result.mappingSignature + cells += result.speechPreview.toString() return cells.joinToString(",") { quote(it) } } diff --git a/app/src/main/java/com/example/soundvisualizer/ai/RealtimeAiPipeline.kt b/app/src/main/java/com/example/soundvisualizer/ai/RealtimeAiPipeline.kt index eb4810d0..6af90cf4 100644 --- a/app/src/main/java/com/example/soundvisualizer/ai/RealtimeAiPipeline.kt +++ b/app/src/main/java/com/example/soundvisualizer/ai/RealtimeAiPipeline.kt @@ -74,6 +74,19 @@ class RealtimeAiPipeline private constructor( companion object { const val AI_PREDICT_INTERVAL_MS = 250L + + /** 조기 표시(#422)를 받아들이는 마감. 정규 추론이 소리를 가져간 때부터 잰다. 다음 정규 추론이 시작할 무렵이다. */ + internal const val SPEECH_PREVIEW_ACCEPT_NS = AI_PREDICT_INTERVAL_MS * 1_000_000L + + /** + * 조기 표시가 사라지는 때. 정규 추론이 소리를 가져간 때부터 잰다. 다음 정규 결과가 오면 그 전에라도 사라진다. + * + * 다음 정규 결과는 250ms 뒤에 추론을 시작해 전처리와 추론이 끝나야 나온다. 처음 값 275ms 는 그 시간이 25ms 라는 + * 가정이었고, 더 걸리는 폰에서는 조기 표시가 먼저 사라져 사람 소리 → 환경음 → 사람 소리로 깜빡인다(#456, Windows 판에서는 + * 틱의 7~9% 로 측정). 그래서 추론이 100ms 걸려도 이어지게 350ms 로 둔다. 다음 정규 결과·위협음·사용자 지정은 지금처럼 + * 바로 이기므로, 달라지는 것은 다음 틱이 무음으로 건너뛰어질 때 조기 표시가 75ms 더 남는 것뿐이다. + */ + internal const val SPEECH_PREVIEW_EXPIRY_NS = 350_000_000L internal const val MIN_TICK_REST_MS = 100L private const val TAG = "RealtimeAiPipeline" private const val LOG_THROTTLE_MS = 2000L @@ -182,6 +195,10 @@ class RealtimeAiPipeline private constructor( fun speechVerifierCallsForTest(): Long = speechWorker?.calls?.get() ?: 0 internal fun beforeVerifierScoreForTest(hook: () -> Unit) { speechWorker?.beforeScoreForTest = hook } + /** 시험이 Silero 가 낸 확률을 바꿔 깨진 값(NaN 등)을 흉내 낸다. */ + internal fun verifierScoreForTest(hook: (Float) -> Float) { speechWorker?.scoreForTest = hook } + fun speechVerifierResetsForTest(): Long = speechWorker?.resets?.get() ?: 0 + fun speechVerifierFailedForTest(): Boolean = speechWorker?.failed?.get() ?: false private fun publishResult(result: AiClassificationResult?) { speechGate.regular(null) @@ -433,7 +450,7 @@ class RealtimeAiPipeline private constructor( val cursor = audioBuffer.cursor() speechGate.regular(SileroPreviewGate.Candidate( SpeechParent(result, decision.postDisplay, decision.postConfidence), cursor.frames, - snapshotBootNs + 250_000_000L, snapshotBootNs + 275_000_000L, cursor.generation)) + snapshotBootNs + SPEECH_PREVIEW_ACCEPT_NS, snapshotBootNs + SPEECH_PREVIEW_EXPIRY_NS, cursor.generation)) } if (log && debuggable) { diff --git a/app/src/main/java/com/example/soundvisualizer/ai/SileroInference.kt b/app/src/main/java/com/example/soundvisualizer/ai/SileroInference.kt index bd3837b2..79b62d05 100644 --- a/app/src/main/java/com/example/soundvisualizer/ai/SileroInference.kt +++ b/app/src/main/java/com/example/soundvisualizer/ai/SileroInference.kt @@ -43,7 +43,7 @@ internal class SileroInference(context: Context) : AutoCloseable { @Suppress("UNCHECKED_CAST") val next = out[1].value as Array> for (layer in 0..1) for (i in 0 until 128) state.put(layer * 128 + i, next[layer][0][i]) block.copyInto(contextTail, 0, 448, 512) - require(probability.isFinite() && probability in 0f..1f) + // 유한하지 않거나 0~1 밖의 값도 그대로 돌려준다. 부르는 쪽(SileroStreamWorker)이 상태를 비우고 이어 간다(#456). return probability } } diff --git a/app/src/main/java/com/example/soundvisualizer/ai/SileroStreamWorker.kt b/app/src/main/java/com/example/soundvisualizer/ai/SileroStreamWorker.kt index 55581bdb..7fa88993 100644 --- a/app/src/main/java/com/example/soundvisualizer/ai/SileroStreamWorker.kt +++ b/app/src/main/java/com/example/soundvisualizer/ai/SileroStreamWorker.kt @@ -20,6 +20,7 @@ internal class SileroStreamWorker(context: Context, private val buffer: AiAudioB val failed = AtomicBoolean(false) val computeNs = AtomicLong() @Volatile var beforeScoreForTest: (() -> Unit)? = null + @Volatile var scoreForTest: ((Float) -> Float)? = null private val owner = Thread({ Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND) try { @@ -48,9 +49,17 @@ internal class SileroStreamWorker(context: Context, private val buffer: AiAudioB val began = SystemClock.elapsedRealtimeNanos() CaptureAudioMath.resampleStreamBlock(pcm, first, next, rate, output) beforeScoreForTest?.invoke() - val probability = model.score(output) + val raw = model.score(output) + val probability = scoreForTest?.invoke(raw) ?: raw computeNs.addAndGet(SystemClock.elapsedRealtimeNanos() - began) calls.incrementAndGet() + if (!probability.isFinite() || probability !in 0f..1f) { + // 깨진 값이 한 번 나오면 이어받는 상태도 믿을 수 없다. 스레드를 끝내면 캡처를 다시 시작할 때까지 조기 표시가 + // 조용히 꺼지므로(#456), 상태를 비우고 근거를 처음부터 다시 모은다. 이 덩어리는 건너뛴다. + model.reset(); resets.incrementAndGet(); discontinuity() + next += 512 + continue + } cursor = buffer.cursor() if (cursor.generation == epoch && !closed.get()) block(epoch, next * rate / 16_000, (next + 512) * rate / 16_000, diff --git a/app/src/test/java/com/example/soundvisualizer/AiDebugCsvTest.kt b/app/src/test/java/com/example/soundvisualizer/AiDebugCsvTest.kt index 82385e4f..10402cb5 100644 --- a/app/src/test/java/com/example/soundvisualizer/AiDebugCsvTest.kt +++ b/app/src/test/java/com/example/soundvisualizer/AiDebugCsvTest.kt @@ -76,6 +76,20 @@ class AiDebugCsvTest { assertEquals(AiDebugCsv.COLUMNS, header) } + @Test + fun `조기 표시인지는 맨 끝 칸에 적어 정규 결과와 가른다`() { + // 조기 표시(#422)는 화면에만 앞당겨 보인 사람 소리다. 정규 결과와 같은 줄 꼴로 적히면 채점할 때 섞여 세어진다(#456). + // 새 열은 늘 끝에 더한다. 앞 칸의 차례가 바뀌면 이미 모아 둔 기록과 맞지 않는다. + assertEquals("speech_preview", AiDebugCsv.COLUMNS.last()) + assertEquals("mapping_signature", AiDebugCsv.COLUMNS[AiDebugCsv.COLUMNS.size - 2]) + + fun last(result: AiClassificationResult) = cells( + AiDebugCsv.row(result, nowMs = 1_500L, captureSource = CaptureSource.InternalPlayback, level = 0.4f, shown = true) + ).last() + assertEquals("false", last(result())) + assertEquals("true", last(result().copy(coarse = AiClassification.SPEECH, speechPreview = true))) + } + @Test fun `쉼표가 든 이름이 칸을 밀지 않는다`() { val row = AiDebugCsv.row( diff --git a/app/src/test/java/com/example/soundvisualizer/AiDebugTextTest.kt b/app/src/test/java/com/example/soundvisualizer/AiDebugTextTest.kt index e5979be2..86ca2449 100644 --- a/app/src/test/java/com/example/soundvisualizer/AiDebugTextTest.kt +++ b/app/src/test/java/com/example/soundvisualizer/AiDebugTextTest.kt @@ -33,6 +33,23 @@ class AiDebugTextTest { private fun format(result: AiClassificationResult?, nowMs: Long = 1_200L) = AiDebugText.format(result, nowMs, level = 0.14f, shown = true, aiAvailable = true) + @Test fun `조기 표시의 첫 줄은 기본 사람 소리 이름 어느 것이 와도 한 줄에 든다`() { + // 첫 줄은 오버레이가 "종류 ● 이름 확신도" 로 붙인다(점과 양옆 여백을 세 칸으로 센다). 조기 표시는 종류가 "SPEECH ~PREVIEW" 로 + // 길어지지만, 사용자 지정이 하나라도 있으면 쓰지 않으므로 이름은 늘 기본 종류가 사람 소리인 것이다. 그 가운데 가장 긴 이름 + // (Hubbub, speech noise, speech babble)이 정확히 한 줄을 채운다. 사람 소리에 더 긴 이름을 넣으면 여기서 걸린다(#456). + val names = checkNotNull(javaClass.classLoader).getResourceAsStream("ai/yamnet_class_map.csv").use { + com.example.soundvisualizer.ai.YamnetCoarseClassifier.loadClassNames(checkNotNull(it)) + } + val speech = names.filter { com.example.soundvisualizer.ai.YamnetThreeClassMapper.mapDisplayNameToCoarse(it) == AiClassification.SPEECH } + assertTrue("사람 소리 이름을 찾지 못했다", speech.size >= 30) + for (name in speech) { + val lines = format(result(coarse = AiClassification.SPEECH, display = name, confidence = 0.99f).copy(speechPreview = true)) + assertEquals("SPEECH ~PREVIEW", lines.coarse) + val header = lines.coarse.length + 3 + lines.label.length + 1 + lines.confidence.length + assertTrue("$header: ${lines.coarse} ● ${lines.label} ${lines.confidence}", header <= AiDebugText.HUD_MAX_COLUMNS) + } + } + @Test fun `종류와 원래 이름과 확신도를 보여준다`() { val lines = format(result()) assertEquals("DANGER", lines.coarse) diff --git a/app/src/test/java/com/example/soundvisualizer/ai/SpeechPreviewTimingTest.kt b/app/src/test/java/com/example/soundvisualizer/ai/SpeechPreviewTimingTest.kt new file mode 100644 index 00000000..4f998fc7 --- /dev/null +++ b/app/src/test/java/com/example/soundvisualizer/ai/SpeechPreviewTimingTest.kt @@ -0,0 +1,34 @@ +package com.example.soundvisualizer.ai + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 조기 표시(#422)의 두 시각. 둘 다 정규 추론이 소리를 가져간 때부터 잰다. + * + * 다음 정규 결과는 [RealtimeAiPipeline.AI_PREDICT_INTERVAL_MS] 뒤에 추론을 시작해, 전처리와 추론이 끝나야 나온다. 조기 표시가 그보다 + * 먼저 사라지면 사람 소리 → 환경음 → 사람 소리로 깜빡인다. 처음 값 275ms 는 추론이 25ms 라는 가정이었다(#456). + */ +class SpeechPreviewTimingTest { + + @Test + fun `조기 표시는 다음 정규 추론이 시작할 때까지만 새로 받아들인다`() { + assertEquals(RealtimeAiPipeline.AI_PREDICT_INTERVAL_MS * 1_000_000L, RealtimeAiPipeline.SPEECH_PREVIEW_ACCEPT_NS) + } + + @Test + fun `조기 표시는 추론이 100ms 걸리는 폰에서도 다음 정규 결과까지 이어진다`() { + val nextResultAtNs = (RealtimeAiPipeline.AI_PREDICT_INTERVAL_MS + 100) * 1_000_000L + assertTrue( + "만료 ${RealtimeAiPipeline.SPEECH_PREVIEW_EXPIRY_NS}ns 가 다음 결과 ${nextResultAtNs}ns 보다 이르다", + RealtimeAiPipeline.SPEECH_PREVIEW_EXPIRY_NS >= nextResultAtNs + ) + } + + @Test + fun `조기 표시는 다음 다음 정규 추론까지 남지는 않는다`() { + // 다음 틱이 무음으로 건너뛰어지면 만료만이 조기 표시를 지운다. 두 주기를 넘기면 말이 끝난 뒤에도 사람 소리 색이 남는다. + assertTrue(RealtimeAiPipeline.SPEECH_PREVIEW_EXPIRY_NS < 2 * RealtimeAiPipeline.AI_PREDICT_INTERVAL_MS * 1_000_000L) + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c038baa9..b7449b27 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -218,7 +218,7 @@ C++은 **버퍼마다 좌우 채널의 최대 진폭(max|sample|)만** 계산합 - 올리는 조건(#353, #358): 단서가 top-5 안에서 확률 0.05 이상이어야 합니다. 단서는 이름에 siren·alarm(alarm clock 제외)·explosion·fireworks·firecracker 가 든 소리와 경적·깨짐 같은 승인 목록(`APPROVED_ATTENTION_CUE_LABELS`)이고, 분류 탭에서 위협음에서 뺀 소리는 빠집니다(`docs/ai/user-mapping.md`). 기본 매핑으로 낸 top-3 투표(`defaultCoarse`)가 사람 소리이거나, 1순위가 말소리·무음 계열이거나, 1순위 확신도가 0.12 미만이면 올리지 않습니다. 총기 후보가 함께 있어도 막지 않지만, 총기 이름 자체는 이 길로 올리지 않습니다. 7. **후처리**: 확신도 기준과 히스테리시스를 적용해 라벨이 매번 흔들리지 않게 합니다. 위협음은 다른 종류보다 빨리 전환됩니다. 확신도가 기준에 못 미치면 **이전 라벨을 유지**합니다. (`AiPostProcessor`) 8. 결과를 `AtomicReference`에 저장합니다. 읽는 쪽은 락 없이 가져갑니다. -9. **사람 소리 조기 표시**(#422): 정규 추론이 첫 사람 소리 후보(확정까지 한 틱 남음)를 내면, 그 뒤에 새로 들어온 소리를 Silero VAD 가 확인해 화면에만 사람 소리를 한 박자 먼저 보입니다. 32ms 덩어리 3개가 연달아 0.9 이상이고 정규 추론이 소리를 가져간 때부터 250ms 안이어야 하며, 다음 정규 결과가 오거나 275ms 가 지나면 사라집니다. 정규 결과·위협음 판단·진동은 바꾸지 않고, 분류 탭에서 바꾼 소리가 하나라도 있으면 쓰지 않습니다. (`SileroStreamWorker`, `SileroPreviewGate`, 측정은 `docs/ai/silero-galaxy-422.md`) +9. **사람 소리 조기 표시**(#422): 정규 추론이 첫 사람 소리 후보(확정까지 한 틱 남음)를 내면, 그 뒤에 새로 들어온 소리를 Silero VAD 가 확인해 화면에만 사람 소리를 한 박자 먼저 보입니다. 32ms 덩어리 3개가 연달아 0.9 이상이고 정규 추론이 소리를 가져간 때부터 250ms 안이어야 하며, 다음 정규 결과가 오거나 350ms 가 지나면 사라집니다(추론이 오래 걸리는 폰에서도 다음 결과까지 이어지게, #456). 정규 결과·위협음 판단·진동은 바꾸지 않고, 분류 탭에서 바꾼 소리가 하나라도 있으면 쓰지 않습니다. (`SileroStreamWorker`, `SileroPreviewGate`, 측정은 `docs/ai/silero-galaxy-422.md`) Qualcomm source frontend가 유일한 production 경로입니다. 개발자 모드는 관찰용 top-5·입력 통계·CSV 기록만 제공하며 frontend 또는 보조 모델을 바꾸는 A/B 선택은 없습니다. Gunshot Booster ONNX 모델과 점수 기반 승격은 production 경로에서 제거되었습니다. diff --git a/docs/ai/safety-guard-diagnostics-351.md b/docs/ai/safety-guard-diagnostics-351.md index 344f690c..73618936 100644 --- a/docs/ai/safety-guard-diagnostics-351.md +++ b/docs/ai/safety-guard-diagnostics-351.md @@ -52,7 +52,7 @@ Confusion matrix (행 정답/열 예측: Ambient, Speech, Danger): 3. **총기 직접 승격의 추가 오경보:** airplane `2-106849-A-47.wav` 0→6프레임, can_opening `2-144031-A-34.wav` 0→1, footsteps `3-103598-A-25.wav` 0→1. veto만 제거 대비 주 평가 다수결/any-Danger 이득은 없다. 따라서 재도입 권장 근거가 부족하다. 4. **Speech guard:** 둘을 함께 제거하면 미확정 ESC crying_baby `1-187207-A-20.wav`의 Speech .223 + Burst .064가 새 Danger가 된다. 다른 crying_baby 및 SESA casual도 변화한다. 이 파일들은 고정 3-class 정답이 없어 개선/회귀를 확정하지 않지만, scored Speech 15개의 무변화만으로 guard 제거가 안전하다고 할 수 없다. 5. **무음/낮은 confidence:** 무음 이름 차단 제거로 fireworks 1개 다수결 회복에 그친다. 낮은 confidence 차단만 제거하면 승격 판정은 104프레임 달라지나 후단 .12 threshold 때문에 UI 변화가 없다. UI 같음은 내부 동작 같음이 아니다. -6. **대화+사이렌 재현(2026-10-08):** [#351에 공유된 원본·해시·혼합법](https://github.com/bibibic76/SoundVisualizer/issues/351#issuecomment-6049314574)에 따라 Wikimedia 원본 4개의 SHA-256을 모두 확인하고, active-RMS 혼합법과 250ms 창으로 독립 실행했다. Finnish 200창은 −24/−18/−12/−6/0dB에서 기본/말소리 차단 제거가 `2/2, 16/16, 19/21, 30/37, 52/65`, VOA 48창은 `1/1, 3/3, 5/9, 7/10, 11/15`, crowd 218창은 `0/0, 0/0, 0/0, 4/4, 18/18`로 공유 결과와 정확히 일치했다. 깨끗한 Finnish/VOA/crowd 466창은 두 정책이 같았고 Finnish 끝의 기차 경적 3창만 Danger였다. 따라서 말소리 차단이 혼합 사이렌 일부를 줄인다는 현상은 재현됐지만, 실제 마이크·반향이 없는 공개 음원 3개뿐이므로 차단 제거의 안전성을 확정하지 않는다. 이 #351 적용에서는 말소리 차단을 유지한다. +6. **대화+사이렌 재현(2026-10-08):** [#351에 공유된 원본·해시·혼합법](https://github.com/SOVIS-Team/SoundVisualizer-Android/issues/351#issuecomment-6049314574)에 따라 Wikimedia 원본 4개의 SHA-256을 모두 확인하고, active-RMS 혼합법과 250ms 창으로 독립 실행했다. Finnish 200창은 −24/−18/−12/−6/0dB에서 기본/말소리 차단 제거가 `2/2, 16/16, 19/21, 30/37, 52/65`, VOA 48창은 `1/1, 3/3, 5/9, 7/10, 11/15`, crowd 218창은 `0/0, 0/0, 0/0, 4/4, 18/18`로 공유 결과와 정확히 일치했다. 깨끗한 Finnish/VOA/crowd 466창은 두 정책이 같았고 Finnish 끝의 기차 경적 3창만 Danger였다. 따라서 말소리 차단이 혼합 사이렌 일부를 줄인다는 현상은 재현됐지만, 실제 마이크·반향이 없는 공개 음원 3개뿐이므로 차단 제거의 안전성을 확정하지 않는다. 이 #351 적용에서는 말소리 차단을 유지한다. ## 재실행 diff --git a/docs/ai/silero-galaxy-422.md b/docs/ai/silero-galaxy-422.md index ea5233d7..1d72780c 100644 --- a/docs/ai/silero-galaxy-422.md +++ b/docs/ai/silero-galaxy-422.md @@ -1,6 +1,6 @@ # #422 Silero 화면 조기 표시 비교 — Galaxy 검증 -머지/배포 승인 전 실기기 확인용 **draft PR #423**이다. 정규 YAMNet 확정/진동/잠금 화면 Danger 경로는 유지하고, 기본 매핑의 첫 Speech 후보가 통과했으나 아직 Ambient로 확정된 경우에만 별도 Silero worker의 **새로운 32ms 입력 3개가 각각 p≥0.9**이면 화면/HUD를 앞당긴다. 다음 정규 추론 시작 이후 새 채택 금지, 다음 정규 결과 또는 parent snapshot+275ms에 임시 표시 종료. 이미 확정된 Danger 위에는 표시하지 않는다. 느린 worker는 backlog를 버리고 재시작하며 정규 추론이 기다리지 않는다. CPU/메모리 경합까지 없다는 보장은 아니다. +머지/배포 승인 전 실기기 확인용 **draft PR #423**이다. 정규 YAMNet 확정/진동/잠금 화면 Danger 경로는 유지하고, 기본 매핑의 첫 Speech 후보가 통과했으나 아직 Ambient로 확정된 경우에만 별도 Silero worker의 **새로운 32ms 입력 3개가 각각 p≥0.9**이면 화면/HUD를 앞당긴다. 다음 정규 추론 시작 이후 새 채택 금지, 다음 정규 결과 또는 parent snapshot+350ms에 임시 표시 종료(처음에는 275ms였고, 정규 추론이 25ms를 넘으면 다음 결과 전에 사라져 깜빡이므로 #456에서 늘렸다). 이미 확정된 Danger 위에는 표시하지 않는다. 느린 worker는 backlog를 버리고 재시작하며 정규 추론이 기다리지 않는다. CPU/메모리 경합까지 없다는 보장은 아니다. ## 비교 APK diff --git a/tools/ai_reference/speech_verifier_policy.py b/tools/ai_reference/speech_verifier_policy.py index 54579d06..24737b38 100644 --- a/tools/ai_reference/speech_verifier_policy.py +++ b/tools/ai_reference/speech_verifier_policy.py @@ -34,7 +34,7 @@ def decide(blocks, event, next_complete, *, threshold=.9, consecutive=3, return None, 0, 0 trigger = event["complete_ms"] deadline = min(event["snapshot_ms"] + 250., next_complete) - expiry = min(event["snapshot_ms"] + 275., next_complete) + expiry = min(event["snapshot_ms"] + 350., next_complete) # product value since #456 (was 275) if deadline <= trigger: return None, 0, 0 clock = 0. if mode == "continuous" else trigger From 7ea56ba45414ad5795b506fd3f3100cd16fd79a2 Mon Sep 17 00:00:00 2001 From: KimDoHwan Date: Wed, 11 Nov 2026 15:40:57 +0900 Subject: [PATCH 2/2] =?UTF-8?q?chore:=20=EB=B2=84=EC=A0=84=EC=9D=84=201.8.?= =?UTF-8?q?3=20=EC=9C=BC=EB=A1=9C=20=EC=98=AC=EB=A6=B0=EB=8B=A4=20(#458)?= =?UTF-8?q?=20(#459)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 28ec5b94..f670d761 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -18,8 +18,8 @@ android { // 37(Android 17)에서 이 앱에 걸리는 변경은 백그라운드 액티비티 시작(BAL) 조이기뿐이었다. 타일·알림 본문· // "다시 켜기" 로 여는 경로가 모두 BAL_ALLOW_NON_APP_VISIBLE_WINDOW 로 허용되는 것을 Android 17 에서 봤다(#155). targetSdk = 37 - versionCode = 16 - versionName = "1.8.2" + versionCode = 17 + versionName = "1.8.3" buildConfigField("boolean", "AI_SILERO_PREVIEW_ENABLED", providers.gradleProperty("aiSileroPreview").map { require(it == "true" || it == "false") { "aiSileroPreview must be true or false" }