Skip to content

GetBitmap: add Rotate argument #48

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Jul 10, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 2 additions & 18 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,10 @@ jobs:
- name: 🛒 Checkout
uses: actions/checkout@v2

- name: ✨ Setup .NET 5
uses: actions/setup-dotnet@v1
with:
dotnet-version: "5.0.x"

- name: ✨ Setup .NET 6
uses: actions/setup-dotnet@v1
with:
dotnet-version: "6.0.x"
include-prerelease: true

- name: 🚚 Restore
run: dotnet restore src
Expand All @@ -42,22 +36,12 @@ jobs:
- name: 📦 Pack
run: dotnet pack src --configuration Release --no-build

- name: 💾 Store Release Package
if: github.event_name == 'release'
uses: actions/upload-artifact@v2
with:
name: Packages
retention-days: 1
path: |
src/Spectrogram/bin/Release/*.nupkg
src/Spectrogram/bin/Release/*.snupkg

- name: 🔑 Configure NuGet Secrets
- name: 🔑 Configure Secrets
if: github.event_name == 'release'
uses: nuget/setup-nuget@v1
with:
nuget-api-key: ${{ secrets.NUGET_API_KEY }}

- name: 🚀 Deploy Release Package
- name: 🚀 Deploy Package
if: github.event_name == 'release'
run: nuget push "src\Spectrogram\bin\Release\*.nupkg" -SkipDuplicate -Source https://api.nuget.org/v3/index.json
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ _"I'm sorry Dave... I'm afraid I can't do that"_
* Source code for the WAV reading method is at the bottom of this page.

```cs
(double[] audio, int sampleRate) = ReadWavMono("hal.wav");
(double[] audio, int sampleRate) = ReadMono("hal.wav");
var sg = new SpectrogramGenerator(sampleRate, fftSize: 4096, stepSize: 500, maxFreq: 3000);
sg.Add(audio);
sg.SaveImage("hal.png");
Expand Down Expand Up @@ -81,7 +81,7 @@ Review the source code of the demo application for additional details and consid
This example demonstrates how to convert a MP3 file to a spectrogram image. A sample MP3 audio file in the [data folder](data) contains the audio track from Ken Barker's excellent piano performance of George Frideric Handel's Suite No. 5 in E major for harpsichord ([_The Harmonious Blacksmith_](https://en.wikipedia.org/wiki/The_Harmonious_Blacksmith)). This audio file is included [with permission](dev/Handel%20-%20Air%20and%20Variations.txt), and the [original video can be viewed on YouTube](https://www.youtube.com/watch?v=Mza-xqk770k).

```cs
(double[] audio, int sampleRate) = ReadWavMono("song.wav");
(double[] audio, int sampleRate) = ReadMono("song.wav");

int fftSize = 16384;
int targetWidthPx = 3000;
Expand Down Expand Up @@ -117,7 +117,7 @@ Spectrogram (2993, 817)
These examples demonstrate the identical spectrogram analyzed with a variety of different colormaps. Spectrogram colormaps can be changed by calling the `SetColormap()` method:

```cs
(double[] audio, int sampleRate) = ReadWavMono("hal.wav");
(double[] audio, int sampleRate) = ReadMono("hal.wav");
var sg = new SpectrogramGenerator(sampleRate, fftSize: 8192, stepSize: 200, maxFreq: 3000);
sg.Add(audio);
sg.SetColormap(Colormap.Jet);
Expand All @@ -141,7 +141,7 @@ Cropped Linear Scale (0-3kHz) | Mel Scale (0-22 kHz)
Amplitude perception in humans, like frequency perception, is logarithmic. Therefore, Mel spectrograms typically display log-transformed spectral power and are presented using Decibel units.

```cs
(double[] audio, int sampleRate) = ReadWavMono("hal.wav");
(double[] audio, int sampleRate) = ReadMono("hal.wav");
var sg = new SpectrogramGenerator(sampleRate, fftSize: 4096, stepSize: 500, maxFreq: 3000);
sg.Add(audio);

Expand All @@ -153,12 +153,12 @@ Bitmap bmp = sg.GetBitmapMel(melSizePoints: 250);
bmp.Save("halMel.png", ImageFormat.Png);
```

## Read data from a WAV File
## Read Data from an Audio File

You should customize your file-reading method to suit your specific application. I frequently use the NAudio package to read data from WAV and MP3 files. This function reads audio data from a mono WAV file and will be used for the examples on this page.

```cs
(double[] audio, int sampleRate) ReadWavMono(string filePath, double multiplier = 16_000)
(double[] audio, int sampleRate) ReadMono(string filePath, double multiplier = 16_000)
{
using var afr = new NAudio.Wave.AudioFileReader(filePath);
int sampleRate = afr.WaveFormat.SampleRate;
Expand Down
2 changes: 1 addition & 1 deletion src/Spectrogram.MicrophoneDemo/Spectrogram.Demo.csproj
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0-windows</TargetFramework>
<TargetFramework>net6.0-windows</TargetFramework>
<OutputType>WinExe</OutputType>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<UseWindowsForms>true</UseWindowsForms>
Expand Down
21 changes: 21 additions & 0 deletions src/Spectrogram.Tests/ImageTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using NUnit.Framework;

namespace Spectrogram.Tests;

internal class ImageTests
{
[Test]
public void Test_Image_Rotations()
{
string filePath = $"../../../../../data/cant-do-that-44100.wav";
(double[] audio, int sampleRate) = AudioFile.ReadWAV(filePath);
SpectrogramGenerator sg = new(sampleRate, 4096, 500, maxFreq: 3000);
sg.Add(audio);

System.Drawing.Bitmap bmp1 = sg.GetBitmap(rotate: false);
bmp1.Save("test-image-original.png");

System.Drawing.Bitmap bmp2 = sg.GetBitmap(rotate: true);
bmp2.Save("test-image-rotated.png");
}
}
2 changes: 1 addition & 1 deletion src/Spectrogram.Tests/Spectrogram.Tests.csproj
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<TargetFramework>net6.0</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>

Expand Down
59 changes: 13 additions & 46 deletions src/Spectrogram/Image.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,55 +10,22 @@ namespace Spectrogram
{
public static class Image
{
public static Bitmap GetBitmap(
List<double[]> ffts,
Colormap cmap,
double intensity = 1,
bool dB = false,
double dBScale = 1,
bool roll = false,
int rollOffset = 0)
public static Bitmap GetBitmap(List<double[]> ffts, Colormap cmap, double intensity = 1,
bool dB = false, double dBScale = 1, bool roll = false, int rollOffset = 0, bool rotate = false)
{
if (ffts.Count == 0)
throw new ArgumentException("Not enough data in FFTs to generate an image yet.");

int Width = ffts.Count;
int Height = ffts[0].Length;

Bitmap bmp = new Bitmap(Width, Height, PixelFormat.Format8bppIndexed);
cmap.Apply(bmp);

var lockRect = new Rectangle(0, 0, Width, Height);
BitmapData bitmapData = bmp.LockBits(lockRect, ImageLockMode.ReadOnly, bmp.PixelFormat);
int stride = bitmapData.Stride;

byte[] bytes = new byte[bitmapData.Stride * bmp.Height];
Parallel.For(0, Width, col =>
ImageMaker maker = new()
{
int sourceCol = col;
if (roll)
{
sourceCol += Width - rollOffset % Width;
if (sourceCol >= Width)
sourceCol -= Width;
}

for (int row = 0; row < Height; row++)
{
double value = ffts[sourceCol][row];
if (dB)
value = 20 * Math.Log10(value * dBScale + 1);
value *= intensity;
value = Math.Min(value, 255);
int bytePosition = (Height - 1 - row) * stride + col;
bytes[bytePosition] = (byte)value;
}
});

Marshal.Copy(bytes, 0, bitmapData.Scan0, bytes.Length);
bmp.UnlockBits(bitmapData);

return bmp;
Colormap = cmap,
Intensity = intensity,
IsDecibel = dB,
DecibelScaleFactor = dBScale,
IsRoll = roll,
RollOffset = rollOffset,
IsRotated = rotate,
};

return maker.GetBitmap(ffts);
}
}
}
101 changes: 101 additions & 0 deletions src/Spectrogram/ImageMaker.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Imaging;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks;

namespace Spectrogram
{
/// <summary>
/// This class converts a collection of FFTs to a colormapped spectrogram image
/// </summary>
public class ImageMaker
{
/// <summary>
/// Colormap used to translate intensity to pixel color
/// </summary>
public Colormap Colormap;

/// <summary>
/// Intensity is multiplied by this number before converting it to the pixel color according to the colormap
/// </summary>
public double Intensity = 1;

/// <summary>
/// If True, intensity will be log-scaled to represent Decibels
/// </summary>
public bool IsDecibel = false;

/// <summary>
/// If <see cref="IsDecibel"/> is enabled, intensity will be scaled by this value prior to log transformation
/// </summary>
public double DecibelScaleFactor = 1;

/// <summary>
/// If False, the spectrogram will proceed in time from left to right across the whole image.
/// If True, the image will be broken and the newest FFTs will appear on the left and oldest on the right.
/// </summary>
public bool IsRoll = false;

/// <summary>
/// If <see cref="IsRoll"/> is enabled, this value indicates the pixel position of the break point.
/// </summary>
public int RollOffset = 0;

/// <summary>
/// If True, the spectrogram will flow top-down (oldest to newest) rather than left-right.
/// </summary>
public bool IsRotated = false;

public ImageMaker()
{

}

public Bitmap GetBitmap(List<double[]> ffts)
{
if (ffts.Count == 0)
throw new ArgumentException("Not enough data in FFTs to generate an image yet.");

int Width = IsRotated ? ffts[0].Length : ffts.Count;
int Height = IsRotated ? ffts.Count : ffts[0].Length;

Bitmap bmp = new(Width, Height, PixelFormat.Format8bppIndexed);
Colormap.Apply(bmp);

Rectangle lockRect = new(0, 0, Width, Height);
BitmapData bitmapData = bmp.LockBits(lockRect, ImageLockMode.ReadOnly, bmp.PixelFormat);
int stride = bitmapData.Stride;

byte[] bytes = new byte[bitmapData.Stride * bmp.Height];
Parallel.For(0, Width, col =>
{
int sourceCol = col;
if (IsRoll)
{
sourceCol += Width - RollOffset % Width;
if (sourceCol >= Width)
sourceCol -= Width;
}

for (int row = 0; row < Height; row++)
{
double value = IsRotated ? ffts[row][sourceCol] : ffts[sourceCol][row];
if (IsDecibel)
value = 20 * Math.Log10(value * DecibelScaleFactor + 1);
value *= Intensity;
value = Math.Min(value, 255);
int bytePosition = (Height - 1 - row) * stride + col;
bytes[bytePosition] = (byte)value;
}
});

Marshal.Copy(bytes, 0, bitmapData.Scan0, bytes.Length);
bmp.UnlockBits(bitmapData);

return bmp;
}
}
}
5 changes: 3 additions & 2 deletions src/Spectrogram/Spectrogram.csproj
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<Version>1.5.0</Version>
<Version>1.6.0</Version>
<Description>A .NET Standard library for creating spectrograms</Description>
<Authors>Scott Harden</Authors>
<Company>Harden Technologies, LLC</Company>
Expand All @@ -21,6 +21,7 @@
<EmbedUntrackedSources>true</EmbedUntrackedSources>
<Deterministic>true</Deterministic>
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
<LangVersion>latest</LangVersion>
</PropertyGroup>

<ItemGroup>
Expand Down
5 changes: 3 additions & 2 deletions src/Spectrogram/SpectrogramGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -267,15 +267,16 @@ public List<double[]> GetMelFFTs(int melBinCount)
/// <param name="dB">If true, output will be log-transformed.</param>
/// <param name="dBScale">If dB scaling is in use, this multiplier will be applied before log transformation.</param>
/// <param name="roll">Behavior of the spectrogram when it is full of data.
/// <param name="rotate">If True, the image will be rotated so time flows from top to bottom (rather than left to right).
/// Roll (true) adds new columns on the left overwriting the oldest ones.
/// Scroll (false) slides the whole image to the left and adds new columns to the right.</param>
public Bitmap GetBitmap(double intensity = 1, bool dB = false, double dBScale = 1, bool roll = false)
public Bitmap GetBitmap(double intensity = 1, bool dB = false, double dBScale = 1, bool roll = false, bool rotate = false)
{
if (FFTs.Count == 0)
throw new InvalidOperationException("Not enough data to create an image. " +
$"Ensure {nameof(Width)} is >0 before calling {nameof(GetBitmap)}().");

return Image.GetBitmap(FFTs, Colormap, intensity, dB, dBScale, roll, NextColumnIndex);
return Image.GetBitmap(FFTs, Colormap, intensity, dB, dBScale, roll, NextColumnIndex, rotate);
}

/// <summary>
Expand Down