Docs/Architecture/DmoModernization.md
Status: DONE (Phase 6c in MODERNIZATION.md). Activation and dispatch are now
[GeneratedComInterface]-based;Experimental_CanCreateOnStaThreadAndUseOnMtaThreadpasses; consumer test buckets (DMO, MediaFoundation, Mp3, Wasapi) remain green;IPropertyStore/PropertyStore/PropVariant/MMDeviceare unchanged in the migration. The notes below were the working plan and are kept for context — see MODERNIZATION.md Phase 6 for the canonical record.
This document captures the design, motivation, and verification plan for finishing the modernization of the NAudio.Wasapi/Dmo folder so that it matches the rest of NAudio.Wasapi (CoreAudioApi, MediaFoundation). It is a sub-plan of the broader MODERNIZATION.md.
The DMO folder is part-way through a migration from legacy [ComImport] interop to source-generated [GeneratedComInterface] dispatch. The interface declarations exist in both forms, but no consuming code uses the modern declarations — they sit as unused parallel files.
Written mid-migration, when both sets of declarations existed side by side. The plan below was carried out: the legacy
[ComImport]files in the left column were deleted (step 7) and are not in the tree, so only the modern column is linked. The DMO types have also since moved fromNAudio.Wasapiinto theNAudio.Dmopackage;IWMResamplerPropsdid not follow them, and now lives beside its sole consumer inNAudio.Wasapi.
Legacy (NAudio.Dmo) | Modern (NAudio.Dmo.Interfaces) | Consumed by |
|---|---|---|
IMediaObject.cs [ComImport] | Interfaces/IMediaObject.cs [GeneratedComInterface] | Legacy only |
IMediaObjectInPlace.cs [ComImport] | Interfaces/IMediaObjectInPlace.cs [GeneratedComInterface] | Legacy only |
IWMResamplerProps.cs [ComImport] | Interfaces/IWMResamplerProps.cs [GeneratedComInterface] | Legacy only (also stored-but-never-called in DmoResampler) |
IEnumDmo, IMediaParamInfo | modern partials | Legacy only |
IPropertyStore (CoreAudioApi/Interfaces/IPropertyStore.cs) [ComImport] | (no modern declaration — see "IPropertyStore is deliberately out of scope" below) | PropertyStore.cs (live, used by MMDevice.Properties); also stored-but-never-called as a dead field in WindowsMediaMp3Decoder and DmoResampler |
Every DMO consumer activates its COM object via new SomeMediaComObject() against a [ComImport] coclass — the legacy CoCreateInstance path that produces a runtime callable wrapper bound to the calling thread's apartment.
Affected consumers:
DmoResampler, ResamplerDmoStream, WindowsMediaMp3Decoder, or DmoMp3FrameDecompressor constructed on an STA thread (WinForms / WPF UI thread, or any code that has been [STAThread]-marked for ASIO) cannot subsequently be used from an MTA audio thread. The legacy RCW raises InvalidComObjectException ("COM object that has been separated from its underlying RCW") once the STA exits, and even before that, cross-apartment QueryInterface fails with E_NOINTERFACE because the resampler DMO and MP3 decoder DMO ship without proxy/stub registration. This is reproduced by Experimental_CanCreateOnStaThreadAndUseOnMtaThread in the resampler fixture (currently failing as expected).[GeneratedComInterface] (no reflection-based marshalling, full trimming support). The DMO folder is the last legacy-COM corner blocking a clean AOT story.[GeneratedComInterface] for interfaces, source-generated function-pointer dispatch, deterministic Release.DmoResampler, ResamplerDmoStream, WindowsMediaMp3Decoder, and DmoMp3FrameDecompressor safe to construct on any thread and use from any thread, in particular the STA→MTA handoff that NAudio's typical WinForms/WPF/ASIO usage produces.[ComImport] interface declarations once consumers have moved over, leaving a single set of declarations under NAudio.Dmo.Interfaces.DmoResampler, ResamplerDmoStream, WindowsMediaMp3Decoder, DmoMp3FrameDecompressor, and MediaObject source-compatible. Internal types (IMediaObject, etc.) are already internal, so their signatures may change freely.IMediaObject — the central dispatch surface for every DMO. Switch all consumers to the modern partial.IMediaObjectInPlace — used by all the effect classes via MediaObjectInPlace.new SomeComImportCoclass() with raw CoCreateInstance (P/Invoke) followed by StrategyBasedComWrappers.GetOrCreateObjectForComInstance, then cast to the modern interface. This is what makes dispatch direct-vtable and avoids apartment marshalling entirely.MediaObject and MediaObjectInPlace wrapper classes: rewrite their internal method bodies to handle the modern interface signatures (which take IntPtr rather than typed structs / arrays — see "Interface signature shape change" below).propertyStoreInterface fields in WindowsMediaMp3Decoder and DmoResampler (see "IPropertyStore is deliberately out of scope" below) — these fields are assigned in the constructor and disposed, but no method on IPropertyStore is ever called through them, so they can simply be deleted.IPropertyStore. See dedicated section below.IDirectSoundFXEcho, IDirectSoundFXChorus, etc.). These are rarely used in real NAudio applications and don't suffer the threading bug at the same severity (effects are generally created and consumed on the same thread). Defer until a user reports a need.IEnumDmo / IMediaParamInfo — internal utilities; migrate opportunistically if the modernization touches them, otherwise leave.IPropertyStore is not migrated as part of this work. Three reasons:
PropVariant blocks source-generated marshalling. Per the existing decision recorded in MODERNIZATION.md ("IPropertyStore — Uses PropVariant with [StructLayout(LayoutKind.Explicit)] — not compatible with source-generated marshaling"), the runtime's source-generated COM marshaller cannot blit PropVariant cleanly. Any [GeneratedComInterface] rewrite would have to switch every parameter to IntPtr and reimplement PropVariant packing / unpacking by hand. This is meaningful work for zero gain on the DMO side (see point 3).IPropertyStore is a shared, live, public surface. It backs PropertyStore.cs, which is exposed via MMDevice.Properties — the API every NAudio caller uses to read device names, form factors, GUIDs, and arbitrary PKEY_* values. Touching IPropertyStore risks regressing all WASAPI device-enumeration consumers, not just DMO ones. The blast radius is wrong for a "fix the DMO threading bug" change.mediaComObject to IPropertyStore in their constructors and store the result as a private field, but no method on either field is ever called. They exist purely so they can be Marshal.ReleaseComObject-ed during Dispose. Deleting the fields is a behavioural no-op and removes the entire IPropertyStore dependency from the DMO modernization.Net effect: this plan does not change IPropertyStore's declaration, does not change PropertyStore.cs, does not change MMDevice.Properties, and does not introduce a parallel modern declaration that would diverge from the legacy one. If at some point in the future the rest of NAudio.Wasapi pushes for a full ComWrappers / NativeAOT story, IPropertyStore can be tackled then as its own focused workstream.
The modern partials in NAudio.Dmo.Interfaces were generated to be ABI-faithful, which means they take IntPtr for everything that is passed by reference at the COM level. The legacy declarations took typed structs and [MarshalAs]-annotated arrays. Migrating MediaObject / MediaObjectInPlace therefore involves rewriting wrapper bodies to do the marshalling explicitly:
| Legacy wrapper call | Modern wrapper body |
|---|---|
mediaObject.GetInputType(idx, n, out DmoMediaType mt) | Pin a DmoMediaType local, pass its address, unpin |
mediaObject.SetInputType(idx, ref mt, flags) | Same — pin address-of-struct |
mediaObject.ProcessInput(idx, IMediaBuffer buf, ...) | Get the IUnknown* for buf via ComWrappers and pass as IntPtr |
mediaObject.ProcessOutput(flags, n, DmoOutputDataBuffer[] bufs, out reserved) | Pin the array, pass IntPtr to the first element |
mediaObject.GetInputStatus(idx, out DmoInputStatusFlags flags) | Receive out int and cast to the flags enum |
This rewrite is mechanical but careful. Each wrapper method should be tested via the existing integration tests (see "Verification" below).
Suggested order, each step independently testable:
MediaObject to the modern IMediaObject. Change the field type, change the constructor parameter, rewrite each wrapper method body to do explicit IntPtr marshalling for DmoMediaType, IMediaBuffer, etc.MediaObjectInPlace similarly.DmoResampler to direct activation. Replace new ResamplerMediaComObject() with P/Invoke CoCreateInstance + StrategyBasedComWrappers.GetOrCreateObjectForComInstance + cast to modern IMediaObject. Delete the dead propertyStoreInterface and resamplerPropsInterface fields entirely (they are never read — see "IPropertyStore is deliberately out of scope"). Update Dispose to release the ComWrappers ComObject.WindowsMediaMp3Decoder to direct activation with the same pattern. Delete its dead propertyStoreInterface field.Effect/ classes to direct activation. Same pattern, applied uniformly.MediaFoundationResampler's constructor probe to use the new activation path (it currently constructs new ResamplerMediaComObject() purely as an existence check, then immediately releases it).IMediaObject.cs, IMediaObjectInPlace.cs, IWMResamplerProps.cs, the legacy IEnumDmo / IMediaParamInfo, and the [ComImport] coclass declarations (ResamplerMediaComObject, WindowsMediaMp3DecoderComObject, and the per-effect coclasses). Do not touch IPropertyStore.Steps 1–2 are the heaviest. Steps 3–6 are largely pattern repetition once 1 is done.
After migration, the following test fixtures must continue to pass with no regressions. They are the gate on shipping the migration.
ReadResamplesAndPreservesFrequency cases (six sample-rate pairs), ReadInSmallChunksEventuallyEndsWithZero, RepositionAfterRewindingSourceRepeatsOutput, ReadAfterDisposeThrows, and the Experimental_* cross-thread tests.ResamplerCanCallProcessInput, ResamplerCanCallProcessOutput, and the Experimental_* cross-thread tests.CanResampleAWholeStreamTo* for both PCM and IEEE float in both directions.CanCreateDmoMp3FrameDecompressor, CanDecompressAnMp3 (decodes a real MP3 file produced by TestFileBuilder), CanExamineInputTypesOnMp3Decoder, CanExamineOutputTypesOnDecoder, WindowsMediaMp3DecoderSupportsStereoMp3.DmoMp3FrameDecompressor for various input formats.IPropertyStore consumers — must remain unaffectedAlthough IPropertyStore is explicitly out of scope, the migration touches files in the same assembly and uses overlapping Marshal.GetObjectForIUnknown / StrategyBasedComWrappers machinery, so it is worth confirming the property-store path is genuinely untouched.
MMDevice.FriendlyName (which goes through PropertyStore.GetValue → IPropertyStore::GetValue → PropVariant). Must continue to pass.MMDevice.MMDeviceEnumerator, read FriendlyName, State, DataFlow, and at least one Properties[PKEY_*] value per device. Confirm no exceptions and identical values to a pre-migration run.Experimental_CanCreateOnStaThreadAndUseOnMtaThread in ResamplerDmoTests.cs. Today: fails with E_NOINTERFACE on the read thread. After migration: must pass and validate that the resampled sine wave preserves its source frequency.Experimental_CanCreateOnStaThreadAndUseOnMtaThread test for DmoMp3FrameDecompressor — construct the decompressor on an STA thread (passing in an MP3 frame's WaveFormat), then decode at least a few frames on a separate MTA thread, and verify that the output sample count matches the expected ratio.Dispose test that creates and disposes 100 resamplers in a tight loop on a single thread, asserting the process does not leak handles or COM references — guards against the deterministic-release rewrite regressing into an under-release.NAudioConsoleTest resampler menu items (NAudioConsoleTest/Dmo/DmoResamplerTests.cs) end-to-end with audible output.Mp3FileReader configured to use DmoMp3FrameDecompressor (the default on Windows).NAudioWpfDemo MediaFoundationResample view to confirm the WPF-thread (STA) flow still works for resampling.MediaObject is migrated but the effects still pass legacy IMediaObject, builds break. Mitigation: migrate all effect activation in the same PR as the MediaObject rewrite, or keep a transitional adapter that accepts both for one PR cycle.MediaFoundationResampler regresses. Its constructor probe uses the same coclass type. Mitigation: explicit test pass through every MediaFoundationResamplerTests case after the change to step 7.Marshal.ReleaseComObject. ComWrappers' ComObject exposes its own dispose pattern. Audit every Marshal.ReleaseComObject site in the DMO folder during the migration and replace with the ComWrappers equivalent — do not leave a mix.IPropertyStore. It would be tempting, partway through the migration, to "while we're here" produce a parallel [GeneratedComInterface] IPropertyStore so that the dead fields in WindowsMediaMp3Decoder / DmoResampler could keep their original cast. Resist this. The dead fields should be deleted outright, not preserved through a new interface declaration. Reasons:
MMDevice.Properties — IPropertyStore is shared with PropertyStore.cs.PropVariant's explicit-layout struct is documented in MODERNIZATION.md as incompatible with source-generated marshalling; any rewrite must reimplement PropVariant packing manually with substantial regression risk.NAudio.Wasapi/Dmo/ references only modern [GeneratedComInterface] interfaces (with IPropertyStore excluded by design).[ComImport] declarations remain in NAudio.Wasapi/Dmo/ (including coclasses). The legacy IPropertyStore declaration in NAudio.Wasapi/CoreAudioApi/Interfaces/ is left untouched.Experimental_CanCreateOnStaThreadAndUseOnMtaThread for both fixtures passes.IPropertyStore consumer tests (MMDevice.Properties paths) pass with no regressions, and the IPropertyStore / PropertyStore / PropVariant / MMDevice files are unchanged in the migration PR.MODERNIZATION.md cross-references this document and marks DMO migration as complete.