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
+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.'