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

비밀번호

댓글 작성자
 




... 151  152  [153]  154  155  156  157  158  159  160  161  162  163  164  165  ...
NoWriterDateCnt.TitleFile(s)
1224정성태1/21/201237211개발 환경 구성: 139. 아마존 EC2에 새로 추가된 "1년 무료 Windows 서버 인스턴스"가 있다는데, 직접 만들어 볼까요? ^^ [11]
1223정성태1/20/201227258.NET Framework: 296. 괜찮은 문자열 해시함수? - 두 번째 이야기 [1]파일 다운로드1
1222정성태1/18/201234994.NET Framework: 295. 괜찮은 문자열 해시 함수? [4]파일 다운로드1
1221정성태1/17/201223943오류 유형: 147. System.Runtime.InteropServices.COMException (0x80005000)
1220정성태1/15/201224106.NET Framework: 294. Master web.config 파일을 수정하려면?파일 다운로드1
1219정성태1/15/201226529.NET Framework: 293. Microsoft PowerPoint 슬라이드를 HTML 파일로 ".files" 폴더 없이 저장하는 방법 (C# 코드)파일 다운로드1
1218정성태1/15/201238978.NET Framework: 292. RSACryptoServiceProvider의 공개키와 개인키 구분 [1]파일 다운로드2
1217정성태1/14/201241115.NET Framework: 291. .NET에서 WAV, MP3 파일 재생하는 방법 [1]파일 다운로드1
1216정성태1/14/201229812오류 유형: 146. Microsoft Visual C++ 재배포 패키지 - 설치 로그 남기는 방법 [1]
1215정성태1/9/201227368제니퍼 .NET: 20. 제니퍼 닷넷 적용 사례 (3) - '닷넷'이 문제일까? '닷넷 개발자'가 문제일까? [6]
1214정성태1/3/201224208제니퍼 .NET: 19. 제니퍼 닷넷 설치/제거 방법 - IIS
1213정성태12/31/201124173.NET Framework: 290. WCF - 접속된 클라이언트의 IP 주소 알아내는 방법 - 두 번째 이야기
1212정성태12/31/201124250오류 유형: 145. The trust relationship between this workstation and the primary domain failed.
1211정성태12/31/201129010.NET Framework: 289. WindowsFormsHost를 사용하는 XBAP 응용 프로그램파일 다운로드1
1210정성태12/30/201148048.NET Framework: 288. FFmpeg.exe를 이용한 C# 동영상 인코더 예제 [9]파일 다운로드1
1209정성태12/29/201122669개발 환경 구성: 138. BizTalk 2006 설치 방법
1208정성태12/28/201145653.NET Framework: 287. Excel Sheet를 WinForm에서 사용하는 방법 [8]파일 다운로드2
1207정성태12/26/201124940.NET Framework: 286. x86/x64로 구분된 코드를 포함하는 경우, 다중으로 어셈블리를 만들어야 할까요?파일 다운로드1
1206정성태12/25/201125953.NET Framework: 285. Shader 강좌와 함께 배워보는 XNA Framework (3) - 텍스처 매핑 예제파일 다운로드1
1205정성태12/25/201131691.NET Framework: 284. Thread 개체의 Interrupt와 Abort의 차이점파일 다운로드1
1204정성태12/22/201125175.NET Framework: 283. MEF를 ASP.NET에 성능 손실 없이 적용하려면? [7]
1203정성태12/21/201125558제니퍼 .NET: 18. MEF가 적용된 ASP.NET 웹 사이트를 제니퍼 닷넷으로 모니터링 해본 결과! [6]
1202정성태12/21/201125952오류 유형: 144. The database '...' cannot be opened because it is version 661.
1201정성태12/14/201141023디버깅 기술: 47. .NET Reflector를 이용한 "소스 코드가 없는" 어셈블리 디버깅 [4]
1200정성태12/11/201126912디버깅 기술: 46. Windbg 확장 DLL 만들기 (2) - Debugger Extension API 사용파일 다운로드1
1199정성태12/11/201128234VC++: 55. JNI DLL 컴파일 시 x86과 x64의 Export된 함수의 이름이 왜 다를까요? [2]파일 다운로드1
... 151  152  [153]  154  155  156  157  158  159  160  161  162  163  164  165  ...