본문 바로가기
개발언어/NAudio

[NAudio] Spectrogram 그리기

by 창용이랑 2021. 7. 8.
728x90

spectrogram 을 완성했다.

 

스스로도 꽤나 무식하고 단순하게 구현하였다는 자각이 있으므로, 해당 부분의 태클은 삼가해 주기를...ㅠ

 

실 사용을 위해서는 상당히 많은 부분에서 손을 대야 할 것 같은데, 어차피 중단된(내 손에서 떠난) 프로젝트라서 뭐... 흐.

포기할 때는 꽤 아까웠는데 하면서 진짜 이거 하면서 느끼기를, 구현은 할 수 있는데(어쨌든 하루만에 만들었으니) '예쁘게' 만드는 데에 얼마나 걸릴지 짐작이 안 갔다.

포기하길 잘한 것 같다. 어휴.

 

아무튼간에, 굉장히 비효율적이나마 구현은 해냈다.

 

NAudio 예제를 보면서 작업하였는데, 여기서 코드를 가져다가 쓰는 바람에 불필요하게 코드가 복잡해졌다.

원본은 spectrogram이 아니라 spectrum analyzer 였기에 '매 순간' 그려지는 것이 필요했는데, 내 경우에는 처음에 모든 샘플을 읽을 필요가 있었다.

그래서 원본은 이벤트를 발생시키기 위한 클래스를 따로 두고 이벤트를 발생, 컨트롤에서 그걸 받아서 그리도록 되어있는데, 사실 내 경우에는 그냥 하나의 클래스에서 파일을 읽고 그려내면 될 것 같다.

그러나 이걸 고치기에는 어려우니까 손을 못 대겠다. 성능상의 문제도 따로 없고...

 

오히려 성능상의 문제라면 다른 쪽에 좀 있는데, 그건 후임자를 믿어보는 걸로.

 

말보다 코드가 나을 것이다.

유저 컨트롤 코드를 보자.

 

SpectrogramControl.xaml.cs 이다.

 

using NAudio.Dsp;
using NAudio.Wave;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Navigation;
using System.Windows.Shapes;
 
namespace SoundWingTest.CustomControl
{
    /// <summary>
    /// SpectrogramControl.xaml에 대한 상호 작용 논리
    /// </summary>
    public partial class SpectrogramControl : UserControl
    {
        
        AudioFileReader fileStream;
        int ChannelCount;
        
        const int BITMAP_WIDTH = 1200;
        const int BITMAP_HEIGHT = 150;
        private const int binsPerPoint = 2; // reduce the number of points we plot for a less jagged line?
        byte[,,] pixelArray;
        WriteableBitmap writeableBitmap;
        public event EventHandler<FftEventArgs> FftCalculated;
        int xPos = 0;
 
 
        public SpectrogramControl()
        {
            InitializeComponent();
        }
 
        public SpectrogramControl(AudioFileReader fileStream, int ChannelCount)
        {
            InitializeComponent();
 
            this.fileStream = fileStream;
            this.ChannelCount = ChannelCount;
 
            Debug.WriteLine("spectrogram control");
            DrawSpectrogram();
        }
 
