Beyley_soweli-pi-sona/sowelipisona/ManagedBass/ManagedBassWaveform.cs
using System.Diagnostics;
using System.Drawing;
using ManagedBass;

namespace sowelipisona.ManagedBass;

//Most code taken from https://github.com/ppy/osu-framework/blob/master/osu.Framework/Audio/Track/Waveform.cs
//Original license: MIT
internal class ManagedBassWaveform {
	private readonly Stream _stream;

	public ManagedBassWaveform(Stream stream) {
		this._stream = stream ?? throw new ArgumentNullException(nameof (stream));
	}

	/// <summary>
	///     <see cref="Point" />s are initially generated to a 1ms resolution to cover most use cases.
	/// </summary>
	private const float RESOLUTION = 0.001f;

	/// <summary>
	///     The data stream is iteratively decoded to provide this many points per iteration so as to not exceed BASS's
	///     internal buffer size.
	/// </summary>
	private const int POINTS_PER_ITERATION = 1000;

	/// <summary>
	///     FFT1024 gives ~40hz accuracy.
	/// </summary>
	private const DataFlags FFT_SAMPLES = DataFlags.FFT1024;

	/// <summary>
	///     Number of bins generated by the FFT. Must correspond to <see cref="FFT_SAMPLES" />.
	/// </summary>
	private const int FFT_BINS = 512;

	/// <summary>
	///     Minimum frequency for low-range (bass) frequencies. Based on lower range of bass drum fallout.
	/// </summary>
	private const float LOW_MIN = 20;

	/// <summary>
	///     Minimum frequency for mid-range frequencies. Based on higher range of bass drum fallout.
	/// </summary>
	private const float MID_MIN = 100;

	/// <summary>
	///     Minimum frequency for high-range (treble) frequencies.
	/// </summary>
	private const float HIGH_MIN = 2000;

	/// <summary>
	///     Maximum frequency for high-range (treble) frequencies. A sane value.
	/// </summary>
	private const float HIGH_MAX = 12000;

	private const int BYTES_PER_SAMPLE = 4;

