Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@
// 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" }
Expand Down Expand Up @@ -186,7 +186,7 @@
}

dependencies {
implementation("androidx.core:core-ktx:1.19.0")

Check warning on line 189 in app/build.gradle.kts

View workflow job for this annotation

GitHub Actions / 빌드 · 유닛 테스트 · APK

Lint GradleDependency

A newer version of androidx.core:core-ktx than 1.19.0 is available: 1.19.1
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.11.0")
implementation("androidx.activity:activity-compose:1.13.0")
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/java/com/example/soundvisualizer/AiDebugCsv.kt
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ object AiDebugCsv {
add("total_ms")
add("mapping_override_count")
add("mapping_signature")
// 화면에만 앞당겨 보인 사람 소리(조기 표시, #422)인지. 정규 결과와 섞어 세지 않게 한다. 새 열은 늘 끝에 더한다(#456).
add("speech_preview")
}

/** 머리글 한 줄. 파일을 새로 열 때 한 번만 쓴다. */
Expand Down Expand Up @@ -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) }
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ internal class SileroInference(context: Context) : AutoCloseable {
@Suppress("UNCHECKED_CAST") val next = out[1].value as Array<Array<FloatArray>>
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
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions app/src/test/java/com/example/soundvisualizer/AiDebugCsvTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 경로에서 제거되었습니다.

Expand Down
2 changes: 1 addition & 1 deletion docs/ai/safety-guard-diagnostics-351.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 적용에서는 말소리 차단을 유지한다.

## 재실행

Expand Down
Loading
Loading