코딩 어드벤처: 사운드 (그리고 푸리에 변환)
요약
본 글은 WAV 파일 형식의 구조를 파싱하고 오디오 데이터를 추출하는 과정을 설명합니다. RIFF와 WAVE 헤더를 읽고, 'format' 및 'data' 섹션에서 메타데이터(샘플 속도, 비트 수 등)를 가져옵니다. 이후 바이트 배열을 실수형 float으로 변환하여 정규화한 후, 시간과 전압에 따른 오디오 신호 데이터를 추출하는 방법을 다룹니다.
핵심 포인트
- WAV 파일의 RIFF/WAVE 헤더 구조 이해가 필수적입니다.
- 파일 섹션별로 메타데이터를 읽고 데이터 청크 위치를 파악해야 합니다.
- 바이트 배열을 float으로 변환하고 -1~1 사이로 정규화하는 과정이 중요합니다.
- 오디오 신호는 시간(x축)과 전압(y축)에 따른 포인트로 시각화할 수 있습니다.
여러분 안녕하세요, 또 다른 코딩 어드벤처 에피소드에 오신 것을 환영합니다. 오늘은 소리를 가지고 좀 놀아보고 싶습니다. 지금 제가 하고 있는 것은 '말하기'라고 알려진 다소 기이한 것입니다. 아마 들어보셨을 겁니다. 기본적으로는 진동하는 성대, 입 모양, 혀의 위치 조합을 통해 공기를 통해 다양한 특정 주파수로 이동하는 압력파를 만들 수 있습니다. 이 중 일부가 여기에 있는 마이크에 도달하여 내부의 작은 막을 함께 진동시키고, 이것이 차례로 자석 위를 코일 전선을 움직여 비례적인 세기의 전압을 유도합니다.
그래서 우리는 와이어를 따라 컴퓨터로 이동하는 작은 신호를 얻게 되고, 이곳에서 초당 수천 번 측정되며, 그 값들은 궁극적으로 여기에 있는 dot wav 파일 내부에 저장됩니다. 오늘 여정을 시작하기 위해 단순히 이 값들을 추출하여 살펴보겠습니다. 그래서 좀 찾아본 끝에 wav 파일 형식을 깔끔하게 설명해 놓은 페이지를 발견했습니다. 그리고 다행히도 꽤 간단해 보입니다. 먼저, 우리는 'RIFF'와 'WAVE'라는 문자를 나타내야 하는 첫 번째 바이트 그룹을 읽어 들입니다. 만약 그렇지 않다면 무언가 심각하게 잘못되었다는 것을 알 수 있습니다.
그 후 코드는 파일의 나머지 부분을 반복하며 각 섹션의 이름을 읽어 들여 그 위치를 작은 조회 테이블에 추가합니다. 이름 뒤에는 항상 해당 섹션의 크기가 따라오기 때문에 다음 섹션으로 쉽게 건너뛸 수 있습니다. 기본적으로 저는 이 안에 실제로 무엇이 들어있는지에 대한 개요만 얻고 싶습니다. 그러니 빠르게 실행해 보겠습니다. 그리고 이게 결과입니다! 제가 주로 관심 있는 것은 ‘format’과 ‘data’ 섹션이라서, 조회 테이블을 사용하여 format 청크로 바로 이동하는 또 다른 작은 함수를 작업해 왔는데, 여기서 우리는 이 파일이 모노(mono)인지 스테레오(stereo)인지, 초당 녹음된 샘플 수, 그리고 각 샘플의 비트 수를 같은 등과 같은 유용한 메타데이터를 읽어 들일 수 있습니다.
다음으로 data 섹션으로 이동하여 데이터를 읽습니다. 하지만 데이터는 여러 가지 다른 형식으로 존재할 수 있기 때문에 마지막 단계는 이를 깔끔한 실수(float) 배열로 변환하는 것입니다. 이를 위해 큰 루프에 진입하여 각 샘플에 포함되어 있다고 알려진 바이트를 계속 가져와 이 유용한 BytesToFloat 함수를 사용하여 float로 변환할 수 있습니다. 글쎄요, 아직 실제로 작성하지는 않았지만, 갖게 되면 유용할 함수가 될 겁니다.
어쨌든, 그 값은 여기에서 정규화(normalization) 계수로 축소됩니다. 이는 해당 바이트 수가 가질 수 있는 최대 값이므로, 값을 -1부터 양수 1 사이의 좋은 범위로 가져옵니다. 이 모든 것이 완료되면, 마침내 샘플과 샘플 레이트를 이 작은 Signal 객체에 패키징하여 여기 출력으로 반환할 수 있습니다. 좋습니다. 저는 여전히 그 BytesToFloat 함수를 빠르게 작성해야 합니다. 그리고 여기 있습니다.
저는 여기서 제가 가장 좋아하는 파일 파싱 트릭 중 하나를 사용할 수 있었습니다. 이 트릭은 최소한 사용 가능한 형식의 절반을 지원하는 데 신경 쓰지 않는 것입니다. 하지만 제가 신경 쓴 부분 중에서 유일하게 약간 까다로운 경우는 이 24비트 형식이었는데, 내장된 타입이 없기 때문에 음수 값을 제대로 처리하려면 비트 조작(bit-manipulation)이 필요했습니다. 어쨌든, HelloEveryone.wav를 로드하여 테스트해 보고, 각 샘플을 나타내는 점들을 그려보겠습니다.
따라서 x축은 시간을 나타내고, y축은 마이크에서 읽어온 전압에 비례합니다. 좋습니다, 빠르게 실행해 보겠습니다. 오디오 포인트들이 나타나는 것을 볼 수 있습니다. 하지만 제가 여기에 출력한 정보도 볼 수 있도록 조금 축소하겠습니다. 이 오디오 파일에는 40,000개 미만의 샘플이 있으며, 샘플은 초당 48,000Hz의 속도로 기록되었습니다. 물론 이를 통해 클립의 지속 시간을 샘플 수를 샘플링 속도로 나누어 계산할 수 있고, 이 경우 약 0.8초가 나옵니다.
조금 확대해 보면, 이 모든 개별 점들을 보는 것이 매우 멋지다고 생각합니다. 그리고 비록 많은 점들이 있지만, 이것이 목소리처럼 복잡한 것을 포착하는 데 필요한 전부라는 사실은 정말 놀랍습니다. 또는 다른 어떤 소리라도 마찬가지입니다. 하지만 이러한 '파형(waveforms)'은 보통 점보다는 선으로 시각화되므로, 그것도 한번 해보겠습니다. 각 샘플을 수직선으로 그리도록 코드를 약간 수정하여, 그 길이로 당연히 샘플의 진폭(amplitude)을 나타내게 했습니다.
그리고 이게 어떤 모습인지 보여드리겠습니다. 저는 단순히 소리만 보고 싶은 게 아니라, 실제로 듣고 싶습니다. 그래서 제가 작업하고 있는 Unity 엔진에서는 AudioRead라는 함수를 노출하는데, 이 함수는 몇 밀리초마다 호출되며 비어있는 데이터 블록을 전달받아 우리가 채워서 사운드 카드로 전송할 수 있게 해줍니다. 따라서 파형 파일에서 아직 샘플이 남아있더라도, 요청된 블록의 크기만큼 반복하면서 데이터를 공급하면 됩니다. 그러면 사운드 카드는 당연히 연결된 스피커나 헤드폰 또는 기타 장치에 그 데이터에 맞춰 진동하도록 지시하고, 이를 통해 우리가 즐길 수 있는 소리 파형을 만들어냅니다.
자, 빠르게 재생해 보겠습니다. 그리고 작동합니다. 실제로 이 과정에서 발생하는 파동을 공기 중에서 볼 수 있다면 정말 멋질 것 같습니다. 하지만 그것이 어렵다면, 예전에 만들었던 유체 시뮬레이션을 사용해서 적어도 파동의 거친 시뮬레이션을 만들어 보는 것이 재미있을 것 같다고 생각했습니다. 3차원에서 이것을 구현하는 것은 분명 흥미로울 수 있지만, 오늘은 단순성을 위해 시뮬레이션의 2D 버전을 사용하는 것이 좋겠습니다.
물론 이 유체가 물보다는 공기처럼 행동하도록 해야 하므로, 중력은 꺼야 할 것 같습니다. 그리고 여기 목표 밀도(target density)도 낮추겠습니다. 현재는 개별 입자를 시각화하고 있지만, 이번에는 압력과 밀도가 밀접하게 관련되어 있기 때문에 유체의 밀도를 보는 것이 더 좋을 것 같습니다. 이를 위해 주어진 텍스처의 각 텍셀(texel)마다 실행되는 새로운 커널을 컴퓨트 셰이더에 빠르게 추가하여, 해당 텍셀의 공간 위치에 대한 밀도 계산을 기반으로 색상을 설정하도록 했습니다.
다시 한번 상기시켜 드리자면, 또는 유체 에피소드를 아직 보지 않으셨다면, 밀도(density)는 본질적으로 해당 위치 주변의 작은 반경 내에 있는 입자의 개수를 부드럽게 합산한 값입니다. 좋습니다. 여기에서 멋진 압력파(pressure waves)를 만들 수 있을 것 같습니다. 잠깐만요—방금 나타난 저 이상한 작은 거품은 무엇인가요? 오 이런, 이제 온통 그런 것들로 가득하네요! 설정을 좀 더 조정해야 할 것 같아요. 이건 일어나서는 안 되는 일입니다.
좋습니다—이제는 조금 더 안정된 것 같습니다. 주된 문제는 제가 아직 물 설정에서 점성(viscosity)을 켜 놓았고, 입자 개수도 약 15k개에서 75k개로 늘렸기 때문에 상황이 좀 엉망이 되었다는 것입니다. 시뮬레이션은 상당히 까다롭습니다. 하지만 어쨌든, 이제 우리의 다음 단계는 여기 유체 속에 작은 직사각형 장애물(rectangular obstacle)을 만드는 것이라고 생각합니다. 이는 시뮬레이션이 이미 지원하는 기능이며, 기본적으로 오디오 샘플과 함께 앞뒤로 진동할 스피커 막 역할을 할 것입니다.
그래서 이것을 제어하기 위해 여기에 함수를 작성했습니다. 이 함수는 입력된 오디오 신호(audio signal)를 받아 현재 시간의 샘플 값(sample value)을 찾고, 이를 사용하여 스피커 막의 위치를 오프셋합니다. 참고로, 샘플 값을 찾아오는 것은 이 헬퍼 함수(helper function)를 통해 수행되는데, 이는 이산적인 샘플 값들을 선형적으로 보간(linearly interpolates)하여 스피커가 순간적으로 점프하는 대신 다소 부드럽게 움직이도록 합니다.
그런 다음, 이 파동들이 만들어내는 소리를 기록하기 위해, 저는 아주 작은 마이크(microphone) 방법을 사용했습니다. 이 방법은 주어진 마이크 위치에서의 유체 밀도(density)를 찾아서, 거기서 유체의 나머지 밀도를 빼냅니다. 그 값이 임의의 계수(arbitrary coefficient)와 곱해져 압력 값(pressure value)을 얻게 되고, 이 값은 기록된 샘플 목록에 저장되어 소리를 재생할 수 있게 됩니다. 좋습니다. 입력 오디오로는 이 4초 클립을 사용하겠습니다…
그리고 저는 여기에 시뮬레이션을 설정하고 아래쪽에 작은 플롯을 추가하여 기록된 압력 값을 보여주도록 했습니다. 이제 남은 것은 이것을 실행해서 어떻게 되는지 보는 것뿐입니다! 결과는 상당히 좋지 않습니다. 녹음한 것을 재생해 보겠습니다…. 하지만 들으실 수 있듯이, 우리가 포착할 수 있었던 것은 침묵의 소리밖에 없습니다. 제 생각에 주된 문제는 시뮬레이션이 진행된 4초 동안 약 1200개의 시뮬레이션 단계(simulation steps)만 실행할 수 있었다는 것입니다. 즉, 1200개의 압력 값만을 포착했을 뿐인데, 원래 오디오에는 192,000개가 포함되어 있었습니다.
그래서 우리의 유일한 선택지는 이것을 극도로 슬로우 모션으로 처리하는 것 같습니다. 좋습니다. 이제 스피커의 시간 단계(timestep)를 늦춰서 입력 오디오의 매 1초가 시뮬레이션에서 100초가 걸리도록 했습니다. 하지만 여전히 상당히 빠르게 진동하고 있어서, 어쩌면 이것도 충분히 느리지 않을 수 있습니다! 하지만 이번에는 압력 플롯이 훨씬 더 유망해 보입니다. 그래서 어떤 소리가 날지 기대됩니다. 듣기에는 조금 고통스러웠지만, 헤드폰을 착용하면 제가 말하는 단어들이 실제로 겨우 들립니다.
정말 멋지다고 생각합니다! 스피커가 유체가 제대로 반응하기에는 여전히 너무 빠르게 움직이는 것 같았기 때문에, 저는 속도를 더 늦춰보려고 합니다. 지금은 입력 오디오의 매 초가 시뮬레이션에서 15분에 해당하도록 하는 터무니없이 느린 업데이트 속도를 테스트하고 있습니다. 좋습니다. 이 4초 클립이 끝날 때까지 참을 수 없이 기다린 한 시간 후에, 마침내 완성되었으니 우리가 녹음한 것을 들어보겠습니다.
오, 나쁘지 않네요! 물론 약간 지직거리는 느낌이지만, 솔직히 기대했던 것보다 더 좋습니다. 자, 갑자기 생각이 났습니다! 아주 현명한 생각은 아니지만 어쨌든 — 여러분 중 일부는 'Bad Apple, but it’s a fluid simulation'에 대해 알고 있을 수도 있습니다. 음, 이 노래도 기술적으로 유체 시뮬레이션이 될 수 있습니다. 고려해 보실 만한 짧은 클립을 여기 준비했습니다. 다음으로 넘어가겠습니다... 현재는 wav 파일을 열고 재생할 수는 있지만, 실제로 저장할 수는 없습니다.
그래서 제가 이걸 할 작은 함수를 빠르게 작업하고 있었습니다. 말할 흥미로운 점은 많지 않지만, 빨리 테스트해 보고 싶습니다. 그래서 welcome.wav를 읽어 들인 다음, 샘플 순서를 역순으로 바꾸거나 약간 꾸며서 저장하는 것을 시도할 수 있습니다. 좋습니다. 이제 우리는... 아, 됐습니다. 결국 코드에서 결정적인 실수를 찾아낸 후, 이제 우리가 저장한 웨이브 파일을 재생할 수 있어야 합니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 YouTube Sebastian Lague (절차적 생성)의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기