	public Waveform GetWaveform() {
		Waveform waveform = new Waveform();

		if (Bass.CurrentDevice < 0)
			throw new Exception("This code cannot currently run if there is no device available");

		int decodeStream = Bass.CreateStream(StreamSystem.NoBuffer, BassFlags.Decode | BassFlags.Float, new FileProcedures {
			Close  = this.FileProcClose,
			Length = this.FileProcLength,
			Read   = this.FileProcRead,
			Seek   = this.FileProcSeek
		});

		float[]? sampleBuffer = null;

		try {
			Bass.ChannelGetInfo(decodeStream, out ChannelInfo info);

			long length     = Bass.ChannelGetLength(decodeStream);
			double miliLength = Bass.ChannelBytes2Seconds(decodeStream, length) * 1000;
			
			int samplesPerPoint = (int)(info.Frequency * RESOLUTION * info.Channels);

			int bytesPerPoint = samplesPerPoint * BYTES_PER_SAMPLE;

			int pointCount = (int)(length / bytesPerPoint);

			waveform.Points = new Waveform.Point[pointCount];

			// Each iteration pulls in several samples
			int bytesPerIteration = bytesPerPoint * POINTS_PER_ITERATION;

			sampleBuffer = new float[bytesPerIteration / BYTES_PER_SAMPLE];

			int pointIndex = 0;

			waveform.GetPointFromTime = time => {
				//Get the progress from 0-1 of the time
				double progress = time / miliLength;

				//Clamp to 0-1
				progress = Math.Max(Math.Min(progress, 1), 0);
				
				return (int)Math.Floor(pointCount * progress);
			};

			// Read sample data
			while (length > 0) {
				length = Bass.ChannelGetData(decodeStream, sampleBuffer, bytesPerIteration);
				int samplesRead = (int)(length / BYTES_PER_SAMPLE);

				// Each point is composed of multiple samples
				for (int i = 0; i < samplesRead && pointIndex < pointCount; i += samplesPerPoint) {
					// We assume one or more channels.
					// For non-stereo tracks, we'll use the single track for both amplitudes.
					// For anything above two tracks we'll use the first and second track.
					Debug.Assert(info.Channels >= 1);
					int secondChannelIndex = info.Channels > 1 ? 1 : 0;

					// Channels are interleaved in the sample data (data[0] -> channel0, data[1] -> channel1, data[2] -> channel0, etc)
					// samplesPerPoint assumes this interleaving behaviour
					Waveform.Point point = new Waveform.Point();

					for (int j = i; j < i + samplesPerPoint; j += info.Channels) {
						// Find the maximum amplitude for each channel in the point
						point.AmplitudeLeft  = Math.Max(point.AmplitudeLeft, Math.Abs(sampleBuffer[j]));
						point.AmplitudeRight = Math.Max(point.AmplitudeRight, Math.Abs(sampleBuffer[j + secondChannelIndex]));
					}

					// BASS may provide unclipped samples, so clip them ourselves
					point.AmplitudeLeft  = Math.Min(1, point.AmplitudeLeft);
					point.AmplitudeRight = Math.Min(1, point.AmplitudeRight);

					waveform.Points[pointIndex++] = point;
				}
			}

			Bass.ChannelSetPosition(decodeStream, 0);
			length = Bass.ChannelGetLength(decodeStream);

			// Read FFT data
			float[] bins         = new float[FFT_BINS];
			int     currentPoint = 0;
			long    currentByte  = 0;

			while (length > 0) {
				length      =  Bass.ChannelGetData(decodeStream, bins, (int)FFT_SAMPLES);
				currentByte += length;

				float lowIntensity  = this.computeIntensity(info, bins, LOW_MIN, MID_MIN);
				float midIntensity  = this.computeIntensity(info, bins, MID_MIN, HIGH_MIN);
				float highIntensity = this.computeIntensity(info, bins, HIGH_MIN, HIGH_MAX);

				// In general, the FFT function will read more data than the amount of data we have in one point
				// so we'll be setting intensities for all points whose data fits into the amount read by the FFT
				// We know that each data point required sampleDataPerPoint amount of data
				for (; currentPoint < waveform.Points.Length && currentPoint * bytesPerPoint < currentByte; currentPoint++) {
					Waveform.Point point = waveform.Points[currentPoint];
					point.LowIntensity            = lowIntensity;
					point.MidIntensity            = midIntensity;
					point.HighIntensity           = highIntensity;
					waveform.Points[currentPoint] = point;
				}
			}

			waveform.Channels = info.Channels;
		}
		finally {
			Bass.StreamFree(decodeStream);
		}

		return waveform;
	}

	private float computeIntensity(ChannelInfo info, float[] bins, float startFrequency, float endFrequency) {
		int startBin = (int)(FFT_BINS * 2 * startFrequency / info.Frequency);
		int endBin   = (int)(FFT_BINS * 2 * endFrequency   / info.Frequency);

		startBin = Math.Max(Math.Min(startBin, 0), bins.Length);
		endBin   = Math.Max(Math.Min(endBin, 0), bins.Length);

		float value = 0;
		for (int i = startBin; i < endBin; i++)
			value += bins[i];
		return value;
	}

	private void FileProcClose(IntPtr user) {
		//this does nothing...
	}

	private long FileProcLength(IntPtr user) {
		if (this._stream is not { CanSeek: true })
			return 0;

		try {
			return this._stream.Length;
		}
		catch {
			return 0;
		}
	}

	private bool FileProcSeek(long offset, IntPtr user) {
		if (this._stream is not { CanSeek: true })
			return false;

		try {
			return this._stream.Seek(offset, SeekOrigin.Begin) == offset;
		}
		catch {
			return false;
		}
	}

	private int FileProcRead(IntPtr buffer, int length, IntPtr user) {
		if (this._stream is not { CanRead: true })
			return 0;

		try {
			unsafe {
				//Create a new staging array to store the read data in
				byte[] arr = new byte[length];
				//Read the data into the array
				int read = this._stream.Read(arr, 0, length);

				//Copy the array into the memory space, the Math.Min call is to ensure that we dont overcopy, only copying the lowest amount of bytes
				fixed (byte* ptr = arr) {
					Buffer.MemoryCopy(ptr, (void*)buffer, Math.Min(length, read), Math.Min(length, read));
				}

				//Return the bytes read
				return read;
			}
		}
		catch {
			return 0;
		}
	}
}
