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

비밀번호

댓글 작성자
 




... 76  77  78  79  80  81  82  83  84  85  86  [87]  88  89  90  ...
NoWriterDateCnt.TitleFile(s)
11761정성태10/25/201821368Graphics: 26. 임의 축을 기반으로 3D 벡터 회전파일 다운로드1
11760정성태10/24/201816796개발 환경 구성: 418. Azure - Runbook 내에서 또 다른 Runbook 스크립트를 실행
11759정성태10/24/201818398개발 환경 구성: 417. Azure - Runbook에서 사용할 수 있는 다양한 메서드를 위한 부가 Module 추가
11758정성태10/23/201820677.NET Framework: 800. C# - Azure REST API 사용을 위한 인증 획득 [3]파일 다운로드1
11757정성태10/19/201817169개발 환경 구성: 416. Visual Studio 2017을 이용한 아두이노 프로그램 개발(및 디버깅)
11756정성태10/19/201820412오류 유형: 500. Visual Studio Code의 아두이노 프로그램 개발 시 인텔리센스가 안 된다면?
11755정성태10/19/201821493오류 유형: 499. Visual Studio Code extension for Arduino - #include errors detected. [1]
11754정성태10/19/201818319개발 환경 구성: 415. Visual Studio Code를 이용한 아두이노 프로그램 개발 - 새 프로젝트
11753정성태10/19/201825715개발 환경 구성: 414. Visual Studio Code를 이용한 아두이노 프로그램 개발
11752정성태10/18/201818341오류 유형: 498. SQL 서버 - Database source is not a supported version of SQL Server
11751정성태10/18/201818565오류 유형: 497. Visual Studio 실행 시 그래픽이 투명해진다거나, 깨진다면?
11750정성태10/18/201816873오류 유형: 496. 비주얼 스튜디오 - One or more projects in the solution were not loaded correctly.
11749정성태10/18/201818989개발 환경 구성: 413. 비주얼 스튜디오에서 작성한 프로그램을 빌드하는 가장 쉬운 방법
11748정성태10/18/201819224개발 환경 구성: 412. Arduino IDE를 Store App으로 설치한 경우 컴파일만 되고 배포가 안 되는 문제
11747정성태10/17/201819943.NET Framework: 799. C# - DLL에도 EXE처럼 Main 메서드를 넣어 실행할 수 있도록 만드는 방법파일 다운로드1
11746정성태10/15/201819494개발 환경 구성: 411. Bitvise SSH Client의 인증서 모드에서 자동 로그인 방법파일 다운로드1
11745정성태10/15/201817307오류 유형: 495. TFS 파일/폴더 삭제 - The item [...] could not be found in your workspace, or you do not have permission to access it.
11744정성태10/15/201818192개발 환경 구성: 410. msbuild로 .pubxml 설정에 따른 배포 파일을 만드는 방법
11743정성태10/15/201819347웹: 37. Bootstrap의 dl/dt/dd 조합에서 문자열이 잘리지 않도록 CSS 설정
11742정성태10/15/201825194스크립트: 13. 윈도우 배치(Batch) 스크립트에서 날짜/시간 문자열을 구하는 방법
11741정성태10/15/201818980Phone: 13. Android - LinearLayout 간략 설명
11740정성태10/15/201820979사물인터넷: 51. Synology NAS(DS216+II)를 이용한 원격 컴퓨터의 전원 스위치 제어
11739정성태10/15/201822634Windows: 151. 윈도우 10의 전원 관리가 "균형 조정(Balanced)"으로 바뀌는 문제
11738정성태10/15/201820899오류 유형: 494. docker - 윈도우에서 실행 시 "unknown shorthand flag" 오류 [1]
11737정성태10/13/201816800오류 유형: 493. Azure Kudu - There are ... items in this directory, but maxViewItems is set to 299
11736정성태10/12/201818444오류 유형: 492. Visual Studio 로딩 시 오류 - The 'Scc Display Information' package did not load correctly.
... 76  77  78  79  80  81  82  83  84  85  86  [87]  88  89  90  ...