💡 핵심 요약 (Executive Summary)
Lo-Fi 작업실의 영혼은 소리(Sound)입니다. 하지만 처음 두 번의 시도에서 수학적 알고리즘(PCM Synthesis)으로 만든 백색소음은 인위적인 기계음과 24MB에 달하는 비대한 번들 크기, 그리고 루프 지점마다 0.2초씩 뚝 끊기는 치명적인 한계를 드러냈습니다. 본 3편에서는 합성음을 과감히 전량 폐기하고 Google Actions Sound Library의 실제 현장 녹음 OGG 음원(총 3.1MB, 87% 경량화)을 도입한 과정과, just_audio의 ConcatenatingAudioSource를 활용해 24시간 무중단(Gapless) 5채널 병렬 믹서를 구축한 오디오 엔지니어링 노하우를 공유합니다.
Google 공식 현장 녹음 음원과 5채널 백색소음 믹서
▲ 2번의 PCM 합성음 폐기 시행착오와 Google 공식 현장 녹음 OGG + 24시간 무중단 5채널 오디오 믹서 아키텍처

1. 첫 번째 & 두 번째 실패: "수학으로 만든 빗소리는 왜 TV 치직거리는 소리가 날까?"

처음 백색소음 기능을 기획할 때는 "외부 음원 파일을 넣으면 저작권 확인도 번거롭고 앱 용량도 커지니, Python 스크립트로 핑크 노이즈(Pink Noise)와 브라운 노이즈(Brown Noise) 수식을 조합해 직접 WAV 파일을 생성하자!"라고 생각했습니다.

1차 시도 (16kHz 합성 WAV) — 처참한 기계음

난수 발생기(Random Noise)에 로우패스 필터(Low-pass Filter)를 씌워 빗소리와 모닥불 소리를 합성한 뒤 스마트폰에서 재생해 보았습니다. 결과는 참담했습니다. 따뜻한 장작 타는 소리가 아니라 마치 고장 난 라디오 주파수가 안 맞을 때 나는 '치직— 치직—' 하는 차가운 전자 노이즈가 귀를 찔렀습니다. 집중을 돕기는커녕 10초만 들어도 두통이 올 지경이었습니다.

2차 시도 (44.1kHz 고해상도 합성 WAV) — 24MB 용량 폭발과 루프 끊김(Gap)

샘플링 레이트가 낮아서 그런가 싶어 44.1kHz 16-bit PCM으로 다중 레이어 포락선(Envelope)을 입혀 트랙당 20~30초 분량의 WAV 파일을 다시 만들었습니다. 하지만 이번에는 두 가지 새로운 재앙이 터졌습니다.

  1. 번들 용량 폭발: 고작 5개의 비압축 WAV 파일이 24MB를 차지해 배보다 배꼽이 더 커졌습니다.
  2. 0.2초의 정적(Loop Gap): 모바일 오디오 디코더가 30초짜리 파일 끝에 도달한 뒤 00:00으로 되감기(Seek)할 때마다 약 0.1~0.2초간 소리가 '뚝' 끊겼다가 다시 시작되었습니다. 조용한 새벽에 30초마다 빗소리가 끊기니 오히려 시선이 시계로 쏠려 집중이 완전히 깨졌습니다.

2. 세 번째 결단: 합성음 전량 폐기 & Google 공식 현장 녹음 음원 도입

"인간의 귀는 생각보다 훨씬 정교하다. 수식으로 흉내 낸 가짜 소리는 과감히 버리고, 실제 마이크로 녹음한 진짜 자연의 소리를 넣자!"

상업적 무료 라이선스가 보장되면서도 스튜디오급 녹음 품질을 갖춘 음원을 리서치하던 중, 구글이 공식 제공하는 Google Actions Sound Library를 발견했습니다. 실제 빗방울이 처마에 떨어지는 소리, 실제 캠프파이어 장작이 타들어 가는 소리, 실제 카페의 공간 소음과 만년필로 종이에 글씨를 쓰는 마찰음까지 완벽하게 구비되어 있었습니다.

채널 ID 믹서 트랙명 Google 공식 현장 녹음 출처 (OGG Vorbis) 파일 크기
rain 🌧️ 창밖 빗소리 weather/rain_heavy_loud.ogg (실제 폭우 녹음) 914 KB
fire 🔥 모닥불 장작 ambience/fire.ogg (실제 캠프파이어 장작 녹음) 522 KB
cafe ☕ 오후의 카페 ambience/coffee_shop.ogg (실제 카페 실내 공간음) 548 KB
pencil ✏️ 연필 사각거림 office/writing_on_paper.ogg (실제 종이 필기 마찰음) 704 KB
forest 🌲 새벽 숲 바람 weather/wind_through_trees.ogg (실제 숲 나뭇잎 바람) 377 KB

고효율 OGG Vorbis 포맷을 채택한 덕분에 5개 스튜디오 원음을 모두 합쳐도 단 3.1MB(기존 24MB 대비 87% 감소)에 불과했고, 이어폰을 꽂고 재생하는 순간 진짜 비 오는 날 창가에 앉아 있는 듯한 깊은 공간감이 살아났습니다.

3. 5채널 병렬 오디오 믹서와 '24시간 가상 스트림' Gapless 루프 엔진

