test(content): verify native Android PCM and complete speech coverage

This commit is contained in:
NINI
2026-10-05 14:00:08 +09:00
parent 1f26fbc421
commit ccc8b77cf5
6 changed files with 130 additions and 7 deletions
@@ -179,9 +179,14 @@
"rms": 0.09600483626127243,
"qualityWarnings": []
},
"review": "not_listened",
"status": "draft",
"processing": "polyphase resample to mono PCM16 44100 Hz; no speech trimming"
"review": "user-rejected",
"status": "rejected",
"processing": "polyphase resample to mono PCM16 44100 Hz; no speech trimming",
"reviewedBy": "user",
"reviewedDate": "2026-10-05",
"reviewTimezone": "Asia/Seoul",
"reviewStatement": "count_08 음성이 이상합니다. 쉼표 없이 붙여서 읽으세요",
"rejectedSha256": "6bde9265a8e4ac5a4209b1ddcebf57251430bd2eb5134a59af403f351a640aa2"
},
{
"id": "count_09",
+3 -1
View File
@@ -12,10 +12,12 @@ R07의 1..30 초기 묶음·목표·종류를 30개 ScriptableObject로 작성
음성 전체 44개 catalog 중 기존 승인 3개의 runtime SHA는 유지했다. 새 회차 `20261005T034100690940Z_96b6d5ac`가 고정 Qwen Korean/Sohee/seed42/GPU 설정으로 생성·재사용한 입력과 원본 파형을 기록한다. count_05만 같은 뜻의 `다섯.` 대본으로 새 take를 만들었고 이전 9.12초 실패 이력은 남겼다. 새 후보는 0.72초의 44.1kHz mono PCM16으로 준비했다. 현재 도움에서 선택할 수 있는 N=2..10, K=1..floor(N/2)의 25개 완전 문장과 guide_find/guide_uncounted/demo_merge6/demo_split10을 모두 별도 오프라인 WAV로 연결했다. 숫자 조각을 붙여 안내 문장을 만들지 않는다.
편집 선택 `SourceAssets/Audio/Voice/edited/20261005T034100690940Z_96b6d5ac/selection.json`은 승인 3개만 이전 승인 selection 경로·파일 SHA·동일 runtime SHA·검수자·날짜·사용자 발언을 historical-approval-reused로 연결한다. 나머지는 실제 파일 검사와 사람 청취 승인을 구분하여 draft/not_listened 상태다. 모델 캐시/conda/SourceAssets는 APK에 들어가지 않는다. 음성 큐의 입력 지연 없음, FIFO, 마지막 숫자 유지, 같은 보드 안내 취소 시 숫자 유지, navigation/pause 큐 해제를 기존 동작대로 유지한다.
편집 선택 `SourceAssets/Audio/Voice/edited/20261005T034100690940Z_96b6d5ac/selection.json`은 승인 3개만 이전 승인 selection 경로·파일 SHA·동일 runtime SHA·검수자·날짜·사용자 발언을 historical-approval-reused로 연결한다. count_08의 현재 runtime SHA 6bde9265a8e4ac5a4209b1ddcebf57251430bd2eb5134a59af403f351a640aa2는 사용자의 “count_08 음성이 이상합니다. 쉼표 없이 붙여서 읽으세요” 피드백으로 rejected/user-rejected 상태이며 파형은 유지했다. 새 “여덟.” 후보는 root의 별도 ignored artifacts에서 생성되어 T7 채택·재검사를 기다린다. 그 밖의 40개는 실제 파일 검사와 사람 청취 승인을 구분하여 draft/not_listened 상태다. 모델 캐시/conda/SourceAssets는 APK에 들어가지 않는다. 음성 큐의 입력 지연 없음, FIFO, 마지막 숫자 유지, 같은 보드 안내 취소 시 숫자 유지, navigation/pause 큐 해제를 기존 동작대로 유지한다.
검증은 Content 전체 EditMode/PlayMode 및 변경된 흐름의 Android를 대상으로 한다. 30개의 정확한 정의, 실제 Core 30개 연속 해답/새 저장 해금/다른 분할/모든 분할의 유효 도움/재플레이/next 저장 우선/no31을 검사한다. Editor PlayMode는 모든 수집 구 55개를 시점 회전으로 발견·pick·직접 drag하고 세 테마의 Free 0..10/복원, arch 실제 빈틈, ghost 보드 불변/재진입을 확인한다. 최종 실행 근거는 `artifacts/verification/Content/`에 보존하며 실패/수정 이력은 red-* 폴더에 남긴다.
T7의 AI 최종 캐릭터/UI 이미지·표정/VFX, SFX/BGM 및 아직 응답받지 않은 사람 청취 승인은 이 기능 구현과 분리된 미완료다. 에뮬레이터의 ABI bridge/GLES 검사는 실제 물리 Android 성능을 증명하지 않는다.
시각 검수 잔여: Free 10개를 두 줄로 공급한 기본 시점에서는 아래 줄 일부 개별 묶음의 숫자 배지가 위 줄 친구 몸체와 겹쳐 보인다. T7에서 모든 배열의 배지·선택·계수 표시 가독성을 최종 검수할 대상으로 기록한다. 배경에 의한 기존 아래 시점 숫자 가림은 Blender 바닥을 원래 평면 범위로 조정하여 Collider를 유지한 채 회귀 검사를 통과시켰다. 탐험 자석 몸체는 시점 회전으로 발견할 수 있으며 장식 Collider를 무시하지 않는다.
Android 음성 검사는 Content만 `record-native-audio.ps1`의 Android AudioRecord REMOTE_SUBMIX 무압축 stereo48k PCM16을 원시 보존하고 각 좌우 프레임의 정확한 정수 평균으로 mono48k를 만든다. 공식 scrcpy4.1 ZIP SHA `5b12172b3264b2889f4583ee64752ce832e29bc8b1089dca81093459697165db`를 검증하며 도구·서버·캐시는 ignored artifacts에만 있어 APK에 들어가지 않는다. 기존 gRPC/T5 경로와 비교기의 기본 동작은 유지한다. Content의 `-RequireFullSpeech`는 기존 전체 .95 상관과 동일 onset에서100ms/50ms 겹침의 모든 source RMS32 이상 창 .95를 함께 요구하고, 유효 창0개/불충분 자료는 실패한다. 실제 native 정상 발화와 다른 clip/앞뒤250ms 삭제/중간250ms 삭제·중복/노이즈 negative를 검사한다. gRPC 경로는 같은 APK·원본의 전체발화 불일치가 있었고 이전 T5 APK/native48/냉간부팅 교차 실패를 원시 보존했다. 내부 Android 경로의 정상 대조와 구분하며 정확한 SDK 원인을 확정하지 않는다.
+20 -1
View File
@@ -1,4 +1,4 @@
param([Parameter(Mandatory)][string]$ReferencePath, [Parameter(Mandatory)][string]$CapturedPath, [Parameter(Mandatory)][string]$OutputPath)
param([Parameter(Mandatory)][string]$ReferencePath, [Parameter(Mandatory)][string]$CapturedPath, [Parameter(Mandatory)][string]$OutputPath, [switch]$RequireFullSpeech)
$ErrorActionPreference='Stop'
Add-Type -TypeDefinition @'
using System;
@@ -46,6 +46,19 @@ public static class AndroidPcmComparison {
double fullCorrelation=fullDot/Math.Sqrt(sourceEnergy*outputEnergy);
return new[]{fullCorrelation,bestLag/24000.0,source.Length/24000.0,output.Length/24000.0,Math.Sqrt(outputEnergy/source.Length),fullDot/sourceEnergy,(double)nonzero};
}
public static double[] Coverage(string original,string captured,double onset) {
var source=Read(original); var output=Read(captured); int lag=(int)Math.Round(onset*24000),window=2400,step=1200;
if(source.Length<window || lag<0 || lag+source.Length>output.Length) throw new Exception("Complete speech coverage data required");
int tested=0,failed=0; double minimum=1;
for(int offset=0;;offset=Math.Min(offset+step,source.Length-window)) {
double dot=0,se=0,oe=0;
for(int j=0;j<window;j++) { double s=source[offset+j],o=output[lag+offset+j]; dot+=s*o;se+=s*s;oe+=o*o; }
// Same PCM32 activity floor as the existing capture check; never omit a quiet speech window by raising it.
if(Math.Sqrt(se/window)>=32) { double c=oe>0 ? dot/Math.Sqrt(se*oe) : 0; tested++;minimum=Math.Min(minimum,c);if(c<0.95)failed++; }
if(offset==source.Length-window) break;
}
return new[]{(double)tested,minimum,(double)failed};
}
}
'@
$values=[AndroidPcmComparison]::Compare((Resolve-Path -LiteralPath $ReferencePath).Path,(Resolve-Path -LiteralPath $CapturedPath).Path)
@@ -53,6 +66,12 @@ $passed=$values[0] -ge 0.95 -and $values[4] -gt 32 -and $values[6] -gt 100
$record=[ordered]@{ reference=$ReferencePath; referenceSha256=(Get-FileHash -LiteralPath $ReferencePath -Algorithm SHA256).Hash;
captured=$CapturedPath; capturedSha256=(Get-FileHash -LiteralPath $CapturedPath -Algorithm SHA256).Hash;
normalizedCorrelation=$values[0]; onsetSeconds=$values[1]; referenceDurationSeconds=$values[2]; capturedDurationSeconds=$values[3]; rmsPcm=$values[4]; gain=$values[5]; nonSilentSamples=$values[6]; thresholdCorrelation=0.95; passed=$passed; auditoryReview=$false }
if ($RequireFullSpeech) {
$coverage=[AndroidPcmComparison]::Coverage((Resolve-Path -LiteralPath $ReferencePath).Path,(Resolve-Path -LiteralPath $CapturedPath).Path,$values[1])
$record.requireFullSpeech=$true;$record.speechWindowMilliseconds=100;$record.speechStepMilliseconds=50;$record.sourceActivityRmsPcm=32
$record.speechWindows=$coverage[0];$record.minimumSpeechCorrelation=$coverage[1];$record.failedSpeechWindows=$coverage[2]
$passed=$passed -and $coverage[0] -gt 0 -and $coverage[2] -eq 0;$record.passed=$passed
}
$record | ConvertTo-Json | Set-Content -LiteralPath $OutputPath -Encoding utf8
Write-Host "실제 PCM 대조: 상관 $($values[0]), 비무음 샘플 $($values[6]), 통과 $passed"
if (!$passed) { exit 1 }
+2 -2
View File
@@ -48,7 +48,7 @@ function Fixture([int]$Stage) {
Step "fixture-$Stage-selection" @('shell','input','tap','1200','616')
}
function StartAudio([string]$Name,[int]$Seconds) {
$args=@('-NoProfile','-File',(Join-Path $PSScriptRoot 'record-audio.ps1'),'-OutputPath',(Join-Path $EvidenceDir "$Name.wav"),'-Seconds',"$Seconds")
$args=@('-NoProfile','-File',(Join-Path $PSScriptRoot 'record-native-audio.ps1'),'-OutputPath',(Join-Path $EvidenceDir "$Name.wav"),'-Seconds',"$Seconds",'-Serial',$Serial)
$process=Start-Process pwsh -ArgumentList ($args | ForEach-Object {'"'+$_+'"'}) -WindowStyle Hidden -RedirectStandardOutput (Join-Path $EvidenceDir "$Name.stdout") -RedirectStandardError (Join-Path $EvidenceDir "$Name.stderr") -PassThru
$recorders.Add(@{name=$Name;process=$process}); Start-Sleep -Seconds 2
}
@@ -58,7 +58,7 @@ function CompareAudio([string]$Name,[string[]]$Ids) {
foreach ($id in $Ids) {
$reference=Join-Path (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent) "Assets/MagnetismHeroes/UnityRuntime/Presentation/Audio/Voice/$id.wav"
$comparison=Join-Path $EvidenceDir "$Name-$id-match.json"
& pwsh -NoProfile -File (Join-Path $PSScriptRoot 'compare-audio.ps1') -ReferencePath $reference -CapturedPath (Join-Path $EvidenceDir "$Name.wav") -OutputPath $comparison
& pwsh -NoProfile -File (Join-Path $PSScriptRoot 'compare-audio.ps1') -ReferencePath $reference -CapturedPath (Join-Path $EvidenceDir "$Name.wav") -OutputPath $comparison -RequireFullSpeech
$exit=$LASTEXITCODE; $match=Get-Content -LiteralPath $comparison -Raw | ConvertFrom-Json
Check "pcm-$Name-$id" ($exit -eq 0 -and $match.passed -and $match.normalizedCorrelation -ge .95 -and $match.rmsPcm -gt 32) ($match | ConvertTo-Json -Compress)
}
+57
View File
@@ -0,0 +1,57 @@
param([Parameter(Mandatory)][string]$OutputPath,[int]$Seconds=15,[string]$Serial='emulator-5554')
$ErrorActionPreference='Stop'
# 검사용 Android AudioRecord REMOTE_SUBMIX 원시 PCM. 서버/도구는 APK에 포함하지 않습니다.
$version='4.1';$zipSha='5b12172b3264b2889f4583ee64752ce832e29bc8b1089dca81093459697165db'
$source="https://github.com/Genymobile/scrcpy/releases/download/v$version/scrcpy-win64-v$version.zip"
$root=Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$cache=Join-Path $root "artifacts/tools/scrcpy-v$version"
New-Item -ItemType Directory -Path $cache -Force | Out-Null
$zip=Join-Path $cache "scrcpy-win64-v$version.zip"
if (!(Test-Path -LiteralPath $zip)) { Invoke-WebRequest -Uri $source -OutFile $zip }
if ((Get-FileHash -LiteralPath $zip).Hash.ToLowerInvariant() -ne $zipSha) { throw 'scrcpy 공식 ZIP SHA 불일치' }
$toolDir=Join-Path $cache "scrcpy-win64-v$version";$exe=Join-Path $toolDir 'scrcpy.exe'
if (!(Test-Path -LiteralPath $exe)) { Expand-Archive -LiteralPath $zip -DestinationPath $cache }
$OutputPath=[IO.Path]::GetFullPath($OutputPath);$stereo=$OutputPath+'.stereo.wav'
$arguments=@("--serial=$Serial",'--no-window','--no-video','--no-control','--no-playback','--audio-codec=raw','--audio-source=output',"--record=$stereo","--time-limit=$Seconds",'--require-audio')
$record=[ordered]@{method='Android AudioRecord REMOTE_SUBMIX via scrcpy RAW';sourceCommit=(& git rev-parse HEAD);sourceDirty=[bool](& git status --porcelain);startedUtc=[DateTime]::UtcNow.ToString('o');serial=$Serial;toolVersion=$version;toolSource=$source;toolZipSha256=$zipSha;toolExecutableSha256=(Get-FileHash $exe).Hash;serverSha256=(Get-FileHash (Join-Path $toolDir 'scrcpy-server')).Hash;arguments=$arguments;auditoryReview=$false}
$oldAdb=$env:ADB
try {
$env:ADB=Join-Path $env:LOCALAPPDATA 'Android/Sdk/platform-tools/adb.exe'
$process=Start-Process $exe -ArgumentList ($arguments | ForEach-Object {'"'+$_+'"'}) -WindowStyle Hidden -RedirectStandardOutput ($OutputPath+'.stdout') -RedirectStandardError ($OutputPath+'.stderr') -PassThru
if (!$process.WaitForExit(($Seconds+20)*1000)) { $process.Kill();throw 'Android PCM 녹음 종료 시간 초과' }
$process.Refresh();$record.exitCode=$process.ExitCode
if ($process.ExitCode -ne 0) { throw "Android PCM recorder exit $($process.ExitCode)" }
Add-Type -TypeDefinition @'
using System;
using System.IO;
public static class AndroidNativePcm {
public static double[] Mono(string input,string output) {
short[] stereo=null;int rate=0;bool format=false;
using(var r=new BinaryReader(File.OpenRead(input))) {
if(new string(r.ReadChars(4))!="RIFF")throw new Exception("RIFF required");r.ReadInt32();if(new string(r.ReadChars(4))!="WAVE")throw new Exception("WAVE required");
while(r.BaseStream.Position+8<=r.BaseStream.Length) {
string id=new string(r.ReadChars(4));int size=r.ReadInt32();long end=r.BaseStream.Position+size;
if(id=="fmt ") {int encoding=r.ReadInt16(),channels=r.ReadInt16();rate=r.ReadInt32();r.ReadInt32();r.ReadInt16();int bits=r.ReadInt16();format=encoding==1&&channels==2&&bits==16&&rate==48000;}
if(id=="data") {if(!format||size%4!=0)throw new Exception("Actual stereo48k PCM16 required");stereo=new short[size/2];for(int i=0;i<stereo.Length;i++)stereo[i]=r.ReadInt16();break;}
r.BaseStream.Position=end+(size%2);
}
}
if(stereo==null||stereo.Length==0)throw new Exception("Actual PCM samples required");
double energy=0;int peak=0,active=0;int count=stereo.Length/2;
using(var w=new BinaryWriter(File.Create(output))) {
w.Write(System.Text.Encoding.ASCII.GetBytes("RIFF"));w.Write(36+count*2);w.Write(System.Text.Encoding.ASCII.GetBytes("WAVEfmt "));w.Write(16);w.Write((short)1);w.Write((short)1);w.Write(rate);w.Write(rate*2);w.Write((short)2);w.Write((short)16);w.Write(System.Text.Encoding.ASCII.GetBytes("data"));w.Write(count*2);
for(int i=0;i<stereo.Length;i+=2) {short value=(short)(((int)stereo[i]+stereo[i+1])/2);w.Write(value);energy+=(double)value*value;peak=Math.Max(peak,Math.Abs((int)value));if(Math.Abs((int)value)>32)active++;}
}
return new[]{rate,(double)count,peak,Math.Sqrt(energy/count),(double)active};
}
}
'@
$levels=[AndroidNativePcm]::Mono($stereo,$OutputPath)
$record.sampleRate=$levels[0];$record.channels=1;$record.bitsPerSample=16;$record.samples=$levels[1];$record.durationSeconds=$levels[1]/$levels[0];$record.peakPcm=$levels[2];$record.rmsPcm=$levels[3];$record.nonSilentSamples=$levels[4]
$record.stereoPath=$stereo;$record.stereoSha256=(Get-FileHash $stereo).Hash;$record.monoSha256=(Get-FileHash $OutputPath).Hash;$record.monoConversion='Each PCM16 stereo frame: (left+right)/2, integer division; original48k rate retained'
if ($levels[2] -ge 32767) { throw 'Android PCM clipping detected' }
} finally {
$env:ADB=$oldAdb;$record.finishedUtc=[DateTime]::UtcNow.ToString('o');$record.sourceCommitEnd=(& git rev-parse HEAD);$record.sourceDirtyEnd=[bool](& git status --porcelain)
$record | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath ($OutputPath+'.json') -Encoding utf8
}
Write-Host "Android 원시 PCM $($record.samples) 샘플: $OutputPath"
+40
View File
@@ -0,0 +1,40 @@
param([Parameter(Mandatory)][string]$ReferencePath,[Parameter(Mandatory)][string]$CapturedPath,[Parameter(Mandatory)][string]$WrongReferencePath,[Parameter(Mandatory)][string]$EvidenceDir)
$ErrorActionPreference='Stop'
New-Item -ItemType Directory -Path $EvidenceDir -Force | Out-Null
$checks=[Collections.Generic.List[object]]::new()
function Match([string]$Name,[string]$Reference,[string]$Captured,[bool]$Expected) {
$path=Join-Path $EvidenceDir "$Name.json"
& pwsh -NoProfile -File (Join-Path $PSScriptRoot 'compare-audio.ps1') -ReferencePath $Reference -CapturedPath $Captured -OutputPath $path -RequireFullSpeech
$code=$LASTEXITCODE;$record=Get-Content -LiteralPath $path -Raw | ConvertFrom-Json
$checks.Add(@{name=$Name;expectedPassed=$Expected;exitCode=$code;record=$record})
if ($record.passed -ne $Expected -or ($Expected -and $code -ne 0) -or (!$Expected -and $code -eq 0)) { throw "PCM negative control mismatch: $Name" }
return $record
}
try {
$positive=Match 'positive-full-speech' $ReferencePath $CapturedPath $true
Add-Type -TypeDefinition @'
using System;
using System.IO;
public static class NativeAudioControls {
public static void Create(string input,string output,string kind,double onset,double duration) {
byte[] bytes=File.ReadAllBytes(input);int rate=BitConverter.ToInt32(bytes,24),start=(int)Math.Round(onset*rate),length=(int)Math.Round(duration*rate),cut=rate/4,middle=start+(int)(rate*.85);
if(BitConverter.ToInt16(bytes,20)!=1 || BitConverter.ToInt16(bytes,22)!=1 || BitConverter.ToInt16(bytes,34)!=16 || System.Text.Encoding.ASCII.GetString(bytes,36,4)!="data") throw new Exception("Canonical monoPCM16 WAV required");
short[] data=new short[(bytes.Length-44)/2];Buffer.BlockCopy(bytes,44,data,0,bytes.Length-44);
if(start<0 || start+length>data.Length || length<rate)throw new Exception("Complete control speech required");
if(kind=="duplicate") {var longer=new short[data.Length+cut];Array.Copy(data,0,longer,0,middle);Array.Copy(data,middle-cut,longer,middle,cut);Array.Copy(data,middle,longer,middle+cut,data.Length-middle);data=longer;}
else if(kind=="noise") {var random=new Random(42);for(int i=0;i<data.Length;i++)data[i]=(short)random.Next(-4096,4097);}
else {int offset=kind=="front"?start:kind=="back"?start+length-cut:middle;Array.Clear(data,offset,cut);}
using(var w=new BinaryWriter(File.Create(output))) {w.Write(System.Text.Encoding.ASCII.GetBytes("RIFF"));w.Write(36+data.Length*2);w.Write(System.Text.Encoding.ASCII.GetBytes("WAVEfmt "));w.Write(16);w.Write((short)1);w.Write((short)1);w.Write(rate);w.Write(rate*2);w.Write((short)2);w.Write((short)16);w.Write(System.Text.Encoding.ASCII.GetBytes("data"));w.Write(data.Length*2);foreach(short sample in data)w.Write(sample);}
}
}
'@
foreach($kind in @('front','back','middle','duplicate','noise')) {
$path=Join-Path $EvidenceDir "$kind.wav";[NativeAudioControls]::Create((Resolve-Path $CapturedPath).Path,$path,$kind,$positive.onsetSeconds,$positive.referenceDurationSeconds)
Match $kind $ReferencePath $path $false | Out-Null
}
Match 'wrong-clip' $WrongReferencePath $CapturedPath $false | Out-Null
$passed=$true
} finally {
@{sourceCommit=(& git rev-parse HEAD);sourceDirty=[bool](& git status --porcelain);passed=($passed -eq $true);checks=$checks.ToArray();finishedUtc=[DateTime]::UtcNow.ToString('o')} | ConvertTo-Json -Depth 8 | Set-Content (Join-Path $EvidenceDir 'regression.json') -Encoding utf8
}
Write-Host 'Native PCM positive and six meaningful negative controls passed.'