Index: mptrack/EffectInfo.cpp
===================================================================
--- mptrack/EffectInfo.cpp	(revision 16687)
+++ mptrack/EffectInfo.cpp	(working copy)
@@ -640,7 +640,7 @@
 			if(chn != CHANNELINDEX_INVALID)
 			{
 				const uint8 macroIndex = sndFile.m_PlayState.Chn[chn].nActiveMacro;
-				const PLUGINDEX plugin = sndFile.GetBestPlugin(chn, PrioritiseChannel, EvenIfMuted) - 1;
+				const PLUGINDEX plugin = sndFile.GetBestPlugin(sndFile.m_PlayState, chn, PrioritiseChannel, EvenIfMuted) - 1;
 				IMixPlugin *pPlugin = (plugin < MAX_MIXPLUGINS ? sndFile.m_MixPlugins[plugin].pMixPlugin : nullptr);
 				pszName.Format(_T("SFx MIDI Macro z=%d (SF%X: %s)"), param, macroIndex, sndFile.m_MidiCfg.GetParameteredMacroName(macroIndex, pPlugin).GetString());
 			} else
Index: mptrack/MIDIMacroDialog.cpp
===================================================================
--- mptrack/MIDIMacroDialog.cpp	(revision 16687)
+++ mptrack/MIDIMacroDialog.cpp	(working copy)
@@ -310,7 +310,7 @@
 		{
 			CString s;
 			m_EditSFx.GetWindowText(s);
-			mpt::String::WriteAutoBuf(m_MidiCfg.szMidiSFXExt[sfx]) = mpt::ToCharset(mpt::Charset::ASCII, s);
+			m_MidiCfg.szMidiSFXExt[sfx] = mpt::ToCharset(mpt::Charset::ASCII, s);
 
 			int sfx_preset = m_MidiCfg.GetParameteredMacroType(sfx);
 			m_CbnSFxPreset.SetCurSel(sfx_preset);
@@ -330,7 +330,7 @@
 		{
 			CString s;
 			m_EditZxx.GetWindowText(s);
-			mpt::String::WriteAutoBuf(m_MidiCfg.szMidiZXXExt[zxx]) = mpt::ToCharset(mpt::Charset::ASCII, s);
+			m_MidiCfg.szMidiZXXExt[zxx] = mpt::ToCharset(mpt::Charset::ASCII, s);
 			m_CbnZxxPreset.SetCurSel(m_MidiCfg.GetFixedMacroType());
 		}
 	}
@@ -442,7 +442,7 @@
 }
 
 
-bool CMidiMacroSetup::ValidateMacroString(CEdit &wnd, char *lastMacro, bool isParametric)
+bool CMidiMacroSetup::ValidateMacroString(CEdit &wnd, const MIDIMacroConfig::Macro &lastMacro, bool isParametric)
 {
 	CString macroStrT;
 	wnd.GetWindowText(macroStrT);
@@ -451,11 +451,11 @@
 	bool allowed = true, caseChange = false;
 	for(char &c : macroStr)
 	{
-		if(c == 'k' || c == 'K')		// Previously, 'K' was used for MIDI channel
+		if(c == 'k' || c == 'K')  // Previously, 'K' was used for MIDI channel
 		{
 			caseChange = true;
 			c = 'c';
-		} else if(c >= 'd' && c <= 'f')	// abc have special meanings, but def can be fixed
+		} else if(c >= 'd' && c <= 'f')  // abc have special meanings, but def can be fixed
 		{
 			caseChange = true;
 			c = c - 'a' + 'A';
@@ -476,7 +476,7 @@
 	if(!allowed)
 	{
 		// Replace text and keep cursor position if we just typed in an invalid character
-		if(lastMacro != macroStr)
+		if(lastMacro != std::string_view{macroStr})
 		{
 			int start, end;
 			wnd.GetSel(start, end);
Index: mptrack/MIDIMacroDialog.h
===================================================================
--- mptrack/MIDIMacroDialog.h	(revision 16687)
+++ mptrack/MIDIMacroDialog.h	(working copy)
@@ -40,7 +40,7 @@
 	BOOL OnInitDialog() override;
 	void DoDataExchange(CDataExchange* pDX) override;
 
-	bool ValidateMacroString(CEdit &wnd, char *lastMacro, bool isParametric);
+	bool ValidateMacroString(CEdit &wnd, const MIDIMacroConfig::Macro &lastMacro, bool isParametric);
 
 	void UpdateMacroList(int macro=-1);
 	void ToggleBoxes(UINT preset, UINT sfx);
Index: mptrack/mod2midi.cpp
===================================================================
--- mptrack/mod2midi.cpp	(revision 16687)
+++ mptrack/mod2midi.cpp	(working copy)
@@ -94,7 +94,9 @@
 
 		void SynchronizeMidiPitchWheelDepth(CHANNELINDEX trackerChn)
 		{
-			const auto midiCh = GetMidiChannel(trackerChn);
+			if(trackerChn >= std::size(m_sndFile.m_PlayState.Chn))
+				return;
+			const auto midiCh = GetMidiChannel(m_sndFile.m_PlayState.Chn[trackerChn], trackerChn);
 			if(!m_overlappingInstruments && m_tempoTrack && m_tempoTrack->m_pitchWheelDepth[midiCh] != m_instr.midiPWD)
 				WritePitchWheelDepth(static_cast<MidiChannel>(midiCh + MidiFirstChannel));
 		}
@@ -306,7 +308,7 @@
 			return true;
 		}
 
-		uint8 GetMidiChannel(CHANNELINDEX trackChannel) const override
+		uint8 GetMidiChannel(const ModChannel &chn, CHANNELINDEX trackChannel) const override
 		{
 			if(m_instr.nMidiChannel == MidiMappedChannel && trackChannel < std::size(m_sndFile.m_PlayState.Chn))
 			{
@@ -316,7 +318,7 @@
 					midiCh++;
 				return midiCh;
 			}
-			return IMidiPlugin::GetMidiChannel(trackChannel);
+			return IMidiPlugin::GetMidiChannel(chn, trackChannel);
 		}
 
 		void MidiCommand(const ModInstrument &instr, uint16 note, uint16 vol, CHANNELINDEX trackChannel) override
@@ -327,8 +329,8 @@
 				note = NOTE_KEYOFF;
 			}
 			SynchronizeMidiChannelState();
-			if(trackChannel < MAX_CHANNELS)
-				m_lastModChannel[GetMidiChannel(trackChannel)] = trackChannel;
+			if(trackChannel < std::size(m_sndFile.m_PlayState.Chn))
+				m_lastModChannel[GetMidiChannel(m_sndFile.m_PlayState.Chn[trackChannel], trackChannel)] = trackChannel;
 			IMidiPlugin::MidiCommand(instr, note, vol, trackChannel);
 		}
 
Index: mptrack/Moddoc.cpp
===================================================================
--- mptrack/Moddoc.cpp	(revision 16687)
+++ mptrack/Moddoc.cpp	(working copy)
@@ -1290,7 +1290,7 @@
 		m_SndFile.m_PlayState.Chn[nChn].dwFlags.set(muteType);
 		if(m_SndFile.m_opl) m_SndFile.m_opl->NoteCut(nChn);
 		// Kill VSTi notes on muted channel.
-		PLUGINDEX nPlug = m_SndFile.GetBestPlugin(nChn, PrioritiseInstrument, EvenIfMuted);
+		PLUGINDEX nPlug = m_SndFile.GetBestPlugin(m_SndFile.m_PlayState, nChn, PrioritiseInstrument, EvenIfMuted);
 		if ((nPlug) && (nPlug<=MAX_MIXPLUGINS))
 		{
 			IMixPlugin *pPlug = m_SndFile.m_MixPlugins[nPlug - 1].pMixPlugin;
Index: mptrack/TrackerSettings.cpp
===================================================================
--- mptrack/TrackerSettings.cpp	(revision 16687)
+++ mptrack/TrackerSettings.cpp	(working copy)
@@ -443,11 +443,11 @@
 	theApp.GetDefaultMidiMacro(macros);
 	for(int isfx = 0; isfx < 16; isfx++)
 	{
-		mpt::String::WriteAutoBuf(macros.szMidiSFXExt[isfx]) = conf.Read<std::string>(U_("Zxx Macros"), MPT_UFORMAT("SF{}")(mpt::ufmt::HEX(isfx)), macros.szMidiSFXExt[isfx]);
+		macros.szMidiSFXExt[isfx] = conf.Read<std::string>(U_("Zxx Macros"), MPT_UFORMAT("SF{}")(mpt::ufmt::HEX(isfx)), macros.szMidiSFXExt[isfx]);
 	}
 	for(int izxx = 0; izxx < 128; izxx++)
 	{
-		mpt::String::WriteAutoBuf(macros.szMidiZXXExt[izxx]) = conf.Read<std::string>(U_("Zxx Macros"), MPT_UFORMAT("Z{}")(mpt::ufmt::HEX0<2>(izxx | 0x80)), macros.szMidiZXXExt[izxx]);
+		macros.szMidiZXXExt[izxx] = conf.Read<std::string>(U_("Zxx Macros"), MPT_UFORMAT("Z{}")(mpt::ufmt::HEX0<2>(izxx | 0x80)), macros.szMidiZXXExt[izxx]);
 	}
 
 
Index: soundlib/Fastmix.cpp
===================================================================
--- soundlib/Fastmix.cpp	(revision 16687)
+++ soundlib/Fastmix.cpp	(working copy)
@@ -345,7 +345,7 @@
 
 		//Look for plugins associated with this implicit tracker channel.
 #ifndef NO_PLUGINS
-		PLUGINDEX nMixPlugin = GetBestPlugin(m_PlayState.ChnMix[nChn], PrioritiseInstrument, RespectMutes);
+		PLUGINDEX nMixPlugin = GetBestPlugin(m_PlayState, m_PlayState.ChnMix[nChn], PrioritiseInstrument, RespectMutes);
 
 		if ((nMixPlugin > 0) && (nMixPlugin <= MAX_MIXPLUGINS) && m_MixPlugins[nMixPlugin - 1].pMixPlugin != nullptr)
 		{
Index: soundlib/Load_dbm.cpp
===================================================================
--- soundlib/Load_dbm.cpp	(revision 16687)
+++ soundlib/Load_dbm.cpp	(working copy)
@@ -623,10 +623,10 @@
 		for(uint32 i = 0; i < 32; i++)
 		{
 			uint32 param = (i * 127u) / 32u;
-			mpt::String::WriteAutoBuf(m_MidiCfg.szMidiZXXExt[i     ]) = MPT_AFORMAT("F0F080{}")(mpt::afmt::HEX0<2>(param));
-			mpt::String::WriteAutoBuf(m_MidiCfg.szMidiZXXExt[i + 32]) = MPT_AFORMAT("F0F081{}")(mpt::afmt::HEX0<2>(param));
-			mpt::String::WriteAutoBuf(m_MidiCfg.szMidiZXXExt[i + 64]) = MPT_AFORMAT("F0F082{}")(mpt::afmt::HEX0<2>(param));
-			mpt::String::WriteAutoBuf(m_MidiCfg.szMidiZXXExt[i + 96]) = MPT_AFORMAT("F0F083{}")(mpt::afmt::HEX0<2>(param));
+			m_MidiCfg.szMidiZXXExt[i     ] = MPT_AFORMAT("F0F080{}")(mpt::afmt::HEX0<2>(param));
+			m_MidiCfg.szMidiZXXExt[i + 32] = MPT_AFORMAT("F0F081{}")(mpt::afmt::HEX0<2>(param));
+			m_MidiCfg.szMidiZXXExt[i + 64] = MPT_AFORMAT("F0F082{}")(mpt::afmt::HEX0<2>(param));
+			m_MidiCfg.szMidiZXXExt[i + 96] = MPT_AFORMAT("F0F083{}")(mpt::afmt::HEX0<2>(param));
 		}
 	}
 #endif // NO_PLUGINS
Index: soundlib/Load_med.cpp
===================================================================
--- soundlib/Load_med.cpp	(revision 16687)
+++ soundlib/Load_med.cpp	(working copy)
@@ -1042,7 +1042,7 @@
 
 	// Setup a program change macro for command 1C (even if MIDI plugin is disabled, as otherwise these commands may act as filter commands)
 	m_MidiCfg.ClearZxxMacros();
-	strcpy(m_MidiCfg.szMidiSFXExt[0], "Cc z");
+	m_MidiCfg.szMidiSFXExt[0] = "Cc z";
 
 	file.Rewind();
 	PATTERNINDEX basePattern = 0;
@@ -1216,8 +1216,8 @@
 					file.ReadStruct(dumpHeader);
 					if(!file.Seek(dumpHeader.dataPointer) || !file.CanRead(dumpHeader.length))
 						continue;
-					auto &macro = m_MidiCfg.szMidiZXXExt[dump];
-					auto length = std::min(static_cast<size_t>(dumpHeader.length), std::size(macro) / 2u);
+					MIDIMacroConfig::Macro::RawType macro;
+					auto length = std::min(static_cast<size_t>(dumpHeader.length), macro.size() / 2u);
 					for(size_t i = 0; i < length; i++)
 					{
 						const uint8 byte = file.ReadUint8(), high = byte >> 4, low = byte & 0x0F;
@@ -1224,6 +1224,7 @@
 						macro[i * 2] = high + (high < 0x0A ? '0' : 'A' - 0x0A);
 						macro[i * 2 + 1] = low + (low < 0x0A ? '0' : 'A' - 0x0A);
 					}
+					m_MidiCfg.szMidiZXXExt[dump] = macro;
 				}
 			}
 		}
