Microsoft MVP성태의 닷넷 이야기
닷넷: 2240. C# - WAV 파일 포맷 + LIST 헤더 [링크 복사], [링크+제목 복사],
조회: 1353
글쓴 사람
정성태 (techsharer at outlook.com)
홈페이지
첨부 파일

(시리즈 글이 9개 있습니다.)
.NET Framework: 618. C# - NAudio를 이용한 MP3 파일 재생
; https://www.sysnet.pe.kr/2/0/11092

닷넷: 2236. C# - Audio 장치 열람 (Windows Multimedia, NAudio)
; https://www.sysnet.pe.kr/2/0/13594

닷넷: 2237. C# - Audio 장치 열기 (Windows Multimedia, NAudio)
; https://www.sysnet.pe.kr/2/0/13595

닷넷: 2238. C# - WAV 기본 파일 포맷
; https://www.sysnet.pe.kr/2/0/13596

닷넷: 2239. C# - WAV 파일의 PCM 데이터 생성 및 출력
; https://www.sysnet.pe.kr/2/0/13597

닷넷: 2240. C# - WAV 파일 포맷 + LIST 헤더
; https://www.sysnet.pe.kr/2/0/13598

닷넷: 2241. C# - WAV 파일의 PCM 사운드 재생(Windows Multimedia)
; https://www.sysnet.pe.kr/2/0/13599

닷넷: 2243. C# - PCM 사운드 재생(NAudio)
; https://www.sysnet.pe.kr/2/0/13601

닷넷: 2244. C# - PCM 오디오 데이터를 연속(Streaming) 재생 (Windows Multimedia)
; https://www.sysnet.pe.kr/2/0/13602




C# - WAV 파일 포맷 + LIST 헤더

ffmpeg를 이용하면 미디어 파일의 포맷을 다양하게 바꿀 수 있습니다. 일례로 오디오 파일의 경우, 다채널 오디오에서 특정 채널의 음을 제거하는 것도 가능한데요,

Manipulating audio channels with ffmpeg
; https://img.sauf.ca/pictures/2017-11-08/dba6fb7be0442f4a76742978fa21766a.pdf

Manipulating audio channels
; https://trac.ffmpeg.org/wiki/AudioChannelManipulation

가령 이전에 소개한 글에서 Octave를 이용해 생성한 Stereo wav 파일을 다음과 같은 명령으로 한쪽 채널의 음을 소거할 수 있습니다.

// Left 채널 음 소거
ffmpeg -i test_stereo.wav -map_channel -1 -map_channel 0.0.1 left_mute.wav

그런데 Right 채널 음 소거가 어렵군요. ^^; 단순히 map_channel 인자로는 잘 안되고 pan 인자로 했더니 만들어집니다. (혹시 이유를 아시는 분은 덧글 부탁드립니다. ^^)

// 이렇게 하면 안 되고,
ffmpeg -i test_stereo.wav -map_channel -1 -map_channel 0.0.0 right_mute.wav

// 이렇게 해야 Stereo로 Right 채널 음 소거가 된 출력 파일이 만들어집니다.
ffmpeg -i test_stereo.wav -af pan="stereo|c0=c0" right_mute.wav

암튼 이렇게 ffmpeg로 출력한 wav 파일을 지난번에 만든 프로그램으로 출력해 보면 데이터가 비정상적으로 나옵니다.

ChunkId: RIFF, FileSize: 1411302, TypeHeader: WAVE, FormatMarker: fmt , SubChunkSize: 16, AudioFormat: 1, 
Channels: 2, SampleRate: 44100, ByteRate: 176400, BlockAlign: 4, BitsPerSample: 16, DataChunkHeader: LIST, DataSize: 26

생뚱맞게 LIST chunk가 나오는 바람에 이하 DataSize가 틀린 값이 나오는데요, 이에 대해 검색해 보면 다음과 같은 글이 나옵니다.

List chunk (of a RIFF file)
; https://www.recordingblogs.com/wiki/list-chunk-of-a-wave-file

Wave file format
; https://www.recordingblogs.com/wiki/wave-file-format

