C# - WAV 파일의 PCM 데이터 생성 및 출력
지난 글에서,
C# - WAV 기본 파일 포맷
; https://www.sysnet.pe.kr/2/0/13596
WAV 파일의 간단한 헤더 구조를 봤는데요, 그 덕분에 적절한 PCM 데이터만 있다면 금방 wav 파일을 만들 수 있습니다. 실제로 해볼까요? ^^
우선, 적당한 PCM 데이터를 생성해야 하는데요, 사실 PCM은 단순히 음의 크기를 나타내는 배열 형식의 자료 구조이기 때문에 간단하게
Octave에서 다음과 같이 처리할 수 있습니다.
# doremi.m
# https://docs.octave.org/v4.0.0/Two_002dDimensional-Plots.html
sample_rate = 22050;
frequency = [262 294 330 349 392 440 494 523]; # 도레미...의 주파수
sound = []
t = 0:1/sample_rate:1;
for doremi_freq = frequency
s = cos(2*pi*doremi_freq*t);
sound = [sound s];
endfor
22Khz 샘플링으로 "도, 레, 미, 파, 솔, 라, 시, 도"에 해당하는 주파수의 데이터를 각각 1초씩 생성해 sound 변수에 넣었습니다. 일부 데이터를 plot으로 그려보면 단순히 -1~+1 사이에서 주파수에 따라 요동치는 간단한 형식입니다.
>> plot(t(1:200), sound(1:200));
직접 소리를 내 확인하는 것도 가능한데요,
soundsc 함수를 사용하면 ^^ 도레미... 음이 1초 간격으로 들릴 것입니다.
>> doremi;
sound = [](0x0)
>> soundsc(sound, sample_rate)
자, 그럼 이 데이터를 binary 포맷으로 저장하고,
>> save -binary test.pcm sound
지난 글에 소개한 Octave Reader를 통해 C#에서 PCM 데이터 파일을 읽어들입니다. 단지, 현재 Octave가 출력한 PCM 데이터가 -1~+1 범위의 double 값이므로, wav 파일에서 PCM 데이터로 동작하기 위해서는 이 값을 정수 형태로 바꿔야 합니다. 이를 위해 다음과 같이 보조 메서드를 하나 추가해 줍니다. (아니면, 애당초 Octave 측에서 정수로 바뀐 데이터를 출력해도 됩니다.)
// https://www.sysnet.pe.kr/2/0/13324#src
public struct BinaryOctaveFile
{
// ...[생략]...
public short[] PCMDataAsShorts()
{
int dataCount = Rows * Columns;
short[] data = new short[dataCount];
for (int i = 0; i < dataCount; i ++)
{
data[i] = (short)(Data[i] * 32767);
}
return data;
}
}
자, 그럼 이 메서드로 다음과 같이 Octave가 생성한 PCM 데이터를 읽어내
WAV 헤더 파일과 함께 출력해 주는 Create 메서드에 전달하면 wav 파일이 만들어집니다.
{
BinaryOctaveFile octave = BinaryOctaveFile.Read(@"test.pcm");
short[] data = octave.PCMDataAsShorts();
byte[] buffer = new byte[data.Length * 2];
Buffer.BlockCopy(data, 0, buffer, 0, buffer.Length);
// Octave에서 생성한 데이터 유형: sample_rate = 22050
// bits_per_sample = 16
// channels = 1
WaveFile.Create(@"C:\Temp\test.wav", 22050, 16, 1, buffer);
}
이렇게 저장한 파일이 정상적인지, 다시 WaveFile로 읽어들여 포맷을 체크할 수 있습니다. ^^
{
BinaryOctaveFile octave = BinaryOctaveFile.Read(@"test.pcm");
short[] data = octave.PCMDataAsShorts();
byte[] buffer = new byte[data.Length * 2];
Buffer.BlockCopy(data, 0, buffer, 0, buffer.Length);
WaveFile.Create(@"C:\Temp\test.wav", 22050, 16, 1, buffer);
}
{
string waveFilePath = @"C:\Temp\test.wav";
WaveFile wf = new WaveFile(waveFilePath);
Console.WriteLine(wf.Header);
}
/* 출력 결과
ChunkId: RIFF, FileSize: 352852, TypeHeader: WAVE, FormatMarker: fmt , SubChunkSize: 16, AudioFormat: 1, Channels: 1, SampleRate: 22050, ByteRate: 44100, BlockAlign: 2, BitsPerSample: 16, DataChunkHeader: data, DataSize: 352816
*/
헤더 규약에 맞게 wav 파일을 생성했으니, 당연히 읽기 시에도 출력이 정상적으로 나오고, 게다가 Media Player 등을 이용해 test.wav 파일을 재생해 보면 ^^ 소리가 잘 들립니다.
하는 김에, 스테레오용 PCM도 생성해 볼까요? 사실 Mono는 단일 음이 연속해서 Byte[] 버퍼에 채워지는 형식이지만,
[M1 M2 M3 ...]
Stereo 형식이면 Left Channel과 Right Channel 음이 번갈아서 버퍼에 채워지게 됩니다.
[L1 R1 L2 R2 L3 R3 ...]
따라서, 이전의 도레미... 음을 다음과 같이 "
repelem"을 이용해 (이번엔 44Khz의) 동일한 음으로 Left/Right Channel을 생성할 수 있습니다.
sample_rate = 44100;
frequency = [262 294 330 349 392 440 494 523];
sound = []
t = 0:1/sample_rate:1;
for doremi_freq = frequency
s = cos(2*pi*doremi_freq*t);
s = repelem(s, 2);
sound = [sound s];
endfor
역시 이렇게 생성한 sound 데이터를 test_stereo.pcm 파일로 저장하고 wav 헤더를 추가해 주면,
{
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);
// Octave에서 생성한 데이터 유형: sample_rate = 44100
// channels = 2
// 데이터를 2바이트로 변환: bits_per_sample = 16
WaveFile.Create(@"C:\Temp\test_stereo.wav", 44100, 16, 2, buffer);
}
동작하는 test_stereo.wav 음악 파일을 만들 수 있습니다.
하다 보니 재미있군요. ^^ 그럼 이번에는 Stereo지만, Right Channel 음을 무음으로 만들어 보겠습니다. 이를 위해 Octave에서는 2번째 데이터에 대해 0을 만들 수 있도록 아래와 같은 조작을 추가합니다.
sample_rate = 44100;
frequency = [262 294 330 349 392 440 494 523];
sound = []
t = 0:1/sample_rate:1;
even_pos = 2:2:size(t)(2)*2;
for doremi_freq = frequency
s = cos(2*pi*doremi_freq*t);
s = repelem(s, 2);
s(even_pos) = 0;
sound = [sound s];
endfor
재생해 보면, 의도했던 대로 오른쪽 이어폰에서 소리가 발생하지 않습니다.
마지막으로, Octave의 PCM 데이터는 -1~+1 사이의 double 값을 가진, 어찌 보면 정규화된 형식이기 때문에 이 값의 bitsPerSample을 정하는 것은 읽어들이는 쪽에서 알아서 하면 됩니다. 앞선 예제 코드에서는 해당 데이터를 2바이트로 복원했었는데요, 당연히 곱해주는 값을 바꿔 4바이트로 하는 것도 가능합니다.
using System.Text;
namespace Octave;
public struct BinaryOctaveFile
{
// ...[생략]...
public int[] PCMDataAsInt32s()
{
int dataCount = Rows * Columns;
int[] data = new int[dataCount];
for (int i = 0; i < dataCount; i++)
{
data[i] = (int)(Data[i] * 2147483647);
}
return data;
}
}
그럼 파일로 저장할 때도 다음과 같이 변경해야 합니다.
{
BinaryOctaveFile octave = BinaryOctaveFile.Read(@"test_half_stereo.pcm");
int[] data = octave.PCMDataAsInt32s();
byte[] buffer = new byte[data.Length * 4];
Buffer.BlockCopy(data, 0, buffer, 0, buffer.Length);
// Octave에서 생성한 데이터 유형: sample_rate = 44100
// channels = 2
// 데이터를 4바이트로 변환: bits_per_sample = 32
WaveFile.Create(@"C:\Temp\test_half_stereo.wav", 44100, 32, 2, buffer);
}
(
첨부 파일은 이 글의 예제 코드를 포함합니다.)
그나저나, 지난 글에 소개만 했던 WaveFile.Create 메서드는 어쩌면 WAVE 헤더를 읽을 때 사용했던
Marshal.PtrToStructure 호출에 대해,
private WaveHeader structFromBytes(byte[] buffer)
{
GCHandle pData = GCHandle.Alloc(buffer, GCHandleType.Pinned);
WaveHeader instance = Marshal.PtrToStructure<WaveHeader>(pData.AddrOfPinnedObject());
pData.Free();
return instance;
}
그와 짝을 이루는
Marshal.StructureToPtr로 처리할 수도 있었는데요, 아쉽게도 예외 사항이 하나 있어 그렇게 처리할 수는 없었습니다. 왜냐하면 ChunkId와 같은 문자열 필드가,
[StructLayout(LayoutKind.Sequential, CharSet=CharSet.Ansi)]
public struct WaveHeader
{
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 4)]
public string ChunkId;
// ...[생략]...
}
크기는 4로 정해져 있지만, null을 포함하지 않는 크기이기 때문입니다. 그래서 저 필드에 "RIFF"가 들어 있는 것을 Marshal.StructureToPtr로 쓰게 되면 4바이트라는 제약에 null 처리까지 돼 "RIF\0"으로 직렬화되는 문제가 있습니다.
어쩔 수 없습니다. ^^
Marshal.StructureToPtr에 대해서는 CustomMarshaler도 동작하지 않기 때문에 직접 출력을 제어해야 합니다. 그런 이유로, 결국 일일이 헤더 필드 하나하나 쓰도록 만들게 된 것입니다.
[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]