Index: soundlib/Load_mo3.cpp
===================================================================
--- soundlib/Load_mo3.cpp	(revision 16687)
+++ soundlib/Load_mo3.cpp	(working copy)
@@ -907,16 +907,16 @@
 		for(uint32 i = 0; i < 16; i++)
 		{
 			if(fileHeader.sfxMacros[i])
-				mpt::String::WriteAutoBuf(m_MidiCfg.szMidiSFXExt[i]) = MPT_AFORMAT("F0F0{}z")(mpt::afmt::HEX0<2>(fileHeader.sfxMacros[i] - 1));
+				m_MidiCfg.szMidiSFXExt[i] = MPT_AFORMAT("F0F0{}z")(mpt::afmt::HEX0<2>(fileHeader.sfxMacros[i] - 1));
 			else
-				mpt::String::WriteAutoBuf(m_MidiCfg.szMidiSFXExt[i]) = "";
+				m_MidiCfg.szMidiSFXExt[i] = "";
 		}
 		for(uint32 i = 0; i < 128; i++)
 		{
 			if(fileHeader.fixedMacros[i][1])
-				mpt::String::WriteAutoBuf(m_MidiCfg.szMidiZXXExt[i]) = MPT_AFORMAT("F0F0{}{}")(mpt::afmt::HEX0<2>(fileHeader.fixedMacros[i][1] - 1), mpt::afmt::HEX0<2>(fileHeader.fixedMacros[i][0].get()));
+				m_MidiCfg.szMidiZXXExt[i] = MPT_AFORMAT("F0F0{}{}")(mpt::afmt::HEX0<2>(fileHeader.fixedMacros[i][1] - 1), mpt::afmt::HEX0<2>(fileHeader.fixedMacros[i][0].get()));
 			else
-				mpt::String::WriteAutoBuf(m_MidiCfg.szMidiZXXExt[i]) = "";
+				m_MidiCfg.szMidiZXXExt[i] = "";
 		}
 	}
 
Index: soundlib/Load_symmod.cpp
===================================================================
--- soundlib/Load_symmod.cpp	(revision 16687)
+++ soundlib/Load_symmod.cpp	(working copy)
@@ -955,11 +955,11 @@
 		const uint8 reso = static_cast<uint8>(std::min(127, event.inst * 127 / 185));
 
 		if(type == 1)  // lowpass filter
-			mpt::String::WriteAutoBuf(macro) = MPT_AFORMAT("F0F000{} F0F001{} F0F00200")(mpt::afmt::HEX0<2>(cutoff), mpt::afmt::HEX0<2>(reso));
+			macro = MPT_AFORMAT("F0F000{} F0F001{} F0F00200")(mpt::afmt::HEX0<2>(cutoff), mpt::afmt::HEX0<2>(reso));
 		else if(type == 2)  // highpass filter
-			mpt::String::WriteAutoBuf(macro) = MPT_AFORMAT("F0F000{} F0F001{} F0F00210")(mpt::afmt::HEX0<2>(cutoff), mpt::afmt::HEX0<2>(reso));
+			macro = MPT_AFORMAT("F0F000{} F0F001{} F0F00210")(mpt::afmt::HEX0<2>(cutoff), mpt::afmt::HEX0<2>(reso));
 		else  // no filter or unsupported filter type