        //파일의 정보(샘플)을 읽어들인다.
        private void loadFile()
        {
            long length = fileStream.Length;
            long sampleCount = length / 4;  //float은 4byte, float PCM
 
            //fft length에 따라서 spectrogram 의 width가 변화한다.
            int fftlen = 128;
            int resultWidth = (int)(sampleCount / fftlen / fileStream.WaveFormat.Channels); //예상되는 fft block의 수, spectogram width.
            while(resultWidth > BITMAP_WIDTH)//화면 길이를 넘지 않도록 fft length를 조정.
            {
                fftlen *= 2;
                resultWidth = (int)(sampleCount / fftlen / fileStream.WaveFormat.Channels);
            }
 
            Debug.WriteLine("resultWidth ? : " + resultWidth + " fft len : ? " + fftlen);
 
            //sampleAggregator 클래스를 이용하여 fft를 진행한다.
            SampleAggregator sampleAggregator = new SampleAggregator(fileStream, ChannelCount, fftlen);
            sampleAggregator.PerformFFT = true;
            sampleAggregator.FftCalculated += (s, a) => FftCalculated?.Invoke(this, a);
            this.FftCalculated += audioGraph_FftCalculated;
 
            var samplesPerSecond = (fileStream.WaveFormat.SampleRate * fileStream.WaveFormat.Channels);
            int blockAlign = fileStream.WaveFormat.BlockAlign;
            if (samplesPerSecond % blockAlign != 0)
            {
                Debug.WriteLine("wrong align. sps : " + samplesPerSecond + " , align : " + blockAlign);
                samplesPerSecond += samplesPerSecond % blockAlign;
            }
 
            var readBuffer = new float[samplesPerSecond];
                    
            int samplesRead;
            do
            {
                samplesRead = sampleAggregator.Read(readBuffer, 0, samplesPerSecond);             
            } while (samplesRead > 0);
 
            //원래 위치로 복귀
            fileStream.Position = 0;
 
        }
 
        //적절한 y 좌표를 가져온다
        private int getYPos(int len, int n)
        {
            double percent = (double)n / (len / 2);
 
            int y = (int)(BITMAP_HEIGHT * percent);
            if (y >= BITMAP_HEIGHT)
                y = BITMAP_HEIGHT-1;
 
            y = BITMAP_HEIGHT - (y + 1);
            
            return y;
        }
 
        //FFT 이벤트. fft length만큼의 sample이 모여서 연산되었을 때 발생.
        //여기서 bitmap 을 만든다. 한 번의 이벤트에서 세로줄 하나(1pixel)의 그림을 그리고 있음.
        void audioGraph_FftCalculated(object sender, FftEventArgs e)
        {
            if (e.Channel != ChannelCount)
                return;
 
            Complex[] fftResults = e.Result;
                       
            for (int n = 0; n < fftResults.Length / 2; n += binsPerPoint)
            {
                // averaging out bins
                double color = 0;
                for (int bin = 0; bin < binsPerPoint; bin++)
                {
                    color += GetColorLog(fftResults[n + bin]);
                }
 
                //RGB 색상값 계산
                int colorsum = (int)color;
                int blue, green, red;
 
                blue = colorsum - 512;
                blue = blue < 0 ? 0 : blue;
                blue = blue >= 256 ? 255 : blue;
 
                if (colorsum > 512)
                {
                    green = 255;
                }
                else if (colorsum < 256)
                {
                    green = 0;
                }
                else
                {
                    green = (colorsum % 512) - 256;
                    green = green < 0 ? 0 : green;
                    green = green >= 256 ? 255 : green;
                }
 
                red = colorsum > 256 ? 255 : colorsum;
 
                pixelArray[getYPos(fftResults.Length, n), xPos, 0] = (Byte)blue;//B                
                pixelArray[getYPos(fftResults.Length, n), xPos, 1] = (Byte)green;//G                
                pixelArray[getYPos(fftResults.Length, n), xPos, 2] = (Byte)red;//R        
 
 
            }
 
            //다음 픽셀로
            xPos++;
         
        }
 
