Step 7 of 8
Analysis & Mastering
Working out what is in the audio, and making it sound like something else.
Everything on this page is offline work, not part of the real-time path: hand it a file, get back chords, notes or a mastered copy. Run it off the UI thread β some of it takes minutes. The matcher is the one that reaches back into the live path: it can hand its result to a running chain as settings rather than render a file, though the analysis behind it is still offline work.
Chord Detection ships only in the full OwnAudioSharp package. Audio Matchering is in OwnAudioSharp.Mobile too, but neither is in OwnAudioSharp.Basic, which is a playback/recording engine only. MT3 transcription lives in its own add-on package β OwnAudioSharp.Mt3 β reference it only when you need it.
Chord Detection
Detects musical chords, key, and tempo from audio files. Namespace: OwnaudioNET.Features.OwnChordDetect
var (chords, key, bpm) = ChordDetect.DetectFromFile("song.mp3", intervalSecond: 1.0f);
foreach (var chord in chords)
Console.WriteLine($"{chord.StartTime:F1}s-{chord.EndTime:F1}s {chord.ChordName} ({chord.Confidence:F2})");
Console.WriteLine($"Key: {key} BPM: {bpm}");
// With MT3 an analysis takes minutes, so pass a progress callback (0..1 over the whole run)
var (chords2, key2, bpm2) = ChordDetect.DetectFromFile("song.mp3", mt3, 1.0f,
p => Console.Write($"\rAnalyzing: {p:P0}"));// Mixes all files before analysis
var (chords, key, bpm) = ChordDetect.DetectFromFiles(
new[] { "guitar.wav", "piano.wav" },
intervalSecond: 0.5f);// Continuous detection from an active note stream. The detector is kept between
// calls on purpose β a fresh one every frame would wipe the stability history.
var (chord, stability) = ChordDetect.DetectRealtime(
notes, // List<Note>
DetectionMode.Optimized,
buffersize: 5); // how many recent frames vote on the answer
if (stability > 0.6f)
Console.WriteLine($"Chord: {chord} Stability: {stability:P0}");intervalSecond is only a fallback window size. When tempo detection succeeds the window is derived from the BPM instead (quarter note below 100 BPM, half note up to 150, whole note above) and the argument is ignored.
Return Types
| Member | Type | Description |
|---|---|---|
chords | List<TimedChord> | Chord progression on the timeline. |
TimedChord.StartTime / EndTime | float | Chord span in seconds. |
TimedChord.ChordName | string | Chord name (e.g. "Cmaj", "Am7"). "N" means silence, "Unknown" means nothing cleared the confidence threshold. |
TimedChord.Confidence | float | Cosine similarity against the chord template, 0.0β1.0. |
TimedChord.Notes | string[] | Constituent note names, spelled to fit the key. |
key | MusicalKey | Detected musical key (KrumhanslβSchmuckler). |
bpm | int | Detected tempo in BPM. Falls back to 120 when detection fails. |
stability | float | Real-time agreement across the rolling buffer, 0.0β1.0. |
Transcription backends
Chord analysis never touches audio directly β it works from a List<Note>. Whatever produces those notes sits behind INoteTranscriber, so it can be swapped:
| Backend | Package | Rate | Notes |
|---|---|---|---|
BasicPitchTranscriber | built in (model embedded, ~200 KB) | 22050 Hz | The default. Fast, but pitch-only β it cannot tell instruments apart. |
Mt3Transcriber | OwnAudioSharp.Mt3, models supplied by you | 16000 Hz | Transformer. Labels every note with a MIDI program and flags drums. Roughly 4Γ realtime on CPU. |
Every offline entry point takes an optional transcriber. Audio is decoded straight into whatever sample rate that backend asks for.
// Default β identical to the calls above
var (chords, key, bpm) = ChordDetect.DetectFromFile("song.mp3");
// MT3 instead
using var mt3 = new Mt3Transcriber(Mt3ModelPaths.FromDirectory("/models/mt3"));
var (chords2, key2, bpm2) = ChordDetect.DetectFromFile("song.mp3", mt3);Note
The transcription output, in OwnaudioNET.Features.Extensions. Sorted by start time.
| Field | Type | Description |
|---|---|---|
StartTime / EndTime | float | Seconds. |
Pitch | int | MIDI pitch 0β127. Pitch % 12 gives the pitch class. |
Amplitude | float | 0.0β1.0. |
PitchBend | float[]? | Optional bend curve. |
Program | int | MIDI program the note was played on. Always 0 from BasicPitch β only MT3 fills it in. |
IsDrum | bool | Percussion hit. Only MT3 ever sets it; drums are dropped before chord analysis either way. |
MT3 Transcription OwnAudioSharp.Mt3
Multi-track music transcription. Where BasicPitch hears pitches, MT3 hears instruments: a sequence-to-sequence transformer that emits MIDI-like events with a program number attached, so a bass line and a piano voicing stay separate instead of collapsing into one smear on the chromagram. For chord detection that separation is usually worth more than the extra pitch accuracy.
Add it with dotnet add package OwnAudioSharp.Mt3. Separate package on purpose: ONNX Runtime is linked into the native library and costs about 26 MB per platform. Desktop only β win-x64, win-arm64, linux-x64, linux-arm64 and osx-arm64. Intel Macs are not covered: there is no prebuilt ONNX Runtime for x86_64-apple-darwin. The rest of OwnAudioSharp still runs there.
Getting the model files
The model weights are not in the package β they are about 290 MB, far too much to put in everyone's NuGet download. Grab the four files below, put them all in one folder, and hand that folder's path to Mt3ModelPaths.FromDirectory(). Keep the file names as they are; that is what the helper looks for.
| File | Size | Download |
|---|---|---|
mt3_encoder.onnx | 92 MB | Hugging Face |
mt3_decoder_init.onnx | 101 MB | Hugging Face |
mt3_decoder_step.onnx | 89 MB | Hugging Face |
vocab.json | < 1 KB | Hugging Face |
mkdir -p ~/models/mt3 && cd ~/models/mt3
BASE=https://huggingface.co/ModernMube/HTDemucs_onnx/resolve/main/mt3-onnx
for f in mt3_encoder.onnx mt3_decoder_init.onnx mt3_decoder_step.onnx vocab.json; do
curl -L -o "$f" "$BASE/$f?download=true"
done// The folder you downloaded the four files into
using var transcriber = new Mt3Transcriber(
Mt3ModelPaths.FromDirectory("/Users/me/models/mt3"));If you would rather export your own weights from a different MT3-family checkpoint, the export scripts live in tools/mt3/ in the repository and produce exactly these four files. Mind the checkpoint's own licence in that case β YourMT3, the usual source of PyTorch MT3 weights, is GPL-3.0. This package contains none of its code and only reads exported weights at runtime, but what you may ship alongside those weights is between you and that licence.
using var transcriber = new Mt3Transcriber(
Mt3ModelPaths.FromDirectory("/models/mt3"),
threads: 0, // 0 lets ONNX Runtime decide
skipDrums: true); // drops percussion natively
var (chords, key, bpm) = ChordDetect.DetectFromFile("song.wav", transcriber);float[] samples = /* mono PCM at transcriber.PreferredSampleRate */;
var notes = transcriber.Transcribe(samples, transcriber.PreferredSampleRate,
progress => Console.Write($"\r{progress:P0}"));
foreach (var n in notes.Where(n => !n.IsDrum))
Console.WriteLine($"{n.StartTime:F2}s pitch {n.Pitch} program {n.Program}");Mt3ModelPaths
| Member | Type | Description |
|---|---|---|
Encoder | string | Encoder graph. Raw audio in β the mel spectrogram is baked into the graph. |
DecoderInit | string | First decoder step, priming the KV cache. |
DecoderStep | string | Every step after, consuming the cache. |
Vocab | string | Token codec layout, dumped from the same checkpoint. |
FromDirectory(string) | Mt3ModelPaths | Picks all four out of a folder holding an export. |
MT3 decodes autoregressively β up to a thousand tokens per two seconds of audio. Even with the KV cache the native side keeps, a full song is minutes of CPU work, not seconds. Run it offline, off the UI thread, and use the progress callback. All four files must come from the same export run β do not mix files from different sources, because a vocabulary paired with a decoder it was not dumped from produces confident nonsense rather than an error.
Everything below the C# surface is Rust β the ONNX sessions, the greedy decode loop, the MT3 event codec and the note state machine all live in ownaudio_mt3_ffi. The managed side only marshals a float buffer in and a note array out.
Auto-Mastering (Matchering)
Point it at a reference track you like the sound of, and it moves your mix's spectral and dynamic character towards it. Namespace: OwnaudioNET.Features.Matchering. Everything below is an instance method on AudioAnalyzer β default-construct one and keep it, it caches its FFT setup and its preset targets.
var analyzer = new AudioAnalyzer();
AudioSpectrum spectrum = analyzer.AnalyzeAudioFile("track.wav");analyzer.ProcessEQMatching(
sourceFile: "my-mix.wav",
targetFile: "reference.wav", // the "sound" to match
outputFile: "mastered.wav");analyzer.ProcessWithEnhancedPreset(
sourceFile: "my-mix.wav",
outputFile: "for-club.wav",
system: PlaybackSystem.ClubPA,
eqOnlyMode: false);analyzer.BatchProcessWithEnhancedPreset(
sourceFiles: new[] { "track1.wav", "track2.wav", "track3.wav" },
baseSampleFile: "reference.wav",
outputDirectory: "output/",
system: PlaybackSystem.StudioMonitors,
fileNameSuffix: "_mastered");PlaybackSystem Values
| Value | Description |
|---|---|
PlaybackSystem.ConcertPA | Large venue sound reinforcement |
PlaybackSystem.ClubPA | Club / DJ rig, dance music |
PlaybackSystem.HiFiSpeakers | Hi-Fi home speakers |
PlaybackSystem.StudioMonitors | Near-field studio monitors |
PlaybackSystem.Headphones | Over-ear headphones |
PlaybackSystem.Earbuds | IEMs and earbuds |
PlaybackSystem.CarStereo | Car audio, compensated for road noise |
PlaybackSystem.Television | TV or soundbar, dialogue first |
PlaybackSystem.RadioBroadcast | FM/AM broadcast chain |
PlaybackSystem.Smartphone | Phone or tablet speaker |
GetAvailablePresets() is static and hands back a copy of the whole table β name, description, the 30-band curve and the dynamics settings β which is what you want for a preset picker.
Matching a live chain instead of a file
The four methods above are offline renderers: file in, file out. To drive a real-time mastering chain you need the step before the render β the numbers the effects have to be set to. Three additions hand that over, without changing anything the offline path does.
var analyzer = new AudioAnalyzer();
// The mix as it currently sounds - no temp wav, no decode round trip
AudioSpectrum mix = analyzer.AnalyzeAudioBuffer(masterSum, 48000, 2);
// The sound to move towards: a reference file...
AudioSpectrum reference = analyzer.AnalyzeAudioFile("reference.wav");
// ...or a playback system preset, built entirely in memory
AudioSpectrum preset = analyzer.GetPresetTargetSpectrum(PlaybackSystem.ClubPA);
MatcheringProfile profile = analyzer.CalculateProfile(mix, reference, sampleRate: 48000);
// Against a preset there is an overload that also brings the preset's own AGC block
MatcheringProfile presetProfile = analyzer.CalculateProfile(mix, PlaybackSystem.ClubPA, sampleRate: 48000);
// The profile is 30 third-octave bands, so it needs the 30-band EQ β
// the same filter bank the offline renderer drives. Its band centres
// already sit on the profile's frequencies, so read them back per band.
var eq = new Equalizer30BandEffect(sampleRate: 48000f);
for (int band = 0; band < 30; band++)
eq.SetBandGain(band, eq.GetBandFrequency(band), profile.QFactors[band], profile.BandGainsDb[band]);
compressor.Threshold = CompressorEffect.DbToLinear(profile.CompThresholdDb);
compressor.Ratio = profile.CompRatio;
dynamicAmp.TargetRmsLevelDb = profile.TargetLoudness;| Member | Meaning |
|---|---|
WantedCurveDb[30] | The curve we want to hear, dB. This is the one worth drawing. |
BandGainsDb[30] | What the filter bank has to be set to for that curve to come out of it. A 1/3-octave bell bleeds into its neighbours, so setting each band to its wanted value overshoots by 60β120%; these gains come from solving the bank's response matrix. |
QFactors[30] | The Q the solution assumed per band. |
CompThresholdDb, CompRatio | Compressor settings. The threshold is in dB β CompressorEffect wants it linear, so run it through CompressorEffect.DbToLinear. |
TargetLoudness, MaxGain | AGC target and gain ceiling for DynamicAmpEffect. |
SourceLoudness, SourceCrestDb, TargetCrestDb | Measured values, for a status readout. |
CutOnlyShiftDb | How far the curve got pushed down; 0 when cutOnly was off. |
There is no audio in a MatcheringProfile, so it serializes into a project file as is.
fixedQ β solve against the Q that actually plays
CalculateProfile defaults fixedQ to AudioAnalyzer.NativeBandQ (4.318474), the constant Q of the native 30-band equalizer. The engine has no per-band Q parameter, so that is the only Q a real-time chain ever plays; handing the solver the optimized per-band Qs instead would solve for a filter bank that is not the one making the sound. Pass fixedQ: 0 to get the per-band Qs anyway β that is what the offline render uses, since it runs the managed EQ where the Q does get through.
cutOnly β headroom without a gain stage
The offline chain buys headroom with a pre-gain stage before the EQ. A real-time chain assembled from native effects has no such stage, so cutOnly: true (the default) subtracts the curve's maximum from all 30 bands: the loudest band lands on 0 dB, everything else goes negative, and the DynamicAmpEffect behind it brings the level back to TargetLoudness. The broadband offset is already out of the curve by then, so this is a pure level change and leaves the tonal shape alone.
The shift stops short if it would push the deepest cut past the Β±9 dB per-band clamp β a curve that hits the rail is no longer the curve that was measured, just a flattened version of it. CutOnlyShiftDb reports what was actually applied.
Analysis is seconds of work on a full song, and AnalyzeAudioFile takes a static lock so only one file analysis runs at a time. Call all of this off the UI thread. Material shorter than one ~10 s segment throws β check the length before you start.