diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 30c0ab38..01e2408a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,11 +29,50 @@ jobs: shell: pwsh working-directory: LiveSPICEVst run: dotnet publish -c Release --framework net8.0-windows /p:DebugType=None /p:UseSharedCompilation=false /p:UseRazorBuildServer=false + # --check asserts results against Tests/Stats/*.csv; --sampleRate 44100 because + # that is the configuration the committed baselines were generated with. - name: Run circuit tests shell: pwsh working-directory: Tests - run: dotnet run -c Release --framework net6.0-windows test "Circuits\*.schx" + run: dotnet run -c Release --framework net6.0-windows test "Circuits\*.schx" --check --sampleRate 44100 - name: Run examples shell: pwsh working-directory: Tests - run: dotnet run -c Release --framework net6.0-windows test "Examples\*.schx" + run: dotnet run -c Release --framework net6.0-windows test "Examples\*.schx" --check --sampleRate 44100 + + test-macos: + name: Test (macOS) + runs-on: macos-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + submodules: true + - uses: actions/setup-dotnet@v2 + with: + # https://dotnet.microsoft.com/en-us/download/dotnet + dotnet-version: 8.0.x + # LiveSPICE/LiveSPICEVst are WPF and can't build here; LiveSPICE.Core.sln is the + # portable subset (Util, Audio, Circuit, ComputerAlgebra, Tests). + - name: Build portable projects + run: dotnet build LiveSPICE.Core.sln -c Release + # Same assertion as the Windows job: macOS reproduces the Windows-generated + # baselines to ~1e-12 (scale-normalized) at this configuration. + - name: Run circuit tests + working-directory: Tests + run: dotnet run -c Release --framework net8.0 --no-build test "Circuits/*.schx" --check --sampleRate 44100 + - name: Run examples + working-directory: Tests + run: dotnet run -c Release --framework net8.0 --no-build test "Examples/*.schx" --check --sampleRate 44100 + # The live audio path needs a device, which CI runners don't have. The offline render path + # exercises schematic load, solve, codegen and the buffer loop without one. + - name: List audio devices + working-directory: LiveSPICE.CLI + run: dotnet run -c Release --no-build -- list + - name: Render a circuit offline + working-directory: LiveSPICE.CLI + run: | + dotnet run -c Release --no-build -- tone --output /tmp/tone.wav --frequency 82 --seconds 1 + dotnet run -c Release --no-build -- render \ + --schematic "../Tests/Examples/Passive 1stOrder Lowpass RC.schx" \ + --input /tmp/tone.wav --output /tmp/out.wav diff --git a/Circuit/Circuit.csproj b/Circuit/Circuit.csproj index 9a4e5cd0..cad3f31f 100644 --- a/Circuit/Circuit.csproj +++ b/Circuit/Circuit.csproj @@ -1,6 +1,6 @@  - netstandard2.0 + netstandard2.0;net8.0 Library Circuit Circuit diff --git a/Circuit/Schematic/Schematic.cs b/Circuit/Schematic/Schematic.cs index 1f528c14..4c2ce65c 100644 --- a/Circuit/Schematic/Schematic.cs +++ b/Circuit/Schematic/Schematic.cs @@ -1,6 +1,7 @@ using ComputerAlgebra; using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Runtime.InteropServices; using System.Xml.Linq; @@ -346,13 +347,15 @@ private void LogComponents() Log.WriteLine(MessageType.Verbose, " (" + Wires.Count() + " wires)"); } - // The .NET wrapper for this doesn't support allowing overwriting until .NET 8 :( +#if !NET8_0_OR_GREATER + // File.Move can't overwrite when targeting netstandard2.0, so fall back to the Win32 call there. [return: MarshalAs(UnmanagedType.Bool)] [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] static extern bool MoveFileEx(string existingFileName, string newFileName, int flags); static int MOVEFILE_REPLACE_EXISTING = 1; static int MOVEFILE_COPY_ALLOWED = 2; +#endif public void Save(string FileName) { @@ -362,6 +365,10 @@ public void Save(string FileName) // VST plugins are watching for changes. string temp = FileName + ".temp"; doc.Save(temp); +#if NET8_0_OR_GREATER + // Overwriting File.Move is atomic where the platform supports it, and is portable. + File.Move(temp, FileName, overwrite: true); +#else if (!MoveFileEx(temp, FileName, MOVEFILE_COPY_ALLOWED | MOVEFILE_REPLACE_EXISTING)) { // If the MoveFileEx call failed, just save it the regular way. This should never @@ -371,6 +378,7 @@ public void Save(string FileName) // be written for some reason). doc.Save(FileName); } +#endif Log.WriteLine(MessageType.Info, "Schematic saved to '" + FileName + "'"); LogComponents(); } diff --git a/CoreAudio/AudioUnitApi.cs b/CoreAudio/AudioUnitApi.cs new file mode 100644 index 00000000..d64bc8c5 --- /dev/null +++ b/CoreAudio/AudioUnitApi.cs @@ -0,0 +1,217 @@ +using System; +using System.Runtime.InteropServices; + +namespace CoreAudio +{ + [StructLayout(LayoutKind.Sequential)] + internal struct AudioComponentDescription + { + public uint componentType; + public uint componentSubType; + public uint componentManufacturer; + public uint componentFlags; + public uint componentFlagsMask; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct AudioStreamBasicDescription + { + public double mSampleRate; + public uint mFormatID; + public uint mFormatFlags; + public uint mBytesPerPacket; + public uint mFramesPerPacket; + public uint mBytesPerFrame; + public uint mChannelsPerFrame; + public uint mBitsPerChannel; + public uint mReserved; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct AudioTimeStamp + { + public double mSampleTime; + public ulong mHostTime; + public double mRateScalar; + public ulong mWordClockTime; + public long mSMPTETime1; + public long mSMPTETime2; + public long mSMPTETime3; + public uint mFlags; + public uint mReserved; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct AURenderCallbackStruct + { + public AudioUnitApi.AURenderCallback inputProc; + public IntPtr inputProcRefCon; + } + + /// + /// Helper for building and reading the variable-length AudioBufferList structure by hand. + /// The layout is { uint mNumberBuffers; AudioBuffer mBuffers[]; } where AudioBuffer is + /// { uint mNumberChannels; uint mDataByteSize; void* mData; }. The list is pointer-aligned, + /// so the first buffer starts at IntPtr.Size, not at 4. + /// + internal static class AudioBufferList + { + // uint + uint + pointer, rounded up to pointer alignment. + public static readonly int AudioBufferSize = IntPtr.Size == 8 ? 16 : 12; + + public static int SizeOf(int Buffers) + { + return IntPtr.Size + Buffers * AudioBufferSize; + } + + /// Allocate a zeroed AudioBufferList for the given number of non-interleaved buffers. + public static IntPtr Allocate(int Buffers) + { + int size = SizeOf(Buffers); + IntPtr list = Marshal.AllocHGlobal(size); + for (int i = 0; i < size; ++i) + Marshal.WriteByte(list, i, 0); + Marshal.WriteInt32(list, 0, Buffers); + return list; + } + + public static int GetCount(IntPtr List) + { + return Marshal.ReadInt32(List, 0); + } + + public static void SetCount(IntPtr List, int Count) + { + Marshal.WriteInt32(List, 0, Count); + } + + private static int Offset(int Index) + { + return IntPtr.Size + Index * AudioBufferSize; + } + + public static void SetBuffer(IntPtr List, int Index, int Channels, int ByteSize, IntPtr Data) + { + int offset = Offset(Index); + Marshal.WriteInt32(List, offset, Channels); + Marshal.WriteInt32(List, offset + 4, ByteSize); + Marshal.WriteIntPtr(List, offset + 8, Data); + } + + public static IntPtr GetData(IntPtr List, int Index) + { + return Marshal.ReadIntPtr(List, Offset(Index) + 8); + } + + public static int GetDataByteSize(IntPtr List, int Index) + { + return Marshal.ReadInt32(List, Offset(Index) + 4); + } + + public static int GetChannels(IntPtr List, int Index) + { + return Marshal.ReadInt32(List, Offset(Index)); + } + + public static void SetDataByteSize(IntPtr List, int Index, int ByteSize) + { + Marshal.WriteInt32(List, Offset(Index) + 4, ByteSize); + } + } + + internal static class AudioUnitApi + { + private const string AudioToolbox = "/System/Library/Frameworks/AudioToolbox.framework/AudioToolbox"; + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + public delegate int AURenderCallback( + IntPtr inRefCon, ref uint ioActionFlags, ref AudioTimeStamp inTimeStamp, + uint inBusNumber, uint inNumberFrames, IntPtr ioData); + + public static uint FourCC(string s) { return CoreAudioApi.FourCC(s); } + + public static readonly uint kAudioUnitType_Output = FourCC("auou"); + public static readonly uint kAudioUnitSubType_HALOutput = FourCC("ahal"); + public static readonly uint kAudioUnitManufacturer_Apple = FourCC("appl"); + public static readonly uint kAudioFormatLinearPCM = FourCC("lpcm"); + + public const uint kAudioFormatFlagIsFloat = 1 << 0; + public const uint kAudioFormatFlagIsPacked = 1 << 3; + public const uint kAudioFormatFlagIsNonInterleaved = 1 << 5; + + public const uint kAudioUnitProperty_StreamFormat = 8; + public const uint kAudioUnitProperty_SetRenderCallback = 23; + public const uint kAudioUnitProperty_MaximumFramesPerSlice = 14; + public const uint kAudioOutputUnitProperty_CurrentDevice = 2000; + public const uint kAudioOutputUnitProperty_EnableIO = 2003; + public const uint kAudioOutputUnitProperty_SetInputCallback = 2005; + + public const uint kAudioUnitScope_Global = 0; + public const uint kAudioUnitScope_Input = 1; + public const uint kAudioUnitScope_Output = 2; + + // AUHAL bus numbering: bus 0 is output to the device, bus 1 is input from it. + public const uint OutputBus = 0; + public const uint InputBus = 1; + + [DllImport(AudioToolbox)] + public static extern IntPtr AudioComponentFindNext(IntPtr inComponent, ref AudioComponentDescription inDesc); + + [DllImport(AudioToolbox)] + public static extern int AudioComponentInstanceNew(IntPtr inComponent, out IntPtr outInstance); + + [DllImport(AudioToolbox)] + public static extern int AudioComponentInstanceDispose(IntPtr inInstance); + + [DllImport(AudioToolbox)] + public static extern int AudioUnitInitialize(IntPtr inUnit); + + [DllImport(AudioToolbox)] + public static extern int AudioUnitUninitialize(IntPtr inUnit); + + [DllImport(AudioToolbox)] + public static extern int AudioOutputUnitStart(IntPtr ci); + + [DllImport(AudioToolbox)] + public static extern int AudioOutputUnitStop(IntPtr ci); + + [DllImport(AudioToolbox)] + public static extern int AudioUnitRender( + IntPtr inUnit, ref uint ioActionFlags, ref AudioTimeStamp inTimeStamp, + uint inOutputBusNumber, uint inNumberFrames, IntPtr ioData); + + [DllImport(AudioToolbox)] + public static extern int AudioUnitSetProperty( + IntPtr inUnit, uint inID, uint inScope, uint inElement, ref uint inData, uint inDataSize); + + [DllImport(AudioToolbox)] + public static extern int AudioUnitSetProperty( + IntPtr inUnit, uint inID, uint inScope, uint inElement, ref AudioStreamBasicDescription inData, uint inDataSize); + + [DllImport(AudioToolbox)] + public static extern int AudioUnitSetProperty( + IntPtr inUnit, uint inID, uint inScope, uint inElement, ref AURenderCallbackStruct inData, uint inDataSize); + + [DllImport(AudioToolbox)] + public static extern int AudioUnitGetProperty( + IntPtr inUnit, uint inID, uint inScope, uint inElement, ref AudioStreamBasicDescription outData, ref uint ioDataSize); + + /// The canonical non-interleaved 32 bit float format, one buffer per channel. + public static AudioStreamBasicDescription NonInterleavedFloat32(double SampleRate, int Channels) + { + return new AudioStreamBasicDescription() + { + mSampleRate = SampleRate, + mFormatID = kAudioFormatLinearPCM, + mFormatFlags = kAudioFormatFlagIsFloat | kAudioFormatFlagIsPacked | kAudioFormatFlagIsNonInterleaved, + // For non-interleaved formats these are per-channel, so they describe one buffer. + mBytesPerPacket = sizeof(float), + mFramesPerPacket = 1, + mBytesPerFrame = sizeof(float), + mChannelsPerFrame = (uint)Channels, + mBitsPerChannel = 8 * sizeof(float), + mReserved = 0, + }; + } + } +} diff --git a/CoreAudio/CoreAudio.csproj b/CoreAudio/CoreAudio.csproj new file mode 100644 index 00000000..5802a998 --- /dev/null +++ b/CoreAudio/CoreAudio.csproj @@ -0,0 +1,18 @@ + + + netstandard2.0 + Library + CoreAudio + CoreAudio + Copyright © 2020 + 1.0.0.0 + 1.0.0.0 + + + + + + + + + diff --git a/CoreAudio/CoreAudioApi.cs b/CoreAudio/CoreAudioApi.cs new file mode 100644 index 00000000..c5c867e9 --- /dev/null +++ b/CoreAudio/CoreAudioApi.cs @@ -0,0 +1,313 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Text; + +namespace CoreAudio +{ + /// + /// Most Core Audio results are four character codes rather than a small dense enum, so unlike + /// MMRESULT in WaveAudio there is nothing useful to name them with. Render the code as ASCII + /// when it is printable, and fall back to the numeric value. + /// + public class CoreAudioException : Exception + { + private int status; + public int Status { get { return status; } } + + public CoreAudioException(int Status) : base(FourCC(Status)) { status = Status; } + public CoreAudioException(string Message, int Status) : base(Message + " (" + FourCC(Status) + ")") { status = Status; } + + public static void CheckThrow(int Status) + { + if (Status != NoError) + throw new CoreAudioException(Status); + } + + public static void CheckThrow(string Message, int Status) + { + if (Status != NoError) + throw new CoreAudioException(Message, Status); + } + + public const int NoError = 0; + + public static string FourCC(int Status) + { + byte[] bytes = new byte[] + { + (byte)((Status >> 24) & 0xFF), + (byte)((Status >> 16) & 0xFF), + (byte)((Status >> 8) & 0xFF), + (byte)(Status & 0xFF), + }; + foreach (byte i in bytes) + { + // Not a printable four character code, so the number is all we have. + if (i < 0x20 || i > 0x7E) + return Status.ToString(); + } + return "'" + Encoding.ASCII.GetString(bytes) + "' (" + Status.ToString() + ")"; + } + } + + [StructLayout(LayoutKind.Sequential)] + internal struct AudioObjectPropertyAddress + { + public uint mSelector; + public uint mScope; + public uint mElement; + + public AudioObjectPropertyAddress(uint Selector, uint Scope) + { + mSelector = Selector; + mScope = Scope; + mElement = CoreAudioApi.kAudioObjectPropertyElementMain; + } + } + + /// + /// The Core Audio HAL property API, used for device discovery. The AudioUnit API used to + /// actually move samples is in AudioUnitApi.cs. + /// + internal static class CoreAudioApi + { + private const string CoreAudioFramework = "/System/Library/Frameworks/CoreAudio.framework/CoreAudio"; + private const string CoreFoundationFramework = "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation"; + + public const uint kAudioObjectSystemObject = 1; + public const uint kAudioObjectPropertyElementMain = 0; + + public static uint FourCC(string s) + { + return ((uint)s[0] << 24) | ((uint)s[1] << 16) | ((uint)s[2] << 8) | (uint)s[3]; + } + + // Selectors and scopes. These are four character codes in the headers; spelling them out + // here keeps them greppable against Apple's documentation. + public static readonly uint kAudioHardwarePropertyDevices = FourCC("dev#"); + public static readonly uint kAudioHardwarePropertyDefaultInputDevice = FourCC("dIn "); + public static readonly uint kAudioHardwarePropertyDefaultOutputDevice = FourCC("dOut"); + public static readonly uint kAudioObjectPropertyName = FourCC("lnam"); + public static readonly uint kAudioObjectPropertyElementName = FourCC("lchn"); + public static readonly uint kAudioDevicePropertyStreamConfiguration = FourCC("slay"); + public static readonly uint kAudioDevicePropertyNominalSampleRate = FourCC("nsrt"); + public static readonly uint kAudioDevicePropertyBufferFrameSize = FourCC("fsiz"); + + public static readonly uint kAudioObjectPropertyScopeGlobal = FourCC("glob"); + public static readonly uint kAudioObjectPropertyScopeInput = FourCC("inpt"); + public static readonly uint kAudioObjectPropertyScopeOutput = FourCC("outp"); + + [DllImport(CoreAudioFramework)] + public static extern int AudioObjectGetPropertyDataSize( + uint inObjectID, ref AudioObjectPropertyAddress inAddress, + uint inQualifierDataSize, IntPtr inQualifierData, out uint outDataSize); + + [DllImport(CoreAudioFramework)] + public static extern int AudioObjectGetPropertyData( + uint inObjectID, ref AudioObjectPropertyAddress inAddress, + uint inQualifierDataSize, IntPtr inQualifierData, ref uint ioDataSize, IntPtr outData); + + [DllImport(CoreAudioFramework)] + public static extern int AudioObjectSetPropertyData( + uint inObjectID, ref AudioObjectPropertyAddress inAddress, + uint inQualifierDataSize, IntPtr inQualifierData, uint inDataSize, IntPtr inData); + + [DllImport(CoreFoundationFramework)] + private static extern IntPtr CFStringGetCStringPtr(IntPtr theString, uint encoding); + + [DllImport(CoreFoundationFramework)] + [return: MarshalAs(UnmanagedType.I1)] + private static extern bool CFStringGetCString(IntPtr theString, byte[] buffer, long bufferSize, uint encoding); + + [DllImport(CoreFoundationFramework)] + private static extern void CFRelease(IntPtr cf); + + private const uint kCFStringEncodingUTF8 = 0x08000100; + + /// Read a fixed-size property into a blittable struct. + public static T GetProperty(uint Object, uint Selector, uint Scope, uint Element = kAudioObjectPropertyElementMain) where T : struct + { + AudioObjectPropertyAddress address = new AudioObjectPropertyAddress(Selector, Scope) { mElement = Element }; + uint size = (uint)Marshal.SizeOf(typeof(T)); + IntPtr buffer = Marshal.AllocHGlobal((int)size); + try + { + CoreAudioException.CheckThrow(AudioObjectGetPropertyData(Object, ref address, 0, IntPtr.Zero, ref size, buffer)); + return (T)Marshal.PtrToStructure(buffer, typeof(T)); + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + + public static void SetProperty(uint Object, uint Selector, uint Scope, T Value) where T : struct + { + AudioObjectPropertyAddress address = new AudioObjectPropertyAddress(Selector, Scope); + uint size = (uint)Marshal.SizeOf(typeof(T)); + IntPtr buffer = Marshal.AllocHGlobal((int)size); + try + { + Marshal.StructureToPtr(Value, buffer, false); + CoreAudioException.CheckThrow(AudioObjectSetPropertyData(Object, ref address, 0, IntPtr.Zero, size, buffer)); + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + + /// Read a variable-size property into raw memory. Caller frees via Marshal.FreeHGlobal. + public static IntPtr GetPropertyRaw(uint Object, uint Selector, uint Scope, out uint Size, uint Element = kAudioObjectPropertyElementMain) + { + AudioObjectPropertyAddress address = new AudioObjectPropertyAddress(Selector, Scope) { mElement = Element }; + CoreAudioException.CheckThrow(AudioObjectGetPropertyDataSize(Object, ref address, 0, IntPtr.Zero, out Size)); + IntPtr buffer = Marshal.AllocHGlobal((int)Math.Max(Size, 1)); + try + { + uint size = Size; + CoreAudioException.CheckThrow(AudioObjectGetPropertyData(Object, ref address, 0, IntPtr.Zero, ref size, buffer)); + Size = size; + return buffer; + } + catch + { + Marshal.FreeHGlobal(buffer); + throw; + } + } + + /// Read a CFString property and marshal it to a managed string. + public static string GetStringProperty(uint Object, uint Selector, uint Scope, uint Element = kAudioObjectPropertyElementMain) + { + IntPtr cfstring = IntPtr.Zero; + AudioObjectPropertyAddress address = new AudioObjectPropertyAddress(Selector, Scope) { mElement = Element }; + uint size = (uint)IntPtr.Size; + IntPtr buffer = Marshal.AllocHGlobal((int)size); + try + { + int status = AudioObjectGetPropertyData(Object, ref address, 0, IntPtr.Zero, ref size, buffer); + if (status != CoreAudioException.NoError) + return null; + cfstring = Marshal.ReadIntPtr(buffer); + if (cfstring == IntPtr.Zero) + return null; + return CFStringToString(cfstring); + } + finally + { + Marshal.FreeHGlobal(buffer); + if (cfstring != IntPtr.Zero) + CFRelease(cfstring); + } + } + + private static string CFStringToString(IntPtr CFString) + { + // Fast path: the CFString may already have a UTF-8 backing store we can read directly. + IntPtr ptr = CFStringGetCStringPtr(CFString, kCFStringEncodingUTF8); + if (ptr != IntPtr.Zero) + return Marshal.PtrToStringAnsi(ptr); + + byte[] bytes = new byte[1024]; + if (!CFStringGetCString(CFString, bytes, bytes.Length, kCFStringEncodingUTF8)) + return null; + int length = Array.IndexOf(bytes, (byte)0); + return Encoding.UTF8.GetString(bytes, 0, length < 0 ? bytes.Length : length); + } + + public static uint[] EnumerateDevices() + { + uint size; + IntPtr buffer = GetPropertyRaw(kAudioObjectSystemObject, kAudioHardwarePropertyDevices, kAudioObjectPropertyScopeGlobal, out size); + try + { + uint[] devices = new uint[size / sizeof(uint)]; + for (int i = 0; i < devices.Length; ++i) + devices[i] = (uint)Marshal.ReadInt32(buffer, i * sizeof(uint)); + return devices; + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + + /// + /// Total channel count for a scope, summed over the device's streams. The property is an + /// AudioBufferList: a uint count followed by that many AudioBuffer structs. + /// + public static int GetChannelCount(uint Device, uint Scope) + { + uint size; + IntPtr buffer; + try + { + buffer = GetPropertyRaw(Device, kAudioDevicePropertyStreamConfiguration, Scope, out size); + } + catch (CoreAudioException) + { + // A device with no streams in this scope may refuse the property outright. + return 0; + } + try + { + if (size < sizeof(uint)) + return 0; + int count = Marshal.ReadInt32(buffer); + int channels = 0; + // AudioBuffer is { uint mNumberChannels; uint mDataByteSize; void* mData; }, but it + // is laid out after a pointer-aligned mNumberBuffers field. + int offset = IntPtr.Size; + for (int i = 0; i < count; ++i) + { + channels += Marshal.ReadInt32(buffer, offset); + offset += AudioBufferList.AudioBufferSize; + } + return channels; + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + + public static double GetSampleRate(uint Device) + { + return GetProperty(Device, kAudioDevicePropertyNominalSampleRate, kAudioObjectPropertyScopeGlobal); + } + + public static uint GetBufferFrameSize(uint Device) + { + return GetProperty(Device, kAudioDevicePropertyBufferFrameSize, kAudioObjectPropertyScopeGlobal); + } + + public static string GetDeviceName(uint Device) + { + return GetStringProperty(Device, kAudioObjectPropertyName, kAudioObjectPropertyScopeGlobal) ?? ("Device " + Device); + } + + /// + /// Per-channel name. Most devices do not set one, so fall back to a positional name. Element + /// numbering for channels is 1-based. + /// + public static string GetChannelName(uint Device, uint Scope, int Index) + { + string name = null; + try + { + name = GetStringProperty(Device, kAudioObjectPropertyElementName, Scope, (uint)(Index + 1)); + } + catch (CoreAudioException) + { + } + string direction = Scope == kAudioObjectPropertyScopeInput ? "In" : "Out"; + string positional = direction + " " + (Index + 1); + // Many devices set the element name to just the channel number, which adds nothing. + if (string.IsNullOrEmpty(name) || name == (Index + 1).ToString()) + return positional; + return positional + ": " + name; + } + } +} diff --git a/CoreAudio/Device.cs b/CoreAudio/Device.cs new file mode 100644 index 00000000..01d9c264 --- /dev/null +++ b/CoreAudio/Device.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace CoreAudio +{ + /// + /// A single channel of a Core Audio device. Name must be stable and unique within the device, + /// because channel selections are persisted and restored by name. + /// + internal class Channel : Audio.Channel + { + private string name; + public override string Name { get { return name; } } + + /// Index of this channel within its scope, used to find the buffer in the callback. + public int Index { get; private set; } + + public Channel(string Name, int Index) + { + name = Name; + this.Index = Index; + } + + public override string ToString() { return name; } + } + + internal class Device : Audio.Device + { + private uint id; + public uint Id { get { return id; } } + + public Device(uint Id) : base(CoreAudioApi.GetDeviceName(Id)) + { + id = Id; + + int inputCount = CoreAudioApi.GetChannelCount(Id, CoreAudioApi.kAudioObjectPropertyScopeInput); + int outputCount = CoreAudioApi.GetChannelCount(Id, CoreAudioApi.kAudioObjectPropertyScopeOutput); + + inputs = Enumerable.Range(0, inputCount) + .Select(i => (Audio.Channel)new Channel(CoreAudioApi.GetChannelName(Id, CoreAudioApi.kAudioObjectPropertyScopeInput, i), i)) + .ToArray(); + outputs = Enumerable.Range(0, outputCount) + .Select(i => (Audio.Channel)new Channel(CoreAudioApi.GetChannelName(Id, CoreAudioApi.kAudioObjectPropertyScopeOutput, i), i)) + .ToArray(); + } + + public double SampleRate { get { return CoreAudioApi.GetSampleRate(id); } } + + public override Audio.Stream Open(Audio.Stream.SampleHandler Callback, Audio.Channel[] Input, Audio.Channel[] Output) + { + return new Stream( + this, + Callback, + Input.Cast().ToArray(), + Output.Cast().ToArray()); + } + } +} diff --git a/CoreAudio/Driver.cs b/CoreAudio/Driver.cs new file mode 100644 index 00000000..58aa925a --- /dev/null +++ b/CoreAudio/Driver.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using Util; + +namespace CoreAudio +{ + /// + /// Core Audio driver for macOS. Audio.Driver.Drivers finds this by reflecting over loaded + /// assemblies, so this type must stay public with a public parameterless constructor. + /// + public class Driver : Audio.Driver + { + public override string Name { get { return "Core Audio"; } } + + public Driver() + { + // The P/Invokes below only resolve on macOS. Leaving devices empty is the graceful + // outcome elsewhere; throwing would be caught and logged as an error on every + // enumeration. + if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + return; + + uint[] ids; + try + { + ids = CoreAudioApi.EnumerateDevices(); + } + catch (Exception Ex) + { + Log.Global.WriteLine(MessageType.Error, "Error enumerating Core Audio devices: {0}", Ex.Message); + return; + } + + foreach (uint id in ids) + { + // One bad device shouldn't hide the rest. + try + { + Device device = new Device(id); + if (device.InputChannels.Length > 0 || device.OutputChannels.Length > 0) + devices.Add(device); + } + catch (Exception Ex) + { + Log.Global.WriteLine(MessageType.Warning, "Error opening Core Audio device {0}: {1}", id, Ex.Message); + } + } + + Log.Global.WriteLine(MessageType.Info, "Found {0} Core Audio devices.", devices.Count); + } + } +} diff --git a/CoreAudio/Stream.cs b/CoreAudio/Stream.cs new file mode 100644 index 00000000..1ae1b0c2 --- /dev/null +++ b/CoreAudio/Stream.cs @@ -0,0 +1,321 @@ +using System; +using System.Runtime.InteropServices; +using Util; + +namespace CoreAudio +{ + /// + /// An AUHAL stream. A single AudioUnit bound to one device drives everything: the render + /// callback on the output bus pulls input from the input bus first, so there is one clock and + /// no ring buffer. That requires input and output to be the same device, which is what + /// Audio.Device.Open already assumes. + /// + internal class Stream : Audio.Stream + { + private IntPtr unit = IntPtr.Zero; + + private Channel[] input; + private Channel[] output; + private Audio.SampleBuffer[] inputBuffers; + private Audio.SampleBuffer[] outputBuffers; + + // Scratch AudioBufferList that AudioUnitRender fills with the device's input. + private IntPtr inputList = IntPtr.Zero; + private int deviceInputChannels; + private int deviceOutputChannels; + + private int bufferFrames; + private double sampleRate; + public override double SampleRate { get { return sampleRate; } } + + private SampleHandler callback; + + // Holding the delegate in a field is what keeps the native thunk alive; if this were only + // a local, the GC could collect the delegate while Core Audio still held its function + // pointer. It must outlive AudioComponentInstanceDispose. + private readonly AudioUnitApi.AURenderCallback renderCallback; + + private volatile bool running = false; + private volatile int oversizedCallbacks = 0; + /// Number of callbacks that asked for more frames than were allocated. + public int OversizedCallbacks { get { return oversizedCallbacks; } } + + public Stream(Device Device, SampleHandler Callback, Channel[] Input, Channel[] Output) + : base(Input, Output) + { + callback = Callback; + input = Input; + output = Output; + + deviceInputChannels = Device.InputChannels.Length; + deviceOutputChannels = Device.OutputChannels.Length; + + if (deviceOutputChannels == 0) + throw new NotSupportedException( + "'" + Device.Name + "' has no output channels. Input-only devices need an Aggregate Device; " + + "combine the input and output in Audio MIDI Setup and select that instead."); + if (Input.Length > 0 && deviceInputChannels == 0) + throw new NotSupportedException("'" + Device.Name + "' has no input channels."); + + sampleRate = CoreAudioApi.GetSampleRate(Device.Id); + + try + { + bufferFrames = (int)CoreAudioApi.GetBufferFrameSize(Device.Id); + } + catch (CoreAudioException) + { + bufferFrames = 512; + } + if (bufferFrames <= 0) + bufferFrames = 512; + + renderCallback = new AudioUnitApi.AURenderCallback(Render); + + try + { + Open(Device); + } + catch + { + Teardown(); + throw; + } + + Log.Global.WriteLine(MessageType.Info, + "Core Audio stream opened on '{0}': {1} Hz, {2} frames, {3} in, {4} out", + Device.Name, sampleRate, bufferFrames, Input.Length, Output.Length); + } + + private void Open(Device Device) + { + AudioComponentDescription desc = new AudioComponentDescription() + { + componentType = AudioUnitApi.kAudioUnitType_Output, + componentSubType = AudioUnitApi.kAudioUnitSubType_HALOutput, + componentManufacturer = AudioUnitApi.kAudioUnitManufacturer_Apple, + componentFlags = 0, + componentFlagsMask = 0, + }; + IntPtr component = AudioUnitApi.AudioComponentFindNext(IntPtr.Zero, ref desc); + if (component == IntPtr.Zero) + throw new CoreAudioException("No HAL output AudioUnit available", 0); + + CoreAudioException.CheckThrow("AudioComponentInstanceNew", + AudioUnitApi.AudioComponentInstanceNew(component, out unit)); + + uint enable = 1; + uint disable = 0; + + // Output is always enabled: the output bus render callback is what drives the stream. + CoreAudioException.CheckThrow("EnableIO(output)", AudioUnitApi.AudioUnitSetProperty( + unit, AudioUnitApi.kAudioOutputUnitProperty_EnableIO, AudioUnitApi.kAudioUnitScope_Output, + AudioUnitApi.OutputBus, ref enable, sizeof(uint))); + + bool captureInput = input.Length > 0; + uint inputEnable = captureInput ? enable : disable; + CoreAudioException.CheckThrow("EnableIO(input)", AudioUnitApi.AudioUnitSetProperty( + unit, AudioUnitApi.kAudioOutputUnitProperty_EnableIO, AudioUnitApi.kAudioUnitScope_Input, + AudioUnitApi.InputBus, ref inputEnable, sizeof(uint))); + + uint deviceId = Device.Id; + CoreAudioException.CheckThrow("CurrentDevice", AudioUnitApi.AudioUnitSetProperty( + unit, AudioUnitApi.kAudioOutputUnitProperty_CurrentDevice, AudioUnitApi.kAudioUnitScope_Global, + 0, ref deviceId, sizeof(uint))); + + // Non-interleaved float32 in both directions. This is the format Audio.Util's + // converters expect: one contiguous buffer per channel, unit stride. + AudioStreamBasicDescription outputFormat = + AudioUnitApi.NonInterleavedFloat32(sampleRate, deviceOutputChannels); + CoreAudioException.CheckThrow("StreamFormat(output)", AudioUnitApi.AudioUnitSetProperty( + unit, AudioUnitApi.kAudioUnitProperty_StreamFormat, AudioUnitApi.kAudioUnitScope_Input, + AudioUnitApi.OutputBus, ref outputFormat, (uint)Marshal.SizeOf(typeof(AudioStreamBasicDescription)))); + + if (captureInput) + { + AudioStreamBasicDescription inputFormat = + AudioUnitApi.NonInterleavedFloat32(sampleRate, deviceInputChannels); + CoreAudioException.CheckThrow("StreamFormat(input)", AudioUnitApi.AudioUnitSetProperty( + unit, AudioUnitApi.kAudioUnitProperty_StreamFormat, AudioUnitApi.kAudioUnitScope_Output, + AudioUnitApi.InputBus, ref inputFormat, (uint)Marshal.SizeOf(typeof(AudioStreamBasicDescription)))); + } + + uint maxFrames = (uint)bufferFrames; + CoreAudioException.CheckThrow("MaximumFramesPerSlice", AudioUnitApi.AudioUnitSetProperty( + unit, AudioUnitApi.kAudioUnitProperty_MaximumFramesPerSlice, AudioUnitApi.kAudioUnitScope_Global, + 0, ref maxFrames, sizeof(uint))); + + AURenderCallbackStruct cb = new AURenderCallbackStruct() + { + inputProc = renderCallback, + inputProcRefCon = IntPtr.Zero, + }; + CoreAudioException.CheckThrow("SetRenderCallback", AudioUnitApi.AudioUnitSetProperty( + unit, AudioUnitApi.kAudioUnitProperty_SetRenderCallback, AudioUnitApi.kAudioUnitScope_Input, + AudioUnitApi.OutputBus, ref cb, (uint)Marshal.SizeOf(typeof(AURenderCallbackStruct)))); + + // Allocate everything up front. SampleBuffer pins its array for life, so allocating in + // the render callback would both fragment the heap and block on the GC. + inputBuffers = new Audio.SampleBuffer[input.Length]; + for (int i = 0; i < inputBuffers.Length; ++i) + inputBuffers[i] = new Audio.SampleBuffer(bufferFrames); + outputBuffers = new Audio.SampleBuffer[output.Length]; + for (int i = 0; i < outputBuffers.Length; ++i) + outputBuffers[i] = new Audio.SampleBuffer(bufferFrames); + + if (captureInput) + { + inputList = AudioBufferList.Allocate(deviceInputChannels); + for (int i = 0; i < deviceInputChannels; ++i) + { + IntPtr data = Marshal.AllocHGlobal(bufferFrames * sizeof(float)); + AudioBufferList.SetBuffer(inputList, i, 1, bufferFrames * sizeof(float), data); + } + } + + CoreAudioException.CheckThrow("AudioUnitInitialize", AudioUnitApi.AudioUnitInitialize(unit)); + running = true; + CoreAudioException.CheckThrow("AudioOutputUnitStart", AudioUnitApi.AudioOutputUnitStart(unit)); + } + + /// + /// The render callback. Runs on Core Audio's realtime thread: no allocation, no logging, + /// no locking, and no managed exception may escape back into native code. + /// + private int Render(IntPtr inRefCon, ref uint ioActionFlags, ref AudioTimeStamp inTimeStamp, + uint inBusNumber, uint inNumberFrames, IntPtr ioData) + { + try + { + int frames = (int)inNumberFrames; + if (frames > bufferFrames) + { + // Should not happen given MaximumFramesPerSlice, but truncating is far better + // than overrunning the pinned buffers. + oversizedCallbacks++; + frames = bufferFrames; + } + + if (!running || ioData == IntPtr.Zero) + { + SilenceOutput(ioData, (int)inNumberFrames); + return CoreAudioException.NoError; + } + + if (inputBuffers.Length > 0) + { + uint flags = 0; + // Reset the sizes: AudioUnitRender both reads and writes them. + for (int i = 0; i < deviceInputChannels; ++i) + AudioBufferList.SetDataByteSize(inputList, i, frames * sizeof(float)); + + int status = AudioUnitApi.AudioUnitRender( + unit, ref flags, ref inTimeStamp, AudioUnitApi.InputBus, (uint)frames, inputList); + if (status != CoreAudioException.NoError) + { + for (int i = 0; i < inputBuffers.Length; ++i) + inputBuffers[i].Clear(); + } + else + { + for (int i = 0; i < input.Length; ++i) + Audio.Util.LEf32ToLEf64( + AudioBufferList.GetData(inputList, input[i].Index), + inputBuffers[i].Raw, + (uint)frames); + } + } + + callback(frames, inputBuffers, outputBuffers, sampleRate); + + // Zero every device channel first, so channels the user didn't select stay silent. + SilenceOutput(ioData, (int)inNumberFrames); + + for (int i = 0; i < output.Length; ++i) + { + int ch = output[i].Index; + if (ch >= AudioBufferList.GetCount(ioData)) + continue; + // LEf64ToLEf32 does not clamp, and neither does Core Audio. + double[] samples = outputBuffers[i].Samples; + for (int j = 0; j < frames; ++j) + { + double s = samples[j]; + if (s > 1.0) samples[j] = 1.0; + else if (s < -1.0) samples[j] = -1.0; + else if (double.IsNaN(s)) samples[j] = 0.0; + } + Audio.Util.LEf64ToLEf32( + outputBuffers[i].Raw, + AudioBufferList.GetData(ioData, ch), + (uint)frames); + } + } + catch (Exception) + { + // An exception crossing back into a Core Audio IOProc is fatal to the process. + SilenceOutput(ioData, (int)inNumberFrames); + } + return CoreAudioException.NoError; + } + + private static void SilenceOutput(IntPtr ioData, int Frames) + { + if (ioData == IntPtr.Zero) + return; + int count = AudioBufferList.GetCount(ioData); + for (int i = 0; i < count; ++i) + { + IntPtr data = AudioBufferList.GetData(ioData, i); + if (data != IntPtr.Zero) + Audio.Util.ZeroMemory(data, (uint)(Frames * sizeof(float))); + } + } + + public override void Stop() + { + running = false; + Teardown(); + Log.Global.WriteLine(MessageType.Info, "Core Audio stream stopped."); + } + + private void Teardown() + { + // Stop the unit before freeing anything the callback touches. AudioOutputUnitStop does + // not return until the IOProc has finished, so after this the buffers are safe to free. + // Teardown calls are unchecked so a failure here can't mask the original error. + if (unit != IntPtr.Zero) + { + AudioUnitApi.AudioOutputUnitStop(unit); + AudioUnitApi.AudioUnitUninitialize(unit); + AudioUnitApi.AudioComponentInstanceDispose(unit); + unit = IntPtr.Zero; + } + + if (inputList != IntPtr.Zero) + { + for (int i = 0; i < deviceInputChannels; ++i) + { + IntPtr data = AudioBufferList.GetData(inputList, i); + if (data != IntPtr.Zero) + Marshal.FreeHGlobal(data); + } + Marshal.FreeHGlobal(inputList); + inputList = IntPtr.Zero; + } + + if (inputBuffers != null) + { + foreach (Audio.SampleBuffer i in inputBuffers) + i.Dispose(); + inputBuffers = new Audio.SampleBuffer[] { }; + } + if (outputBuffers != null) + { + foreach (Audio.SampleBuffer i in outputBuffers) + i.Dispose(); + outputBuffers = new Audio.SampleBuffer[] { }; + } + } + } +} diff --git a/LiveSPICE.CLI/LiveSPICE.CLI.csproj b/LiveSPICE.CLI/LiveSPICE.CLI.csproj new file mode 100644 index 00000000..068fcc75 --- /dev/null +++ b/LiveSPICE.CLI/LiveSPICE.CLI.csproj @@ -0,0 +1,20 @@ + + + net8.0 + Exe + livespice + LiveSPICE.CLI + LiveSPICE.CLI + Copyright © 2020 + 1.0.0.0 + 1.0.0.0 + disable + + + + + + + + + diff --git a/LiveSPICE.CLI/Program.cs b/LiveSPICE.CLI/Program.cs new file mode 100644 index 00000000..2fd9cd5b --- /dev/null +++ b/LiveSPICE.CLI/Program.cs @@ -0,0 +1,448 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using Util; + +namespace LiveSPICE.CLI +{ + class Program + { + static int Main(string[] args) + { + if (args.Length == 0 || args[0] == "-h" || args[0] == "--help" || args[0] == "help") + { + Usage(); + return args.Length == 0 ? 1 : 0; + } + + Args a = new Args(args.Skip(1)); + try + { + switch (args[0]) + { + case "list": return List(); + case "tone": return Tone(a); + case "render": return Render(a); + case "play": return Play(a); + case "loopback": return Loopback(a); + default: + Console.Error.WriteLine("Unknown command '" + args[0] + "'."); + Usage(); + return 1; + } + } + catch (Exception Ex) + { + Console.Error.WriteLine("Error: " + Ex.Message); + return 1; + } + } + + static void Usage() + { + Console.WriteLine(@"LiveSPICE headless player. + + livespice list + List audio drivers, devices and channels. + + livespice tone --output [--frequency 82] [--seconds 1] + [--rate 44100] [--amplitude 0.5] [--harmonics 1] + Generate a test tone. + + livespice render --schematic --input --output + [--oversample 8] [--iterations 8] [--input-gain 1] [--output-gain 1] + Render a wav through a circuit offline. No audio device needed. + + livespice play --schematic [--device ] [--inputs 0] [--outputs 0,1] + [--oversample 8] [--iterations 8] [--input-gain 1] [--output-gain 1] + [--seconds ] + Play live through a circuit. Runs until Ctrl-C unless --seconds is given."); + } + + // ---------------------------------------------------------------- list + + /// + /// Audio.Driver.Drivers discovers backends by reflecting over loaded assemblies, and .NET + /// only loads an assembly when a type from it is first used. A ProjectReference alone is + /// not enough, so touch the type. LiveSPICE's WPF app solves this with App.LoadAssemblies. + /// + static void LoadBackends() + { + if (typeof(CoreAudio.Driver) == null) + throw new InvalidOperationException(); + } + + static int List() + { + LoadBackends(); + int devices = 0; + foreach (Audio.Driver driver in Audio.Driver.Drivers) + { + Console.WriteLine(driver.Name); + foreach (Audio.Device device in driver.Devices) + { + devices++; + Console.WriteLine(" {0} ({1} in, {2} out)", + device.Name, device.InputChannels.Length, device.OutputChannels.Length); + foreach (Audio.Channel i in device.InputChannels) + Console.WriteLine(" in [{0}] {1}", Array.IndexOf(device.InputChannels, i), i.Name); + foreach (Audio.Channel i in device.OutputChannels) + Console.WriteLine(" out [{0}] {1}", Array.IndexOf(device.OutputChannels, i), i.Name); + } + } + if (devices == 0) + Console.WriteLine("No audio devices found."); + return 0; + } + + // ---------------------------------------------------------------- tone + + static int Tone(Args a) + { + string output = a.Required("output"); + double frequency = a.Double("frequency", 82); + double seconds = a.Double("seconds", 1); + int rate = (int)a.Double("rate", 44100); + double amplitude = a.Double("amplitude", 0.5); + int harmonics = (int)a.Double("harmonics", 1); + + int count = (int)(seconds * rate); + double[] samples = new double[count]; + for (int n = 0; n < count; ++n) + { + double t = (double)n / rate; + double s = 0; + for (int h = 1; h <= harmonics; ++h) + s += Math.Sin(2 * Math.PI * frequency * h * t) / harmonics; + samples[n] = amplitude * s; + } + new Wav(rate, new[] { samples }).Write(output); + Console.WriteLine("Wrote {0}: {1} Hz, {2} samples at {3} Hz.", output, frequency, count, rate); + return 0; + } + + // -------------------------------------------------------------- render + + static int Render(Args a) + { + string schematic = a.Required("schematic"); + string inputFile = a.Required("input"); + string outputFile = a.Required("output"); + + ConsoleLog log = new ConsoleLog() { Verbosity = MessageType.Info }; + SimulationHost host = NewHost(a, log); + host.Load(schematic); + + Wav wav = Wav.Read(inputFile); + double[] input = wav.Channels[0]; + double[] output = new double[input.Length]; + + Console.WriteLine("Building simulation at {0} Hz...", wav.SampleRate); + DateTime begin = DateTime.Now; + host.BuildNow(wav.SampleRate); + Console.WriteLine("Built in {0:F2} s.", (DateTime.Now - begin).TotalSeconds); + + // Chunked, both to mirror the live path and to prove buffer boundaries don't matter. + const int Block = 1024; + begin = DateTime.Now; + double[] inBlock = new double[Block]; + double[] outBlock = new double[Block]; + for (int n = 0; n < input.Length; n += Block) + { + int count = Math.Min(Block, input.Length - n); + Array.Copy(input, n, inBlock, 0, count); + host.Process(count, inBlock, outBlock, wav.SampleRate); + Array.Copy(outBlock, 0, output, n, count); + } + double elapsed = (DateTime.Now - begin).TotalSeconds; + + new Wav(wav.SampleRate, new[] { output }).Write(outputFile); + + double peak = output.Length > 0 ? output.Max(Math.Abs) : 0; + double rms = output.Length > 0 ? Math.Sqrt(output.Sum(i => i * i) / output.Length) : 0; + Console.WriteLine("Wrote {0}: {1} samples, peak {2:G4}, rms {3:G4}.", outputFile, output.Length, peak, rms); + Console.WriteLine("Rendered {0:F2} s of audio in {1:F2} s ({2:F1}x realtime).", + (double)input.Length / wav.SampleRate, elapsed, + elapsed > 0 ? ((double)input.Length / wav.SampleRate) / elapsed : 0); + return peak > 0 ? 0 : 2; + } + + // ---------------------------------------------------------------- play + + static int Play(Args a) + { + string schematic = a.Required("schematic"); + string deviceName = a.String("device", null); + double seconds = a.Double("seconds", 0); + + ConsoleLog log = new ConsoleLog() { Verbosity = MessageType.Info }; + SimulationHost host = NewHost(a, log); + host.Load(schematic); + + Audio.Device device = FindDevice(deviceName); + Audio.Channel[] inputs = SelectChannels(device.InputChannels, a.String("inputs", null), 1); + Audio.Channel[] outputs = SelectChannels(device.OutputChannels, a.String("outputs", null), int.MaxValue); + + Console.WriteLine("Device: {0}", device.Name); + Console.WriteLine("Inputs: {0}", inputs.Length > 0 ? string.Join(", ", inputs.Select(i => i.Name)) : "(none)"); + Console.WriteLine("Outputs: {0}", outputs.Length > 0 ? string.Join(", ", outputs.Select(i => i.Name)) : "(none)"); + + double[] silence = null; + double builtRate = 0; + long callbacks = 0; + + Audio.Stream stream = null; + Audio.Stream.SampleHandler handler = (Count, In, Out, Rate) => + { + callbacks++; + if (Out.Length == 0) + return; + + if (builtRate != Rate) + { + builtRate = Rate; + host.BuildAsync(Rate); + } + + double[] inBuffer; + if (In.Length > 0) + { + inBuffer = In[0].Samples; + } + else + { + if (silence == null || silence.Length < Count) + silence = new double[Count]; + inBuffer = silence; + } + + host.Process(Count, inBuffer, Out[0].Samples, Rate); + + // Same signal to every selected output channel. + for (int i = 1; i < Out.Length; ++i) + Array.Copy(Out[0].Samples, Out[i].Samples, Count); + }; + + stream = device.Open(handler, inputs, outputs); + Console.WriteLine("Playing '{0}' at {1} Hz. Press Ctrl-C to stop.", host.Name, stream.SampleRate); + if (inputs.Length > 0) + Console.WriteLine("(If input is silent, grant microphone access to your terminal in " + + "System Settings > Privacy & Security > Microphone.)"); + + ManualResetEventSlim done = new ManualResetEventSlim(false); + Console.CancelKeyPress += (s, e) => { e.Cancel = true; done.Set(); }; + if (seconds > 0) + done.Wait(TimeSpan.FromSeconds(seconds)); + else + done.Wait(); + + stream.Stop(); + Console.WriteLine("Stopped after {0} callbacks, {1} rebuild(s).", callbacks, host.Rebuilds); + return 0; + } + + // ------------------------------------------------------------ loopback + + /// + /// Play a tone and record what comes back, exercising the audio path with no simulation in + /// the way. On a loopback device (BlackHole) the recording should reproduce the tone, which + /// verifies enumeration, format negotiation, the render callback and both conversions. + /// + static int Loopback(Args a) + { + string deviceName = a.String("device", null); + double frequency = a.Double("frequency", 440); + double seconds = a.Double("seconds", 1); + double amplitude = a.Double("amplitude", 0.5); + string record = a.String("record", null); + + Audio.Device device = FindDevice(deviceName); + Audio.Channel[] inputs = SelectChannels(device.InputChannels, a.String("inputs", null), 1); + Audio.Channel[] outputs = SelectChannels(device.OutputChannels, a.String("outputs", null), int.MaxValue); + Console.WriteLine("Device: {0} ({1} in, {2} out)", device.Name, inputs.Length, outputs.Length); + + List captured = new List(); + double phase = 0; + long frames = 0; + object sync = new object(); + + Audio.Stream.SampleHandler handler = (Count, In, Out, Rate) => + { + double step = 2 * Math.PI * frequency / Rate; + for (int i = 0; i < Count; ++i, phase += step) + { + double s = amplitude * Math.Sin(phase); + for (int c = 0; c < Out.Length; ++c) + Out[c].Samples[i] = s; + } + if (In.Length > 0) + { + lock (sync) + for (int i = 0; i < Count; ++i) + captured.Add(In[0].Samples[i]); + } + frames += Count; + }; + + Audio.Stream stream = device.Open(handler, inputs, outputs); + Console.WriteLine("Running {0} s at {1} Hz...", seconds, stream.SampleRate); + Thread.Sleep((int)(seconds * 1000)); + stream.Stop(); + + double[] samples; + lock (sync) + samples = captured.ToArray(); + + Console.WriteLine("Rendered {0} frames, captured {1}.", frames, samples.Length); + if (samples.Length == 0) + { + Console.WriteLine("No input captured (no input channels selected)."); + return 0; + } + + if (record != null) + new Wav((int)stream.SampleRate, new[] { samples }).Write(record); + + // Skip the first buffers: the loop takes a moment to come up. + int skip = Math.Min(samples.Length / 4, (int)stream.SampleRate / 10); + double peak = 0, sum = 0; + for (int i = skip; i < samples.Length; ++i) + { + peak = Math.Max(peak, Math.Abs(samples[i])); + sum += samples[i] * samples[i]; + } + int n = samples.Length - skip; + double rms = Math.Sqrt(sum / Math.Max(n, 1)); + + // Single frequency fit at the tone: how much of the capture is the tone we played? + double re = 0, im = 0; + for (int i = 0; i < n; ++i) + { + double w = 2 * Math.PI * frequency * i / stream.SampleRate; + re += samples[skip + i] * Math.Cos(w); + im -= samples[skip + i] * Math.Sin(w); + } + double toneAmplitude = 2 * Math.Sqrt(re * re + im * im) / n; + + Console.WriteLine("Captured peak {0:G4}, rms {1:G4}, {2:F1} Hz component {3:G4} (played {4:G4}).", + peak, rms, frequency, toneAmplitude, amplitude); + + if (peak == 0) + { + Console.WriteLine("Input was silent. If this is a loopback device, check that the terminal has " + + "microphone access in System Settings > Privacy & Security > Microphone."); + return 2; + } + // A loopback device should return most of what we played. + bool ok = toneAmplitude > 0.5 * amplitude; + Console.WriteLine(ok ? "PASS: loopback reproduced the tone." : "FAIL: captured signal does not match the tone."); + return ok ? 0 : 3; + } + + // --------------------------------------------------------------- utils + + static SimulationHost NewHost(Args a, ILog log) + { + return new SimulationHost(log) + { + Oversample = (int)a.Double("oversample", 8), + Iterations = (int)a.Double("iterations", 8), + InputGain = a.Double("input-gain", 1), + OutputGain = a.Double("output-gain", 1), + }; + } + + static Audio.Device FindDevice(string Name) + { + LoadBackends(); + List all = Audio.Driver.Drivers.SelectMany(i => i.Devices).ToList(); + if (all.Count == 0) + throw new NotSupportedException("No audio devices found."); + if (string.IsNullOrEmpty(Name)) + { + // Prefer something that can actually do duplex. + return all.FirstOrDefault(i => i.InputChannels.Length > 0 && i.OutputChannels.Length > 0) + ?? all.First(i => i.OutputChannels.Length > 0); + } + Audio.Device device = all.FirstOrDefault(i => i.Name == Name) + ?? all.FirstOrDefault(i => i.Name.IndexOf(Name, StringComparison.OrdinalIgnoreCase) >= 0); + if (device == null) + throw new NotSupportedException("No device matching '" + Name + "'. Try 'livespice list'."); + return device; + } + + static Audio.Channel[] SelectChannels(Audio.Channel[] Available, string Spec, int DefaultCount) + { + if (Available.Length == 0) + return new Audio.Channel[] { }; + if (string.IsNullOrEmpty(Spec)) + return Available.Take(Math.Min(DefaultCount, Available.Length)).ToArray(); + if (Spec == "none") + return new Audio.Channel[] { }; + + List selected = new List(); + foreach (string i in Spec.Split(',')) + { + int index; + if (!int.TryParse(i.Trim(), out index) || index < 0 || index >= Available.Length) + throw new ArgumentException("Channel '" + i.Trim() + "' is out of range (0.." + (Available.Length - 1) + ")."); + selected.Add(Available[index]); + } + return selected.ToArray(); + } + + /// + /// Minimal --name value parser. System.CommandLine would be the consistent choice, but the + /// version this repo pins is a 2.0 beta and this is four commands. + /// + class Args + { + private readonly Dictionary values = new Dictionary(); + + public Args(IEnumerable args) + { + string key = null; + foreach (string i in args) + { + if (i.StartsWith("--")) + { + if (key != null) values[key] = "true"; + key = i.Substring(2); + } + else if (key != null) + { + values[key] = i; + key = null; + } + } + if (key != null) values[key] = "true"; + } + + public string String(string Name, string Default) + { + string value; + return values.TryGetValue(Name, out value) ? value : Default; + } + + public string Required(string Name) + { + string value = String(Name, null); + if (string.IsNullOrEmpty(value)) + throw new ArgumentException("Missing required argument --" + Name + "."); + return value; + } + + public double Double(string Name, double Default) + { + string value = String(Name, null); + if (value == null) return Default; + double result; + if (!double.TryParse(value, System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out result)) + throw new ArgumentException("--" + Name + " expects a number, got '" + value + "'."); + return result; + } + } + } +} diff --git a/LiveSPICE.CLI/SimulationHost.cs b/LiveSPICE.CLI/SimulationHost.cs new file mode 100644 index 00000000..27484492 --- /dev/null +++ b/LiveSPICE.CLI/SimulationHost.cs @@ -0,0 +1,188 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Circuit; +using ComputerAlgebra; +using Util; + +namespace LiveSPICE.CLI +{ + /// + /// Glue between a loaded schematic and an audio callback: build the transient solution off the + /// audio thread, then run it per buffer. Mono in, mono out (all speakers summed), which is what + /// a guitar signal chain needs. + /// + public class SimulationHost + { + private readonly object sync = new object(); + private readonly ILog log; + + private Circuit.Circuit circuit; + private Simulation simulation; + + public int Oversample { get; set; } = 8; + public int Iterations { get; set; } = 8; + public double InputGain { get; set; } = 1; + public double OutputGain { get; set; } = 1; + + public string Name { get; private set; } + /// Set when a background build failed; rethrown from the next Process call. + private Exception buildException; + private int rebuilds = 0; + public int Rebuilds { get { return rebuilds; } } + + public SimulationHost(ILog Log) { log = Log; } + + public void Load(string FileName) + { + Schematic schematic = Schematic.Load(FileName, log); + circuit = schematic.Build(log); + Name = System.IO.Path.GetFileNameWithoutExtension(FileName); + } + + /// The circuit's single input expression, or null if it has none. + private Expression FindInput() + { + List ins = circuit.Components.OfType().Select(i => i.In).ToList(); + if (ins.Count == 0) + throw new NotSupportedException("Circuit '" + Name + "' has no input component."); + if (ins.Count > 1) + throw new NotSupportedException( + "Circuit '" + Name + "' has " + ins.Count + " inputs; only one is supported."); + return ins[0]; + } + + /// All speakers summed to mono, matching the VST and benchmark conventions. + private Expression FindOutput() + { + Expression sum = 0; + foreach (Speaker i in circuit.Components.OfType()) + sum += i.Out; + if (sum.EqualsZero()) + throw new NotSupportedException("Circuit '" + Name + "' has no speaker output."); + return sum; + } + + /// + /// Build and fully warm a simulation. Must not run on the audio thread: this does the + /// symbolic solve AND forces the Linq expression tree to compile. + /// + private Simulation Build(double SampleRate) + { + Analysis analysis = circuit.Analyze(); + TransientSolution ts = TransientSolution.Solve(analysis, (Real)1 / (SampleRate * Oversample), log); + + Simulation s = new Simulation(ts) + { + Log = log, + Oversample = Oversample, + Iterations = Iterations, + Input = new[] { FindInput() }, + Output = new[] { FindOutput() }, + }; + + // Simulation.Run compiles the process on first call. Left to the audio thread that is a + // multi-millisecond stall inside the callback, so spend it here instead. + s.Run(new double[64], new[] { new double[64] }); + return s; + } + + /// Build synchronously. Used by the offline render path. + public void BuildNow(double SampleRate) + { + Simulation s = Build(SampleRate); + lock (sync) + simulation = s; + } + + /// + /// Build in the background, leaving Process to emit silence until it is ready. The live + /// path needs this because the sample rate isn't known until the stream is open. + /// + public Task BuildAsync(double SampleRate) + { + return Task.Run(() => + { + try + { + Simulation s = Build(SampleRate); + lock (sync) + simulation = s; + log.WriteLine(MessageType.Info, "Simulation ready ({0} Hz, oversample {1}, iterations {2}).", + SampleRate, Oversample, Iterations); + } + catch (Exception Ex) + { + lock (sync) + buildException = Ex; + } + }); + } + + public bool Ready { get { lock (sync) return simulation != null; } } + + /// + /// Run one buffer. Safe to call from an audio callback: no allocation, and the lock is only + /// contended by a publish at the end of a background build. + /// + public void Process(int Count, double[] In, double[] Out, double SampleRate) + { + Exception pending = null; + lock (sync) + { + if (buildException != null) + { + pending = buildException; + buildException = null; + } + } + if (pending != null) + throw pending; + + if (InputGain != 1) + for (int i = 0; i < Count; ++i) + In[i] *= InputGain; + + lock (sync) + { + if (simulation == null) + { + Array.Clear(Out, 0, Count); + return; + } + + try + { + simulation.Run(Count, new[] { In }, new[] { Out }); + } + catch (SimulationDiverged Ex) + { + // Diverging early means the circuit is genuinely unstable; later means a + // transient worth recovering from. + log.WriteLine(MessageType.Error, "Simulation diverged: {0}", Ex.Message); + Array.Clear(Out, 0, Count); + bool retry = Ex.At > SampleRate; + simulation = null; + if (retry) + { + rebuilds++; + BuildAsync(SampleRate); + } + return; + } + catch (Exception Ex) + { + log.WriteLine(MessageType.Error, "Simulation error: {0}", Ex.Message); + Array.Clear(Out, 0, Count); + simulation = null; + return; + } + } + + if (OutputGain != 1) + for (int i = 0; i < Count; ++i) + Out[i] *= OutputGain; + } + } +} diff --git a/LiveSPICE.CLI/Wav.cs b/LiveSPICE.CLI/Wav.cs new file mode 100644 index 00000000..cd3e340c --- /dev/null +++ b/LiveSPICE.CLI/Wav.cs @@ -0,0 +1,126 @@ +using System; +using System.IO; + +namespace LiveSPICE.CLI +{ + /// + /// Minimal WAV reader/writer. Nothing in the repo does file audio I/O (WaveAudio is live device + /// I/O despite the name), and the offline render path needs it. Reads 16 bit PCM and 32 bit + /// float; writes 32 bit float so a render round trip is lossless. + /// + public class Wav + { + public int SampleRate { get; set; } + public double[][] Channels { get; set; } + + public int ChannelCount { get { return Channels.Length; } } + public int SampleCount { get { return Channels.Length > 0 ? Channels[0].Length : 0; } } + + public Wav(int SampleRate, double[][] Channels) + { + this.SampleRate = SampleRate; + this.Channels = Channels; + } + + private const ushort FormatPcm = 1; + private const ushort FormatFloat = 3; + + public static Wav Read(string FileName) + { + using (FileStream file = File.OpenRead(FileName)) + using (BinaryReader reader = new BinaryReader(file)) + { + if (new string(reader.ReadChars(4)) != "RIFF") + throw new InvalidDataException("Not a RIFF file: " + FileName); + reader.ReadUInt32(); + if (new string(reader.ReadChars(4)) != "WAVE") + throw new InvalidDataException("Not a WAVE file: " + FileName); + + ushort format = 0, channels = 0, bits = 0; + int rate = 0; + byte[] data = null; + + while (file.Position + 8 <= file.Length) + { + string id = new string(reader.ReadChars(4)); + uint size = reader.ReadUInt32(); + long next = file.Position + size + (size % 2); + + if (id == "fmt ") + { + format = reader.ReadUInt16(); + channels = reader.ReadUInt16(); + rate = (int)reader.ReadUInt32(); + reader.ReadUInt32(); // byte rate + reader.ReadUInt16(); // block align + bits = reader.ReadUInt16(); + } + else if (id == "data") + { + data = reader.ReadBytes((int)size); + } + + if (next > file.Length) break; + file.Position = next; + } + + if (data == null || channels == 0) + throw new InvalidDataException("Missing fmt or data chunk: " + FileName); + + int bytesPerSample = bits / 8; + int frames = data.Length / (bytesPerSample * channels); + double[][] result = new double[channels][]; + for (int c = 0; c < channels; ++c) + result[c] = new double[frames]; + + for (int n = 0; n < frames; ++n) + { + for (int c = 0; c < channels; ++c) + { + int offset = (n * channels + c) * bytesPerSample; + if (format == FormatFloat && bits == 32) + result[c][n] = BitConverter.ToSingle(data, offset); + else if (format == FormatPcm && bits == 16) + result[c][n] = BitConverter.ToInt16(data, offset) / 32767.0; + else if (format == FormatPcm && bits == 32) + result[c][n] = BitConverter.ToInt32(data, offset) / 2147483647.0; + else + throw new NotSupportedException( + "Unsupported WAV format " + format + " with " + bits + " bits."); + } + } + return new Wav(rate, result); + } + } + + public void Write(string FileName) + { + int channels = ChannelCount; + int frames = SampleCount; + int dataBytes = frames * channels * sizeof(float); + + using (FileStream file = File.Create(FileName)) + using (BinaryWriter writer = new BinaryWriter(file)) + { + writer.Write("RIFF".ToCharArray()); + writer.Write((uint)(36 + dataBytes)); + writer.Write("WAVE".ToCharArray()); + + writer.Write("fmt ".ToCharArray()); + writer.Write((uint)16); + writer.Write(FormatFloat); + writer.Write((ushort)channels); + writer.Write((uint)SampleRate); + writer.Write((uint)(SampleRate * channels * sizeof(float))); + writer.Write((ushort)(channels * sizeof(float))); + writer.Write((ushort)(8 * sizeof(float))); + + writer.Write("data".ToCharArray()); + writer.Write((uint)dataBytes); + for (int n = 0; n < frames; ++n) + for (int c = 0; c < channels; ++c) + writer.Write((float)Channels[c][n]); + } + } + } +} diff --git a/LiveSPICE.Core.sln b/LiveSPICE.Core.sln new file mode 100644 index 00000000..249f4d82 --- /dev/null +++ b/LiveSPICE.Core.sln @@ -0,0 +1,63 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Util", "Util\Util.csproj", "{A92D9A5E-E4B1-4AE4-8F5C-C2EAF9ECFF5C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Audio", "Audio\Audio.csproj", "{BBC52758-0C64-4AFB-AD8B-29CFEDD94EAB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Circuit", "Circuit\Circuit.csproj", "{3C18FFEE-E755-4CE8-BE94-67932B09294C}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ComputerAlgebra", "ComputerAlgebra", "{108ADBE6-64C1-41C7-99CE-9B5B47061BE0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ComputerAlgebra", "ComputerAlgebra\ComputerAlgebra\ComputerAlgebra.csproj", "{9332C261-5996-4BDD-8878-068B9B147C3D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "Tests\Tests.csproj", "{65D2D24A-A83D-4029-8B83-D01EBC5376BC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoreAudio", "CoreAudio\CoreAudio.csproj", "{4390E359-D8F9-4E52-B6A4-81702142DCF9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiveSPICE.CLI", "LiveSPICE.CLI\LiveSPICE.CLI.csproj", "{205382E5-A209-4DB7-B69C-B1C1888FA254}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A92D9A5E-E4B1-4AE4-8F5C-C2EAF9ECFF5C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A92D9A5E-E4B1-4AE4-8F5C-C2EAF9ECFF5C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A92D9A5E-E4B1-4AE4-8F5C-C2EAF9ECFF5C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A92D9A5E-E4B1-4AE4-8F5C-C2EAF9ECFF5C}.Release|Any CPU.Build.0 = Release|Any CPU + {BBC52758-0C64-4AFB-AD8B-29CFEDD94EAB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BBC52758-0C64-4AFB-AD8B-29CFEDD94EAB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BBC52758-0C64-4AFB-AD8B-29CFEDD94EAB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BBC52758-0C64-4AFB-AD8B-29CFEDD94EAB}.Release|Any CPU.Build.0 = Release|Any CPU + {3C18FFEE-E755-4CE8-BE94-67932B09294C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3C18FFEE-E755-4CE8-BE94-67932B09294C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3C18FFEE-E755-4CE8-BE94-67932B09294C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3C18FFEE-E755-4CE8-BE94-67932B09294C}.Release|Any CPU.Build.0 = Release|Any CPU + {9332C261-5996-4BDD-8878-068B9B147C3D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9332C261-5996-4BDD-8878-068B9B147C3D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9332C261-5996-4BDD-8878-068B9B147C3D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9332C261-5996-4BDD-8878-068B9B147C3D}.Release|Any CPU.Build.0 = Release|Any CPU + {65D2D24A-A83D-4029-8B83-D01EBC5376BC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {65D2D24A-A83D-4029-8B83-D01EBC5376BC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {65D2D24A-A83D-4029-8B83-D01EBC5376BC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {65D2D24A-A83D-4029-8B83-D01EBC5376BC}.Release|Any CPU.Build.0 = Release|Any CPU + {4390E359-D8F9-4E52-B6A4-81702142DCF9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4390E359-D8F9-4E52-B6A4-81702142DCF9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4390E359-D8F9-4E52-B6A4-81702142DCF9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4390E359-D8F9-4E52-B6A4-81702142DCF9}.Release|Any CPU.Build.0 = Release|Any CPU + {205382E5-A209-4DB7-B69C-B1C1888FA254}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {205382E5-A209-4DB7-B69C-B1C1888FA254}.Debug|Any CPU.Build.0 = Debug|Any CPU + {205382E5-A209-4DB7-B69C-B1C1888FA254}.Release|Any CPU.ActiveCfg = Release|Any CPU + {205382E5-A209-4DB7-B69C-B1C1888FA254}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {9332C261-5996-4BDD-8878-068B9B147C3D} = {108ADBE6-64C1-41C7-99CE-9B5B47061BE0} + EndGlobalSection +EndGlobal diff --git a/Tests/Program.cs b/Tests/Program.cs index e6cdc808..235e8bc6 100644 --- a/Tests/Program.cs +++ b/Tests/Program.cs @@ -18,8 +18,9 @@ static async Task Main(string[] args) .WithArgument("pattern", "Glob pattern for files to test") .WithOption(new[] { "--plot" }, "Plot results") .WithOption(new[] { "--stats" }, "Write statistics") + .WithOption(new[] { "--check" }, "Check results against saved statistics (use --sampleRate 44100, the rate the baselines were generated at)") .WithOption(new[] { "--samples" }, () => 4800, "Samples") - .WithHandler(CommandHandler.Create(Test))) + .WithHandler(CommandHandler.Create(Test))) .WithCommand("benchmark", "Run benchmarks", c => c .WithArgument("pattern", "Glob pattern for files to benchmark") .WithHandler(CommandHandler.Create(Benchmark))) @@ -27,10 +28,13 @@ static async Task Main(string[] args) .WithGlobalOption(new Option("--oversample", () => 8, "Oversample")) .WithGlobalOption(new Option("--iterations", () => 8, "Iterations")); - return await rootCommand.InvokeAsync(args); + int result = await rootCommand.InvokeAsync(args); + return result != 0 ? result : (checkFailures > 0 ? 1 : 0); } - public static void Test(string pattern, bool plot, bool stats, int sampleRate, int samples, int oversample, int iterations) + private static int checkFailures = 0; + + public static void Test(string pattern, bool plot, bool stats, bool check, int sampleRate, int samples, int oversample, int iterations) { var log = new ConsoleLog() { Verbosity = MessageType.Info }; var tester = new Test(); @@ -38,10 +42,21 @@ public static void Test(string pattern, bool plot, bool stats, int sampleRate, i foreach (var circuit in GetCircuits(pattern, log)) { var outputs = tester.Run(circuit, t => Harmonics(t, 0.5, 82, 2), sampleRate, samples, oversample, iterations); + if (check) + { + checkFailures += tester.CheckStatistics(circuit.Name, outputs, log); + } +#if PLOTTING if (plot) { tester.PlotAll(circuit.Name, outputs); } +#else + if (plot) + { + log.WriteLine(MessageType.Warning, "--plot is not supported on this platform (requires System.Drawing/WinForms); ignoring."); + } +#endif if (stats) { tester.WriteStatistics(circuit.Name, outputs); diff --git a/Tests/Test.cs b/Tests/Test.cs index c2244990..71166731 100644 --- a/Tests/Test.cs +++ b/Tests/Test.cs @@ -1,8 +1,11 @@ using Circuit; using ComputerAlgebra; +#if PLOTTING using Plotting; +#endif using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; using System.Text; @@ -155,6 +158,7 @@ public double[] Benchmark( return new double[] { analyzeTime, solveTime, rate }; } +#if PLOTTING public void PlotAll(string Title, Dictionary> Outputs) { Plot p = new Plot() @@ -173,26 +177,110 @@ public void PlotAll(string Title, Dictionary> Outputs) { Name = i.Key.ToString() })); System.IO.Directory.CreateDirectory("Plots"); - p.Save("Plots\\" + Title + ".bmp"); + p.Save(Path.Combine("Plots", Title + ".bmp")); } - public void WriteStatistics(string Title, Dictionary> Outputs) +#endif + private static Dictionary ComputeStatistics(Dictionary> Outputs) { - string cols = "{0}, {1}, {2}, {3}, {4}"; - - StringBuilder sb = new StringBuilder(); - sb.AppendLine(string.Format(cols, "var", "mean", "min", "max", "rms")); + var stats = new Dictionary(); foreach (var i in Outputs) { double mean = i.Value.Sum() / i.Value.Count; double min = i.Value.Min(); double max = i.Value.Max(); double rms = Math.Sqrt(i.Value.Select(v => v * v).Sum()) / i.Value.Count; - sb.AppendLine(string.Format(cols, i.Key, mean, min, max, rms)); + stats[i.Key.ToString()] = new[] { mean, min, max, rms }; } + return stats; + } - string path = "Stats\\" + Title + ".csv"; + public void WriteStatistics(string Title, Dictionary> Outputs) + { + string cols = "{0}, {1}, {2}, {3}, {4}"; + + StringBuilder sb = new StringBuilder(); + sb.AppendLine(string.Format(CultureInfo.InvariantCulture, cols, "var", "mean", "min", "max", "rms")); + foreach (var i in ComputeStatistics(Outputs)) + sb.AppendLine(string.Format(CultureInfo.InvariantCulture, cols, i.Key, i.Value[0], i.Value[1], i.Value[2], i.Value[3])); + + string path = Path.Combine("Stats", Title + ".csv"); System.IO.Directory.CreateDirectory("Stats"); File.WriteAllText(path, sb.ToString()); } + + // Circuits whose solutions deterministically amplify platform floating-point + // differences (hard clipping recirculated through feedback), checked with a + // loose tolerance. Their results are run-to-run deterministic on any one + // platform, but the trajectory differs measurably across platforms. + private static readonly Dictionary looseTolerance = new Dictionary + { + { "Pro Co Rat", 1e-1 }, + }; + + /// + /// Compare simulation statistics against the saved baseline in Stats/. + /// The committed baselines were generated at --sampleRate 44100 (defaults otherwise); + /// checks only make sense at the configuration that produced the baseline. + /// Deviations are normalized by each variable's signal scale, max(|min|, |max|), + /// because the mean of an AC signal is near zero and a plain relative error there + /// is meaningless. + /// + /// 0 if the circuit matches its baseline, 1 otherwise. + public int CheckStatistics(string Title, Dictionary> Outputs, ILog Log) + { + string path = Path.Combine("Stats", Title + ".csv"); + if (!File.Exists(path)) + { + Log.WriteLine(MessageType.Error, "CHECK FAIL {0}: no baseline at '{1}'", Title, path); + return 1; + } + + var computed = ComputeStatistics(Outputs); + var baseline = new Dictionary(); + foreach (string line in File.ReadAllLines(path).Skip(1)) + { + if (string.IsNullOrWhiteSpace(line)) continue; + string[] fields = line.Split(','); + baseline[fields[0].Trim()] = fields.Skip(1).Take(4) + .Select(x => double.Parse(x, CultureInfo.InvariantCulture)).ToArray(); + } + + double tol = looseTolerance.TryGetValue(Title, out double loose) ? loose : 1e-6; + string[] statNames = { "mean", "min", "max", "rms" }; + int failures = 0; + double worst = 0; + string worstAt = ""; + foreach (var v in baseline) + { + if (!computed.TryGetValue(v.Key, out double[]? c)) + { + Log.WriteLine(MessageType.Error, "CHECK FAIL {0}: baseline variable {1} not produced", Title, v.Key); + failures++; + continue; + } + double scale = Math.Max(Math.Max(Math.Abs(v.Value[1]), Math.Abs(v.Value[2])), 1e-12); + for (int i = 0; i < 4; ++i) + { + double dev = Math.Abs(c[i] - v.Value[i]) / scale; + if (dev > worst) { worst = dev; worstAt = v.Key + "." + statNames[i]; } + // NaN deviation must fail, hence the negated comparison. + if (!(dev <= tol)) + { + Log.WriteLine(MessageType.Error, "CHECK FAIL {0}: {1}.{2} baseline={3} computed={4} deviation={5:G3}", + Title, v.Key, statNames[i], v.Value[i], c[i], dev); + failures++; + } + } + } + foreach (string k in computed.Keys.Where(k => !baseline.ContainsKey(k))) + { + Log.WriteLine(MessageType.Error, "CHECK FAIL {0}: variable {1} missing from baseline", Title, k); + failures++; + } + + if (failures == 0) + Log.WriteLine(MessageType.Info, "CHECK OK {0} (max deviation {1:G3} at {2})", Title, worst, worstAt); + return failures > 0 ? 1 : 0; + } } } \ No newline at end of file diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj index 3c091cb1..4fa5daeb 100644 --- a/Tests/Tests.csproj +++ b/Tests/Tests.csproj @@ -1,6 +1,8 @@  - net6.0-windows + net6.0-windows;net8.0 + + net8.0 Exe CircuitTests CircuitTests @@ -9,8 +11,13 @@ 1.0.0.0 8 enable - true + + true + $(DefineConstants);PLOTTING + + +