사용자마다 집중이 잘 되는 소리의 조합은 다릅니다. 누군가는 "빗소리 70% + 모닥불 40%"를 좋아하고, 누군가는 "카페 소음 30% + 연필 소리 60%"를 선호합니다. 이를 위해 AmbientAudioService(lib/service/ambient_audio_service.dart)는 단일 오디오 플레이어가 아닌 5개의 독립된 AudioPlayer 인스턴스를 병렬로 구동하는 멀티채널 믹서(Multi-Channel Mixer)로 설계되었습니다.

flowchart TD UI["🎚️ 사운드 믹서 UI (5채널 개별 ON/OFF & 볼륨 슬라이더)"] Preset["✨ 원클릭 추천 프리셋\n(비 오는 날의 서재 / 스터디 카페 / 숲속 오두막)"] Service["AmbientAudioService\n(audio_session: mixWithOthers + duckOthers)"] subgraph Players["5 Independent AudioPlayer Instances (Parallel Mixing)"] P1["Player 1: rain.ogg\n(ConcatenatingAudioSource x24h)"] P2["Player 2: fire.ogg\n(ConcatenatingAudioSource x24h)"] P3["Player 3: cafe.ogg\n(ConcatenatingAudioSource x24h)"] P4["Player 4: pencil.ogg\n(ConcatenatingAudioSource x24h)"] P5["Player 5: forest.ogg\n(ConcatenatingAudioSource x24h)"] end UI --> Service Preset --> Service Service --> P1 & P2 & P3 & P4 & P5

어떻게 루프 끊김(Seek Latency)을 0.0초로 만들었는가?

모바일 OS에서 단일 오디오 파일에 LoopMode.one을 걸면, 파일 끝에 도달할 때 디코더 버퍼가 플러시(Flush)되면서 미세한 정적이 발생합니다. 이 문제를 해결하기 위해 just_audio의 ConcatenatingAudioSource(무중단 이어붙이기 플레이리스트 버퍼)를 활용하여, 각 트랙의 길이에 맞춰 최소 24시간 분량(예: 48초 트랙은 1,800회 연속 큐)의 가상 스트림을 메모리에 사전 바인딩했습니다.

// lib/service/ambient_audio_service.dart — 5채널 병렬 믹서 & 24시간 Gapless 루프 구현
class AmbientAudioService extends ChangeNotifier {
  final Map<String, AudioPlayer> _players = {};

  Future<void> _configureAudioSession() async {
    final session = await AudioSession.instance;
    await session.configure(
      AudioSessionConfiguration(
        avAudioSessionCategory: AVAudioSessionCategory.playback,
        // 유튜브·애플뮤직 등 외부 음악 앱과 백색소음을 동시에 믹싱 재생 허용!
        avAudioSessionCategoryOptions:
            AVAudioSessionCategoryOptions.mixWithOthers,
        androidAudioAttributes: const AndroidAudioAttributes(
          contentType: AndroidAudioContentType.music,
          usage: AndroidAudioUsage.media,
        ),
        androidAudioFocusGainType:
            AndroidAudioFocusGainType.gainTransientMayDuck,
      ),
    );
  }

  Future<AudioPlayer> _getOrCreatePlayer(AmbientTrack track) async {
    if (_players.containsKey(track.id)) {
      return _players[track.id]!;
    }
    final player = AudioPlayer();

    // 단일 파일 되감기(Seek) 시 발생하는 0.2초의 끊김을 원천 제거하기 위해
    // ConcatenatingAudioSource로 24시간 연속 재생 큐(Gapless Prefetch) 구성
    final children = List<AudioSource>.generate(
      track.repeatCountFor24Hours,
      (_) => AudioSource.asset(track.assetPath),
    );
    final playlist = ConcatenatingAudioSource(
      useLazyPreparation: true,
      children: children,
    );

    await player.setAudioSource(playlist, preload: true);
    await player.setLoopMode(LoopMode.all);
    await player.setVolume(track.volume);
    _players[track.id] = player;
    return player;
  }
}

또한 AVAudioSessionCategoryOptions.mixWithOthers 옵션을 활성화하여, 사용자가 멜론이나 유튜브 뮤직으로 잔잔한 피아노 연주곡을 틀어놓은 상태에서도 우리 앱의 빗소리와 모닥불 소리를 배경에 함께 깔아(Overlay Mixing) 들을 수 있도록 배려했습니다.

4. 다음 이야기 예고: iOS 26 실기기 크래시(`brk #0xf00d`)와의 사투

이렇게 완성된 v2.0 앱을 안드로이드 폰과 갤럭시 워치에 먼저 올렸을 때는 모든 것이 순조로웠습니다. 하지만 최신 iOS 26.3.1이 설치된 아이폰과 watchOS 26.2가 올라간 Apple Watch Series 10 실기기를 맥북에 연결해 실행 버튼을 누른 순간, 앱은 흰 화면만 띄운 채 0.1초 만에 강제 종료(`EXC_BAD_ACCESS / SIGKILL`)되었습니다.

이어지는 [Part 4: iOS 26 JIT 메모리 보호 크래시 돌파와 Apple Watch Series 10 원형 UI & 아이콘 제작기]에서는 iOS 26 커널의 TXM(Trusted Execution Monitor) 보안 정책이 일으킨 JIT 크래시의 원인을 어셈블리 명령어(`brk #0xf00d`) 레벨에서 규명하고 돌파한 과정과, 애플워치 원형 UI 제작기를 다룹니다.