diff --git a/SourceAssets/Audio/Voice/edited/20261005T034100690940Z_96b6d5ac/selection.json b/SourceAssets/Audio/Voice/edited/20261005T034100690940Z_96b6d5ac/selection.json index edb7ccf..33a51d8 100644 --- a/SourceAssets/Audio/Voice/edited/20261005T034100690940Z_96b6d5ac/selection.json +++ b/SourceAssets/Audio/Voice/edited/20261005T034100690940Z_96b6d5ac/selection.json @@ -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", diff --git a/docs/content.md b/docs/content.md index e17f687..18dd809 100644 --- a/docs/content.md +++ b/docs/content.md @@ -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 원인을 확정하지 않는다. diff --git a/tools/android/compare-audio.ps1 b/tools/android/compare-audio.ps1 index 1abc7b8..4e76d96 100644 --- a/tools/android/compare-audio.ps1 +++ b/tools/android/compare-audio.ps1 @@ -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.Lengthoutput.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=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 } diff --git a/tools/android/exercise-content.ps1 b/tools/android/exercise-content.ps1 index 044760e..4e7b77e 100644 --- a/tools/android/exercise-content.ps1 +++ b/tools/android/exercise-content.ps1 @@ -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) } diff --git a/tools/android/record-native-audio.ps1 b/tools/android/record-native-audio.ps1 new file mode 100644 index 0000000..a4986f3 --- /dev/null +++ b/tools/android/record-native-audio.ps1 @@ -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;i32)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" diff --git a/tools/android/test-native-audio.ps1 b/tools/android/test-native-audio.ps1 new file mode 100644 index 0000000..d7accb5 --- /dev/null +++ b/tools/android/test-native-audio.ps1 @@ -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