PCM WebSocket Recorder PoC
이 문서는 ~/work/20.PoC에 만든 Android PCM 녹음 PoC와 Cloudflare WebSocket relay를 실기기에서 검증하는 방법을 정리한다.
목표는 우리 디바이스에서 system/priv-app으로 동작하는 백그라운드 마이크 녹음 서비스가 16 kHz mono PCM16 chunk를 WebSocket으로 송신하고, 웹페이지에서 수신 상태를 확인하며, 웹페이지에서 보낸 TTS WAV 파일을 기기가 수신해 재생하는 것이다.
현재 앱은 서비스앱 기준으로 정리되어 있다. Launcher Activity는 Manifest에서 제거했고, 녹음 시작 트리거는 BOOT_COMPLETED 또는 명시적 foreground service start다.
1. 현재 구현 위치
| 영역 | 경로 | 설명 |
|---|---|---|
| Android PoC app | /home/silogood/work/20.PoC/app |
foreground service 기반 PCM 녹음/송신 + WAV 수신/재생 앱 |
| Cloudflare relay | /home/silogood/work/20.PoC/cloudflare-pcm-relay |
Worker + Durable Object 양방향 WebSocket relay |
| PoC README | /home/silogood/work/20.PoC/README.md |
로컬 명령/업링크/다운링크 포맷 요약 |
주요 Android 파일:
| 파일 | 역할 |
|---|---|
BootCompletedReceiver.java |
부팅 완료 후 기본 WebSocket URL로 녹음 서비스 자동 시작 |
PcmRecorderService.java |
foreground service, AudioRecord, PCM chunk 송신, WAV downlink 저장/재생 |
PcmWebSocketStreamer.java |
OkHttp WebSocket client, binary/text downlink callback |
PcmRecorderConfig.java |
기본 Worker WebSocket URL과 URL 유효성 판단 |
PcmFrameHeader.java |
PCM1 binary frame header 생성 |
PcmStreamConfig.java |
16 kHz / mono / PCM16 / chunk size 설정 |
PcmChunkRingBuffer.java |
녹음 thread와 WebSocket sender thread 분리용 bounded queue |
WavPayload.java |
RIFF/WAVE header 판별 |
AndroidManifest.xml |
RECORD_AUDIO, INTERNET, RECEIVE_BOOT_COMPLETED, FOREGROUND_SERVICE_MICROPHONE 선언 |
주요 Cloudflare 파일:
| 파일 | 역할 |
|---|---|
src/worker.js |
/, /upload, /listen, Durable Object session relay |
public/index.html |
웹 모니터 페이지, waveform/frame/bytes 표시, WAV 파일 전송 UI |
wrangler.jsonc |
Worker, Durable Object binding, migration 설정 |
2. Cloudflare 포트 제약과 선택 구조
Cloudflare Pages/Workers는 일반 TCP 서버처럼 임의 포트를 열어 raw socket을 받는 구조가 아니다. Worker가 받을 수 있는 기본 경로는 HTTPS/WebSocket이고, 실제 외부 포트는 일반적으로 443이다.
따라서 이 PoC는 아래 방식으로 설계한다.
| 요구 | 구현 방식 |
|---|---|
| 앱에서 PCM을 실시간 전송 | wss://{worker-host}/upload?session=demo WebSocket |
| 웹페이지에서 들어오는 PCM 확인 | https://{worker-host}/ 접속 후 /listen?session=demo WebSocket |
| 웹페이지에서 기기로 WAV 전송 | 같은 /listen?session=demo WebSocket에서 raw RIFF/WAVE binary 송신 |
| session별 송신자/수신자 매칭 | Cloudflare Durable Object |
| waveform/재생 확인 | 브라우저 JavaScript에서 PCM16 frame 파싱 |
| 웹 실시간 재생 | 80ms jitter buffer 기반 WebAudio scheduling |
| 기기 TTS WAV 수신 확인 | Android가 WAV를 cache file로 저장 후 MediaPlayer 재생 |
임의 TCP port 수신이 반드시 필요하면 Cloudflare Spectrum 또는 별도 TCP 서버가 필요하다. 현재 요구인 “웹페이지에서 받아보고 앱에서 쏘는 PoC”에는 Worker WebSocket relay가 더 단순하다.
3. Android -> Web PCM 업링크 포맷
Android 앱은 AudioRecord를 아래 설정으로 연다.
| 항목 | 값 |
|---|---|
| Audio source | MediaRecorder.AudioSource.VOICE_RECOGNITION |
| Sample rate | 16000 |
| Channel | mono |
| Encoding | PCM_16BIT |
| Chunk frames | 320 |
| Chunk bytes | 640 |
WebSocket binary frame은 아래 구조다.
0..3 magic: "PCM1"
4..7 sequence: int32 little-endian
8..11 payloadBytes: int32 little-endian
12..15 flags/reserved: int32 little-endian
16.. PCM16 little-endian payload
웹페이지는 이 frame을 파싱해서 sequence, frame count, byte count, waveform을 표시한다. Enable audio playback을 누르면 브라우저에서 PCM을 실시간 재생한다.
실시간성을 위해 Android 송신은 녹음과 네트워크 송신을 분리한다.
AudioRecord.read()
-> PcmChunkRingBuffer.offer()
-> senderLoop()
-> PcmWebSocketStreamer.sendPcm()
-> Worker /upload
-> Browser /listen
-> WebAudio jitter playback
PcmChunkRingBuffer는 bounded queue다. 네트워크가 순간적으로 밀리면 오래된 chunk를 drop해서 “늦게라도 모두 재생”보다 “현재 음성에 가까운 실시간성”을 우선한다.
4. Web -> Android WAV 다운링크 포맷
웹페이지는 선택한 WAV 파일을 별도 wrapper 없이 raw RIFF/WAVE binary 그대로 전송한다.
Browser monitor
-> /listen?session=demo WebSocket
-> Durable Object
-> current Android /upload sender
-> Android PcmWebSocketStreamer.onMessage(ByteString)
Android는 수신 binary의 header를 아래 조건으로 확인한다.
0..3 "RIFF"
8..11 "WAVE"
조건을 만족하면 다음 순서로 처리한다.
WavPayload.isRiffWave(bytes)
-> getCacheDir()/tts_downlink.wav 저장
-> MediaPlayer.setDataSource(file)
-> prepare()
-> start()
현재 버전은 PoC 단계라 WAV 전체 파일을 한 번에 전송한다. 실제 TTS 스트리밍처럼 작은 조각 단위 재생이 필요하면 WAV1 chunk protocol 또는 raw PCM downlink protocol을 별도로 정의해야 한다.
5. Build
현재 시스템에는 별도 gradle 명령이 없으므로 기존 skmagic_ondeviceai_agent의 Gradle wrapper를 사용한다.
/home/silogood/work/2.A1_LLM_Aent/skmagic_ondeviceai_agent/gradlew -p /home/silogood/work/20.PoC testDebugUnitTest
/home/silogood/work/2.A1_LLM_Aent/skmagic_ondeviceai_agent/gradlew -p /home/silogood/work/20.PoC assembleDebug
APK 위치:
/home/silogood/work/20.PoC/app/build/outputs/apk/debug/app-debug.apk
현재 검증 결과:
| 검증 | 결과 |
|---|---|
testDebugUnitTest |
BUILD SUCCESSFUL |
assembleDebug |
BUILD SUCCESSFUL |
| merged Manifest | Launcher Activity 없음, BootCompletedReceiver + microphone foreground service만 선언 |
BOOT_COMPLETED simulation |
frames=512 bytes=335872 lastSeq=511 |
현재 Worker 배포 URL:
https://pcm-socket-recorder-relay.silogood.workers.dev/
6. Local relay 테스트
PC에서 Cloudflare Worker를 로컬로 띄운다.
cd /home/silogood/work/20.PoC/cloudflare-pcm-relay
npx wrangler dev --ip 0.0.0.0
브라우저에서 접속:
http://{PC_IP}:8787/
예:
http://192.168.10.20:8787/
웹페이지에서:
- session을
demo로 둔다. Connect monitor를 누른다.- Android 앱에서 같은 session으로 송신한다.
Frames,Bytes,Sequence, waveform이 증가하는지 확인한다.- 필요한 경우
Enable audio playback을 눌러 브라우저에서 PCM 재생을 확인한다. TTS WAV file에서 WAV 파일을 선택하고Send WAV to device를 누른다.- 웹페이지에
WAV sent to Android가 표시되는지 확인한다. - 기기에서 WAV가 재생되는지 확인한다.
앱 입력 URL:
ws://{PC_IP}:8787/upload?session=demo
에뮬레이터에서는:
ws://10.0.2.2:8787/upload?session=demo
실기기에서는 PC와 기기가 같은 네트워크에 있어야 하고, PC 방화벽이 8787 접근을 막지 않아야 한다.
WSL/Windows portproxy 또는 방화벽 때문에 LAN 접근이 막히면 Quick Tunnel로 우회한다.
cd /home/silogood/work/20.PoC/cloudflare-pcm-relay
npx wrangler dev --ip 127.0.0.1 --port 8788 --tunnel --show-interactive-dev-session=false
현재 안정 배포 URL:
https://pcm-socket-recorder-relay.silogood.workers.dev/
Android 앱 기본 WebSocket URL:
wss://pcm-socket-recorder-relay.silogood.workers.dev/upload?session=demo
다운링크 로그:
adb logcat | grep -E "Received WAV downlink|WAV playback started|WAV playback error"
주의:
- 녹음 중 기기 스피커로 WAV를 재생하면 재생음이 마이크에 다시 들어갈 수 있다.
- 순수 다운링크 검증은 녹음을 멈춘 상태에서 별도 수신 연결을 구성하거나, 이어폰/에코 제어 환경에서 수행하는 것이 좋다.
7. 실기기 system/priv-app 설치
우리 디바이스에서 서비스 환경으로 검증할 때는 일반 앱 설치보다 /system/priv-app push 설치를 기준으로 본다. 백그라운드 마이크 녹음을 안정적으로 유지하려면 system/priv-app 권한 경계에서 검증해야 하기 때문이다.
기본 설치 절차:
adb root
adb remount
adb shell mkdir -p /system/priv-app/PcmSocketRecorder
adb push /home/silogood/work/20.PoC/app/build/outputs/apk/debug/app-debug.apk /system/priv-app/PcmSocketRecorder/PcmSocketRecorder.apk
adb reboot
부팅 후 설치 확인:
adb shell pm list packages | grep pcm
adb shell dumpsys package com.skmagic.poc.pcmstreamer | grep -i granted -A 40
명시적 서비스 시작:
adb shell am start-foreground-service \
-n com.skmagic.poc.pcmstreamer/.PcmRecorderService \
-a com.skmagic.poc.pcmstreamer.START \
--es ws_url "wss://pcm-socket-recorder-relay.silogood.workers.dev/upload?session=demo"
명시적 서비스 종료:
adb shell am start-foreground-service \
-n com.skmagic.poc.pcmstreamer/.PcmRecorderService \
-a com.skmagic.poc.pcmstreamer.STOP
로그 확인:
adb logcat | grep -E "PcmRecorderService|PcmWebSocketStreamer|AudioRecord"
중요:
- 현재 Manifest에는 Launcher Activity가 없다. 서비스앱이므로 화면 진입으로 시작하지 않는다.
/system/priv-app에 push만 하면 즉시 설치되는 것이 아니라 보통 재부팅 후 PackageManager가 scan하면서 설치된다./system/priv-app에 넣는다고 항상 모든 privileged 권한이 자동 부여되는 것은 아니다.RECORD_AUDIO같은 dangerous permission은 system/priv-app이라고 항상 자동 승인되는 것이 아니다. 제품 이미지에서는 default permission grant 또는 권한 allowlist 정책을 확인해야 한다.- platform signing, privileged permission allowlist, SELinux 정책은 디바이스 이미지 정책에 따라 추가 조정이 필요할 수 있다.
- 현재 PoC는 debug APK다. 제품형 검증에서는 platform key signing 여부를 확인해야 한다.
- Android 13+에서 notification 권한은 일반 앱 설치 시 runtime 허용이 필요하지만, system app 정책에서는 별도 allowlist/권한 상태를 확인해야 한다.
8. 권한과 백그라운드 마이크 판단
현재 Manifest 권한:
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
서비스 선언:
<service
android:name=".PcmRecorderService"
android:exported="false"
android:foregroundServiceType="microphone" />
<receiver
android:name=".BootCompletedReceiver"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
판단:
- 일반 앱도 foreground service + microphone type이면 일정 수준 백그라운드 녹음이 가능하다.
- 하지만 서비스 디바이스에서 장시간 안정성을 보려면 system/priv-app 설치가 맞다.
- 제품 서비스화에는
BOOT_COMPLETED, reconnect, 설정 저장, 권한 allowlist, watchdog이 추가되어야 한다.
9. Cloudflare deploy
아래 명령으로 Worker를 배포한다.
cd /home/silogood/work/20.PoC/cloudflare-pcm-relay
npx wrangler deploy
성공하면 앱 URL은 아래 형태다.
wss://pcm-socket-recorder-relay.silogood.workers.dev/upload?session=demo
웹 모니터:
https://pcm-socket-recorder-relay.{workers-dev-subdomain}.workers.dev/
Custom domain을 붙이면 앱 URL은 custom domain 기준으로 바꾼다.
10. Pass / Fail 기준
| 항목 | Pass 기준 |
|---|---|
| APK build | assembleDebug 성공 |
| Worker dry-run | npx wrangler deploy --dry-run 성공 |
| priv-app 설치 | reboot 후 pm list packages에 package 표시 |
| 권한 | RECORD_AUDIO, foreground service 관련 권한 granted 또는 system policy상 허용 |
| service 실행 | logcat에 PCM recording started 출력 |
| WebSocket 연결 | logcat에 WebSocket opened 출력 |
| PCM 송신 | 웹페이지 Frames, Bytes, Sequence 증가 |
| waveform/실시간 재생 | 브라우저 waveform이 음성에 반응하고 Enable audio playback 후 WebAudio로 재생 |
| WAV 다운링크 | 웹페이지 WAV sent to Android 표시 |
| WAV 재생 | logcat에 Received WAV downlink, WAV playback started 출력 |
| 백그라운드 | 화면 off/background 후에도 일정 시간 frame 증가 유지 |
현재 실기기 검증 결과:
device: 192.168.123.108:5555
listener URL: wss://columns-compact-ooo-characterized.trycloudflare.com/listen
result: frames=342 bytes=224352 lastSeq=341
기기 로그:
PcmRecorderService: PCM recording started: 16kHz mono PCM16, chunkBytes=640
PcmWebSocketStreamer: WebSocket opened
PcmRecorderService: PCM chunks recorded: 350
PcmRecorderService: PCM chunks sent: 350
PcmWebSocketStreamer: WebSocket closed: 1000, client closing
Fail 시 먼저 볼 것:
| 증상 | 확인 |
|---|---|
| 앱에서 녹음 시작 안 됨 | RECORD_AUDIO 권한, foregroundServiceType="microphone", logcat AudioRecord init failed |
| WebSocket 연결 실패 | URL, PC/Cloudflare 네트워크, wrangler dev --ip 0.0.0.0, 방화벽 |
| 웹페이지 frame 증가 없음 | session 이름 불일치, /upload와 /listen 경로 확인 |
| priv-app 설치 후 앱 없음 | push 경로, APK 파일명, reboot, package parse error |
| 백그라운드에서 중단 | system/priv-app 권한, foreground notification, doze/power policy |
11. 후속 보강 필요 항목
현재 PoC는 “수동 실행 PoC”다. 서비스처럼 장시간 운용하려면 아래가 필요하다.
| 항목 | 이유 |
|---|---|
BOOT_COMPLETED receiver |
부팅 후 자동 서비스 준비 |
| 설정 저장 | WebSocket URL/session을 SharedPreferences 또는 device config로 유지 |
| reconnect | 네트워크 끊김/Worker 재배포 후 자동 복구 |
| start/stop broadcast 또는 bound service | UI 없이 외부 제어 가능 |
| platform signing | system/priv-app 권한 안정화 |
| privapp permission allowlist | privileged 권한 grant 명시 |
| foreground notification 정책 | 서비스 장시간 유지 |
| PCM file dump option | 송신 문제와 녹음 문제 분리 디버깅 |
| TLS/WSS 운영 | 실제 Cloudflare 배포 환경에서는 wss:// 사용 |
| session auth | 임의 사용자가 /upload로 보내는 것을 차단 |
11. 관련 명령 모음
Build:
/home/silogood/work/2.A1_LLM_Aent/skmagic_ondeviceai_agent/gradlew -p /home/silogood/work/20.PoC testDebugUnitTest
/home/silogood/work/2.A1_LLM_Aent/skmagic_ondeviceai_agent/gradlew -p /home/silogood/work/20.PoC assembleDebug
Local relay:
cd /home/silogood/work/20.PoC/cloudflare-pcm-relay
npx wrangler dev --ip 0.0.0.0
Priv-app push:
adb root
adb remount
adb shell mkdir -p /system/priv-app/PcmSocketRecorder
adb push /home/silogood/work/20.PoC/app/build/outputs/apk/debug/app-debug.apk /system/priv-app/PcmSocketRecorder/PcmSocketRecorder.apk
adb reboot
Log:
adb logcat | grep -E "PcmRecorderService|PcmWebSocketStreamer|AudioRecord"
Worker dry-run:
cd /home/silogood/work/20.PoC/cloudflare-pcm-relay
npx wrangler deploy --dry-run