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 = 17
versionName = "1.8.3"
versionCode = 18
versionName = "1.8.4"
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
23 changes: 20 additions & 3 deletions app/src/main/java/com/example/soundvisualizer/AiDebugText.kt
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,14 @@ object AiDebugText {
)
}

val coarse = result.coarse.uppercase(Locale.US) + if (result.speechPreview) " ~PREVIEW" else ""
val confidence = twoDecimals(result.confidence)
return AiDebugLines(
colorLabel = result.coarse,
coarse = result.coarse.uppercase(Locale.US) + if (result.speechPreview) " ~PREVIEW" else "",
coarse = coarse,
// 확정된 이름이 아직 없으면 빈 문자열이 온다. 임계값을 한 번도 넘지 못한 상태다.
label = result.display.ifEmpty { "(none)" },
confidence = twoDecimals(result.confidence),
label = fitHeaderLabel(result.display.ifEmpty { "(none)" }, coarse, confidence),
confidence = confidence,
detail = detailOf(result, nowMs),
verdict = verdictOf(result),
timing = "$levelText shown ${yesNo(shown)} ${timingOf(result)}",
Expand All @@ -113,6 +115,21 @@ object AiDebugText {
return "cue ${yesNo(result.dangerCuePromoted)}"
}

/** 첫 줄에서 종류와 이름 사이의 점이 차지하는 칸 수. 점(8dp)과 그 양옆 여백(6dp 씩)이다. */
const val HEADER_DOT_COLUMNS = 3

/**
* 첫 줄("종류 ● 이름 확신도")이 [HUD_MAX_COLUMNS] 를 넘지 않게 이름을 줄인다(#461).
*
* 정규 결과는 가장 긴 이름(40자)도 들어가므로 줄지 않는다. 조기 표시는 종류 칸이 "SPEECH ~PREVIEW" 로 여덟 자 길고, 이름은 그
* 프레임의 1위 이름이라(종류는 상위 3개의 투표) 긴 환경음 이름이 실릴 수 있다. 그때 35자를 넘는 이름만 끝을 [ELLIPSIS] 로 줄인다.
* 그대로 두면 첫 줄이 두 줄로 꺾여 표시 높이가 흔들린다(#165). 기록(CSV)에는 온전한 이름이 남는다.
*/
private fun fitHeaderLabel(label: String, coarse: String, confidence: String): String {
val room = HUD_MAX_COLUMNS - coarse.length - HEADER_DOT_COLUMNS - 1 - confidence.length
return if (label.length <= room) label else label.take(room - ELLIPSIS.length) + ELLIPSIS
}

/** 고정폭 칸에 맞춘 이름. 짧으면 공백으로 채워 확률이 한 줄에 세로로 맞는다. */
private fun fitName(name: String): String =
if (name.length > TOP5_NAME_WIDTH) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,20 +33,52 @@ 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 {
private fun classNames(): List<String> =
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) {

private fun headerColumns(lines: AiDebugLines) =
lines.coarse.length + AiDebugText.HEADER_DOT_COLUMNS + lines.label.length + 1 + lines.confidence.length

@Test fun `조기 표시의 첫 줄은 어느 이름이 실려도 한 줄에 든다`() {
// 첫 줄은 오버레이가 "종류 ● 이름 확신도" 로 붙인다. 조기 표시는 종류가 "SPEECH ~PREVIEW" 로 여덟 자 길다.
// 이름은 그 프레임의 1위 이름이고 종류는 상위 3개의 투표라, 1위가 환경음 이름이어도 2·3위의 사람 소리가 합쳐 이기면 그 이름이
// 실린다. #456 의 시험은 "이름은 늘 사람 소리 이름"이라고 잘못 가정해, "Livestock, farm animals, working animals" 가 실리면
// 63자가 되어 두 줄로 꺾이는 것을 놓쳤다(#461). 521개 이름을 모두 넣어 본다.
val names = classNames()
assertEquals(521, names.size)
var longest = 0
var shortened = 0
for (name in names) {
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
val header = headerColumns(lines)
assertTrue("$header: ${lines.coarse} ● ${lines.label} ${lines.confidence}", header <= AiDebugText.HUD_MAX_COLUMNS)
longest = maxOf(longest, header)
if (lines.label != name) {
shortened++
assertTrue(lines.label, lines.label.endsWith(AiDebugText.ELLIPSIS))
assertTrue(lines.label, name.startsWith(lines.label.dropLast(AiDebugText.ELLIPSIS.length)))
}
}
assertEquals(AiDebugText.HUD_MAX_COLUMNS, longest)
assertTrue("줄인 이름이 하나도 없다", shortened > 0)
// 기본 종류가 사람 소리인 이름은 가장 긴 것(35자)도 줄지 않는다. 조기 표시에 가장 자주 실리는 이름들이다.
fun preview(name: String) =
format(result(coarse = AiClassification.SPEECH, display = name, confidence = 0.99f).copy(speechPreview = true)).label
assertEquals("Hubbub, speech noise, speech babble", preview("Hubbub, speech noise, speech babble"))
assertEquals("Livestock, farm animals, working a" + AiDebugText.ELLIPSIS, preview("Livestock, farm animals, working animals"))
}

@Test fun `정규 결과의 첫 줄은 어느 이름도 줄이지 않는다`() {
// 정규 결과는 종류 칸이 짧아 가장 긴 이름(40자)도 한 줄에 든다. 채점할 때 보는 이름이 잘리면 안 된다.
for (coarse in listOf(AiClassification.AMBIENT, AiClassification.SPEECH, AiClassification.DANGER)) {
for (name in classNames()) {
val lines = format(result(coarse = coarse, display = name, confidence = 0.99f))
assertEquals(name, lines.label)
assertTrue(headerColumns(lines) <= AiDebugText.HUD_MAX_COLUMNS)
}
}
}

Expand Down
Loading