|
We are using logging (log4net) but nothing was logged. But I added some logging points and found out that it is the dynamic loading of an external dll that fails.
(Activator.CreateInstance)
ERROR!! Assemblyladdningsfel! Exception has been thrown by the target of an invocation.
ERROR!! Exception has been thrown by the target of an invocation.ERROR!! The type initializer for
Don't know why yet, but probably due to some stupid 2008 security control.
|
|
|
|
|
No, surely not "MyExternalDll", but the constructor of a class therein. Look again at the original message!
|
|
|
|
|
Depends on what your code is trying to do. There could be a security exception and an unhandled exception might stop the service. It is very difficult to guess without seeing your code.
|
|
|
|
|
Daniel Jansson wrote: If I copy the entire folder to a Win7 machine (64bit) and install it in the
exact same way, it works like it should.
Incorrect process.
1. Install it on the Win 7 machine
2. Install it on Win 2008
3. Delete the files from Win 7
4. Copy the files as installed from Win 2008 to Win 7.
5. See if it runs.
If it doesn't compare files.
Daniel Jansson wrote: Any ideas at all on how to resolve this?
Presuming the install works then it means it is an environment problem.
First change the user that the service runs under to you. Try to run it.
If that doesn't work then open permissions up completely on the install dir. If that doesn't fix it then restore then and continue.
Next step is to determine if it is a start up problem or a code problem. You might be able to determine this by adding a log line as the very first line of the Startup (Service method itself). If that gets into the log then you know that some other process is falling. You can debut that by putting a try/catch in Startup, which you should have anyways and log it. This doesn't work if you start threads and you will need to add more logging for exceptions in threads (which you should also be doing any ways.)
If the first log line doesn't show up then it becomes more difficult because it means that the service class is attempting to pull in a dll and initialization for that fails. Resolving that is done by creating a proxy in the Service which uses dynamic loading of everything else. Basically clone all the real functionality in the Service class, remove the functionality from the service class, then use dynamic loading to load and execute the second class with appropriate exception handling and logging for that.
Of course the above assumes that logging itself isn't the problem.
|
|
|
|
|
I am looking for a C# Class which talks to a Biometric device which is connected to the PC and then captures the finger print image. A generic solution which is not dependent on any third part software is what I am looking at.
A little about the solution I am working on its win forms application which displays the captured finger print image and also stores the same as an image.
|
|
|
|
|
.NET Framework does not provide any built-in API to work with Biometric devices. The only way to do this is using the API provided by the Biometric device vendor. Check if you vendor has an API library that is .NET compatible and start using it.
|
|
|
|
|
If you are developing for windows 7 and the biometric device is recognized by windows (control panel - biometric devices) then a starting point would be this blog post of Thomas Lebrun : Link.
|
|
|
|
|
Hello all,
I want to disable click event of picture box. Means click on the picture box not fire.
How i do this?
With Regards
Sanjeev
|
|
|
|
|
Simple. Don't hook up the event to an event handler.
|
|
|
|
|
If you want to unsubscribe after previous subscription to the event, just use -= :
yourPictureBox.Click -= YourMethod;
|
|
|
|
|
I have been scouring the net looking for updated information on interfacing a C# applilcation to a MIDI control interface (M-Audio Xponent). The best I could come up with is PureMidi, Midi-dot-net, and MIDI out setter here on codeplex. Unfortunately, I need the raw information, and need to strip out hundreds of lines of code to make it work. I can get the input and output devices fine, but I need to send simple MidiOutshortmsg and MidiOutLongMsg (sysex), and recieve MidiInshortMsgs. None of the examples really touch on long messages, and all color the short into actual deciphered note info that I don't want. The examples are much too complex for my needs. I need something very simple without 10 support files full of nonsence to sift through and strip down. Most everything is also for vc2005 or before, and are in dreadful need up update to VC2010/2012. The Midi toolkit is useless to me. It won't compile,and I can't determine why. I've spent the last week looking, and the 3 examples provided are the best usefull examples I've found to date, but are much too complex as stated. Please help, I'm beyond frustrated. All I need are simple examples to send and recieve raw hex data. I can parse it from there.
Thanks for any assistance you can provide.
Jeff
|
|
|
|
|
Hi Jeff,
midiOutShortMsg is quite simple, midiOutLongMsg is more complex due to the way that the MIDIHDR structure is used.
Receiving SysEx can be tricky as you will need to add and control the receive buffers.
I can help you with this but I don't think I'll have time until around this time tomorrow unfortunately. If you haven't marked this as solved by then I'll dig out some code to assist.
In the meantime, for output you're going to need these functions:
midiOutGetNumDevs
midiOutProc (this will be a delegate in C#)
midiOutOpen
midiOutShortMsg
midiOutPrepareHeader
midiOutLongMsg
midiOutUnprepareHeader
midiOutClose
and these structs
MIDIOUTCAPS
MIDIHDR
You will find all the definitions for these on MSDN. They are shown in C++. You will need to create PInvoke versions (to call into Winmm.dll) of these in C#. Search your system for MMSystem.h as there are many things in there (such as the values for constants used etc) that will be handy.
Edit: 20 hrs and no other answers, give me 8 to get some sleep (UK) and I'll post some working code and pointers. Do you have a link to the MIDI implementation chart for the device you're trying to communicate with?
modified 6-Sep-12 19:39pm.
|
|
|
|
|
Dave, Thanks! Yes! I do have the MIDI command chart for the Xponent, but I will be making this mostly generic. I don't actually have to recieve sysex, but that would be an added benefit.
I only really need to send a single sysex message to put the unit into advanced mode, and another message to put it back to normal.
I've also been looking at an autohotkey example of Midiin and Midiout that supposedly has sysex, but I have yet to see anything in it refering to MidiOutLongMsg. I appriciate your assistance and willingness to assist. Thanks again!
Jeff
|
|
|
|
|
Hi Jeff,
Two versions, one simple and one more complex. Any questions, just ask
This is using a console app and procedurally rather than using OOP as it's simpler to understand. This is as simple as I can make it!
using System;
using System.Runtime.InteropServices;
using System.Threading;
namespace SimpleSysExSender
{
class Program
{
const int MMSYSERR_NOERROR = 0;
const int MIDIERR_STILLPLAYING = 65;
static readonly int MidiHdrSize = Marshal.SizeOf(typeof(MIDIHDR));
static void Main(string[] args)
{
int id = 0;
byte[] data = new byte[] { 0xF0, 0x7E, 0x7F, 0x09, 0x01, 0xF7 };
if (id >= 0 && id < midiOutGetNumDevs())
{
IntPtr handle;
if (midiOutOpen(out handle, id, IntPtr.Zero, IntPtr.Zero, 0) == MMSYSERR_NOERROR)
{
IntPtr dataHandle = Marshal.AllocHGlobal(data.Length);
Marshal.Copy(data, 0, dataHandle, data.Length);
MIDIHDR buffer = new MIDIHDR(dataHandle, data.Length);
GCHandle gcHandle = GCHandle.Alloc(buffer, GCHandleType.Pinned);
IntPtr address = gcHandle.AddrOfPinnedObject();
if (midiOutPrepareHeader(handle, address, MidiHdrSize) == MMSYSERR_NOERROR)
{
midiOutLongMsg(handle, address, MidiHdrSize);
while (midiOutUnprepareHeader(handle, address, MidiHdrSize) == MIDIERR_STILLPLAYING)
Thread.Sleep(1);
}
gcHandle.Free();
Marshal.FreeHGlobal(dataHandle);
midiOutClose(handle);
}
}
}
[DllImport("Winmm.dll", SetLastError = true)]
public static extern int midiOutClose(IntPtr hmo);
[DllImport("Winmm.dll", SetLastError = true)]
public static extern int midiOutGetNumDevs();
[DllImport("Winmm.dll", SetLastError = true)]
public static extern int midiOutLongMsg(IntPtr hmo, IntPtr lpMidiOutHdr, int cbMidiOutHdr);
[DllImport("Winmm.dll", SetLastError = true)]
public static extern int midiOutOpen(out IntPtr lphmo, int uDeviceID, IntPtr dwCallback, IntPtr dwCallbackInstance, int dwFlags);
[DllImport("Winmm.dll", SetLastError = true)]
public static extern int midiOutPrepareHeader(IntPtr hmo, IntPtr lpMidiOutHdr, int cbMidiOutHdr);
public delegate void MidiOutProc(IntPtr hmo, int wMsg, IntPtr dwInstance, IntPtr dwParam1, IntPtr dwParam2);
[DllImport("Winmm.dll", SetLastError = true)]
public static extern int midiOutUnprepareHeader(IntPtr hmo, IntPtr lpMidiOutHdr, int cbMidiOutHdr);
}
[StructLayout(LayoutKind.Sequential)]
internal struct MIDIHDR
{
private IntPtr lpData;
private int dwBufferLength;
private int dwBytesRecorded;
private IntPtr dwUser;
private int dwFlags;
private IntPtr lpNext;
private IntPtr reserved;
private int dwOffset;
private IntPtr dwReserved;
public MIDIHDR(IntPtr lpData, int dwBufferLength)
{
this.lpData = lpData;
this.dwBufferLength = dwBufferLength;
dwBytesRecorded = dwBufferLength;
dwUser = IntPtr.Zero;
dwFlags = 0;
lpNext = IntPtr.Zero;
reserved = IntPtr.Zero;
dwOffset = 0;
dwReserved = IntPtr.Zero;
}
}
}
This is a more OOP version of the same thing (sorry for the length!). More robust and more comments, but may be overkill for your requirements:
using System;
using System.Collections;
using System.Collections.Generic;
using System.Globalization;
using System.Runtime.InteropServices;
using System.Threading;
namespace MidiSysExSender
{
class Program
{
static void Main(string[] args)
{
if (MidiOutputCollection.Outputs.Count > 0)
{
MidiOutput output = MidiOutputCollection.Outputs[0];
output.Open();
output.SendSysEx(MidiSystemExclusiveMessage.GeneralMidiOn);
output.Close();
Console.WriteLine("Done");
}
else
Console.WriteLine("No MIDI Outs found");
Console.ReadKey();
}
}
public class MidiOutput
{
private NativeMethods.MidiOutProc callback;
private MIDIOUTCAPS caps;
private IntPtr handle;
private int id;
internal MidiOutput(int id)
{
MIDIOUTCAPS caps;
int nativeResult = NativeMethods.midiOutGetDevCaps(id, out caps, MIDIOUTCAPS.Size);
if (nativeResult == NativeMethods.MMSYSERR_NOERROR)
{
callback = Callback;
this.caps = caps;
this.id = id;
}
else
throw new InvalidOperationException();
}
public int ChannelMask
{
get { return caps.ChannelMask; }
}
public Version DriverVersion
{
get { return caps.DriverVersion; }
}
public int Id
{
get { return id; }
}
public bool IsOpen
{
get { return handle != IntPtr.Zero; }
}
public int ManufacturerId
{
get { return caps.ManufacturerId; }
}
public string Name
{
get { return caps.Name; }
}
public int Notes
{
get { return caps.Notes; }
}
public int ProductId
{
get { return caps.ProductId; }
}
public MidiOutputTechnology Technology
{
get { return caps.Technology; }
}
public int Voices
{
get { return caps.Voices; }
}
public MidiOutputSupport Support
{
get { return caps.Support; }
}
private void Callback(IntPtr hmo, int wMsg, IntPtr dwInstance, IntPtr dwParam1, IntPtr dwParam2)
{
}
public void Close()
{
if (IsOpen)
{
int nativeResult = NativeMethods.midiOutClose(handle);
if (nativeResult == NativeMethods.MMSYSERR_NOERROR)
handle = IntPtr.Zero;
else
throw new InvalidOperationException();
}
}
public void Open()
{
if (!IsOpen)
{
IntPtr handle;
int nativeResult = NativeMethods.midiOutOpen(out handle, id, callback, IntPtr.Zero, NativeMethods.CALLBACK_FUNCTION);
if (nativeResult == NativeMethods.MMSYSERR_NOERROR)
this.handle = handle;
else
throw new InvalidOperationException();
}
}
public void SendSysEx(MidiSystemExclusiveMessage sysEx)
{
if (!IsOpen)
Open();
MIDIHDR buffer = new MIDIHDR(sysEx);
GCHandle gcHandle = GCHandle.Alloc(buffer, GCHandleType.Pinned);
IntPtr address = gcHandle.AddrOfPinnedObject();
int nativeResult = NativeMethods.midiOutPrepareHeader(handle, address, MIDIHDR.Size);
if (nativeResult == NativeMethods.MMSYSERR_NOERROR)
{
NativeMethods.midiOutLongMsg(handle, address, MIDIHDR.Size);
while (NativeMethods.midiOutUnprepareHeader(handle, address, MIDIHDR.Size) == NativeMethods.MIDIERR_STILLPLAYING)
Thread.Sleep(1);
}
gcHandle.Free();
buffer.Free();
}
public override string ToString()
{
return string.Format(CultureInfo.InvariantCulture, "MIDI Output {0}: {1}", id, Name);
}
}
public class MidiOutputCollection : IDisposable, IEnumerable<MidiOutput>
{
private List<MidiOutput> list;
private MidiOutputCollection()
{
int count = NativeMethods.midiOutGetNumDevs();
list = new List<MidiOutput>(count);
if (count > 0)
{
for (int id = 0; id < count; id++)
list.Add(new MidiOutput(id));
}
}
~MidiOutputCollection()
{
Dispose(false);
}
public MidiOutput this[int index]
{
get { return list[index]; }
}
public int Count
{
get { return list.Count; }
}
public static MidiOutputCollection Outputs
{
get { return InstanceHolder.Instance; }
}
public void Dispose()
{
Dispose(true);
}
private void Dispose(bool disposing)
{
if (disposing)
{ }
foreach (MidiOutput midiOutput in this)
midiOutput.Close();
}
public IEnumerator<MidiOutput> GetEnumerator()
{
return list.GetEnumerator();
}
IEnumerator IEnumerable.GetEnumerator()
{
return list.GetEnumerator();
}
public int IndexOf(MidiOutput midiOutput)
{
return list.IndexOf(midiOutput);
}
private class InstanceHolder
{
static InstanceHolder()
{ }
private static readonly MidiOutputCollection instance = new MidiOutputCollection();
internal static MidiOutputCollection Instance
{
get { return instance; }
}
}
}
public enum MidiOutputTechnology
{
Undefined,
Port = NativeMethods.MOD_MIDIPORT,
Synthesizer = NativeMethods.MOD_SYNTH,
SquareWaveSynthesizer = NativeMethods.MOD_SQSYNTH,
FMSynthesizer = NativeMethods.MOD_FMSYNTH,
MidiMapper = NativeMethods.MOD_MAPPER,
WavetableSynthesizer = NativeMethods.MOD_WAVETABLE,
SoftwareSynthesizer = NativeMethods.MOD_SWSYNTH
}
[Flags]
public enum MidiOutputSupport
{
None = 0,
PatchCaching = NativeMethods.MIDICAPS_CACHE,
StereoVolume = NativeMethods.MIDICAPS_LRVOLUME,
StreamOut = NativeMethods.MIDICAPS_STREAM,
Volume = NativeMethods.MIDICAPS_VOLUME,
}
public class MidiSystemExclusiveMessage : IEnumerable<byte>
{
public const byte SOX = 0xF0;
public const byte EOX = 0xF7;
public const byte MaxByte = 0x7F;
public static MidiSystemExclusiveMessage GeneralMidiOn = new MidiSystemExclusiveMessage(0x7E, 0x7F, 0x09, 0x01);
public static MidiSystemExclusiveMessage GeneralMidiOff = new MidiSystemExclusiveMessage(0x7E, 0x7F, 0x09, 0x02);
private List<byte> list;
public MidiSystemExclusiveMessage(params byte[] data)
{
if (data == null)
throw new ArgumentNullException("data");
list = new List<byte>(data.Length + 2);
list.Add(SOX);
foreach (byte b in data)
if (b <= MaxByte)
list.Add(b);
list.Add(EOX);
list.TrimExcess();
}
public byte this[int index]
{
get { return list[index]; }
}
public int Count
{
get { return list.Count; }
}
public IEnumerator<byte> GetEnumerator()
{
return list.GetEnumerator();
}
IEnumerator IEnumerable.GetEnumerator()
{
return list.GetEnumerator();
}
public byte[] ToArray()
{
return list.ToArray();
}
}
#region Interop
internal static class NativeMethods
{
public const int MAXPNAMELEN = 32;
public const int MOD_MIDIPORT = 1;
public const int MOD_SYNTH = 2;
public const int MOD_SQSYNTH = 3;
public const int MOD_FMSYNTH = 4;
public const int MOD_MAPPER = 5;
public const int MOD_WAVETABLE = 6;
public const int MOD_SWSYNTH = 7;
public const int MIDICAPS_VOLUME = 0x0001;
public const int MIDICAPS_LRVOLUME = 0x0002;
public const int MIDICAPS_CACHE = 0x0004;
public const int MIDICAPS_STREAM = 0x0008;
private const int MIDIERR_BASE = 64;
public const int MMSYSERR_NOERROR = 0;
public const int MIDIERR_STILLPLAYING = (MIDIERR_BASE + 1);
public const int CALLBACK_FUNCTION = 0x00030000;
[DllImport("Winmm.dll", SetLastError = true)]
public static extern int midiOutClose(IntPtr hmo);
[DllImport("Winmm.dll", SetLastError = true)]
public static extern int midiOutGetDevCaps(int uDeviceID, out MIDIOUTCAPS lpMidiOutCaps, int cbMidiOutCaps);
[DllImport("Winmm.dll", SetLastError = true)]
public static extern int midiOutGetNumDevs();
[DllImport("Winmm.dll", SetLastError = true)]
public static extern int midiOutLongMsg(IntPtr hmo, IntPtr lpMidiOutHdr, int cbMidiOutHdr);
[DllImport("Winmm.dll", SetLastError = true)]
public static extern int midiOutOpen(out IntPtr lphmo, int uDeviceID, MidiOutProc dwCallback, IntPtr dwCallbackInstance, int dwFlags);
[DllImport("Winmm.dll", SetLastError = true)]
public static extern int midiOutPrepareHeader(IntPtr hmo, IntPtr lpMidiOutHdr, int cbMidiOutHdr);
public delegate void MidiOutProc(IntPtr hmo, int wMsg, IntPtr dwInstance, IntPtr dwParam1, IntPtr dwParam2);
[DllImport("Winmm.dll", SetLastError = true)]
public static extern int midiOutUnprepareHeader(IntPtr hmo, IntPtr lpMidiOutHdr, int cbMidiOutHdr);
}
[StructLayout(LayoutKind.Sequential)]
internal struct MIDIOUTCAPS
{
public static readonly int Size = Marshal.SizeOf(typeof(MIDIOUTCAPS));
private short wMid;
private short wPid;
private int vDriverVersion;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = NativeMethods.MAXPNAMELEN)]
private string szPname;
private short wTechnology;
private short wVoices;
private short wNotes;
private short wChannelMask;
private int dwSupport;
public int ManufacturerId
{
get { return wMid; }
}
public int ProductId
{
get { return wPid; }
}
public Version DriverVersion
{
get { return new Version((vDriverVersion >> 8) & 0xFF, vDriverVersion & 0xFF, 0, 0); }
}
public string Name
{
get { return szPname; }
}
public MidiOutputTechnology Technology
{
get { return (MidiOutputTechnology)wTechnology; }
}
public int Voices
{
get { return wVoices; }
}
public int Notes
{
get { return wNotes; }
}
public int ChannelMask
{
get { return wChannelMask & 0xFFFF; }
}
public MidiOutputSupport Support
{
get { return (MidiOutputSupport)dwSupport; }
}
}
[StructLayout(LayoutKind.Sequential)]
internal struct MIDIHDR
{
public static readonly int Size = Marshal.SizeOf(typeof(MIDIHDR));
private IntPtr lpData;
private int dwBufferLength;
private int dwBytesRecorded;
private IntPtr dwUser;
private int dwFlags;
private IntPtr lpNext;
private IntPtr reserved;
private int dwOffset;
private IntPtr dwReserved;
public MIDIHDR(MidiSystemExclusiveMessage data)
{
IntPtr dataHandle = Marshal.AllocHGlobal(data.Count);
Marshal.Copy(data.ToArray(), 0, dataHandle, data.Count);
lpData = dataHandle;
dwBufferLength = data.Count;
dwBytesRecorded = data.Count;
dwUser = IntPtr.Zero;
dwFlags = 0;
lpNext = IntPtr.Zero;
reserved = IntPtr.Zero;
dwOffset = 0;
dwReserved = IntPtr.Zero;
}
public void Free()
{
Marshal.FreeHGlobal(lpData);
}
}
#endregion Interop
}
|
|
|
|
|
Thank you very much Dave. I shall give these a try tonight! I'll try to parse it to my brain until then.
I very much appriciate your assistance!
Jeff
|
|
|
|
|
Dave,
Thanks again! The sysex examples work great with my sysex code. The buttons flash in responce as desired. Now to send and recieve MidiShortMsgs, and I should be good to go.
Jeff
|
|
|
|
|
Managed to send "short" messages to the controller now, so all I have left is to determine how to callback messages from the controller. Your assistance is greatly appriciated.
Jeff
|
|
|
|
|
With MIDI In, the procedure is similar. The problem is you have to prepare a MIDIHDR buffer of x size (x is up to you), add it using midiInAddBuffer and that will be returned to you through a MIM_LONGDATA message in a MidiInProc callback - dwParam1 (you will need to cast the IntPtr to int ) contains the MIDIHDR (dwParam2 contains the timestamp which you probably don't need). To get a callback, you will need to set CALLBACK_FUNCTION in the dwFlags when using midiInOpen and also call midiInStart .
Checking the dwBytesRecorded (you will need to add a property to expose it) will tell you how much data there actually is in the buffer. The real data can be copied out of the buffer using Marshal.Copy . Don't forget to unprepare the buffer, free the GCHandle and the data pointer! If it is full and the last byte isn't EOX, you will need to quickly add another buffer to get the rest of the sysex data (you can add more than one buffer)!
Once you have all the data and freed everything you can stop and close the input when you're done - one issue though. You may have prepared and added a buffer(s) but received no sysex into it so it's just sitting there. To deal with these you will need to call midiInReset , get the buffers via the callback, unprepare them (and free the GCHandle and data pointer) before calling midiInClose . There is a midiInStop function, this doesn't return unused buffers. I personally call midiInReset , get the buffers in the callback (freeing and unpreparing them) and then call midiInStop to ensure compatibility with all device drivers just in case.
If you get stuck I will happily knock you up some smple code. Based on what I've already given you and this[^] you should be OK though.
Short messages are a doddle. Open, Start, receive MIM_DATA in the callback and extract the bytes from dwParam1, and once you're done Stop (or Reset then Stop) and Close.
Edit: Just checked, calling midiInClose on a MIDI In that still has buffers will not close the device. Instead the function result will be MIDIERR_STILLPLAYING.
modified 8-Sep-12 4:29am.
|
|
|
|
|
Thanks Dave!
I'm not sure I'm that smart, but with a bit more research on the web, I may figure it out. TBO, I need to re-learn C# programming every time I have a project like this to work on. When complete, I'll probably not touch programming in C# until I get another project I want to tackle maby 6-12 months from now. I tend to do most of my programming in AutoHotkey, unless I want something a bit beefier. It's definatly easier to step through studio code than ahk code, as you can't set breakpoins and step into AHK code.
|
|
|
|
|
I'll see if I can knock up some code for you. May take a few days due to other commitments and it's not the easiest part of the MIDI API to deal with! If you get it sorted, let me know
|
|
|
|
|
That would be more than fine! Thanks again Dave!
|
|
|
|
|
Haven't forgotten this Jeff! I figured that this was worth making into a full blown article so I'm putting in more effort than I would otherwise - fun stuff though
I'll keep you updated - hopefully it'll finish up with a library that will make it a doddle to use all the low level MIDI API and Stream API directly from C# with no effort. It shouldn't take long now, it's nearly finished.
|
|
|
|
|
That would be so awesome Dave! There needs to be something better and more updated available like that for the population. I'm sure someone besides myself will benefit from your efforts! Thanks for doing so, and I look forward to the article.
|
|
|
|
|
Well work has put a delay on this. Got a huge project on with a deadline of only two weeks so throwing in 12 hour plus days I'm not going to get time to do anything with this until that's over unfortunately.
In the meantime, this is what I have completed so far. All the MIDI In and MIDI Out is working including SysEx
Not everything is commented and handle locking needs some more attention to ensure thread safety. There is no inclusion of the MIDI Stream API yet.
Check out the GeneralInstructions.txt file in the root folder. That should be enough to get you underway.
http://www.codeproject.com/script/Membership/Uploads/3392848/Midi.Net_Solution.zip[^]
I will get back to this and complete it/clean it up and write the article, but it's usable as is and will probably be enough for your purposes.
|
|
|
|
|
Dave,
Thanks for this! I'll attempt to decipher what you've done here. It is much appriciated.
|
|
|
|
|