Microsoft MVP성태의 닷넷 이야기
닷넷: 2240. C# - WAV 파일 포맷 + LIST 헤더 [링크 복사], [링크+제목 복사],
조회: 10919
글쓴 사람
정성태 (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

비밀번호

댓글 작성자
 




... 121  122  123  124  125  126  127  128  129  130  131  [132]  133  134  135  ...
NoWriterDateCnt.TitleFile(s)
1756정성태9/23/201427489기타: 48. NVidia 제품의 과다한 디스크 사용 [2]
1755정성태9/22/201434281오류 유형: 241. Unity Web Player를 설치해도 여전히 설치하라는 화면이 나오는 경우 [4]
1754정성태9/22/201424670VC++: 80. 내 컴퓨터에서 C++ AMP 코드가 실행이 될까요? [1]
1753정성태9/22/201420612오류 유형: 240. Lync로 세미나 참여 시 소리만 들리지 않는 경우 [1]
1752정성태9/21/201441071Windows: 100. 윈도우 8 - RDP 연결을 이용해 VNC처럼 사용자 로그온 화면을 공유하는 방법 [5]
1751정성태9/20/201438958.NET Framework: 464. 프로세스 간 통신 시 소켓 필요 없이 간단하게 Pipe를 열어 통신하는 방법 [1]파일 다운로드1
1750정성태9/20/201423834.NET Framework: 463. PInvoke 호출을 이용한 비동기 파일 작업파일 다운로드1
1749정성태9/20/201423733.NET Framework: 462. 커널 객체를 위한 null DACL 생성 방법파일 다운로드1
1748정성태9/19/201425386개발 환경 구성: 238. [Synergy] 여러 컴퓨터에서 키보드, 마우스 공유
1747정성태9/19/201428502오류 유형: 239. psexec 실행 오류 - The system cannot find the file specified.
1746정성태9/18/201426106.NET Framework: 461. .NET EXE 파일을 닷넷 프레임워크 버전에 상관없이 실행할 수 있을까요? - 두 번째 이야기 [6]파일 다운로드1
1745정성태9/17/201423039개발 환경 구성: 237. 리눅스 Integration Services 버전 업그레이드 하는 방법 [1]
1744정성태9/17/201431065.NET Framework: 460. GetTickCount / GetTickCount64와 0x7FFE0000 주솟값 [4]파일 다운로드1
1743정성태9/16/201420985오류 유형: 238. 설치 오류 - Failed to get size of pseudo bundle
1742정성태8/27/201426977개발 환경 구성: 236. Hyper-V에 설치한 리눅스 VM의 VHD 크기 늘리는 방법 [2]
1741정성태8/26/201421334.NET Framework: 459. GetModuleHandleEx로 알아보는 .NET 메서드의 DLL 모듈 관계파일 다운로드1
1740정성태8/25/201432525.NET Framework: 458. 닷넷 GC가 순환 참조를 해제할 수 있을까요? [2]파일 다운로드1
1739정성태8/24/201426565.NET Framework: 457. 교착상태(Dead-lock) 해결 방법 - Lock Leveling [2]파일 다운로드1
1738정성태8/23/201422054.NET Framework: 456. C# - CAS를 이용한 Lock 래퍼 클래스파일 다운로드1
1737정성태8/20/201419765VS.NET IDE: 93. Visual Studio 2013 동기화 문제
1736정성태8/19/201425581VC++: 79. [부연] CAS Lock 알고리즘은 과연 빠른가? [2]파일 다운로드1
1735정성태8/19/201418241.NET Framework: 455. 닷넷 사용자 정의 예외 클래스의 최소 구현 코드 - 두 번째 이야기
1734정성태8/13/201419903오류 유형: 237. Windows Media Player cannot access the file. The file might be in use, you might not have access to the computer where the file is stored, or your proxy settings might not be correct.
1733정성태8/13/201426363.NET Framework: 454. EmptyWorkingSet Win32 API를 사용하는 C# 예제파일 다운로드1
1732정성태8/13/201434481Windows: 99. INetCache 폴더가 다르게 보이는 이유
1731정성태8/11/201427087개발 환경 구성: 235. 점(.)으로 시작하는 파일명을 탐색기에서 만드는 방법
... 121  122  123  124  125  126  127  128  129  130  131  [132]  133  134  135  ...