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

비밀번호

댓글 작성자
 




... 136  137  138  139  140  141  142  143  144  145  146  147  148  149  [150]  ...
NoWriterDateCnt.TitleFile(s)
1303정성태6/26/201227404개발 환경 구성: 152. sysnet DB를 SQL Azure 데이터베이스로 마이그레이션
1302정성태6/25/201229461개발 환경 구성: 151. Azure 웹 사이트에 사용자 도메인 네임 연결하는 방법
1301정성태6/20/201225766오류 유형: 156. KB2667402 윈도우 업데이트 실패 및 마이크로소프트 Answers 웹 사이트 대응
1300정성태6/20/201231787.NET Framework: 329. C# - Rabin-Miller 소수 생성방법을 이용하여 RSACryptoServiceProvider의 개인키를 직접 채워보자 [1]파일 다운로드2
1299정성태6/18/201232897제니퍼 .NET: 21. 제니퍼 닷넷 - Ninject DI 프레임워크의 성능 분석 [2]파일 다운로드2
1298정성태6/14/201234411VS.NET IDE: 72. Visual Studio에서 pfx 파일로 서명한 경우, 암호는 어디에 저장될까? [2]
1297정성태6/12/201231056VC++: 63. 다른 프로세스에 환경 변수 설정하는 방법파일 다운로드1
1296정성태6/5/201227699.NET Framework: 328. 해당 DLL이 Managed인지 / Unmanaged인지 확인하는 방법 - 두 번째 이야기 [4]파일 다운로드1
1295정성태6/5/201225086.NET Framework: 327. RSAParameters와 System.Numerics.BigInteger 이야기파일 다운로드1
1294정성태5/27/201248544.NET Framework: 326. 유니코드와 한글 - 유니코드와 닷넷을 이용한 한글 처리 [7]파일 다운로드2
1293정성태5/24/201229776.NET Framework: 325. System.Drawing.Bitmap 데이터를 Parallel.For로 처리하는 방법 [2]파일 다운로드1
1292정성태5/24/201223755.NET Framework: 324. First-chance exception에 대해 조건에 따라 디버거가 멈추게 할 수는 없을까? [1]파일 다운로드1
1291정성태5/23/201230283VC++: 62. 배열 초기화를 위한 기계어 코드 확인 [2]
1290정성태5/18/201235083.NET Framework: 323. 관리자 권한이 필요한 작업을 COM+에 대행 [7]파일 다운로드1
1289정성태5/17/201239242.NET Framework: 322. regsvcs.exe로 어셈블리 등록 시 시스템 변경 사항 [5]파일 다운로드2
1288정성태5/17/201226467.NET Framework: 321. regasm.exe로 어셈블리 등록 시 시스템 변경 사항 (3) - Type Library파일 다운로드1
1287정성태5/17/201229304.NET Framework: 320. regasm.exe로 어셈블리 등록 시 시스템 변경 사항 (2) - .NET 4.0 + .NET 2.0 [2]
1286정성태5/17/201238233.NET Framework: 319. regasm.exe로 어셈블리 등록 시 시스템 변경 사항 (1) - .NET 2.0 + x86/x64/AnyCPU [5]
1285정성태5/16/201233267.NET Framework: 318. gacutil.exe로 어셈블리 등록 시 시스템 변경 사항파일 다운로드1
1284정성태5/15/201225704오류 유형: 155. Windows Phone 연결 상태에서 DRIVER POWER STATE FAILURE 블루 스크린 뜨는 현상
1283정성태5/12/201233316.NET Framework: 317. C# 관점에서의 Observer 패턴 구현 [1]파일 다운로드1
1282정성태5/12/201226110Phone: 6. Windows Phone 7 Silverlight에서 Google Map 사용하는 방법 [3]파일 다운로드1
1281정성태5/9/201233197.NET Framework: 316. WPF/Silverlight의 그래픽 단위와 Anti-aliasing 처리를 이해하자 [1]파일 다운로드1
1280정성태5/9/201226159오류 유형: 154. Could not load type 'System.ServiceModel.Activation.HttpModule' from assembly 'System.ServiceModel, ...'.
1279정성태5/9/201224919.NET Framework: 315. 해당 DLL이 Managed인지 / Unmanaged인지 확인하는 방법 [1]파일 다운로드1
1278정성태5/8/201226151오류 유형: 153. Visual Studio 디버깅 - Unable to break execution. This process is not currently executing the type of code that you selected to debug.
... 136  137  138  139  140  141  142  143  144  145  146  147  148  149  [150]  ...