그러니까, 그 간단한 wav 포맷이 fmt, data 이외에도 Silent, Wave, list, Fact, Cue, Playlist, List, Sample, Instrument와 같은 서브 헤더들의 확장이 있었던 것입니다. ^^;

자, 그러면 정리해 볼까요? 우선 RIFF 헤더는 "Resource Interchange File Format"라는 말에서 의미하는 것처럼 Wave 파일을 위한 헤더는 아닙니다. 즉, Wave 이외에 다른 리소스도 포함할 수 있는 것인데, 이번 글에서는 RIFF 하위에 Wave만 있는 파일을 가정하고 설명하는 것입니다.

그리고 Wave 하위에는 일반적으로 fmt, data 헤더가 있지만 기타 다른 헤더들도 나올 수 있습니다. 따라서 다음과 같이 구조가 이뤄집니다.

// WAVE 하위의 포맷은 순서 없음

RIFF - WAVE - fmt
            + data
            + Silent
            + Wave list
            + Fact
            + Cue
            + Playlist
            + List
            + Sample
            - Instrument

이번 글에서는 저 모든 포맷을 해석할 수는 없고 이전에 해석한 fmt, data와 함께 List 헤더 해석을 추가하고 기타 헤더들은 포함됐어도 해석하지 않고 넘어가도록 만들겠습니다.




위의 설명을 이해했다면, 이제 더 이상 Marshal.PtrToStructure을 이용한 파일 읽기가 불가능하다는 것을 알 수 있을 것입니다.

그래서 지난 글에서 작성한 읽기 및 클래스 구조를 모두 뜯어고쳐야 하는데요, 우선, RIFF/WAVE 구조에 맞게 WAVE에 포함되는 하위 헤더 중 우리가 해석할 List, Format, Data에 대해 각각 필드로 포함하도록 만듭니다.

public class WaveFile
{
    ListChunk _list;
    FormatChunk _format;
    DataChunk _data;

    // ...[생략]...
}

이후, 헤더의 Id에 따라 각각 하위 헤더에 읽기를 맡기는 식으로, 그리고 필요 없는 헤더는 그냥 넘어가는 식으로 해석하면 됩니다.

public class WaveFile
{
    // ...[생략]...

    public static WaveFile Read(string filePath)
    {
        WaveFile wave = new WaveFile();
        
        using (FileStream fs = File.OpenRead(filePath))
        using (BinaryReader br = new BinaryReader(fs, Encoding.ASCII))
        {
            string type = Encoding.ASCII.GetString(br.ReadBytes(4));
            if (type != "RIFF")
            {
                throw new ApplicationException("RIFF signature not found");
            }

            int remainSize = br.ReadInt32();
            
            while (remainSize > 0)
            {
                int chunkReadSize = 0;
                string? chunkTypeId = br.ForwardAnsi4(ref chunkReadSize)?.ToLower();

                switch (chunkTypeId)
                {
                    case "wave":
                        break;

                    case "fmt ":
                        wave._format = FormatChunk.Read(br, ref chunkReadSize);
                        break;

                    case "data":
                        wave._data = DataChunk.Read(br, ref chunkReadSize);
                        break;

                    case "list":
                        wave._list = ListChunk.Read(br, ref chunkReadSize);
                        break;

                    case "slnt": // Silent
                    case "wavl": // Wave list
                    case "fact":
                    case "cue ":
                    case "plst": // Playlist
                    case "smpl": // sample
                    case "inst": // Instrument
                        {
                            int chunkSize = br.ForwardInt32(ref chunkReadSize);
                            br.ForwardBytes(chunkSize, ref chunkReadSize);
                        }
                        break;

                    default:
                        throw new ApplicationException($"Not supported sub chunk: {chunkTypeId}");
                }

                remainSize -= chunkReadSize;
            }
        }

        return wave;
    }
}

남은 작업은, 자신의 헤더에 맞는 데이터를 읽어들이는 코드를 작성하면 됩니다. 가령 "fmt "에 해당하는 헤더는 이렇게 구현합니다.