        //spectrogram 그리기
        private void DrawSpectrogram()
        {
            writeableBitmap = new WriteableBitmap(BITMAP_WIDTH, BITMAP_HEIGHT, 96, 96, PixelFormats.Bgra32, null);
            pixelArray = new byte[BITMAP_HEIGHT, BITMAP_WIDTH, 4];
 
            //초기화, rgb는 0으로, a는 255로.
            for (int y = 0; y < BITMAP_HEIGHT; y++)
            {
                for (int x = 0; x < BITMAP_WIDTH; x++)
                {
                    for (int i = 0; i < 3; i++)
                    {
                        pixelArray[y, x, i] = 0;
                    }
                    pixelArray[y, x, 3] = 255;
                }
            }
 
            //파일 읽어들이기
            loadFile();
            
 
            Debug.WriteLine("spectrogram sameple load fin");
 
            //비트맵으로 변환하여 화면에 띄운다
            byte[] byteArray = new byte[BITMAP_HEIGHT * BITMAP_WIDTH * 4];
            int index = 0;
            for (int row = 0; row < BITMAP_HEIGHT; row++)
            {
                for (int col = 0; col < BITMAP_WIDTH; col++)
                {
                    for (int i = 0; i < 4; i++)
                    {
                        byteArray[index++] = pixelArray[row, col, i];
                    }
                }
            }
 
            Int32Rect rectangle = new Int32Rect(0, 0, BITMAP_WIDTH, BITMAP_HEIGHT);
 
            int stride = 4 * BITMAP_WIDTH;
 
            writeableBitmap.WritePixels(rectangle, byteArray, stride, 0);
            Image image = new Image();
 
            image.Stretch = Stretch.None;
            image.Margin = new Thickness(0);
 
            this.mainCanvas.Children.Add(image);
            image.Source = writeableBitmap;
 
        }
 
        //색상 값 계산. 진폭의 크기.
        private double GetColorLog(Complex c)
        {
            // not entirely sure whether the multiplier should be 10 or 20 in this case.
            // going with 10 from here http://stackoverflow.com/a/10636698/7532
            double intensityDB = 10 * Math.Log10(Math.Sqrt(c.X * c.X + c.Y * c.Y));
            double minDB = -90;
            if (intensityDB < minDB) intensityDB = minDB;
            double percent = intensityDB / minDB;
            // we want 0dB to be at the top (i.e. yPos = 0)
            // double yPos = percent * ActualHeight;
            double yPos = percent * 384;
 
            return yPos;
        }
 
    }
}
 
Colored by Color Scripter

 

이렇다.

여기저기 하드코딩이 박혀있고(widht, height 등) fft length 결정도 대강이라서 width가 딱 맞질 않고 적당히 1200 안으로만 떨어지게 짜다 보니, 601 같은 경우도 생기더라...ㅋㅋㅋㅋㅋ;;;

확실히 시각화 쪽에서는 scale이 일이다. 사실 waveform에서도 똑같은 문제가 있는데... 흐.

 

xaml 코드는 매우 단순하다.

 

<UserControl x:Class="SoundWingTest.CustomControl.SpectrogramControl"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             xmlns:local="clr-namespace:SoundWingTest.CustomControl"
             xmlns:soundwingtest="clr-namespace:SoundWingTest"
             mc:Ignorable="d" 
             d:DesignHeight="450" d:DesignWidth="800">
    <StackPanel Orientation="Horizontal">
        <Border BorderBrush="Black" BorderThickness="1">
            <soundwingtest:AxisVerticalControl Width="50" DockPanel.Dock="Left"></soundwingtest:AxisVerticalControl>
        </Border>
        <Border BorderBrush="Black" BorderThickness="1">
            <Canvas x:Name="mainCanvas" Height="150" Width="1200" Background="Cornsilk" >
 
            </Canvas>
        </Border>
    </StackPanel>
</UserControl>
 
Colored by Color Scripter

중요한 건 하나도 없으니 무시해도 좋다.

 

중요한 부분인 SampleAggregator 클래스.

샘플을 모아서 fft 연산을 하고 이벤트를 발생시킨다.

 

using NAudio.Dsp;
using NAudio.Wave;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
 
namespace SoundWingTest
{
     public class SampleAggregator : ISampleProvider
    {
        // volume
        public event EventHandler<MaxSampleEventArgs> MaximumCalculated;
        private float maxValue;
        private float minValue;
        public int NotificationCount { get; set; }
        int count;
 