-			mpt::String::WriteAutoBuf(macro) = "F0F0007F F0F00100";
+			macro = "F0F0007F F0F00100";
 		return true;
 	} else if(event.command == SymEvent::DSPEcho)
 	{
@@ -966,7 +966,7 @@
 		const uint8 type = (event.note < 5) ? event.note : 0;
 		const uint8 length = (event.param < 128) ? event.param : 127;
 		const uint8 feedback = (event.inst < 128) ? event.inst : 127;
-		mpt::String::WriteAutoBuf(macro) = MPT_AFORMAT("F0F080{} F0F081{} F0F082{}")(mpt::afmt::HEX0<2>(type), mpt::afmt::HEX0<2>(length), mpt::afmt::HEX0<2>(feedback));
+		macro = MPT_AFORMAT("F0F080{} F0F081{} F0F082{}")(mpt::afmt::HEX0<2>(type), mpt::afmt::HEX0<2>(length), mpt::afmt::HEX0<2>(feedback));
 		return true;
 	} else if(event.command == SymEvent::DSPDelay)
 	{
Index: soundlib/MIDIMacros.cpp
===================================================================
--- soundlib/MIDIMacros.cpp	(revision 16687)
+++ soundlib/MIDIMacros.cpp	(working copy)
@@ -9,9 +9,8 @@
 
 
 #include "stdafx.h"
+#include "MIDIMacros.h"
 #include "../soundlib/MIDIEvents.h"
-#include "MIDIMacros.h"
-#include "../common/mptStringBuffer.h"
 #include "../common/misc_util.h"
 
 #ifdef MODPLUG_TRACKER
@@ -60,7 +59,7 @@
 			bool found = true;
 			for(uint32 j = 0; j < 128; j++)
 			{
-				if(strncmp(macros[j], szMidiZXXExt[j], MACRO_LENGTH))
+				if(macros[j] != szMidiZXXExt[j])
 				{
 					found = false;
 					break;
@@ -77,17 +76,17 @@
 {
 	switch(macroType)
 	{
-	case kSFxUnused:     mpt::String::WriteAutoBuf(parameteredMacro) = ""; break;
-	case kSFxCutoff:     mpt::String::WriteAutoBuf(parameteredMacro) = "F0F000z"; break;
-	case kSFxReso:       mpt::String::WriteAutoBuf(parameteredMacro) = "F0F001z"; break;
-	case kSFxFltMode:    mpt::String::WriteAutoBuf(parameteredMacro) = "F0F002z"; break;
-	case kSFxDryWet:     mpt::String::WriteAutoBuf(parameteredMacro) = "F0F003z"; break;
-	case kSFxCC:         mpt::String::WriteAutoBuf(parameteredMacro) = MPT_AFORMAT("Bc{}z")(mpt::afmt::HEX0<2>(subType & 0x7F)); break;
-	case kSFxPlugParam:  mpt::String::WriteAutoBuf(parameteredMacro) = MPT_AFORMAT("F0F{}z")(mpt::afmt::HEX0<3>(std::min(subType, 0x17F) + 0x80)); break;
-	case kSFxChannelAT:  mpt::String::WriteAutoBuf(parameteredMacro) = "Dcz"; break;
-	case kSFxPolyAT:     mpt::String::WriteAutoBuf(parameteredMacro) = "Acnz"; break;
-	case kSFxPitch:      mpt::String::WriteAutoBuf(parameteredMacro) = "Ec00z"; break;
-	case kSFxProgChange: mpt::String::WriteAutoBuf(parameteredMacro) = "Ccz"; break;
+	case kSFxUnused:     parameteredMacro = ""; break;
+	case kSFxCutoff:     parameteredMacro = "F0F000z"; break;
+	case kSFxReso:       parameteredMacro = "F0F001z"; break;
+	case kSFxFltMode:    parameteredMacro = "F0F002z"; break;
+	case kSFxDryWet:     parameteredMacro = "F0F003z"; break;
+	case kSFxCC:         parameteredMacro = MPT_AFORMAT("Bc{}z")(mpt::afmt::HEX0<2>(subType & 0x7F)); break;
+	case kSFxPlugParam:  parameteredMacro = MPT_AFORMAT("F0F{}z")(mpt::afmt::HEX0<3>(std::min(subType, 0x17F) + 0x80)); break;
+	case kSFxChannelAT:  parameteredMacro = "Dcz"; break;
+	case kSFxPolyAT:     parameteredMacro = "Acnz"; break;
+	case kSFxPitch:      parameteredMacro = "Ec00z"; break;
+	case kSFxProgChange: parameteredMacro = "Ccz"; break;
 	case kSFxCustom:
 	default:
 		MPT_ASSERT_NOTREACHED();
@@ -100,7 +99,7 @@
 {
 	Macro parameteredMacro;
 	CreateParameteredMacro(parameteredMacro, macroType, subType);
-	return mpt::String::ReadAutoBuf(parameteredMacro);
+	return parameteredMacro;
 }
 
 
@@ -113,44 +112,44 @@
 		switch(macroType)
 		{
 		case kZxxUnused:
-			mpt::String::WriteAutoBuf(fixedMacros[i]) = "";
+			fixedMacros[i] = "";
 			break;
 		case kZxxReso4Bit:
 			param = i * 8;
 			if(i < 16)
-				mpt::String::WriteAutoBuf(fixedMacros[i]) = MPT_AFORMAT("F0F001{}")(mpt::afmt::HEX0<2>(param));
+				fixedMacros[i] = MPT_AFORMAT("F0F001{}")(mpt::afmt::HEX0<2>(param));
 			else
-				mpt::String::WriteAutoBuf(fixedMacros[i]) = "";
+				fixedMacros[i] = "";
 			break;
 		case kZxxReso7Bit:
-			mpt::String::WriteAutoBuf(fixedMacros[i]) = MPT_AFORMAT("F0F001{}")(mpt::afmt::HEX0<2>(param));
+			fixedMacros[i] = MPT_AFORMAT("F0F001{}")(mpt::afmt::HEX0<2>(param));
 			break;
 		case kZxxCutoff:
-			mpt::String::WriteAutoBuf(fixedMacros[i]) = MPT_AFORMAT("F0F000{}")(mpt::afmt::HEX0<2>(param));
+			fixedMacros[i] = MPT_AFORMAT("F0F000{}")(mpt::afmt::HEX0<2>(param));
 			break;
 		case kZxxFltMode:
-			mpt::String::WriteAutoBuf(fixedMacros[i]) = MPT_AFORMAT("F0F002{}")(mpt::afmt::HEX0<2>(param));
+			fixedMacros[i] = MPT_AFORMAT("F0F002{}")(mpt::afmt::HEX0<2>(param));
 			break;
 		case kZxxResoFltMode:
 			param = (i & 0x0F) * 8;
 			if(i < 16)
-				mpt::String::WriteAutoBuf(fixedMacros[i]) = MPT_AFORMAT("F0F001{}")(mpt::afmt::HEX0<2>(param));
+				fixedMacros[i] = MPT_AFORMAT("F0F001{}")(mpt::afmt::HEX0<2>(param));
 			else if(i < 32)
-				mpt::String::WriteAutoBuf(fixedMacros[i]) = MPT_AFORMAT("F0F002{}")(mpt::afmt::HEX0<2>(param));
+				fixedMacros[i] = MPT_AFORMAT("F0F002{}")(mpt::afmt::HEX0<2>(param));
 			else
-				mpt::String::WriteAutoBuf(fixedMacros[i]) = "";
+				fixedMacros[i] = "";
 			break;
 		case kZxxChannelAT:
-			mpt::String::WriteAutoBuf(fixedMacros[i]) = MPT_AFORMAT("Dc{}")(mpt::afmt::HEX0<2>(param));
+			fixedMacros[i] = MPT_AFORMAT("Dc{}")(mpt::afmt::HEX0<2>(param));
 			break;
 		case kZxxPolyAT:
-			mpt::String::WriteAutoBuf(fixedMacros[i]) = MPT_AFORMAT("Acn{}")(mpt::afmt::HEX0<2>(param));
+			fixedMacros[i] = MPT_AFORMAT("Acn{}")(mpt::afmt::HEX0<2>(param));
 			break;
 		case kZxxPitch:
-			mpt::String::WriteAutoBuf(fixedMacros[i]) = MPT_AFORMAT("Ec00{}")(mpt::afmt::HEX0<2>(param));
+			fixedMacros[i] = MPT_AFORMAT("Ec00{}")(mpt::afmt::HEX0<2>(param));
 			break;
 		case kZxxProgChange:
-			mpt::String::WriteAutoBuf(fixedMacros[i]) = MPT_AFORMAT("Cc{}")(mpt::afmt::HEX0<2>(param));
+			fixedMacros[i] = MPT_AFORMAT("Cc{}")(mpt::afmt::HEX0<2>(param));
 			break;
 		case kZxxCustom:
 		default:
@@ -167,7 +166,7 @@
 {
 	for(auto left = begin(), right = other.begin(); left != end(); left++, right++)
 	{
-		if(strncmp(*left, *right, MACRO_LENGTH))
+		if(*left != *right)
 			return false;
 	}
 	return true;
@@ -344,11 +343,11 @@
 	MemsetZero(szMidiSFXExt);
 	MemsetZero(szMidiZXXExt);
 
-	strcpy(szMidiGlb[MIDIOUT_START], "FF");
-	strcpy(szMidiGlb[MIDIOUT_STOP], "FC");
-	strcpy(szMidiGlb[MIDIOUT_NOTEON], "9c n v");
-	strcpy(szMidiGlb[MIDIOUT_NOTEOFF], "9c n 0");
-	strcpy(szMidiGlb[MIDIOUT_PROGRAM], "Cc p");
+	szMidiGlb[MIDIOUT_START] = "FF";
+	szMidiGlb[MIDIOUT_STOP] = "FC";
+	szMidiGlb[MIDIOUT_NOTEON] = "9c n v";
+	szMidiGlb[MIDIOUT_NOTEOFF] = "9c n 0";
+	szMidiGlb[MIDIOUT_PROGRAM] = "Cc p";
 	// SF0: Z00-Z7F controls cutoff
 	CreateParameteredMacro(0, kSFxCutoff);
 	// Z80-Z8F controls resonance
@@ -369,16 +368,15 @@
 {
 	for(auto &macro : *this)
 	{
-		macro[MACRO_LENGTH - 1] = '\0';
-		std::fill(std::find(std::begin(macro), std::end(macro), '\0'), std::end(macro), '\0');
+		macro.Sanitize();
 	}
 }
 
 
 // Helper function for UpgradeMacros()
-void MIDIMacroConfig::UpgradeMacroString(Macro &macro) const
+void MIDIMacroConfig::Macro::UpgradeLegacyMacro() noexcept
 {
-	for(auto &c : macro)
+	for(auto &c : *this)
 	{
 		if(c >= 'a' && c <= 'f') // Both A-F and a-f were treated as hex constants
 		{
@@ -399,7 +397,7 @@
 {
 	for(auto &macro : *this)
 	{
-		UpgradeMacroString(macro);
+		macro.UpgradeLegacyMacro();
 	}
 }
 
Index: soundlib/MIDIMacros.h
===================================================================
--- soundlib/MIDIMacros.h	(revision 16687)
+++ soundlib/MIDIMacros.h	(working copy)
@@ -86,19 +86,93 @@
 
 struct MIDIMacroConfigData
 {
-	typedef char Macro[MACRO_LENGTH];
+	struct Macro
+	{
+	public:
+		using RawType = std::array<char, MACRO_LENGTH>;
+		constexpr auto begin() const noexcept { return m_data.begin(); }
+		constexpr auto cbegin() const noexcept { return m_data.cbegin(); }
+		constexpr auto end() const noexcept { return m_data.end(); }
+		constexpr auto cend() const noexcept { return m_data.cend(); }
+		constexpr auto data() const noexcept { return m_data.data(); }
+		constexpr auto size() const noexcept { return m_data.size(); }
+	private:
+		constexpr auto begin() noexcept { return m_data.begin(); }
+		constexpr auto end() noexcept { return m_data.end(); }
+		constexpr auto data() noexcept { return m_data.data(); }
+
+	public:
+		Macro &operator=(const Macro &other) = default;
+		Macro &operator=(const RawType &other) noexcept
+		{
+			return (*this = std::string_view{other.data(), other.size()});
+		}
+		Macro &operator=(const std::string_view &other) noexcept
+		{
+			const size_t copyLength = std::min({m_data.size() - 1u, other.size(), other.find('\0')});
+			std::copy(other.begin(), other.begin() + copyLength, begin());
+			std::fill(begin() + copyLength, end(), '\0');
+			return *this;
+		}
+
+		bool operator==(const Macro &other) const noexcept
+		{
+			return m_data == other.m_data;  // Don't care about data past null-terminator as operator= and Sanitize() ensure there is no data behind it.
+		}
+		bool operator!=(const Macro &other) const noexcept
+		{
+			return !(*this == other);
+		}
+
+		operator mpt::span<const char>() const noexcept
+		{
+			return {data(), length()};
+		}
+		operator std::string_view() const noexcept
+		{
+			return {data(), length()};
+		}
+		operator std::string() const
+		{
+			return {data(), length()};
+		}
+
+		size_t length() const noexcept
+		{
+			return static_cast<size_t>(std::distance(begin(), std::find(begin(), end(), '\0')));
+		}
+
+		void clear() noexcept
+		{
+			m_data.fill('\0');
+		}
+
+		void Sanitize() noexcept
+		{
+			m_data.back() = '\0';
+			std::fill(begin() + length(), end(), '\0');
+		}
+
+		void UpgradeLegacyMacro() noexcept;
+
+	private:
+		RawType m_data;
+	};
+
 	// encoding is ASCII
-	Macro szMidiGlb[9];      // Global MIDI macros
-	Macro szMidiSFXExt[16];  // Parametric MIDI macros
-	Macro szMidiZXXExt[128]; // Fixed MIDI macros
+	Macro szMidiGlb[9];              // Global MIDI macros
+	Macro szMidiSFXExt[NUM_MACROS];  // Parametric MIDI macros
+	Macro szMidiZXXExt[128];         // Fixed MIDI macros
 
-	Macro *begin() { return std::begin(szMidiGlb); }
-	const Macro *begin() const { return std::begin(szMidiGlb); }
-	Macro *end() { return std::end(szMidiZXXExt); }
-	const Macro *end() const { return std::end(szMidiZXXExt); }
+	Macro *begin() noexcept { return std::begin(szMidiGlb); }
+	const Macro *begin() const noexcept { return std::begin(szMidiGlb); }
+	Macro *end() noexcept { return std::end(szMidiZXXExt); }
+	const Macro *end() const noexcept { return std::end(szMidiZXXExt); }
 };
 
-MPT_BINARY_STRUCT(MIDIMacroConfigData, 4896) // this is directly written to files, so the size must be correct!
+// This is directly written to files, so the size must be correct!
+MPT_BINARY_STRUCT(MIDIMacroConfigData::Macro, 32)
+MPT_BINARY_STRUCT(MIDIMacroConfigData, 4896)
 
 class MIDIMacroConfig : public MIDIMacroConfigData
 {
Index: soundlib/ModInstrument.cpp
===================================================================
--- soundlib/ModInstrument.cpp	(revision 16687)
+++ soundlib/ModInstrument.cpp	(working copy)
@@ -314,13 +314,9 @@
 }
 
 
-uint8 ModInstrument::GetMIDIChannel(const CSoundFile &sndFile, CHANNELINDEX chn) const
+uint8 ModInstrument::GetMIDIChannel(const ModChannel &channel, CHANNELINDEX chn) const
 {
-	if(chn >= std::size(sndFile.m_PlayState.Chn))
-		return 0;
-
 	// For mapped channels, return their pattern channel, modulo 16 (because there are only 16 MIDI channels)
-	const ModChannel &channel = sndFile.m_PlayState.Chn[chn];
 	if(nMidiChannel == MidiMappedChannel)
 		return static_cast<uint8>((channel.nMasterChn ? (channel.nMasterChn - 1u) : chn) % 16u);
 	else if(HasValidMIDIChannel())
Index: soundlib/ModInstrument.h
===================================================================
--- soundlib/ModInstrument.h	(revision 16687)
+++ soundlib/ModInstrument.h	(working copy)
@@ -21,7 +21,7 @@
 
 OPENMPT_NAMESPACE_BEGIN
 
-class CSoundFile;
+struct ModChannel;
 
 // Instrument Nodes
 struct EnvelopeNode
@@ -150,7 +150,7 @@
 	void SetResonance(uint8 resonance, bool enable) { nIFR = std::min(resonance, uint8(0x7F)) | (enable ? 0x80 : 0x00); }
 
 	bool HasValidMIDIChannel() const { return (nMidiChannel >= 1 && nMidiChannel <= 17); }
-	uint8 GetMIDIChannel(const CSoundFile &sndFile, CHANNELINDEX chn) const;
+	uint8 GetMIDIChannel(const ModChannel &channel, CHANNELINDEX chn) const;
 
 	void SetTuning(CTuning *pT)
 	{
Index: soundlib/plugins/PlugInterface.cpp
===================================================================
--- soundlib/plugins/PlugInterface.cpp	(revision 16687)
+++ soundlib/plugins/PlugInterface.cpp	(working copy)
@@ -775,13 +775,19 @@
 
 
 // Get the MIDI channel currently associated with a given tracker channel
-uint8 IMidiPlugin::GetMidiChannel(CHANNELINDEX trackChannel) const
+uint8 IMidiPlugin::GetMidiChannel(const ModChannel &chn, CHANNELINDEX trackChannel) const
 {
-	if(trackChannel >= std::size(m_SndFile.m_PlayState.Chn))
+	if(auto ins = chn.pModInstrument; ins != nullptr)
+		return ins->GetMIDIChannel(chn, trackChannel);
+	else
 		return 0;
+}
 
-	if(auto ins = m_SndFile.m_PlayState.Chn[trackChannel].pModInstrument; ins != nullptr)
-		return ins->GetMIDIChannel(m_SndFile, trackChannel);
+
+uint8 IMidiPlugin::GetMidiChannel(CHANNELINDEX trackChannel) const
+{
+	if(trackChannel < std::size(m_SndFile.m_PlayState.Chn))
+		return GetMidiChannel(m_SndFile.m_PlayState.Chn[trackChannel], trackChannel);
 	else
 		return 0;
 }
Index: soundlib/plugins/PlugInterface.h
===================================================================
--- soundlib/plugins/PlugInterface.h	(revision 16687)
+++ soundlib/plugins/PlugInterface.h	(working copy)
@@ -25,6 +25,7 @@
 struct VSTPluginLib;
 struct SNDMIXPLUGIN;
 struct ModInstrument;
+struct ModChannel;
 class CSoundFile;
 class CModDoc;
 class CAbstractVstEditor;
@@ -275,9 +276,11 @@
 	bool IsNotePlaying(uint8 note, CHANNELINDEX trackerChn) override;
 
 	// Get the MIDI channel currently associated with a given tracker channel
-	virtual uint8 GetMidiChannel(CHANNELINDEX trackChannel) const;
+	virtual uint8 GetMidiChannel(const ModChannel &chn, CHANNELINDEX trackChannel) const;
 
 protected:
+	uint8 GetMidiChannel(CHANNELINDEX trackChannel) const;
+
 	// Plugin wants to send MIDI to OpenMPT
 	virtual void ReceiveMidi(uint32 midiCode);
 	virtual void ReceiveSysex(mpt::const_byte_span sysex);
Index: soundlib/Snd_fx.cpp
===================================================================
--- soundlib/Snd_fx.cpp	(revision 16687)
+++ soundlib/Snd_fx.cpp	(working copy)
@@ -828,6 +828,13 @@
 			case CMD_PANBRELLO:
 				Panbrello(chn, param);
 				break;
+
+			case CMD_MIDI:
+			case CMD_SMOOTHMIDI:
+				if(param < 0x80)
+					ProcessMIDIMacro(playState, nChn, false, m_MidiCfg.szMidiSFXExt[chn.nActiveMacro], chn.rowCommand.param, 0, true);
+				else
+					ProcessMIDIMacro(playState, nChn, false, m_MidiCfg.szMidiZXXExt[param & 0x7F], chn.rowCommand.param, 0, true);
 			default:
 				break;
 			}
@@ -2268,7 +2275,7 @@
 	IMixPlugin *pPlugin = nullptr;
 	if(srcChn.HasMIDIOutput() && ModCommand::IsNote(srcChn.nNote)) // instro sends to a midi chan
 	{
-		PLUGINDEX plugin = GetBestPlugin(nChn, PrioritiseInstrument, RespectMutes);
+		PLUGINDEX plugin = GetBestPlugin(m_PlayState, nChn, PrioritiseInstrument, RespectMutes);
 
 		if(plugin > 0 && plugin <= MAX_MIXPLUGINS)
 		{
@@ -3371,7 +3378,7 @@
 			{
 				SetFinetune(nChn, m_PlayState, cmd == CMD_FINETUNE_SMOOTH);
 #ifndef NO_PLUGINS
-				if(IMixPlugin *plugin = GetChannelInstrumentPlugin(nChn); plugin != nullptr)
+				if(IMixPlugin *plugin = GetChannelInstrumentPlugin(m_PlayState.Chn[nChn]); plugin != nullptr)
 					plugin->MidiPitchBendRaw(chn.GetMIDIPitchBend(), nChn);
 #endif  // NO_PLUGINS
 			}
@@ -3860,7 +3867,7 @@
 	if(pitchBend)
 	{
 #ifndef NO_PLUGINS
-		IMixPlugin *plugin = GetChannelInstrumentPlugin(nChn);
+		IMixPlugin *plugin = GetChannelInstrumentPlugin(m_PlayState.Chn[nChn]);
 		if(plugin != nullptr)
 		{
 			int8 pwd = 13;	// Early OpenMPT legacy... Actually it's not *exactly* 13, but close enough...
@@ -4811,23 +4818,104 @@
 
 // Process a MIDI Macro.
 // Parameters:
+// playState: The playback state to operate on.
 // nChn: Mod channel to apply macro on
 // isSmooth: If true, internal macros are interpolated between two rows
-// macro: Actual MIDI Macro string
-// param: Parameter for parametric macros (Z00 - Z7F)
+// macro: MIDI Macro string to process
+// param: Parameter for parametric macros (Zxx / \xx parameter)
 // plugin: Plugin to send MIDI message to (if not specified but needed, it is autodetected)
-void CSoundFile::ProcessMIDIMacro(CHANNELINDEX nChn, bool isSmooth, const char *macro, uint8 param, PLUGINDEX plugin)
+// localOnly: Do not execute macros that would modify state outside of playState (e.g. sending messages to plugins)
+void CSoundFile::ProcessMIDIMacro(PlayState &playState, CHANNELINDEX nChn, bool isSmooth, const MIDIMacroConfigData::Macro &macro, uint8 param, PLUGINDEX plugin, bool localOnly)
 {
-	ModChannel &chn = m_PlayState.Chn[nChn];
-	const ModInstrument *pIns = GetNumInstruments() ? chn.pModInstrument : nullptr;
+	playState.m_MidiMacroScratchSpace.resize(macro.length() + 1);
+	auto out = mpt::as_span(playState.m_MidiMacroScratchSpace);
 
-	uint8 out[MACRO_LENGTH];
-	uint32 outPos = 0;  // output buffer position, which also equals the number of complete bytes
+	ParseMIDIMacro(playState, nChn, isSmooth, macro, out, param, plugin);
+
+	// Macro string has been parsed and translated, now send the message(s)...
+	uint32 outSize = static_cast<uint32>(out.size());
+	uint32 sendPos = 0;
+	uint8 runningStatus = 0;
+	while(sendPos < out.size())
+	{
+		uint32 sendLen = 0;
+		if(out[sendPos] == 0xF0)
+		{
+			// SysEx start
+			if((outSize - sendPos >= 4) && (out[sendPos + 1] == 0xF0 || out[sendPos + 1] == 0xF1))
+			{
+				// Internal macro (normal (F0F0) or extended (F0F1)), 4 bytes long
+				sendLen = 4;
+			} else
+			{
+				// SysEx message, find end of message
+				for(uint32 i = sendPos + 1; i < outSize; i++)
+				{
+					if(out[i] == 0xF7)
+					{
+						// Found end of SysEx message
+						sendLen = i - sendPos + 1;
+						break;
+					}
+				}
+				if(sendLen == 0)
+				{
+					// Didn't find end, so "invent" end of SysEx message
+					out[outSize++] = 0xF7;
+					sendLen = outSize - sendPos;
+				}
+			}
+		} else if(!(out[sendPos] & 0x80))
+		{
+			// Missing status byte? Try inserting running status
+			if(runningStatus != 0)
+			{
+				sendPos--;
+				out[sendPos] = runningStatus;
+			} else
+			{
+				// No running status to re-use; skip this byte
+				sendPos++;
+			}
+			continue;
+		} else
+		{
+			// Other MIDI messages
+			sendLen = std::min(static_cast<uint32>(MIDIEvents::GetEventLength(out[sendPos])), outSize - sendPos);
+		}
+
+		if(sendLen == 0)
+			break;
+
+		if(out[sendPos] < 0xF0)
+		{
+			runningStatus = out[sendPos];
+		}
+		const auto midiMsg = mpt::as_span(out.data() + sendPos, sendLen);
+		uint32 bytesSent = 0;
+		// Local-only messages are messages that can be processed directly in the replay routines without sending stuff to plugins.
+		bytesSent = SendMIDIData(playState, nChn, isSmooth, midiMsg, plugin, localOnly);
+		// If there's no error in the macro data (e.g. unrecognized internal MIDI macro), we have sendLen == bytesSent.
+		if(bytesSent > 0)
+			sendPos += bytesSent;
+		else
+			sendPos += sendLen;
+	}
+}
+
+
+void CSoundFile::ParseMIDIMacro(PlayState &playState, CHANNELINDEX nChn, bool isSmooth, const mpt::span<const char> macro, mpt::span<uint8> &out, uint8 param, PLUGINDEX plugin) const
+{
+	ModChannel &chn = playState.Chn[nChn];
+	const ModInstrument *pIns = chn.pModInstrument;
+
 	const uint8 lastZxxParam = chn.lastZxxParam;  // always interpolate based on original value in case z appears multiple times in macro string
 	uint8 updateZxxParam = 0xFF;                  // avoid updating lastZxxParam immediately if macro contains both internal and external MIDI message
+
 	bool firstNibble = true;
-
-	for(uint32 pos = 0; pos < (MACRO_LENGTH - 1) && macro[pos]; pos++)
+	size_t outPos = 0;  // output buffer position, which also equals the number of complete bytes
+	size_t pos = 0;
+	for(; pos < macro.size() && outPos < out.size(); pos++)
 	{
 		bool isNibble = false;  // did we parse a nibble or a byte value?
 		uint8 data = 0;         // data that has just been parsed
@@ -4837,8 +4925,7 @@
 		{
 			isNibble = true;
 			data = static_cast<uint8>(macro[pos] - '0');
-		}
-		else if(macro[pos] >= 'A' && macro[pos] <= 'F')
+		} else if(macro[pos] >= 'A' && macro[pos] <= 'F')
 		{
 			isNibble = true;
 			data = static_cast<uint8>(macro[pos] - 'A' + 0x0A);
@@ -4848,12 +4935,12 @@
 			isNibble = true;
 			data = 0xFF;
 #ifndef NO_PLUGINS
-			const PLUGINDEX plug = (plugin != 0) ? plugin : GetBestPlugin(nChn, PrioritiseChannel, EvenIfMuted);
+			const PLUGINDEX plug = (plugin != 0) ? plugin : GetBestPlugin(playState, nChn, PrioritiseChannel, EvenIfMuted);
 			if(plug > 0 && plug <= MAX_MIXPLUGINS)
 			{
 				auto midiPlug = dynamic_cast<const IMidiPlugin *>(m_MixPlugins[plug - 1u].pMixPlugin);
 				if(midiPlug)
-					data = midiPlug->GetMidiChannel(nChn);
+					data = midiPlug->GetMidiChannel(playState.Chn[nChn], nChn);
 			}
 #endif // NO_PLUGINS
 			if(data == 0xFF)
@@ -4860,7 +4947,7 @@
 			{
 				// Fallback if no plugin was found
 				if(pIns)
-					data = pIns->GetMIDIChannel(*this, nChn);
+					data = pIns->GetMIDIChannel(playState.Chn[nChn], nChn);
 				else
 					data = 0;
 			}
@@ -4936,7 +5023,7 @@
 			{
 				// Interpolation for external MIDI messages - interpolation for internal messages
 				// is handled separately to allow for more than 7-bit granularity where it's possible
-				data = static_cast<uint8>(CalculateSmoothParamChange(lastZxxParam, data));
+				data = static_cast<uint8>(CalculateSmoothParamChange(playState, lastZxxParam, data));
 				chn.lastZxxParam = data;
 				updateZxxParam = 0x80;
 			} else if(updateZxxParam == 0xFF)
@@ -4946,13 +5033,13 @@
 		} else if(macro[pos] == 's')
 		{
 			// SysEx Checksum (not an original Impulse Tracker macro variable, but added for convenience)
-			uint32 startPos = outPos;
+			auto startPos = outPos;
 			while(startPos > 0 && out[--startPos] != 0xF0);
 			if(outPos - startPos < 5 || out[startPos] != 0xF0)
 			{
 				continue;
 			}
-			for(uint32 p = startPos + 5; p != outPos; p++)
+			for(auto p = startPos + 5u; p != outPos; p++)
 			{
 				data += out[p];
 			}
@@ -4977,7 +5064,7 @@
 			firstNibble = !firstNibble;
 		} else  // parsed a byte (variable)
 		{
-			if(!firstNibble)	// From MIDI.TXT: '9n' is exactly the same as '09 n' or '9 n' -- so finish current byte first
+			if(!firstNibble)  // From MIDI.TXT: '9n' is exactly the same as '09 n' or '9 n' -- so finish current byte first
 			{
 				outPos++;
 			}
@@ -4993,83 +5080,19 @@
 	if(updateZxxParam < 0x80)
 		chn.lastZxxParam = updateZxxParam;
 
-	// Macro string has been parsed and translated, now send the message(s)...
-	uint32 sendPos = 0;
-	uint8 runningStatus = 0;
-	while(sendPos < outPos)
-	{
-		uint32 sendLen = 0;
-		if(out[sendPos] == 0xF0)
-		{
-			// SysEx start
-			if((outPos - sendPos >= 4) && (out[sendPos + 1] == 0xF0 || out[sendPos + 1] == 0xF1))
-			{
-				// Internal macro (normal (F0F0) or extended (F0F1)), 4 bytes long
-				sendLen = 4;
-			} else
-			{
-				// SysEx message, find end of message
-				for(uint32 i = sendPos + 1; i < outPos; i++)
-				{
-					if(out[i] == 0xF7)
-					{
-						// Found end of SysEx message
-						sendLen = i - sendPos + 1;
-						break;
-					}
-				}
-				if(sendLen == 0)
-				{
-					// Didn't find end, so "invent" end of SysEx message
-					out[outPos++] = 0xF7;
-					sendLen = outPos - sendPos;
-				}
-			}
-		} else if(!(out[sendPos] & 0x80))
-		{
-			// Missing status byte? Try inserting running status
-			if(runningStatus != 0)
-			{
-				sendPos--;
-				out[sendPos] = runningStatus;
-			} else
-			{
-				// No running status to re-use; skip this byte
-				sendPos++;
-			}
-			continue;
-		} else
-		{
-			// Other MIDI messages
-			sendLen = std::min(static_cast<uint32>(MIDIEvents::GetEventLength(out[sendPos])), outPos - sendPos);
-		}
-
-		if(sendLen == 0)
-			break;
-
-		if(out[sendPos] < 0xF0)
-		{
-			runningStatus = out[sendPos];
-		}
-		uint32 bytesSent = SendMIDIData(nChn, isSmooth, out + sendPos, sendLen, plugin);
-		// If there's no error in the macro data (e.g. unrecognized internal MIDI macro), we have sendLen == bytesSent.
-		if(bytesSent > 0)
-			sendPos += bytesSent;
-		else
-			sendPos += sendLen;
-	}
+	out = out.first(outPos);
 }
 
 
 // Calculate smooth MIDI macro slide parameter for current tick.
-float CSoundFile::CalculateSmoothParamChange(float currentValue, float param) const
+float CSoundFile::CalculateSmoothParamChange(const PlayState &playState, float currentValue, float param)
 {
-	MPT_ASSERT(m_PlayState.TicksOnRow() > m_PlayState.m_nTickCount);
-	const uint32 ticksLeft = m_PlayState.TicksOnRow() - m_PlayState.m_nTickCount;
+	MPT_ASSERT(playState.TicksOnRow() > playState.m_nTickCount);
+	const uint32 ticksLeft = playState.TicksOnRow() - playState.m_nTickCount;
 	if(ticksLeft > 1)
 	{
 		// Slide param
-		const float step = (param - currentValue) / (float)ticksLeft;
+		const float step = (param - currentValue) / static_cast<float>(ticksLeft);
 		return (currentValue + step);
 	} else
 	{
@@ -5080,12 +5103,10 @@
 
 
 // Process exactly one MIDI message parsed by ProcessMIDIMacro. Returns bytes sent on success, 0 on (parse) failure.
-uint32 CSoundFile::SendMIDIData(CHANNELINDEX nChn, bool isSmooth, const unsigned char *macro, uint32 macroLen, PLUGINDEX plugin)
+uint32 CSoundFile::SendMIDIData(PlayState &playState, CHANNELINDEX nChn, bool isSmooth, const mpt::span<const unsigned char> macro, PLUGINDEX plugin, bool localOnly)
 {
-	if(macroLen < 1)
-	{
+	if(macro.size() < 1)
 		return 0;
-	}
 
 	if(macro[0] == 0xFA || macro[0] == 0xFC || macro[0] == 0xFF)
 	{
@@ -5092,16 +5113,16 @@
 		// Start Song, Stop Song, MIDI Reset - both interpreted internally and sent to plugins
 		for(CHANNELINDEX chn = 0; chn < GetNumChannels(); chn++)
 		{
-			m_PlayState.Chn[chn].nCutOff = 0x7F;
-			m_PlayState.Chn[chn].nResonance = 0x00;
+			playState.Chn[chn].nCutOff = 0x7F;
+			playState.Chn[chn].nResonance = 0x00;
 		}
 	}
 
-	ModChannel &chn = m_PlayState.Chn[nChn];
+	ModChannel &chn = playState.Chn[nChn];
 	if(macro[0] == 0xF0 && (macro[1] == 0xF0 || macro[1] == 0xF1))
 	{
 		// Internal device.
-		if(macroLen < 4)
+		if(macro.size() < 4)
 		{
 			return 0;
 		}
@@ -5113,16 +5134,13 @@
 		{
 			// F0.F0.00.xx: Set CutOff
 			if(!isSmooth)
-			{
 				chn.nCutOff = param;
-			} else
-			{
-				chn.nCutOff = mpt::saturate_round<uint8>(CalculateSmoothParamChange(chn.nCutOff, param));
-			}
+			else
+				chn.nCutOff = mpt::saturate_round<uint8>(CalculateSmoothParamChange(playState, chn.nCutOff, param));
 			chn.nRestoreCutoffOnNewNote = 0;
 			int cutoff = SetupChannelFilter(chn, !chn.dwFlags[CHN_FILTER]);
 
-			if(cutoff >= 0 && chn.dwFlags[CHN_ADLIB] && m_opl)
+			if(cutoff >= 0 && chn.dwFlags[CHN_ADLIB] && m_opl && !localOnly)
 			{
 				// Cutoff doubles as modulator intensity for FM instruments
 				m_opl->Volume(nChn, static_cast<uint8>(cutoff / 4), true);
@@ -5133,12 +5151,9 @@
 		{
 			// F0.F0.01.xx: Set Resonance
 			if(!isSmooth)
-			{
 				chn.nResonance = param;
-			} else
-			{
-				chn.nResonance = (uint8)CalculateSmoothParamChange((float)chn.nResonance, (float)param);
-			}
+			else
+				chn.nResonance = mpt::saturate_round<uint8>(CalculateSmoothParamChange(playState, chn.nResonance, param));
 			chn.nRestoreResonanceOnNewNote = 0;
 			SetupChannelFilter(chn, !chn.dwFlags[CHN_FILTER]);
 
@@ -5157,38 +5172,32 @@
 		} else if(macroCode == 0x03 && !isExtended)
 		{
 			// F0.F0.03.xx: Set plug dry/wet
-			const PLUGINDEX plug = (plugin != 0) ? plugin : GetBestPlugin(nChn, PrioritiseChannel, EvenIfMuted);
-			if(plug > 0 && plug <= MAX_MIXPLUGINS && param < 0x80)
+			const PLUGINDEX plug = (plugin != 0) ? plugin : GetBestPlugin(playState, nChn, PrioritiseChannel, EvenIfMuted);
+			if(plug > 0 && plug <= MAX_MIXPLUGINS && param < 0x80 && !localOnly)
 			{
-				const float newRatio = (0x7F - (param & 0x7F)) / 127.0f;
+				const float newRatio = (127 - param) / 127.0f;
 				if(!isSmooth)
-				{
 					m_MixPlugins[plug - 1].fDryRatio = newRatio;
-				} else
-				{
-					m_MixPlugins[plug - 1].fDryRatio = CalculateSmoothParamChange(m_MixPlugins[plug - 1].fDryRatio, newRatio);
-				}
+				else
+					m_MixPlugins[plug - 1].fDryRatio = CalculateSmoothParamChange(playState, m_MixPlugins[plug - 1].fDryRatio, newRatio);
 			}
 
 			return 4;
-		} else if((macroCode & 0x80) || isExtended)
+		} else if(((macroCode & 0x80) || isExtended))
 		{
 			// F0.F0.{80|n}.xx / F0.F1.n.xx: Set VST effect parameter n to xx
-			const PLUGINDEX plug = (plugin != 0) ? plugin : GetBestPlugin(nChn, PrioritiseChannel, EvenIfMuted);
-			const uint32 plugParam = isExtended ? (0x80 + macroCode) : (macroCode & 0x7F);
-			if(plug > 0 && plug <= MAX_MIXPLUGINS)
+			const PLUGINDEX plug = (plugin != 0) ? plugin : GetBestPlugin(playState, nChn, PrioritiseChannel, EvenIfMuted);
+			if(plug > 0 && plug <= MAX_MIXPLUGINS && !localOnly)
 			{
 				IMixPlugin *pPlugin = m_MixPlugins[plug - 1].pMixPlugin;
 				if(pPlugin && param < 0x80)
 				{
+					const uint32 plugParam = isExtended ? (0x80 + macroCode) : (macroCode & 0x7F);
 					const float fParam = param / 127.0f;
 					if(!isSmooth)
-					{
 						pPlugin->SetParameter(plugParam, fParam);
-					} else
-					{
-						pPlugin->SetParameter(plugParam, CalculateSmoothParamChange(pPlugin->GetParameter(plugParam), fParam));
-					}
+					else
+						pPlugin->SetParameter(plugParam, CalculateSmoothParamChange(playState, pPlugin->GetParameter(plugParam), fParam));
 				}
 			}
 
@@ -5198,7 +5207,7 @@
 
 		// If we reach this point, the internal macro was invalid.
 
-	} else
+	} else if(!localOnly)
 	{
 #ifndef NO_PLUGINS
 		// Not an internal device. Pass on to appropriate plugin.
@@ -5208,7 +5217,7 @@
 			PLUGINDEX plug = 0;
 			if(!chn.dwFlags[CHN_NOFX])
 			{
-				plug = (plugin != 0) ? plugin : GetBestPlugin(nChn, PrioritiseChannel, EvenIfMuted);
+				plug = (plugin != 0) ? plugin : GetBestPlugin(playState, nChn, PrioritiseChannel, EvenIfMuted);
 			}
 
 			if(plug > 0 && plug <= MAX_MIXPLUGINS)
@@ -5218,12 +5227,12 @@
 				{
 					if(macro[0] == 0xF0)
 					{
-						pPlugin->MidiSysexSend(mpt::as_span(mpt::byte_cast<const std::byte*>(macro), macroLen));
+						pPlugin->MidiSysexSend(mpt::byte_cast<mpt::const_byte_span>(macro));
 					} else
 					{
-						uint32 len = std::min(static_cast<uint32>(MIDIEvents::GetEventLength(macro[0])), macroLen);
+						size_t len = std::min(static_cast<size_t>(MIDIEvents::GetEventLength(macro[0])), macro.size());
 						uint32 curData = 0;
-						memcpy(&curData, macro, len);
+						memcpy(&curData, macro.data(), len);
 						pPlugin->MidiSend(curData);
 					}
 				}
@@ -5233,7 +5242,7 @@
 		MPT_UNREFERENCED_PARAMETER(plugin);
 #endif // NO_PLUGINS
 
-		return macroLen;
+		return static_cast<uint32>(macro.size());
 	}
 
 	return 0;
@@ -6165,7 +6174,7 @@
 }
 
 
-PLUGINDEX CSoundFile::GetBestPlugin(CHANNELINDEX nChn, PluginPriority priority, PluginMutePriority respectMutes) const
+PLUGINDEX CSoundFile::GetBestPlugin(const PlayState &playState, CHANNELINDEX nChn, PluginPriority priority, PluginMutePriority respectMutes) const
 {
 	if (nChn >= MAX_CHANNELS)		//Check valid channel number
 	{
@@ -6177,23 +6186,23 @@
 	switch (priority)
 	{
 		case ChannelOnly:
-			plugin = GetChannelPlugin(nChn, respectMutes);
+			plugin = GetChannelPlugin(playState, nChn, respectMutes);
 			break;
 		case InstrumentOnly:
-			plugin  = GetActiveInstrumentPlugin(nChn, respectMutes);
+			plugin  = GetActiveInstrumentPlugin(playState.Chn[nChn], respectMutes);
 			break;
 		case PrioritiseInstrument:
-			plugin  = GetActiveInstrumentPlugin(nChn, respectMutes);
+			plugin  = GetActiveInstrumentPlugin(playState.Chn[nChn], respectMutes);
 			if(!plugin || plugin > MAX_MIXPLUGINS)
 			{
-				plugin = GetChannelPlugin(nChn, respectMutes);
+				plugin = GetChannelPlugin(playState, nChn, respectMutes);
 			}
 			break;
 		case PrioritiseChannel:
-			plugin  = GetChannelPlugin(nChn, respectMutes);
+			plugin  = GetChannelPlugin(playState, nChn, respectMutes);
 			if(!plugin || plugin > MAX_MIXPLUGINS)
 			{
-				plugin = GetActiveInstrumentPlugin(nChn, respectMutes);
+				plugin = GetActiveInstrumentPlugin(playState.Chn[nChn], respectMutes);
 			}
 			break;
 	}
@@ -6202,9 +6211,9 @@
 }
 
 
-PLUGINDEX CSoundFile::GetChannelPlugin(CHANNELINDEX nChn, PluginMutePriority respectMutes) const
+PLUGINDEX CSoundFile::GetChannelPlugin(const PlayState &playState, CHANNELINDEX nChn, PluginMutePriority respectMutes) const
 {
-	const ModChannel &channel = m_PlayState.Chn[nChn];
+	const ModChannel &channel = playState.Chn[nChn];
 
 	PLUGINDEX plugin;
 	if((respectMutes == RespectMutes && channel.dwFlags[CHN_MUTE | CHN_SYNCMUTE]) || channel.dwFlags[CHN_NOFX])
@@ -6214,8 +6223,7 @@
 	{
 		// If it looks like this is an NNA channel, we need to find the master channel.
 		// This ensures we pick up the right ChnSettings.
-		// NB: nMasterChn == 0 means no master channel, so we need to -1 to get correct index.
-		if (nChn >= m_nChannels && channel.nMasterChn > 0)
+		if(channel.nMasterChn > 0)
 		{
 			nChn = channel.nMasterChn - 1;
 		}
@@ -6232,20 +6240,21 @@
 }
 
 
-PLUGINDEX CSoundFile::GetActiveInstrumentPlugin(CHANNELINDEX nChn, PluginMutePriority respectMutes) const
+PLUGINDEX CSoundFile::GetActiveInstrumentPlugin(const ModChannel &chn, PluginMutePriority respectMutes)
 {
 	// Unlike channel settings, pModInstrument is copied from the original chan to the NNA chan,
 	// so we don't need to worry about finding the master chan.
 
 	PLUGINDEX plug = 0;
-	if(m_PlayState.Chn[nChn].pModInstrument != nullptr)
+	if(chn.pModInstrument != nullptr)
 	{
-		if(respectMutes == RespectMutes && m_PlayState.Chn[nChn].pModSample && m_PlayState.Chn[nChn].pModSample->uFlags[CHN_MUTE])
+		// TODO this looks fishy. Shouldn't it check the mute status of the instrument itself?!
+		if(respectMutes == RespectMutes && chn.pModSample && chn.pModSample->uFlags[CHN_MUTE])
 		{
 			plug = 0;
 		} else
 		{
-			plug = m_PlayState.Chn[nChn].pModInstrument->nMixPlug;
+			plug = chn.pModInstrument->nMixPlug;
 		}
 	}
 	return plug;
@@ -6255,10 +6264,10 @@
 // Retrieve the plugin that is associated with the channel's current instrument.
 // No plugin is returned if the channel is muted or if the instrument doesn't have a MIDI channel set up,
 // As this is meant to be used with instrument plugins.
-IMixPlugin *CSoundFile::GetChannelInstrumentPlugin(CHANNELINDEX chn) const
+IMixPlugin *CSoundFile::GetChannelInstrumentPlugin(const ModChannel &chn) const
 {
 #ifndef NO_PLUGINS
-	if(m_PlayState.Chn[chn].dwFlags[CHN_MUTE | CHN_SYNCMUTE])
+	if(chn.dwFlags[CHN_MUTE | CHN_SYNCMUTE])
 	{
 		// Don't process portamento on muted channels. Note that this might have a side-effect
 		// on other channels which trigger notes on the same MIDI channel of the same plugin,
@@ -6266,9 +6275,9 @@
 		return nullptr;
 	}
 
-	if(m_PlayState.Chn[chn].HasMIDIOutput())
+	if(chn.HasMIDIOutput())
 	{
-		const ModInstrument *pIns = m_PlayState.Chn[chn].pModInstrument;
+		const ModInstrument *pIns = chn.pModInstrument;
 		// Instrument sends to a MIDI channel
 		if(pIns->nMixPlug != 0 && pIns->nMixPlug <= MAX_MIXPLUGINS)
 		{
Index: soundlib/Sndfile.h
===================================================================
--- soundlib/Sndfile.h	(revision 16687)
+++ soundlib/Sndfile.h	(working copy)
@@ -583,10 +583,13 @@
 		CHANNELINDEX ChnMix[MAX_CHANNELS]; // Index of channels in Chn to be actually mixed
 		ModChannel Chn[MAX_CHANNELS];      // Mixing channels... First m_nChannels channels are master channels (i.e. they are never NNA channels)!
 
+		std::vector<uint8> m_MidiMacroScratchSpace;
+
 	public:
 		PlayState()
 		{
 			std::fill(std::begin(Chn), std::end(Chn), ModChannel());
+			m_MidiMacroScratchSpace.reserve(MACRO_LENGTH);
 		}
 
 		void ResetGlobalVolumeRamping()
@@ -1115,9 +1118,10 @@
 	void GlobalVolSlide(ModCommand::PARAM param, uint8 &nOldGlobalVolSlide);
 
 	void ProcessMacroOnChannel(CHANNELINDEX nChn);
-	void ProcessMIDIMacro(CHANNELINDEX nChn, bool isSmooth, const char *macro, uint8 param = 0, PLUGINDEX plugin = 0);
-	float CalculateSmoothParamChange(float currentValue, float param) const;
-	uint32 SendMIDIData(CHANNELINDEX nChn, bool isSmooth, const unsigned char *macro, uint32 macroLen, PLUGINDEX plugin);
+	void ProcessMIDIMacro(PlayState &playState, CHANNELINDEX nChn, bool isSmooth, const MIDIMacroConfigData::Macro &macro, uint8 param = 0, PLUGINDEX plugin = 0, bool localOnly = false);
+	void ParseMIDIMacro(PlayState &playState, CHANNELINDEX nChn, bool isSmooth, const mpt::span<const char> macro, mpt::span<uint8> &out, uint8 param = 0, PLUGINDEX plugin = 0) const;
+	static float CalculateSmoothParamChange(const PlayState &playState, float currentValue, float param);
+	uint32 SendMIDIData(PlayState &playState, CHANNELINDEX nChn, bool isSmooth, const mpt::span<const unsigned char> macro, PLUGINDEX plugin, bool localOnly);
 	void SendMIDINote(CHANNELINDEX chn, uint16 note, uint16 volume);
 
 	int SetupChannelFilter(ModChannel &chn, bool bReset, int envModifier = 256) const;
@@ -1243,12 +1247,12 @@
 	void ProcessStereoSeparation(long countChunk);
 
 private:
-	PLUGINDEX GetChannelPlugin(CHANNELINDEX nChn, PluginMutePriority respectMutes) const;
-	PLUGINDEX GetActiveInstrumentPlugin(CHANNELINDEX, PluginMutePriority respectMutes) const;
-	IMixPlugin *GetChannelInstrumentPlugin(CHANNELINDEX chn) const;
+	PLUGINDEX GetChannelPlugin(const PlayState &playState, CHANNELINDEX nChn, PluginMutePriority respectMutes) const;
+	static PLUGINDEX GetActiveInstrumentPlugin(const ModChannel &chn, PluginMutePriority respectMutes);
+	IMixPlugin *GetChannelInstrumentPlugin(const ModChannel &chn) const;
 
 public:
-	PLUGINDEX GetBestPlugin(CHANNELINDEX nChn, PluginPriority priority, PluginMutePriority respectMutes) const;
+	PLUGINDEX GetBestPlugin(const PlayState &playState, CHANNELINDEX nChn, PluginPriority priority, PluginMutePriority respectMutes) const;
 
 };
 
Index: soundlib/Sndmix.cpp
===================================================================
--- soundlib/Sndmix.cpp	(revision 16687)
+++ soundlib/Sndmix.cpp	(working copy)
@@ -1705,7 +1705,7 @@
 
 			// Process MIDI vibrato for plugins:
 #ifndef NO_PLUGINS
-			IMixPlugin *plugin = GetChannelInstrumentPlugin(nChn);
+			IMixPlugin *plugin = GetChannelInstrumentPlugin(m_PlayState.Chn[nChn]);
 			if(plugin != nullptr)
 			{
 				// If the Pitch Wheel Depth is configured correctly (so it's the same as the plugin's PWD),
@@ -1728,7 +1728,7 @@
 	{
 		// Stop MIDI vibrato for plugins:
 #ifndef NO_PLUGINS
-		IMixPlugin *plugin = GetChannelInstrumentPlugin(nChn);
+		IMixPlugin *plugin = GetChannelInstrumentPlugin(m_PlayState.Chn[nChn]);
 		if(plugin != nullptr)
 		{
 			plugin->MidiVibrato(0, 0, nChn);
@@ -2528,15 +2528,15 @@
 	if(nChn < GetNumChannels())
 	{
 		// TODO evaluate per-plugin macros here
-		//ProcessMIDIMacro(nChn, false, m_MidiCfg.szMidiGlb[MIDIOUT_PAN]);
-		//ProcessMIDIMacro(nChn, false, m_MidiCfg.szMidiGlb[MIDIOUT_VOLUME]);
+		//ProcessMIDIMacro(m_PlayState, nChn, false, m_MidiCfg.szMidiGlb[MIDIOUT_PAN]);
+		//ProcessMIDIMacro(m_PlayState, nChn, false, m_MidiCfg.szMidiGlb[MIDIOUT_VOLUME]);
 
 		if((chn.rowCommand.command == CMD_MIDI && m_SongFlags[SONG_FIRSTTICK]) || chn.rowCommand.command == CMD_SMOOTHMIDI)
 		{
 			if(chn.rowCommand.param < 0x80)
-				ProcessMIDIMacro(nChn, (chn.rowCommand.command == CMD_SMOOTHMIDI), m_MidiCfg.szMidiSFXExt[chn.nActiveMacro], chn.rowCommand.param);
+				ProcessMIDIMacro(m_PlayState, nChn, (chn.rowCommand.command == CMD_SMOOTHMIDI), m_MidiCfg.szMidiSFXExt[chn.nActiveMacro], chn.rowCommand.param);
 			else
-				ProcessMIDIMacro(nChn, (chn.rowCommand.command == CMD_SMOOTHMIDI), m_MidiCfg.szMidiZXXExt[(chn.rowCommand.param & 0x7F)], 0);
+				ProcessMIDIMacro(m_PlayState, nChn, (chn.rowCommand.command == CMD_SMOOTHMIDI), m_MidiCfg.szMidiZXXExt[(chn.rowCommand.param & 0x7F)], 0);
 		}
 	}
 }
@@ -2562,7 +2562,7 @@
 	}
 
 	// Check instrument plugins
-	const PLUGINDEX nPlugin = GetBestPlugin(nChn, PrioritiseInstrument, RespectMutes);
+	const PLUGINDEX nPlugin = GetBestPlugin(m_PlayState, nChn, PrioritiseInstrument, RespectMutes);
 	IMixPlugin *pPlugin = nullptr;
 	if(nPlugin > 0 && nPlugin <= MAX_MIXPLUGINS)
 	{