class FormatChunk
{
    public CompressionCode CompressionCode; /* 1 is PCM */

    public short Channels;

    public int SampleRate;

    public int ByteRate; /* (Sample Rate * BitsPerSample * Channels) / 8 */

    public short BlockAlign; /* (BitsPerSample * Channels) / 8 */

    public short BitsPerSample;

    public static FormatChunk Read(BinaryReader br, ref int totalRead)
    {
        FormatChunk format = new FormatChunk();

        int chunkSize = br.ForwardInt32(ref totalRead);

        format.CompressionCode = (CompressionCode)br.ForwardUInt16(ref totalRead);

        if (format.CompressionCode != CompressionCode.Microsoft_PCM)
        {
            throw new Exception("Only supported for Microsoft PCM");
        }

        format.Channels = br.ForwardInt16(ref totalRead);
        format.SampleRate = br.ForwardInt32(ref totalRead);
        format.ByteRate = br.ForwardInt32(ref totalRead);
        format.BlockAlign = br.ForwardInt16(ref totalRead);
        format.BitsPerSample = br.ForwardInt16(ref totalRead);

        if (chunkSize > 16)
        {
            short extraFormatBytes = br.ForwardInt16(ref totalRead);
            br.ForwardBytes(extraFormatBytes, ref totalRead);
        }

        return format;
    }

    public override string ToString()
    {
        return $"{CompressionCode}, {SampleRate} Hz, {Channels} channels, {BitsPerSample}";
    }
}

어렵지 않죠? ^^ 헤더 파싱 중에 주의해야 할 점이 있다면 LIST 헤더의 경우 key=value 쌍에서 value는 반드시 word-aligned라는 점입니다. 만약 "kevin"이라는 값을 줬으면 5바이트를 차지하지만 word-aligned이어야 하므로 6바이트로 길이를 맞춰야 합니다. (그 외 "data", "list" 헤더를 읽어들이는 코드는 첨부 파일을 참고하세요.)




자, 이렇게 개선한 Read를 이용하면,

{
    string waveFilePath = @"C:\temp\right_mute.wav";
    WaveFile? wf = WaveFile.Read(waveFilePath);

    Console.WriteLine(wf);
}

/* 출력 결과
Metadata:
        ISFT    Lavf58.76.100
Microsoft_PCM, 44100 Hz, 2 channels, 16, duration: 00:00:08.00, 1411 kb/s
*/

보는 바와 같이 ffmpeg가 만든 파일을 잘 해석하고 있습니다. 물론, 쓰기 기능도 INFO 헤더를 쓰는 것도 구현했으므로 다음과 같이 작성하면,

{
    BinaryOctaveFile octave = BinaryOctaveFile.Read(@"test_stereo.pcm");
    short[] data = octave.PCMDataAsShorts();

    byte[] buffer = new byte[data.Length * 2];
    Buffer.BlockCopy(data, 0, buffer, 0, buffer.Length);

    Dictionary<string, string> info = new Dictionary<string, string>() {
        { InfoKeys.ISFT, "sysnet" },
        { InfoKeys.ICOP, "Copyright sysnet 2023" },
    };

    WaveFile.Write(@"C:\Temp\test_stereo.wav", 44100, 16, 2, buffer, info);
}

이제 저렇게 생성한 파일을 ffprobe로 다음과 같이 확인할 수 있습니다.

c:\temp> ffprobe -hide_banner test_stereo.wav
Input #0, wav, from 'test_stereo.wav':
  Metadata:
    encoder         : sysnet
    copyright       : Copyright sysnet 2023
  Duration: 00:00:08.00, bitrate: 1411 kb/s
  Stream #0:0: Audio: pcm_s16le ([1][0][0][0] / 0x0001), 44100 Hz, 2 channels, s16, 1411 kb/s

(첨부 파일은 이 글의 예제 코드를 포함합니다.)




그런데, 위의 Sub Chunk 헤더 분석으로 "C:\Windows\Media\Alarm09.wav" 파일을 해보면 "cdif"라는 헤더가 나옵니다. 검색해 보면, 듣보잡 헤더인지 정보를 찾을 수가 없습니다.