        // FFT
        public event EventHandler<FftEventArgs> FftCalculated;
        public bool PerformFFT { get; set; }
        private readonly Complex[] fftBuffer;
        private readonly FftEventArgs fftArgs;
        private int fftPos;
        private readonly int fftLength;
        private readonly int m;
        private readonly ISampleProvider source;
 
        private readonly int channels;
        int Channel;
 
        //원하는 채널 번호와 fft length를 받아, fft 결과물을 이벤트로 넘겨준다.
        public SampleAggregator(ISampleProvider source, int channel, int fftLength = 1024)
        {
            Channel = channel;
            channels = source.WaveFormat.Channels;
            if (!IsPowerOfTwo(fftLength))
            {
                throw new ArgumentException("FFT Length must be a power of two");
            }
            m = (int)Math.Log(fftLength, 2.0);
            this.fftLength = fftLength;
            fftBuffer = new Complex[fftLength];
            fftArgs = new FftEventArgs(fftBuffer, Channel);//channel select
            this.source = source;
        }
 
        static bool IsPowerOfTwo(int x)
        {
            return (x & (x - 1)) == 0;
        }
 
        public void Reset()
        {
            count = 0;
            maxValue = minValue = 0;
        }
 
        private void Add(float value)
        {
            if (PerformFFT && FftCalculated != null)
            {
                //Y는 허수부분. 오디오에 허수는 없으므로 Y는 0
                fftBuffer[fftPos].X = (float)(value * FastFourierTransform.HammingWindow(fftPos, fftLength));
                fftBuffer[fftPos].Y = 0;
                fftPos++;
                //fft length만큼의 sample이 모이면 fft 연산을 하고 이벤트를 발생시킨다.
                if (fftPos >= fftBuffer.Length)
                {
                    fftPos = 0;
                    FastFourierTransform.FFT(true, m, fftBuffer);
                    FftCalculated(this, fftArgs);
                }
            }
 
            maxValue = Math.Max(maxValue, value);
            minValue = Math.Min(minValue, value);
            count++;
            if (count >= NotificationCount && NotificationCount > 0)
            {
                MaximumCalculated?.Invoke(this, new MaxSampleEventArgs(minValue, maxValue));
                Reset();
            }
        }
 
        public WaveFormat WaveFormat => source.WaveFormat;
 
        public int Read(float[] buffer, int offset, int count)
        {
            var samplesRead = source.Read(buffer, offset, count);
 
            for (int n = 0; n < samplesRead; n += channels)
            {
                //원하는 채널의 데이터만 가져온다.
                 Add(buffer[n + offset+ Channel-1]);               
            }
            return samplesRead;
        }
    }
 
    public class MaxSampleEventArgs : EventArgs
    {
        [DebuggerStepThrough]
        public MaxSampleEventArgs(float minValue, float maxValue)
        {
            MaxSample = maxValue;
            MinSample = minValue;
        }
        public float MaxSample { get; private set; }
        public float MinSample { get; private set; }
    }
 
    public class FftEventArgs : EventArgs
    {
        [DebuggerStepThrough]
        public FftEventArgs(Complex[] result, int channel)
        {
            Result = result;
            Channel = channel;
        }
        public Complex[] Result { get; private set; }
        public int Channel { get; private set; }
    }
}
 
Colored by Color Scripter

원본 예제는 재생 중에 이 기능을 쓰니까, 일종의 signal chain 에 편입시키기 위해서 이렇게 따로 클래스를 만들지만 내 경우에는 그럴 필요는 없는데... 수정하기에 시간이 좀 걸리지 싶었다.

 

 

출처 : https://m.blog.naver.com/luku756/221953064277

'개발언어 > NAudio' 카테고리의 다른 글

[NAudio] 반복재생하기  (0) 2021.07.08
[NAudio] 재생 속도 조절  (0) 2021.07.08
[NAudio] 6. Recording Audio  (0) 2021.07.08
[NAudio] 5. Working With Codecs  (0) 2021.07.08
[NAuidio] 4. Changing Wave Formats  (0) 2021.07.08