아래의 문서에 보면,

Resource Interchange File Format (RIFF)
; https://learn.microsoft.com/en-us/windows/win32/xaudio2/resource-interchange-file-format--riff-

sub chunk의 규격이 "chunkID, chunkSize, data" 식으로 나온다고 하므로, chunkTypeId에 따른 코드를 단순히 default 처리해 넘어가도록 처리하는 것이 나을 수도 있습니다.

switch (chunkTypeId)
{
    case "wave":
        break;

    case "fmt ":
        wave._format = FormatChunk.Read(br, ref chunkReadSize);
        break;

    case "data":
        wave._data = DataChunk.Read(br, ref chunkReadSize, wave._loadAllData);
        break;

    case "list":
        wave._list = ListChunk.Read(br, ref chunkReadSize);
        break;

    default: //  "cdif", "slnt", "wavl", "fact", "cue ",  "plst", "smpl", "inst"
        {
            int chunkSize = br.ForwardInt32(ref chunkReadSize);
            br.ForwardBytes(chunkSize, ref chunkReadSize);
        }
        break;
}




가지고 있던 flac 파일을 ffmpeg를 이용해 wav 파일로 변환했더니 Microsoft_PCM이 아닌 포맷이 나옵니다. 바로 Extensible 포맷인데요, 다행히 아래의 문서를 보면 어렵지 않게 지원을 추가할 수 있습니다.

Extensible Wave-Format Descriptors
; https://learn.microsoft.com/en-us/windows-hardware/drivers/audio/extensible-wave-format-descriptors

문서에 포함된 한 장의 그림에서 잘 설명하고 있는데,

waveformt_def_1.png

WAVEFORMATEXTENSIBLE은 기존 WAVEFORMATEX에서 3개의 필드가 더 추가된 유형입니다.

/*
WAVEFORMATEXTENSIBLE structure (ksmedia.h)
; https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ksmedia/ns-ksmedia-waveformatextensible
*/

typedef struct {
  WAVEFORMATEX Format;
  union {
    WORD wValidBitsPerSample;
    WORD wSamplesPerBlock;
    WORD wReserved;
  } Samples;
  DWORD        dwChannelMask;
  GUID         SubFormat;
} WAVEFORMATEXTENSIBLE, *PWAVEFORMATEXTENSIBLE;

단지, 여기서 만들고 있는 것은 PCM 데이터로 제약이 있으므로 SubFormat 필드를 체크해,

Media Type Identifiers for the Windows Media Format SDK
; https://learn.microsoft.com/en-us/windows/win32/wmformat/media-type-identifiers

"00000001-0000-0010-8000-00AA00389B71" (WMMEDIASUBTYPE_PCM) 값 유형에 대해서만 재생할 수 있도록 해야 합니다. 따라서 이를 반영해 다음과 같이 FormatChunk.Read 코드를 완성할 수 있습니다.

public static FormatChunk Read(BinaryReader br, ref int totalRead)
{
    FormatChunk format = new FormatChunk();

    // ...[생략]...

    if (chunkSize > 16)
    {
        format.cbSize = br.ForwardInt16(ref totalRead);
        
        if (format.CompressionCode == CompressionCode.Extensible)
        {
            format.SamplesValidBitsPerSample = br.ForwardInt16(ref totalRead);
            format.ChannelMask = br.ForwardInt32(ref totalRead);
            format.SubFormat = br.ForwardGuid(ref totalRead); 

            if (format.SubFormat != WMMEDIASUBTYPE_PCM)
            {
                throw new ApplicationException("Extensible format PCM is supported only");
            }
        }
        else
        {
            br.ForwardBytes(format.cbSize, ref totalRead);
        }
    }

    return format;
}

지난 글을 다룰 때만 해도 WAV 파일의 포맷이 우스워 보였는데... ^^;




[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]







[최초 등록일: ]
[최종 수정일: 4/20/2024]

Creative Commons License
이 저작물은 크리에이티브 커먼즈 코리아 저작자표시-비영리-변경금지 2.0 대한민국 라이센스에 따라 이용하실 수 있습니다.
by SeongTae Jeong, mailto:techsharer at outlook.com

비밀번호

댓글 작성자
 




[1]  2  3  4  5  6  7  8  9  10  11  12  13  14  15  ...
NoWriterDateCnt.TitleFile(s)
13630정성태5/19/202419닷넷: 2263. C# - Thread가 Task보다 더 빠르다는 어떤 예제(?)
13629정성태5/18/2024213개발 환경 구성: 710. Android - adb.exe를 이용한 파일 전송
13628정성태5/17/2024231개발 환경 구성: 709. Windows - WHPX(Windows Hypervisor Platform)를 이용한 Android Emulator 가속
13627정성태5/17/2024228오류 유형: 903. 파이썬 - UnicodeEncodeError: 'ascii' codec can't encode character '...' in position ...: ordinal not in range(128)
13626정성태5/15/2024316Phone: 15. C# MAUI - MediaElement Source 경로 지정 방법파일 다운로드1
13625정성태5/14/2024668닷넷: 2262. C# - Exception Filter 조건(when)을 갖는 catch 절의 IL 구조
13624정성태5/12/2024858Phone: 14. C# - MAUI에서 MediaElement 사용파일 다운로드1
13623정성태5/11/2024983닷넷: 2261. C# - 구글 OAuth의 JWT (JSON Web Tokens) 해석파일 다운로드1
13622정성태5/10/20241021닷넷: 2260. C# - Google 로그인 연동 (ASP.NET 예제)파일 다운로드1
13621정성태5/10/2024961오류 유형: 902. IISExpress - Failed to register URL "..." for site "..." application "/". Error description: Cannot create a file when that file already exists. (0x800700b7)
13620정성태5/9/20241044VS.NET IDE: 190. Visual Studio가 node.exe를 경유해 Edge.exe를 띄우는 경우
13619정성태5/7/2024990닷넷: 2259. C# - decimal 저장소의 비트 구조파일 다운로드1
13618정성태5/6/20241108닷넷: 2258. C# - double (배정도 실수) 저장소의 비트 구조파일 다운로드1
13617정성태5/5/20241057닷넷: 2257. C# - float (단정도 실수) 저장소의 비트 구조파일 다운로드1
13616정성태5/3/2024993닷넷: 2256. ASP.NET Core 웹 사이트의 HTTP/HTTPS + Dual mode Socket (IPv4/IPv6) 지원 방법파일 다운로드1
13615정성태5/3/2024951닷넷: 2255. C# 배열을 Numpy ndarray 배열과 상호 변환
13614정성태5/2/2024879닷넷: 2254. C# - COM 인터페이스의 상속 시 중복으로 메서드를 선언
13613정성태5/1/2024934닷넷: 2253. C# - Video Capture 장치(Camera) 열거 및 지원 포맷 조회파일 다운로드1
13612정성태4/30/2024938오류 유형: 902. Visual Studio - error MSB3021: Unable to copy file
13611정성태4/29/2024940닷넷: 2252. C# - GUID 타입 전용의 UnmanagedType.LPStruct - 두 번째 이야기파일 다운로드1
13610정성태4/28/20241011닷넷: 2251. C# - 제네릭 인자를 가진 타입을 생성하는 방법 - 두 번째 이야기
13609정성태4/27/20241050닷넷: 2250. PInvoke 호출 시 참조 타입(class)을 마샬링하는 [IN], [OUT] 특성파일 다운로드1
13608정성태4/26/20241121닷넷: 2249. C# - 부모의 필드/프로퍼티에 대해 서로 다른 자식 클래스 간에 Reflection 접근이 동작할까요?파일 다운로드1
13607정성태4/25/20241143닷넷: 2248. C# - 인터페이스 타입의 다중 포인터를 인자로 갖는 C/C++ 함수 연동
13606정성태4/24/20241092닷넷: 2247. C# - tensorflow 연동 (MNIST 예제)파일 다운로드1
[1]  2  3  4  5  6  7  8  9  10  11  12  13  14  15  ...