├── .gitignore ├── Icon.ico ├── Screenshot.png ├── TestData ├── test.mp3 ├── test-sigx-5.1.0.bin ├── test-sigx-6.0.4.bin ├── test-sigx-7.2.0.bin ├── test-sigx-10.1.3.bin ├── test-sigx-10.2.1.bin ├── gabin-full-sigx-10.1.3.bin └── gabin-60s-sigx-5.1.0-fat.bin ├── .github └── workflows │ ├── test-asoundrc │ ├── install-ubuntu-arm64.sh │ ├── codeql.yml │ ├── install-ubuntu-arm64.sources │ └── ci.yml ├── Tagging ├── ShazamResult.cs ├── CaptureAndTag.cs ├── Analysis.cs ├── PeakFinder.cs ├── ShazamApi.cs └── Sig.cs ├── CaptureHelpers ├── ICaptureHelper.cs ├── WasapiLoopbackHelper.cs ├── EternalSilence.cs ├── WasapiCaptureHelper.cs ├── SoxCaptureHelper.cs ├── FileCaptureHelper.cs └── MciCaptureHelper.cs ├── .editorconfig ├── renovate.json ├── Program.cs ├── Test ├── TestExtensions.cs ├── TestHelper.cs ├── PeakDensityResearch.cs └── SignatureComparisonTests.cs ├── LICENSE ├── UI ├── ConsoleHelper.cs ├── Interactive.cs ├── TagFile.cs ├── TagLive.cs └── TagFileBisect.cs ├── README.md ├── TaggingDebug ├── Synthback.cs └── Painter.cs └── Project.csproj /.gitignore: -------------------------------------------------------------------------------- 1 | .vs 2 | bin 3 | obj 4 | *.sln 5 | *.user 6 | -------------------------------------------------------------------------------- /Icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlekseyMartynov/shazam-for-real/HEAD/Icon.ico -------------------------------------------------------------------------------- /Screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlekseyMartynov/shazam-for-real/HEAD/Screenshot.png -------------------------------------------------------------------------------- /TestData/test.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlekseyMartynov/shazam-for-real/HEAD/TestData/test.mp3 -------------------------------------------------------------------------------- /TestData/test-sigx-5.1.0.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlekseyMartynov/shazam-for-real/HEAD/TestData/test-sigx-5.1.0.bin -------------------------------------------------------------------------------- /TestData/test-sigx-6.0.4.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlekseyMartynov/shazam-for-real/HEAD/TestData/test-sigx-6.0.4.bin -------------------------------------------------------------------------------- /TestData/test-sigx-7.2.0.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlekseyMartynov/shazam-for-real/HEAD/TestData/test-sigx-7.2.0.bin -------------------------------------------------------------------------------- /TestData/test-sigx-10.1.3.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlekseyMartynov/shazam-for-real/HEAD/TestData/test-sigx-10.1.3.bin -------------------------------------------------------------------------------- /TestData/test-sigx-10.2.1.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlekseyMartynov/shazam-for-real/HEAD/TestData/test-sigx-10.2.1.bin -------------------------------------------------------------------------------- /TestData/gabin-full-sigx-10.1.3.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlekseyMartynov/shazam-for-real/HEAD/TestData/gabin-full-sigx-10.1.3.bin -------------------------------------------------------------------------------- /TestData/gabin-60s-sigx-5.1.0-fat.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlekseyMartynov/shazam-for-real/HEAD/TestData/gabin-60s-sigx-5.1.0-fat.bin -------------------------------------------------------------------------------- /.github/workflows/test-asoundrc: -------------------------------------------------------------------------------- 1 | pcm.test { 2 | type file 3 | 4 | slave { 5 | pcm null 6 | } 7 | 8 | file /dev/null 9 | infile /tmp/test.wav 10 | } 11 | -------------------------------------------------------------------------------- /Tagging/ShazamResult.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Project; 4 | 5 | class ShazamResult { 6 | public bool Success; 7 | public string ID; 8 | public string AppleSongID; 9 | public string Artist; 10 | public string Title; 11 | public string Url; 12 | public int RetryMs; 13 | } 14 | -------------------------------------------------------------------------------- /CaptureHelpers/ICaptureHelper.cs: -------------------------------------------------------------------------------- 1 | using NAudio.Wave; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | 6 | namespace Project; 7 | 8 | interface ICaptureHelper : IDisposable { 9 | static readonly WaveFormat WAVE_FORMAT = new(Analysis.SAMPLE_RATE, 16, 1); 10 | 11 | bool Live { get; } 12 | ISampleProvider SampleProvider { get; } 13 | Exception Exception { get; } 14 | 15 | void Start(); 16 | } 17 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | insert_final_newline = true 5 | trim_trailing_whitespace = true 6 | indent_style = space 7 | indent_size = 4 8 | 9 | [*.cs] 10 | csharp_new_line_before_open_brace = none 11 | csharp_new_line_before_else = false 12 | csharp_new_line_before_catch = false 13 | csharp_new_line_before_finally = false 14 | csharp_space_after_keywords_in_control_flow_statements = false 15 | 16 | [*.{csproj,md,yml}] 17 | indent_size = 2 18 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ], 6 | "prConcurrentLimit": 2, 7 | "semanticCommits": "enabled", 8 | "ignoreDeps": [ 9 | "System.Drawing.Common" 10 | ], 11 | "packageRules": [ 12 | { 13 | "groupName": "NAudio monorepo", 14 | "matchPackageNames": [ 15 | "NAudio.{/,}**" 16 | ] 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/install-ubuntu-arm64.sh: -------------------------------------------------------------------------------- 1 | CODENAME=$(lsb_release -cs) 2 | 3 | if [ $(uname -m) != "x86_64" ] || [ -z "$CODENAME" ]; then 4 | return 1 5 | fi 6 | 7 | cp -f .github/workflows/install-ubuntu-arm64.sources /etc/apt/sources.list.d/ubuntu.sources 8 | sed -i "s|CODENAME|$CODENAME|g" /etc/apt/sources.list.d/ubuntu.sources 9 | 10 | dpkg --add-architecture arm64 11 | 12 | apt-get update 13 | apt-get install -y llvm libssl-dev:arm64 zlib1g-dev:arm64 binutils-aarch64-linux-gnu gcc-aarch64-linux-gnu 14 | 15 | apt-get install qemu-user 16 | -------------------------------------------------------------------------------- /CaptureHelpers/WasapiLoopbackHelper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | 5 | namespace Project; 6 | 7 | static class WasapiLoopbackHelper { 8 | public static bool Loopback { get; private set; } 9 | 10 | public static void Set(bool loopback) { 11 | Loopback = loopback; 12 | 13 | Console.Write("Source: "); 14 | Console.WriteLine(loopback ? "Loopback device" : "Recording device"); 15 | } 16 | 17 | public static void Toggle() { 18 | Set(!Loopback); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /CaptureHelpers/EternalSilence.cs: -------------------------------------------------------------------------------- 1 | using NAudio.Wave; 2 | using NAudio.Wave.SampleProviders; 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | 7 | namespace Project; 8 | 9 | static class EternalSilence { 10 | readonly static ISampleProvider SILENCE = new SilenceProvider(ICaptureHelper.WAVE_FORMAT).ToSampleProvider(); 11 | 12 | public static ISampleProvider AppendTo(ISampleProvider provider) { 13 | if(provider == null) 14 | return SILENCE; 15 | 16 | return new ConcatenatingSampleProvider([provider, SILENCE]); 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | 6 | namespace Project; 7 | 8 | class Program { 9 | 10 | static async Task Main(string[] args) { 11 | if(args.Length > 0) { 12 | if(args.ElementAtOrDefault(1) == "bisect") { 13 | using var bisect = new TagFileBisect(args[0]); 14 | await bisect.RunAsync(); 15 | } else { 16 | await TagFile.RunAsync(args); 17 | } 18 | } else { 19 | await Interactive.RunAsync(); 20 | } 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /Test/TestExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | 5 | namespace Project.Test { 6 | 7 | static class TestExtensions { 8 | 9 | public static IEnumerable FindMany(this IEnumerable peaks, float bin) { 10 | return peaks.Where(p => Math.Abs(p.InterpolatedBin - bin) < 1); 11 | } 12 | 13 | public static PeakInfo FindOne(this IEnumerable peaks, float bin) { 14 | return FindMany(peaks, bin).FirstOrDefault(); 15 | } 16 | 17 | public static PeakInfo FindOne(this IEnumerable peaks, int stripe, float bin) { 18 | return FindMany(peaks, bin).FirstOrDefault(p => p.StripeIndex == stripe); 19 | } 20 | 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) Aleksey Martynov 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | concurrency: 4 | group: ${{github.workflow}}-${{github.event.pull_request.number || github.sha}} 5 | cancel-in-progress: true 6 | 7 | on: 8 | schedule: 9 | - cron: '0 12 1 * *' 10 | push: 11 | branches: [ "master" ] 12 | pull_request: 13 | # The branches below must be a subset of the branches above 14 | branches: [ "master" ] 15 | 16 | jobs: 17 | analyze: 18 | name: Analyze 19 | runs-on: ubuntu-latest 20 | permissions: 21 | actions: read 22 | contents: read 23 | security-events: write 24 | 25 | steps: 26 | - uses: actions/checkout@v5 27 | 28 | - uses: actions/setup-dotnet@v5 29 | with: 30 | dotnet-version: 8 31 | 32 | - uses: github/codeql-action/init@v4 33 | with: 34 | languages: csharp 35 | 36 | - run: dotnet build 37 | 38 | - uses: github/codeql-action/analyze@v4 39 | with: 40 | category: "/language:csharp" 41 | -------------------------------------------------------------------------------- /UI/ConsoleHelper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | 5 | namespace Project; 6 | 7 | static class ConsoleHelper { 8 | 9 | static ConsoleHelper() { 10 | IsRedirected = Console.IsInputRedirected || Console.IsOutputRedirected; 11 | } 12 | 13 | public static bool IsRedirected { get; private set; } 14 | 15 | public static void WriteProgress(string text) { 16 | if(IsRedirected) 17 | return; 18 | 19 | ClearProgress(); 20 | Console.Write(text); 21 | } 22 | 23 | public static void ClearProgress() { 24 | if(IsRedirected) 25 | return; 26 | 27 | Console.CursorLeft = 0; 28 | Console.Write(new String(' ', Console.WindowWidth - 1)); 29 | Console.CursorLeft = 0; 30 | } 31 | 32 | public static void WriteTime(TimeSpan time) { 33 | Console.Write(time.ToString(@"hh\:mm\:ss")); 34 | Console.Write(" "); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /.github/workflows/install-ubuntu-arm64.sources: -------------------------------------------------------------------------------- 1 | Types: deb 2 | URIs: http://azure.archive.ubuntu.com/ubuntu/ 3 | Suites: CODENAME CODENAME-updates CODENAME-backports 4 | Components: main universe restricted multiverse 5 | Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg 6 | Architectures: amd64 7 | 8 | Types: deb 9 | URIs: http://azure.archive.ubuntu.com/ubuntu/ 10 | Suites: CODENAME-security 11 | Components: main universe restricted multiverse 12 | Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg 13 | Architectures: amd64 14 | 15 | Types: deb 16 | URIs: http://azure.ports.ubuntu.com/ubuntu-ports/ 17 | Suites: CODENAME CODENAME-updates CODENAME-backports 18 | Components: main universe restricted multiverse 19 | Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg 20 | Architectures: arm64 21 | 22 | Types: deb 23 | URIs: http://azure.ports.ubuntu.com/ubuntu-ports/ 24 | Suites: CODENAME-security 25 | Components: main universe restricted multiverse 26 | Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg 27 | Architectures: arm64 28 | -------------------------------------------------------------------------------- /Test/TestHelper.cs: -------------------------------------------------------------------------------- 1 | #if DEBUG 2 | using NAudio.Wave; 3 | using NAudio.Wave.SampleProviders; 4 | using System; 5 | using System.Collections.Generic; 6 | using System.IO; 7 | using System.Linq; 8 | 9 | namespace Project.Test; 10 | 11 | static class TestHelper { 12 | public static readonly string DATA_DIR; 13 | 14 | static TestHelper() { 15 | DATA_DIR = Path.Combine( 16 | Path.GetDirectoryName(typeof(TestHelper).Assembly.Location), 17 | "../../../TestData" 18 | ); 19 | } 20 | 21 | public static void SaveRaw(ISampleProvider sampleProvider, string path) { 22 | using var rawFile = File.OpenWrite(path); 23 | 24 | var wave = new SampleToWaveProvider16(sampleProvider); 25 | var bufLen = 4096; 26 | var buf = new byte[bufLen]; 27 | 28 | while(true) { 29 | var readLen = wave.Read(buf, 0, bufLen); 30 | rawFile.Write(buf, 0, readLen); 31 | if(readLen < bufLen) 32 | break; 33 | } 34 | } 35 | 36 | } 37 | #endif 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![CI](https://github.com/AlekseyMartynov/shazam-for-real/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/AlekseyMartynov/shazam-for-real/actions/workflows/ci.yml) 2 | 3 | ![Screenshot](Screenshot.png?raw=true) 4 | 5 | ## Install 6 | 7 | - Download binary for your platform from [Releases](https://github.com/AlekseyMartynov/shazam-for-real/releases) 8 | 9 | - (Linux/Mac) Set executable bit `chmod +x Shazam` 10 | 11 | - (Linux/Mac) Install [SoX](https://en.wikipedia.org/wiki/SoX) via `sudo apt-get install sox` or `brew install sox` 12 | 13 | ## Usage 14 | 15 | - Interactive: run without arguments 16 | 17 | - Tag `WAV` and `MP3` files 18 | ``` 19 | Shazam PATH [TIME] [till-end] 20 | ``` 21 | **Example 1: Tag at a specific time** 22 | ``` 23 | > Shazam mix.mp3 02:00 24 | 25 | 00:02:00 https://www.shazam.com/track/668835426/okhopa 26 | ``` 27 | **Example 2: Tag till the end of file** 28 | ``` 29 | > Shazam mix.mp3 05:00 till-end 30 | 31 | 00:05:00 https://www.shazam.com/track/668835426/okhopa 32 | 00:05:30 https://www.shazam.com/track/668835426/okhopa 33 | 00:06:00 https://www.shazam.com/track/667516878/in-surdose 34 | 00:06:30 https://www.shazam.com/track/667516878/in-surdose 35 | 00:07:00 https://www.shazam.com/track/667516878/in-surdose 36 | . . . 37 | ``` 38 | -------------------------------------------------------------------------------- /CaptureHelpers/WasapiCaptureHelper.cs: -------------------------------------------------------------------------------- 1 | using NAudio.CoreAudioApi; 2 | using NAudio.Wave; 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | 7 | namespace Project; 8 | 9 | class WasapiCaptureHelper : ICaptureHelper { 10 | readonly WasapiCapture Capture; 11 | readonly BufferedWaveProvider CaptureBuf; 12 | readonly MediaFoundationResampler Resampler; 13 | 14 | public WasapiCaptureHelper() { 15 | Capture = WasapiLoopbackHelper.Loopback ? new WasapiLoopbackCapture() : new WasapiCapture(); 16 | CaptureBuf = new BufferedWaveProvider(Capture.WaveFormat) { ReadFully = false }; 17 | Resampler = new MediaFoundationResampler(CaptureBuf, ICaptureHelper.WAVE_FORMAT); 18 | } 19 | 20 | public void Dispose() { 21 | Resampler.Dispose(); 22 | Capture.Dispose(); 23 | } 24 | 25 | 26 | public bool Live => true; 27 | public ISampleProvider SampleProvider { get; private set; } 28 | public Exception Exception { get; private set; } 29 | 30 | public void Start() { 31 | Capture.DataAvailable += (s, e) => { 32 | CaptureBuf.AddSamples(e.Buffer, 0, e.BytesRecorded); 33 | }; 34 | 35 | Capture.RecordingStopped += (s, e) => { 36 | Exception = e.Exception; 37 | }; 38 | 39 | Capture.StartRecording(); 40 | 41 | SampleProvider = Resampler.ToSampleProvider(); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /UI/Interactive.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | 6 | namespace Project; 7 | 8 | static class Interactive { 9 | 10 | public static async Task RunAsync() { 11 | if(!ConsoleHelper.IsRedirected) 12 | PrintHotkeys(); 13 | 14 | #if WASAPI_CAPTURE 15 | WasapiLoopbackHelper.Set(false); 16 | #endif 17 | 18 | while(true) { 19 | var key = Char.ToLower(ReadKey()); 20 | 21 | if(key == 'q' || key == '\0') 22 | break; 23 | 24 | #if WASAPI_CAPTURE 25 | if(key == 's') { 26 | WasapiLoopbackHelper.Toggle(); 27 | continue; 28 | } 29 | #endif 30 | 31 | if(key == ' ') { 32 | await TagLive.RunAsync(false); 33 | continue; 34 | } 35 | 36 | if(key == 'a') { 37 | await TagLive.RunAsync(true); 38 | } 39 | } 40 | 41 | } 42 | 43 | static void PrintHotkeys() { 44 | Console.WriteLine(String.Join(", ", 45 | "SPACE - tag", 46 | "A - auto", 47 | #if WASAPI_CAPTURE 48 | "S - source", 49 | #endif 50 | "Q - quit" 51 | )); 52 | } 53 | 54 | static char ReadKey() { 55 | try { 56 | return Console.ReadKey(true).KeyChar; 57 | } catch(InvalidOperationException) { 58 | return Console.In.ReadToEnd().FirstOrDefault(); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /TaggingDebug/Synthback.cs: -------------------------------------------------------------------------------- 1 | using MathNet.Numerics; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.IO; 5 | using System.Linq; 6 | 7 | namespace Project; 8 | 9 | class Synthback { 10 | static readonly float[] Envelope = Array.ConvertAll(Window.Hann(2 * Analysis.CHUNK_SIZE), Convert.ToSingle); 11 | 12 | readonly Analysis Analysis; 13 | readonly PeakFinder Finder; 14 | 15 | public Synthback(Analysis analysis, PeakFinder finder) { 16 | Analysis = analysis; 17 | Finder = finder; 18 | } 19 | 20 | public void Synth(string filename) { 21 | var peaks = Finder.EnumerateAllPeaks().ToArray(); 22 | 23 | var stripeCount = 1 + peaks.Max(l => l.StripeIndex); 24 | var wave = new float[stripeCount * Analysis.CHUNK_SIZE]; 25 | 26 | foreach(var p in peaks) { 27 | var startSample = Analysis.CHUNK_SIZE * (p.StripeIndex - 1); 28 | var endSample = startSample + 2 * Analysis.CHUNK_SIZE; 29 | 30 | for(var t = startSample; t < endSample; t++) 31 | wave[t] += MathF.Sin(2 * MathF.PI * Analysis.BinToFreq(p.InterpolatedBin) * t / Analysis.SAMPLE_RATE) 32 | * MathF.Exp(p.LogMagnitude / UInt16.MaxValue) 33 | * Envelope[t - startSample]; 34 | } 35 | 36 | var maxSample = wave.Max(s => Math.Abs(s)); 37 | 38 | var bytes = new List(2 * wave.Length); 39 | foreach(var s in wave) 40 | bytes.AddRange(BitConverter.GetBytes(Convert.ToInt16(Int16.MaxValue * s / maxSample))); 41 | 42 | File.WriteAllBytes(filename, bytes.ToArray()); 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /Project.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exe 5 | net8 6 | Project 7 | Icon.ico 8 | Shazam 9 | Project.Program 10 | $(DefineConstants);NO_WASAPI_CAPTURE 11 | true 12 | 13 | 14 | 15 | true 16 | false 17 | true 18 | true 19 | true 20 | $(AssemblyName).$(RuntimeIdentifier) 21 | true 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /TaggingDebug/Painter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Drawing; 3 | using System.Drawing.Drawing2D; 4 | using System.Runtime.Versioning; 5 | 6 | namespace Project; 7 | 8 | [SupportedOSPlatform("windows")] 9 | class Painter { 10 | readonly Analysis Analysis; 11 | readonly PeakFinder Finder; 12 | 13 | public Painter(Analysis analysis, PeakFinder finder) { 14 | Analysis = analysis; 15 | Finder = finder; 16 | } 17 | 18 | public void Paint(string filename) { 19 | var w = Analysis.StripeCount; 20 | var h = Analysis.BIN_COUNT; 21 | var gamma = 0.25f; 22 | var maxMagnitudeSquared = Analysis.FindMaxMagnitudeSquared(); 23 | 24 | using(var bitmap = new Bitmap(w, h)) { 25 | 26 | for(var x = 0; x < w; x++) { 27 | for(var y = 0; y < h; y++) { 28 | var magnitudeSquared = Analysis.GetMagnitudeSquared(x, y); 29 | var shade = Convert.ToByte(255 * MathF.Pow(magnitudeSquared / maxMagnitudeSquared, gamma / 2)); 30 | bitmap.SetPixel(x, h - y - 1, Color.FromArgb(shade, shade, shade)); 31 | } 32 | } 33 | 34 | using(var g = Graphics.FromImage(bitmap)) { 35 | g.SmoothingMode = SmoothingMode.AntiAlias; 36 | foreach(var p in Finder.EnumerateAllPeaks()) { 37 | var radius = 8 * p.LogMagnitude / UInt16.MaxValue; 38 | var cx = p.StripeIndex; 39 | var cy = h - p.InterpolatedBin - 1; 40 | g.FillEllipse(Brushes.Yellow, cx - radius, cy - radius, 2 * radius, 2 * radius); 41 | } 42 | } 43 | 44 | bitmap.Save(filename); 45 | } 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /UI/TagFile.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | 6 | namespace Project; 7 | 8 | static class TagFile { 9 | 10 | public static async Task RunAsync(string[] args) { 11 | var filePath = args[0]; 12 | var startTime = TimeSpan.Zero; 13 | var tillEnd = false; 14 | 15 | foreach(var a in args.Skip(1)) { 16 | if(a == "till-end") { 17 | tillEnd = true; 18 | } else { 19 | if(!TryParseTime(a, out startTime)) 20 | throw new Exception("Cannot parse time: " + a); 21 | } 22 | } 23 | 24 | await RunAsync(filePath, startTime, tillEnd); 25 | } 26 | 27 | public static async Task RunAsync(string filePath, TimeSpan startTime, bool tillEnd) { 28 | using var captureHelper = new FileCaptureHelper(filePath, startTime); 29 | captureHelper.Start(); 30 | 31 | while(true) { 32 | captureHelper.SkipTo(startTime); 33 | 34 | ConsoleHelper.WriteTime(captureHelper.CurrentTime); 35 | 36 | var result = await CaptureAndTag.RunAsync(captureHelper); 37 | 38 | if(result == null) { 39 | Console.WriteLine("END"); 40 | break; 41 | } 42 | 43 | if(result.Success) { 44 | Console.WriteLine(result.Url); 45 | } else { 46 | Console.WriteLine("-"); 47 | } 48 | 49 | if(!tillEnd) 50 | break; 51 | 52 | startTime += TimeSpan.FromSeconds(30); 53 | } 54 | } 55 | 56 | static bool TryParseTime(string text, out TimeSpan result) { 57 | var segments = text.Split(':'); 58 | var values = new int[3]; 59 | var count = Math.Min(3, segments.Length); 60 | 61 | Array.Reverse(segments); 62 | 63 | for(var i = 0; i < count; i++) { 64 | if(!Int32.TryParse(segments[i], out values[i])) { 65 | result = TimeSpan.Zero; 66 | return false; 67 | } 68 | } 69 | 70 | result = new TimeSpan(values[2], values[1], values[0]); 71 | return true; 72 | } 73 | 74 | } 75 | -------------------------------------------------------------------------------- /UI/TagLive.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics; 4 | using System.Linq; 5 | using System.Threading.Tasks; 6 | 7 | namespace Project; 8 | 9 | static class TagLive { 10 | 11 | public static async Task RunAsync(bool auto) { 12 | var prevUrl = default(string); 13 | 14 | while(true) { 15 | ConsoleHelper.WriteProgress("Listening... "); 16 | 17 | var startTime = DateTime.Now; 18 | 19 | try { 20 | using var captureHelper = CreateCaptureHelper(); 21 | captureHelper.Start(); 22 | 23 | var result = await CaptureAndTag.RunAsync(captureHelper); 24 | 25 | if(result.Success) { 26 | var text = result.Url != prevUrl ? result.Url : "..."; 27 | 28 | ConsoleHelper.ClearProgress(); 29 | if(auto) { 30 | Console.Write(startTime.ToString("HH:mm:ss")); 31 | Console.Write(' '); 32 | } 33 | Console.WriteLine(text); 34 | 35 | if(!ConsoleHelper.IsRedirected && !auto) { 36 | Navigate(result.Url); 37 | } 38 | 39 | prevUrl = result.Url; 40 | } else { 41 | if(!auto) 42 | Console.WriteLine(":("); 43 | } 44 | } catch(Exception x) { 45 | Console.WriteLine("error: " + x.Message); 46 | } 47 | 48 | if(!auto) 49 | break; 50 | 51 | ConsoleHelper.WriteProgress("Idle... "); 52 | 53 | var nextStartTime = startTime + TimeSpan.FromSeconds(15); 54 | while(DateTime.Now < nextStartTime) 55 | await Task.Delay(100); 56 | } 57 | } 58 | 59 | static void Navigate(string url) { 60 | if(OperatingSystem.IsWindows()) { 61 | using var proc = Process.Start("explorer", '"' + url + '"'); 62 | proc.WaitForExit(); 63 | } 64 | } 65 | 66 | static ICaptureHelper CreateCaptureHelper() { 67 | #if WASAPI_CAPTURE 68 | return new WasapiCaptureHelper(); 69 | #else 70 | if(!OperatingSystem.IsWindows()) { 71 | return new SoxCaptureHelper(); 72 | } 73 | 74 | return new MciCaptureHelper(); 75 | #endif 76 | } 77 | 78 | } 79 | -------------------------------------------------------------------------------- /CaptureHelpers/SoxCaptureHelper.cs: -------------------------------------------------------------------------------- 1 | using NAudio.Wave; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Diagnostics; 5 | using System.Linq; 6 | 7 | namespace Project; 8 | 9 | class SoxCaptureHelper : ICaptureHelper { 10 | Process Sox; 11 | WaveStream WaveStream; 12 | 13 | public void Dispose() { 14 | WaveStream?.Dispose(); 15 | 16 | if(Sox != null) { 17 | Sox.Kill(); 18 | Sox.Dispose(); 19 | } 20 | } 21 | 22 | public bool Live => true; 23 | public ISampleProvider SampleProvider { get; private set; } 24 | public Exception Exception { get; private set; } 25 | 26 | 27 | public void Start() { 28 | var fmt = ICaptureHelper.WAVE_FORMAT; 29 | 30 | var pendingSox = new Process { 31 | StartInfo = new ProcessStartInfo { 32 | FileName = "sox", 33 | Arguments = $"-q -d -r {fmt.SampleRate} -c {fmt.Channels} -b {fmt.BitsPerSample} -e signed-integer -t raw -", 34 | UseShellExecute = false, 35 | RedirectStandardOutput = true, 36 | RedirectStandardError = true, 37 | }, 38 | EnableRaisingEvents = true 39 | }; 40 | 41 | pendingSox.Exited += Sox_Exited; 42 | pendingSox.ErrorDataReceived += Sox_ErrorDataReceived; 43 | 44 | try { 45 | pendingSox.Start(); 46 | pendingSox.BeginErrorReadLine(); 47 | } catch { 48 | throw new Exception("Failed to start sox (https://en.wikipedia.org/wiki/SoX)"); 49 | } 50 | 51 | if(!pendingSox.HasExited) { 52 | Sox = pendingSox; 53 | WaveStream = new RawSourceWaveStream(Sox.StandardOutput.BaseStream, fmt); 54 | SampleProvider = WaveStream.ToSampleProvider(); 55 | } 56 | } 57 | 58 | void Sox_Exited(object s, EventArgs e) { 59 | SampleProvider = EternalSilence.AppendTo(SampleProvider); 60 | 61 | var proc = (Process)s; 62 | 63 | var code = proc.ExitCode; 64 | if(code != 0) 65 | Exception = new Exception("sox exited with code " + code); 66 | } 67 | 68 | void Sox_ErrorDataReceived(object s, DataReceivedEventArgs e) { 69 | var text = e.Data; 70 | 71 | if(String.IsNullOrEmpty(text)) 72 | return; 73 | 74 | if(text.Contains("can't encode 0-bit")) 75 | return; 76 | 77 | Console.Error.WriteLine(text); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /CaptureHelpers/FileCaptureHelper.cs: -------------------------------------------------------------------------------- 1 | using NAudio.Wave.SampleProviders; 2 | using NAudio.Wave; 3 | using NLayer.NAudioSupport; 4 | using System; 5 | using System.Buffers; 6 | using System.Collections.Generic; 7 | using System.Linq; 8 | using System.IO; 9 | 10 | namespace Project; 11 | 12 | class FileCaptureHelper : ICaptureHelper { 13 | readonly string FilePath; 14 | readonly TimeSpan StartTime; 15 | 16 | WaveStream WaveStream; 17 | 18 | public FileCaptureHelper(string filePath, TimeSpan startTime = default) { 19 | FilePath = filePath; 20 | StartTime = startTime; 21 | } 22 | 23 | public void Dispose() { 24 | WaveStream?.Dispose(); 25 | } 26 | 27 | public TimeSpan CurrentTime => WaveStream != null ? WaveStream.CurrentTime : TimeSpan.Zero; 28 | public TimeSpan TotalTime => WaveStream != null ? WaveStream.TotalTime : TimeSpan.Zero; 29 | 30 | public bool Live => false; 31 | public ISampleProvider SampleProvider { get; private set; } 32 | public Exception Exception => null; 33 | 34 | public void Start() { 35 | WaveStream = CreateWaveStream(); 36 | WaveStream.CurrentTime = StartTime; 37 | 38 | SampleProvider = WaveStream.ToSampleProvider(); 39 | 40 | if(SampleProvider.WaveFormat.Channels > 1) 41 | SampleProvider = new StereoToMonoSampleProvider(SampleProvider); 42 | 43 | if(SampleProvider.WaveFormat.SampleRate != Analysis.SAMPLE_RATE) 44 | SampleProvider = new WdlResamplingSampleProvider(SampleProvider, Analysis.SAMPLE_RATE); 45 | } 46 | 47 | public void SkipTo(TimeSpan time) { 48 | var len = Analysis.SAMPLE_RATE / 2; 49 | var buf = ArrayPool.Shared.Rent(len); 50 | 51 | try { 52 | while(WaveStream.CurrentTime < time) { 53 | if(SampleProvider.Read(buf, 0, len) < len) 54 | break; 55 | } 56 | } finally { 57 | ArrayPool.Shared.Return(buf); 58 | } 59 | } 60 | 61 | public void SeekTo(TimeSpan time) { 62 | if(WaveStream is not WaveFileReader) { 63 | // TODO not sure about MP3 seek accuracy 64 | throw new NotSupportedException(); 65 | } 66 | WaveStream.CurrentTime = time; 67 | } 68 | 69 | WaveStream CreateWaveStream() { 70 | var ext = Path.GetExtension(FilePath).ToLower(); 71 | return ext switch { 72 | ".mp3" => new Mp3FileReaderBase(FilePath, fmt => new Mp3FrameDecompressor(fmt)), 73 | ".wav" => new WaveFileReader(FilePath), 74 | _ => throw new NotSupportedException($"Extension '{ext}' not supported"), 75 | }; 76 | } 77 | 78 | } 79 | -------------------------------------------------------------------------------- /Tagging/CaptureAndTag.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Runtime.ExceptionServices; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | 8 | namespace Project; 9 | 10 | static class CaptureAndTag { 11 | static readonly float[] CHUNK = new float[Analysis.CHUNK_SIZE]; 12 | 13 | public static async Task RunAsync(ICaptureHelper captureHelper) { 14 | var analysis = new Analysis(); 15 | var finder = new PeakFinder(analysis); 16 | 17 | var retryMs = 3000; 18 | var tagId = Guid.NewGuid().ToString(); 19 | 20 | while(true) { 21 | var readChunkResult = await ReadChunkAsync(captureHelper); 22 | 23 | if(readChunkResult == ReadChunkResult.EOF) 24 | return null; 25 | 26 | if(readChunkResult == ReadChunkResult.SampleProviderChanged) { 27 | analysis = new Analysis(); 28 | finder = new PeakFinder(analysis); 29 | continue; 30 | } 31 | 32 | analysis.AddChunk(CHUNK); 33 | 34 | if(analysis.ProcessedMs >= retryMs) { 35 | //new Painter(analysis, finder).Paint("c:/temp/spectro.png"); 36 | //new Synthback(analysis, finder).Synth("c:/temp/synthback.raw"); 37 | 38 | var sigBytes = Sig.Write(Analysis.SAMPLE_RATE, analysis.ProcessedSamples, finder); 39 | var result = await ShazamApi.SendRequestAsync(tagId, analysis.ProcessedMs, sigBytes); 40 | if(result.Success) 41 | return result; 42 | 43 | retryMs = result.RetryMs; 44 | if(retryMs == 0) 45 | return result; 46 | } 47 | } 48 | } 49 | 50 | static async Task ReadChunkAsync(ICaptureHelper captureHelper) { 51 | var sampleProvider = captureHelper.SampleProvider; 52 | var offset = 0; 53 | var expectedCount = CHUNK.Length; 54 | 55 | while(true) { 56 | if(captureHelper.Exception != null) 57 | ExceptionDispatchInfo.Capture(captureHelper.Exception).Throw(); 58 | 59 | if(captureHelper.SampleProvider != sampleProvider) 60 | return ReadChunkResult.SampleProviderChanged; 61 | 62 | var actualCount = sampleProvider.Read(CHUNK, offset, expectedCount); 63 | 64 | if(actualCount == expectedCount) 65 | return ReadChunkResult.OK; 66 | 67 | if(!captureHelper.Live) 68 | return ReadChunkResult.EOF; 69 | 70 | offset += actualCount; 71 | expectedCount -= actualCount; 72 | 73 | await Task.Delay(100); 74 | } 75 | } 76 | 77 | enum ReadChunkResult { 78 | OK, 79 | SampleProviderChanged, 80 | EOF 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Tagging/Analysis.cs: -------------------------------------------------------------------------------- 1 | using MathNet.Numerics; 2 | using MathNet.Numerics.IntegralTransforms; 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | 7 | namespace Project; 8 | 9 | class Analysis { 10 | public const int SAMPLE_RATE = 16000; 11 | public const int CHUNKS_PER_SECOND = 125; 12 | public const int CHUNK_SIZE = SAMPLE_RATE / CHUNKS_PER_SECOND; 13 | public const int WINDOW_SIZE = CHUNK_SIZE * 16; 14 | public const int BIN_COUNT = WINDOW_SIZE / 2 + 1; 15 | 16 | readonly static float[] HANN = Array.ConvertAll(Window.Hann(WINDOW_SIZE), Convert.ToSingle); 17 | 18 | readonly float[] WindowRing = new float[WINDOW_SIZE]; 19 | readonly List Stripes = new List(3 * CHUNKS_PER_SECOND); 20 | 21 | readonly Complex32[] FFTBuf = new Complex32[WINDOW_SIZE]; 22 | 23 | Action StripeAddedCallback; 24 | 25 | public int ProcessedSamples { get; private set; } 26 | public int ProcessedMs => ProcessedSamples * 1000 / SAMPLE_RATE; 27 | public int StripeCount => Stripes.Count; 28 | 29 | int WindowRingPos => ProcessedSamples % WINDOW_SIZE; 30 | 31 | public void SetStripeAddedCallback(Action callback) { 32 | if(StripeAddedCallback != null) 33 | throw new InvalidOperationException(); 34 | 35 | StripeAddedCallback = callback; 36 | } 37 | 38 | public void AddChunk(float[] chunk) { 39 | if(chunk.Length != CHUNK_SIZE) 40 | throw new Exception(); 41 | 42 | Array.Copy(chunk, 0, WindowRing, WindowRingPos, CHUNK_SIZE); 43 | 44 | ProcessedSamples += CHUNK_SIZE; 45 | 46 | if(ProcessedSamples >= WINDOW_SIZE) 47 | AddStripe(); 48 | } 49 | 50 | void AddStripe() { 51 | for(var i = 0; i < WINDOW_SIZE; i++) { 52 | var waveRingIndex = (WindowRingPos + i) % WINDOW_SIZE; 53 | FFTBuf[i] = new Complex32(WindowRing[waveRingIndex] * HANN[i], 0); 54 | } 55 | 56 | Fourier.Forward(FFTBuf, FourierOptions.NoScaling); 57 | 58 | var stripe = new float[BIN_COUNT]; 59 | for(var bin = 0; bin < BIN_COUNT; bin++) { 60 | // Used in official Shazam since 7.11.0 61 | // https://github.com/marin-m/SongRec/issues/10#issuecomment-731527377 62 | const int scaling = 2; 63 | 64 | stripe[bin] = scaling * FFTBuf[bin].MagnitudeSquared; 65 | } 66 | 67 | Stripes.Add(stripe); 68 | 69 | StripeAddedCallback?.Invoke(); 70 | } 71 | 72 | public float GetMagnitudeSquared(int stripe, int bin) { 73 | return Stripes[stripe][bin]; 74 | } 75 | 76 | public float FindMaxMagnitudeSquared() { 77 | return Stripes.Max(s => s.Max()); 78 | } 79 | 80 | public static int FreqToBin(float freq) { 81 | return Convert.ToInt32(freq * WINDOW_SIZE / SAMPLE_RATE); 82 | } 83 | 84 | public static float BinToFreq(float bin) { 85 | return bin * SAMPLE_RATE / WINDOW_SIZE; 86 | } 87 | 88 | } 89 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | concurrency: 4 | group: ${{github.workflow}}-${{github.event.pull_request.number || github.sha}} 5 | cancel-in-progress: true 6 | 7 | on: 8 | schedule: 9 | - cron: '0 12 1 * *' 10 | workflow_dispatch: 11 | push: 12 | 13 | jobs: 14 | 15 | lint: 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - uses: actions/checkout@v5 20 | 21 | - run: curl -L https://github.com/editorconfig-checker/editorconfig-checker/releases/download/2.7.1/ec-linux-amd64.tar.gz | tar xzf - -C /opt 22 | - run: /opt/bin/ec-linux-amd64 23 | 24 | - uses: actions/setup-dotnet@v5 25 | with: 26 | dotnet-version: 8 27 | 28 | - run: dotnet format Project.csproj --verify-no-changes 29 | 30 | unit-test: 31 | runs-on: ubuntu-latest 32 | 33 | steps: 34 | - uses: actions/checkout@v5 35 | 36 | - uses: actions/setup-dotnet@v5 37 | with: 38 | dotnet-version: 8 39 | 40 | - run: dotnet test --logger "console;verbosity=detailed" 41 | 42 | 43 | publish: 44 | 45 | strategy: 46 | fail-fast: false 47 | matrix: 48 | include: 49 | - { os: windows-latest, rid: win-x64 } 50 | - { os: windows-latest, rid: win-arm64 } 51 | - { os: ubuntu-latest, rid: linux-x64 } 52 | - { os: ubuntu-latest, rid: linux-arm64 } 53 | - { os: macos-latest, rid: osx-x64 } 54 | - { os: macos-latest, rid: osx-arm64 } 55 | 56 | env: 57 | OUT_DIR: ./bin/Release/net8/${{ matrix.rid }}/native 58 | OUT_BIN: ./bin/Release/net8/${{ matrix.rid }}/native/Shazam.${{ matrix.rid }} 59 | TEST_FILE: TestData/test.mp3 60 | TEST_TRACK_SLUG: doo-uap 61 | 62 | runs-on: ${{ matrix.os }} 63 | 64 | steps: 65 | - uses: actions/checkout@v5 66 | 67 | - uses: actions/setup-dotnet@v5 68 | with: 69 | dotnet-version: 8 70 | 71 | - if: matrix.rid == 'linux-arm64' 72 | run: sudo sh -e .github/workflows/install-ubuntu-arm64.sh 73 | 74 | - run: dotnet publish --sc -c Release -r ${{ matrix.rid }} 75 | 76 | # Test with file 77 | 78 | - if: endsWith(matrix.rid, '-x64') || matrix.rid == 'linux-arm64' 79 | run: ${{ env.OUT_BIN }} ${{ env.TEST_FILE }} > test-out 80 | 81 | - if: matrix.rid == 'win-x64' 82 | run: findstr ${{ env.TEST_TRACK_SLUG }} test-out 83 | 84 | - if: matrix.rid == 'linux-x64' || matrix.rid == 'linux-arm64' || matrix.rid == 'osx-x64' 85 | run: grep ${{ env.TEST_TRACK_SLUG }} test-out 86 | 87 | # Test with virtual device (Linux) 88 | 89 | - if: matrix.rid == 'linux-x64' || matrix.rid == 'linux-arm64' 90 | run: | 91 | sudo apt-get update 92 | sudo apt-get install -y alsa-base ffmpeg sox 93 | ffmpeg -i ${{ env.TEST_FILE }} -ar 48000 -ac 2 /tmp/test.wav 94 | cp .github/workflows/test-asoundrc ~/.asoundrc 95 | echo ' ' | AUDIODEV=test ${{ env.OUT_BIN }} > test-out-live 96 | grep ${{ env.TEST_TRACK_SLUG }} test-out-live 97 | 98 | # Test with virtual device (Win) 99 | 100 | - if: matrix.rid == 'win-x64' 101 | uses: AlekseyMartynov/action-vbcable-win@main 102 | 103 | - if: matrix.rid == 'win-x64' 104 | run: | 105 | choco install ffmpeg -y --no-progress 106 | Start-Process 'ffplay' '-loop 0 ${{ env.TEST_FILE }}' 107 | " " | ${{ env.OUT_BIN }} > test-out-live 108 | findstr ${{ env.TEST_TRACK_SLUG }} test-out-live 109 | 110 | # Ready 111 | 112 | - uses: actions/upload-artifact@v5 113 | with: 114 | name: ${{ matrix.rid }} 115 | path: ${{ env.OUT_DIR }}/* 116 | retention-days: 1 117 | -------------------------------------------------------------------------------- /Test/PeakDensityResearch.cs: -------------------------------------------------------------------------------- 1 | #if DEBUG 2 | using System; 3 | using System.Collections.Generic; 4 | using System.IO; 5 | using System.Linq; 6 | using Xunit; 7 | using Xunit.Abstractions; 8 | 9 | namespace Project.Test; 10 | 11 | public class PeakDensityResearch { 12 | readonly ITestOutputHelper Output; 13 | readonly ICollection<(int stripe, int bin)> PeakMap; 14 | 15 | public PeakDensityResearch(ITestOutputHelper output) { 16 | Output = output; 17 | PeakMap = new HashSet<(int, int)>(); 18 | } 19 | 20 | [Fact] 21 | public void Normal() { 22 | Run("gabin-full-sigx-10.1.3.bin"); 23 | } 24 | 25 | [Fact] 26 | public void Fat() { 27 | // Generated using libsigx.so from Android app v7.11 28 | // SigType.SINGLE_FILE, SigOptions.FAT 29 | // 'Fat' signature, presumably, contains raw peaks 30 | // - before bin interpolation 31 | // - before rate limit 32 | Run("gabin-60s-sigx-5.1.0-fat.bin"); 33 | } 34 | 35 | void Run(string fileName) { 36 | LoadPeakMap(fileName, out var bandedPeakRates); 37 | 38 | for(var i = 0; i < bandedPeakRates.Count; i++) 39 | Output.WriteLine($"Band {i} peak rate {bandedPeakRates[i]:0.#} peak/sec"); 40 | 41 | var noPeakBoundary = new Dictionary(); 42 | 43 | var binDelta = 0; 44 | var stripeDelta = 1; 45 | 46 | var nextBinDelta = false; 47 | var stop = false; 48 | 49 | while(true) { 50 | 51 | foreach(var (stripe, bin) in PeakMap) { 52 | if(!HasPeak(stripe, bin, stripeDelta, binDelta)) 53 | continue; 54 | 55 | var minStripeDelta = noPeakBoundary.GetValueOrDefault(binDelta, Int32.MaxValue); 56 | minStripeDelta = Math.Min(minStripeDelta, stripeDelta); 57 | 58 | var stripeDeltaNoPeaks = minStripeDelta - 1; 59 | noPeakBoundary[binDelta] = stripeDeltaNoPeaks; 60 | 61 | if(stripeDelta >= minStripeDelta) 62 | nextBinDelta = true; 63 | 64 | if(minStripeDelta == 1) 65 | stop = true; 66 | 67 | break; 68 | } 69 | 70 | if(stop) 71 | break; 72 | 73 | if(nextBinDelta) { 74 | binDelta++; 75 | stripeDelta = 1; 76 | nextBinDelta = false; 77 | } else { 78 | stripeDelta++; 79 | } 80 | } 81 | 82 | Output.WriteLine("bin\tstripe"); 83 | foreach(var (bin, stripe) in noPeakBoundary.OrderBy(i => i.Key)) 84 | Output.WriteLine($"{bin}\t{stripe}"); 85 | } 86 | 87 | void LoadPeakMap(string fileName, out IReadOnlyList bandedRates) { 88 | var path = Path.Combine(TestHelper.DATA_DIR, fileName); 89 | 90 | Sig.Read( 91 | File.ReadAllBytes(path), 92 | out var sampleRate, 93 | out var sampleCount, 94 | out var bands 95 | ); 96 | 97 | var durationSeconds = 1d * sampleCount / sampleRate; 98 | 99 | bandedRates = bands 100 | .Select(i => i.Count / durationSeconds) 101 | .ToList(); 102 | 103 | foreach(var p in bands.SelectMany(i => i)) { 104 | var (stripe, bin) = (p.StripeIndex, Convert.ToInt32(p.InterpolatedBin)); 105 | PeakMap.Add((stripe, bin)); 106 | } 107 | } 108 | 109 | bool HasPeak(int centerStripe, int centerBin, int stripeDelta, int binDelta) { 110 | return PeakMap.Contains((centerStripe - stripeDelta, centerBin - binDelta)) 111 | || PeakMap.Contains((centerStripe - stripeDelta, centerBin + binDelta)) 112 | || PeakMap.Contains((centerStripe + stripeDelta, centerBin - binDelta)) 113 | || PeakMap.Contains((centerStripe + stripeDelta, centerBin + binDelta)); 114 | } 115 | 116 | } 117 | #endif 118 | -------------------------------------------------------------------------------- /Tagging/PeakFinder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | 5 | namespace Project; 6 | 7 | record PeakInfo( 8 | int StripeIndex, 9 | float InterpolatedBin, 10 | float LogMagnitude 11 | ); 12 | 13 | class PeakFinder { 14 | public const int 15 | // Refer to Test/PeakDensityResearch.Fat() 16 | H_STRIPE_DIST = 45, H_BIN_DIST = 1, 17 | V_STRIPE_DIST = 3, V_BIN_DIST = 10; 18 | 19 | static readonly IReadOnlyList BAND_FREQS = [250, 520, 1450, 3500, 5500]; 20 | 21 | static readonly int 22 | MIN_BIN = Math.Max(Analysis.FreqToBin(BAND_FREQS.Min()), V_BIN_DIST), 23 | MAX_BIN = Math.Min(Analysis.FreqToBin(BAND_FREQS.Max()), Analysis.BIN_COUNT - V_BIN_DIST); 24 | 25 | static readonly float 26 | MIN_MAGN_SQUARED = 1f / 512 / 512, 27 | LOG_MIN_MAGN_SQUARED = MathF.Log(MIN_MAGN_SQUARED); 28 | 29 | readonly Analysis Analysis; 30 | readonly bool Interpolation; 31 | readonly IReadOnlyList> Bands; 32 | 33 | public PeakFinder(Analysis analysis, bool interpolation = true) { 34 | analysis.SetStripeAddedCallback(Analysis_StripeAddedCallback); 35 | 36 | Analysis = analysis; 37 | Interpolation = interpolation; 38 | 39 | Bands = Enumerable.Range(0, BAND_FREQS.Count - 1) 40 | .Select(_ => new List()) 41 | .ToList(); 42 | } 43 | 44 | void Analysis_StripeAddedCallback() { 45 | if(Analysis.StripeCount > 2 * H_STRIPE_DIST) 46 | Find(Analysis.StripeCount - H_STRIPE_DIST - 1); 47 | } 48 | 49 | void Find(int stripe) { 50 | for(var bin = MIN_BIN; bin < MAX_BIN; bin++) { 51 | 52 | if(Analysis.GetMagnitudeSquared(stripe, bin) < MIN_MAGN_SQUARED) 53 | continue; 54 | 55 | if(!IsPeak(stripe, bin, H_STRIPE_DIST, H_BIN_DIST)) 56 | continue; 57 | 58 | if(!IsPeak(stripe, bin, V_STRIPE_DIST, V_BIN_DIST)) 59 | continue; 60 | 61 | AddPeakAt(stripe, bin); 62 | } 63 | } 64 | 65 | public IEnumerable> EnumerateBandedPeaks() { 66 | return Bands; 67 | } 68 | 69 | public IEnumerable EnumerateAllPeaks() { 70 | return Bands.SelectMany(i => i); 71 | } 72 | 73 | public void ApplyRateLimit() { 74 | // Derived by comparison with official signature 75 | // StripeCount / 11 also works 76 | var allowedCount = 12 + Analysis.StripeCount / 12; 77 | 78 | foreach(var peakList in Bands) { 79 | if(peakList.Count <= allowedCount) 80 | continue; 81 | 82 | peakList.Sort((x, y) => -Comparer.Default.Compare(x.LogMagnitude, y.LogMagnitude)); 83 | peakList.RemoveRange(allowedCount, peakList.Count - allowedCount); 84 | peakList.Sort((x, y) => Comparer.Default.Compare(x.StripeIndex, y.StripeIndex)); 85 | } 86 | } 87 | 88 | int GetBandIndex(float bin) { 89 | var freq = Analysis.BinToFreq(bin); 90 | 91 | if(freq < BAND_FREQS[0]) 92 | return -1; 93 | 94 | for(var i = 1; i < BAND_FREQS.Count; i++) { 95 | if(freq < BAND_FREQS[i]) 96 | return i - 1; 97 | } 98 | 99 | return -1; 100 | } 101 | 102 | PeakInfo CreatePeakAt(int stripe, int bin) { 103 | if(!Interpolation) { 104 | return new PeakInfo(stripe, bin, GetLogMagnitude(stripe, bin)); 105 | } 106 | 107 | // Quadratic Interpolation of Spectral Peaks 108 | // https://stackoverflow.com/a/59140547 109 | // https://ccrma.stanford.edu/~jos/sasp/Quadratic_Interpolation_Spectral_Peaks.html 110 | 111 | // https://ccrma.stanford.edu/~jos/parshl/Peak_Detection_Steps_3.html 112 | // "We have found empirically that the frequencies tend to be about twice as accurate" 113 | // "when dB magnitude is used rather than just linear magnitude" 114 | 115 | var alpha = GetLogMagnitude(stripe, bin - 1); 116 | var beta = GetLogMagnitude(stripe, bin); 117 | var gamma = GetLogMagnitude(stripe, bin + 1); 118 | var p = (alpha - gamma) / (alpha - 2 * beta + gamma) / 2; 119 | 120 | return new PeakInfo( 121 | stripe, 122 | bin + p, 123 | beta // - (alpha - gamma) * p / 4 124 | ); 125 | } 126 | 127 | float GetLogMagnitude(int stripe, int bin) { 128 | return 18 * 1024 * (1 - MathF.Log(Analysis.GetMagnitudeSquared(stripe, bin)) / LOG_MIN_MAGN_SQUARED); 129 | } 130 | 131 | bool IsPeak(int stripe, int bin, int stripeDist, int binDist) { 132 | var center = Analysis.GetMagnitudeSquared(stripe, bin); 133 | for(var s = -stripeDist; s <= stripeDist; s++) { 134 | for(var b = -binDist; b <= binDist; b++) { 135 | if(s == 0 && b == 0) 136 | continue; 137 | if(Analysis.GetMagnitudeSquared(stripe + s, bin + b) >= center) 138 | return false; 139 | } 140 | } 141 | return true; 142 | } 143 | 144 | void AddPeakAt(int stripe, int bin) { 145 | var newPeak = CreatePeakAt(stripe, bin); 146 | 147 | var bandIndex = GetBandIndex(newPeak.InterpolatedBin); 148 | if(bandIndex < 0) 149 | return; 150 | 151 | Bands[bandIndex].Add(newPeak); 152 | } 153 | 154 | } 155 | -------------------------------------------------------------------------------- /CaptureHelpers/MciCaptureHelper.cs: -------------------------------------------------------------------------------- 1 | using NAudio.Wave; 2 | using System; 3 | using System.Buffers; 4 | using System.Collections.Generic; 5 | using System.IO; 6 | using System.Linq; 7 | using System.Runtime.InteropServices; 8 | using System.Threading; 9 | 10 | namespace Project; 11 | 12 | partial class MciCaptureHelper : ICaptureHelper { 13 | static readonly object SYNC = new(); 14 | static readonly int GENERATION_COUNT = 3; 15 | static readonly TimeSpan GENERATION_STEP = TimeSpan.FromSeconds(4); 16 | static readonly string TEMP_FILE_PATH = Path.Combine(Path.GetTempPath(), "shazam-for-real-tmp.wav"); 17 | 18 | readonly bool[] GenerationRecording = new bool[GENERATION_COUNT]; 19 | readonly IList GenerationStreams = new List(); 20 | 21 | DateTime StartTime; 22 | Thread WorkerThread; 23 | bool StopRequested; 24 | 25 | public MciCaptureHelper() { 26 | SampleProvider = new RawSourceWaveStream(Stream.Null, ICaptureHelper.WAVE_FORMAT).ToSampleProvider(); 27 | } 28 | 29 | public void Dispose() { 30 | lock(SYNC) { 31 | StopRequested = true; 32 | } 33 | 34 | WorkerThread.Join(); 35 | 36 | foreach(var s in GenerationStreams) 37 | s.Dispose(); 38 | 39 | if(File.Exists(TEMP_FILE_PATH)) 40 | File.Delete(TEMP_FILE_PATH); 41 | } 42 | 43 | public bool Live => true; 44 | public ISampleProvider SampleProvider { get; private set; } 45 | public Exception Exception { get; private set; } 46 | 47 | public void Start() { 48 | WorkerThread = new Thread(WorkerThreadProc_Guarded); 49 | WorkerThread.Start(); 50 | } 51 | 52 | void WorkerThreadProc_Guarded() { 53 | try { 54 | WorkerThreadProc(); 55 | } catch(Exception x) { 56 | Exception = x; 57 | } 58 | } 59 | 60 | void WorkerThreadProc() { 61 | lock(SYNC) { 62 | for(var i = 0; i < GENERATION_COUNT; i++) { 63 | var alias = GetAlias(i); 64 | var format = ICaptureHelper.WAVE_FORMAT; 65 | MciSend("open new Type waveaudio Alias", alias); 66 | MciSend("set", alias, 67 | "bitspersample", format.BitsPerSample, 68 | "channels", format.Channels, 69 | "samplespersec", format.SampleRate, 70 | "bytespersec", format.AverageBytesPerSecond, 71 | "alignment", format.BlockAlign 72 | ); 73 | } 74 | 75 | for(var i = 0; i < GENERATION_COUNT; i++) { 76 | MciSend("record", GetAlias(i)); 77 | GenerationRecording[i] = true; 78 | } 79 | 80 | StartTime = DateTime.Now; 81 | } 82 | 83 | while(true) { 84 | lock(SYNC) { 85 | var allGenerationsStopped = true; 86 | 87 | for(var i = 0; i < GENERATION_COUNT; i++) { 88 | if(GenerationRecording[i]) { 89 | var willStop = StopRequested || DateTime.Now - StartTime > (1 + i) * GENERATION_STEP; 90 | 91 | if(willStop) { 92 | var alias = GetAlias(i); 93 | 94 | if(!StopRequested) { 95 | MciSend("save", alias, TEMP_FILE_PATH); 96 | TempFileToSampleProvider(); 97 | } 98 | 99 | MciSend("close", alias); 100 | GenerationRecording[i] = false; 101 | } 102 | } 103 | 104 | allGenerationsStopped = allGenerationsStopped && !GenerationRecording[i]; 105 | } 106 | 107 | if(allGenerationsStopped) { 108 | SampleProvider = EternalSilence.AppendTo(SampleProvider); 109 | return; 110 | } 111 | } 112 | 113 | Thread.Sleep(100); 114 | } 115 | } 116 | 117 | void TempFileToSampleProvider() { 118 | var stream = new MemoryStream(File.ReadAllBytes(TEMP_FILE_PATH)); 119 | GenerationStreams.Add(stream); 120 | SampleProvider = new WaveFileReader(stream).ToSampleProvider(); 121 | } 122 | 123 | static string GetAlias(int i) { 124 | return "rec" + i; 125 | } 126 | 127 | static string MciSend(params object[] command) { 128 | return MciSend(String.Join(" ", command)); 129 | } 130 | 131 | static string MciSend(string command) { 132 | //Console.WriteLine(command); 133 | 134 | var buf = ArrayPool.Shared.Rent(128); 135 | 136 | try { 137 | var code = mciSendString(command, buf, buf.Length, IntPtr.Zero); 138 | 139 | if(code != 0) { 140 | mciGetErrorString(code, buf, buf.Length); 141 | throw new Exception(BufToString(buf)); 142 | } 143 | 144 | return BufToString(buf); 145 | } finally { 146 | ArrayPool.Shared.Return(buf); 147 | } 148 | } 149 | 150 | static string BufToString(char[] buf) { 151 | var zIndex = Array.IndexOf(buf, '\0'); 152 | 153 | if(zIndex > 0) 154 | return new String(buf, 0, zIndex); 155 | 156 | return new String(buf); 157 | } 158 | 159 | [LibraryImport("winmm", EntryPoint = "mciSendStringW", StringMarshalling = StringMarshalling.Utf16)] 160 | private static partial uint mciSendString(string command, [Out] char[] returnBuf, int returnLen, IntPtr callbackHandle); 161 | 162 | [LibraryImport("winmm", EntryPoint = "mciGetErrorStringW", StringMarshalling = StringMarshalling.Utf16)] 163 | private static partial void mciGetErrorString(uint errorCode, [Out] char[] returnBuf, int returnLen); 164 | } 165 | -------------------------------------------------------------------------------- /UI/TagFileBisect.cs: -------------------------------------------------------------------------------- 1 | //#define ENABLE_DEBUG_CACHE 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Diagnostics; 5 | using System.IO; 6 | using System.Linq; 7 | using System.Threading.Tasks; 8 | 9 | namespace Project; 10 | 11 | sealed class TagFileBisect : IDisposable { 12 | const int RESOLUTION_SEC = 3; 13 | const int MAX_STEP = 1 << 6; 14 | 15 | readonly FileCaptureHelper CaptureHelper; 16 | readonly int GridSize; 17 | readonly int[] Grid; 18 | 19 | readonly Dictionary Urls = []; 20 | 21 | public TagFileBisect(string filePath) { 22 | #if ENABLE_DEBUG_CACHE 23 | DebugCache.EnsureDir(filePath); 24 | #endif 25 | CaptureHelper = new(filePath); 26 | CaptureHelper.Start(); 27 | 28 | GridSize = (int)(CaptureHelper.TotalTime.TotalSeconds / RESOLUTION_SEC); 29 | Grid = new int[GridSize]; 30 | Array.Fill(Grid, -1); 31 | 32 | Urls[0] = "-"; 33 | } 34 | 35 | public void Dispose() { 36 | CaptureHelper.Dispose(); 37 | } 38 | 39 | public async Task RunAsync() { 40 | var tagCount = 0; 41 | 42 | for(var step = MAX_STEP; step > 0; step /= 2) { 43 | Console.WriteLine("Step: " + step); 44 | 45 | for(var gridIndex = 0; gridIndex < GridSize; gridIndex += step) { 46 | 47 | if(Grid[gridIndex] > -1) { 48 | continue; 49 | } 50 | 51 | if(step < MAX_STEP) { 52 | var prevIndex = gridIndex - step; 53 | var nextIndex = gridIndex + step; 54 | if(nextIndex < GridSize - 1) { 55 | var prevID = Grid[prevIndex]; 56 | var nextID = Grid[nextIndex]; 57 | Debug.Assert(prevID > -1 && nextID > -1); 58 | if(prevID == nextID) { 59 | Grid[gridIndex] = prevID; 60 | continue; 61 | } 62 | } 63 | } 64 | 65 | CaptureHelper.SeekTo(GridIndexToTime(gridIndex)); 66 | 67 | ConsoleHelper.WriteTime(CaptureHelper.CurrentTime); 68 | 69 | var tag = await CachedTagAsync(gridIndex); 70 | 71 | var (id, url) = (0, Urls[0]); 72 | 73 | if(tag != null && tag.Success) { 74 | id = int.Parse(tag.ID); 75 | url = tag.Url; 76 | Urls.TryAdd(id, url); 77 | } 78 | 79 | Console.WriteLine(url); 80 | 81 | Grid[gridIndex] = id; 82 | 83 | tagCount++; 84 | } 85 | } 86 | 87 | Console.WriteLine($"Tag/Grid ratio: {1d * tagCount / GridSize:P}"); 88 | 89 | Denoise(); 90 | 91 | Console.WriteLine("---"); 92 | 93 | var prevId = -1; 94 | var dupSegmentTrace = new HashSet(); 95 | 96 | for(var gridIndex = 0; gridIndex < GridSize; gridIndex++) { 97 | var id = Grid[gridIndex]; 98 | var url = Urls[id]; 99 | ConsoleHelper.WriteTime(GridIndexToTime(gridIndex)); 100 | Console.Write(url); 101 | if(id > 0 && id != prevId && !dupSegmentTrace.Add(id)) { 102 | Console.Write(" [DUP]"); 103 | } 104 | Console.WriteLine(); 105 | prevId = id; 106 | } 107 | } 108 | 109 | async Task CachedTagAsync(int gridIndex) { 110 | #if ENABLE_DEBUG_CACHE 111 | if(DebugCache.TryLoad(gridIndex, out var cachedResult)) { 112 | return cachedResult; 113 | } 114 | #endif 115 | var result = await CaptureAndTag.RunAsync(CaptureHelper); 116 | #if ENABLE_DEBUG_CACHE 117 | DebugCache.Save(gridIndex, result); 118 | #endif 119 | return result; 120 | } 121 | 122 | void Denoise() { 123 | for(var i = 1; i < GridSize - 1; i++) { 124 | var id = Grid[i]; 125 | var prevID = Grid[i - 1]; 126 | var nextID = Grid[i + 1]; 127 | 128 | if(prevID == nextID && id != prevID) { 129 | Grid[i] = prevID; 130 | continue; 131 | } 132 | 133 | if(prevID != id && id != nextID) { 134 | Grid[i] = 0; 135 | continue; 136 | } 137 | } 138 | } 139 | 140 | static TimeSpan GridIndexToTime(int i) { 141 | return TimeSpan.FromSeconds(RESOLUTION_SEC * i); 142 | } 143 | 144 | #if ENABLE_DEBUG_CACHE 145 | static class DebugCache { 146 | const string DIR = "c:/temp/shazam-bisect-cache"; 147 | 148 | public static void EnsureDir(string inputFilePath) { 149 | if(Directory.Exists(DIR) && Directory.GetCreationTime(DIR) < File.GetLastWriteTime(inputFilePath)) { 150 | Directory.Delete(DIR, true); 151 | } 152 | if(!Directory.Exists(DIR)) { 153 | Directory.CreateDirectory(DIR); 154 | } 155 | } 156 | 157 | public static void Save(int gridIndex, ShazamResult result) { 158 | var lines = new List(); 159 | if(result != null) { 160 | lines.AddRange([ 161 | result.Success ? "1" : "0", 162 | result.ID, 163 | result.Url, 164 | ]); 165 | } 166 | File.WriteAllLines(GetCacheFilePath(gridIndex), lines); 167 | } 168 | 169 | public static bool TryLoad(int gridIndex, out ShazamResult result) { 170 | result = default; 171 | var filePath = GetCacheFilePath(gridIndex); 172 | if(File.Exists(filePath)) { 173 | var lines = File.ReadAllLines(filePath); 174 | if(lines.Length > 0) { 175 | result = new() { 176 | Success = lines[0] == "1", 177 | ID = lines[1], 178 | Url = lines[2], 179 | }; 180 | } 181 | return true; 182 | } else { 183 | return false; 184 | } 185 | } 186 | 187 | static string GetCacheFilePath(int gridIndex) { 188 | return Path.Join(DIR, gridIndex.ToString()); 189 | } 190 | } 191 | #endif 192 | 193 | } 194 | -------------------------------------------------------------------------------- /Tagging/ShazamApi.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Net.Http; 4 | using System.Net.Http.Headers; 5 | using System.Text.Json; 6 | using System.Threading.Tasks; 7 | 8 | namespace Project; 9 | 10 | static class ShazamApi { 11 | const string COUNTRY = "US"; 12 | 13 | static readonly HttpClient HTTP = new HttpClient(); 14 | static readonly string INSTALLATION_ID = Guid.NewGuid().ToString(); 15 | 16 | static ShazamApi() { 17 | HTTP.DefaultRequestHeaders.UserAgent.ParseAdd("curl/7"); 18 | } 19 | 20 | public static async Task SendRequestAsync(string tagId, int samplems, byte[] sig) { 21 | using var payloadStream = new MemoryStream(); 22 | using var payloadWriter = new Utf8JsonWriter(payloadStream); 23 | 24 | payloadWriter.WriteStartObject(); 25 | payloadWriter.WritePropertyName("signatures"); 26 | payloadWriter.WriteStartArray(); 27 | payloadWriter.WriteStartObject(); 28 | payloadWriter.WriteString("uri", "data:audio/vnd.shazam.sig;base64," + Convert.ToBase64String(sig)); 29 | payloadWriter.WriteNumber("samplems", samplems); 30 | payloadWriter.WriteEndObject(); 31 | payloadWriter.WriteEndArray(); 32 | payloadWriter.WriteString("timezone", "GMT"); 33 | payloadWriter.WriteEndObject(); 34 | payloadWriter.Flush(); 35 | 36 | var url = "https://amp.shazam.com/match/v1/en/" + COUNTRY + "/android/" + INSTALLATION_ID + "/" + tagId; 37 | var postData = new ByteArrayContent(payloadStream.GetBuffer(), 0, (int)payloadStream.Length); 38 | postData.Headers.ContentType = new MediaTypeHeaderValue("application/json"); 39 | 40 | var result = new ShazamResult(); 41 | 42 | var res = await HTTP.PostAsync(url, postData); 43 | var json = await res.Content.ReadAsByteArrayAsync(); 44 | var obj = ParseJson(json); 45 | 46 | PopulateResult(obj, result); 47 | 48 | return result; 49 | } 50 | 51 | static JsonElement ParseJson(byte[] json) { 52 | var reader = new Utf8JsonReader(json.AsSpan()); 53 | return JsonElement.ParseValue(ref reader); 54 | } 55 | 56 | static void PopulateResult(JsonElement rootElement, ShazamResult result) { 57 | if(!rootElement.TryGetProperty("results", out var resultsElement)) 58 | return; 59 | 60 | PopulateID(resultsElement, result); 61 | 62 | if(!String.IsNullOrEmpty(result.ID)) { 63 | result.Success = true; 64 | PopulateAttributes(rootElement, result); 65 | } else { 66 | PopulateRetryMs(resultsElement, result); 67 | } 68 | } 69 | 70 | static void PopulateID(JsonElement resultsElement, ShazamResult result) { 71 | if(!resultsElement.TryGetProperty("matches", out var matchesElement)) 72 | return; 73 | 74 | TryGetFirstItemID(matchesElement, out result.ID); 75 | } 76 | 77 | static void PopulateRetryMs(JsonElement resultsElement, ShazamResult result) { 78 | if(!TryGetNestedProperty(resultsElement, ["retry", "retryInMilliseconds"], out var retryMsElement)) 79 | return; 80 | 81 | if(!retryMsElement.TryGetInt32(out var retryMs)) 82 | return; 83 | 84 | result.RetryMs = retryMs; 85 | } 86 | 87 | static void PopulateAttributes(JsonElement rootElement, ShazamResult result) { 88 | if(!TryGetNestedProperty(rootElement, ["resources", "shazam-songs", result.ID], out var shazamSongElement)) 89 | return; 90 | 91 | if(!shazamSongElement.TryGetProperty("attributes", out var attrsElement)) 92 | return; 93 | 94 | if(attrsElement.TryGetProperty("title", out var titleElement)) 95 | result.Title = titleElement.GetString(); 96 | 97 | if(attrsElement.TryGetProperty("artist", out var artistElement)) 98 | result.Artist = artistElement.GetString(); 99 | 100 | if(attrsElement.TryGetProperty("webUrl", out var webUrlElement)) 101 | result.Url = webUrlElement.GetString(); 102 | 103 | var slug = ""; 104 | 105 | if(!String.IsNullOrEmpty(result.Url)) { 106 | result.Url = ImproveUrl(result.Url); 107 | slug = ExtractSlug(result.Url); 108 | } else { 109 | result.Url = "https://www.shazam.com/track/" + result.ID; 110 | } 111 | 112 | PopulateAppleID(shazamSongElement, result); 113 | 114 | if(String.IsNullOrEmpty(result.AppleSongID)) { 115 | // As of March 2024 116 | // shazam.com/track/[ID] redirects to shazam.com/song/[AppleSongID] 117 | if(TryGetNestedProperty(attrsElement, ["share", "html"], out var shareHtmlElement)) { 118 | result.Url = shareHtmlElement.GetString() + "#" + slug; 119 | } 120 | } else { 121 | // Some URLs redirect to / unless the 'co' parameter is kept 122 | // Examples: 11180294, 51774667, 538859473 123 | result.Url = result.Url + "?co=" + COUNTRY; 124 | } 125 | } 126 | 127 | static string ImproveUrl(string url) { 128 | var qsIndex = url.IndexOf('?'); 129 | if(qsIndex > -1) 130 | url = url.Substring(0, qsIndex); 131 | 132 | // make slug readable 133 | url = Uri.UnescapeDataString(url); 134 | 135 | return url; 136 | } 137 | 138 | static string ExtractSlug(string url) { 139 | return url.Substring(1 + url.LastIndexOf('/')); 140 | } 141 | 142 | static void PopulateAppleID(JsonElement shazamSongElement, ShazamResult result) { 143 | if(!shazamSongElement.TryGetProperty("relationships", out var relsElement)) 144 | return; 145 | 146 | if(TryGetNestedProperty(relsElement, ["songs", "data"], out var songsElement)) 147 | TryGetFirstItemID(songsElement, out result.AppleSongID); 148 | } 149 | 150 | static bool TryGetFirstItemID(JsonElement array, out string id) { 151 | if(array.ValueKind == JsonValueKind.Array && array.GetArrayLength() > 0) { 152 | if(array[0].TryGetProperty("id", out var itemElement)) { 153 | id = itemElement.GetString(); 154 | return true; 155 | } 156 | } 157 | id = default; 158 | return false; 159 | } 160 | 161 | static bool TryGetNestedProperty(JsonElement element, string[] names, out JsonElement value) { 162 | foreach(var name in names) { 163 | if(!element.TryGetProperty(name, out element)) { 164 | value = default; 165 | return false; 166 | } 167 | } 168 | value = element; 169 | return true; 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /Test/SignatureComparisonTests.cs: -------------------------------------------------------------------------------- 1 | #if DEBUG 2 | using MathNet.Numerics; 3 | using NAudio.Wave; 4 | using NAudio.Wave.SampleProviders; 5 | using System; 6 | using System.Collections.Generic; 7 | using System.IO; 8 | using System.Linq; 9 | using Xunit; 10 | 11 | namespace Project.Test { 12 | using Bands = IReadOnlyList>; 13 | 14 | public class SignatureComparisonTests { 15 | 16 | [Theory] 17 | [InlineData("10.2.1")] // https://developer.apple.com/shazamkit/android/ 18 | [InlineData("10.1.3")] // APK v13.45 19 | [InlineData("7.2.0")] // APK v8.66 20 | [InlineData("6.0.4", false)] // APK v8.3 21 | [InlineData("5.1.0", false, 3)] // APK v7.11 22 | public void Ref_SigX(string version, bool interpolation = true, int bandCount = 4) { 23 | CreateFromFile( 24 | Path.Combine(TestHelper.DATA_DIR, "test.mp3"), 25 | interpolation, 26 | out var mySampleCount, 27 | out var myRemainingSampleCount, 28 | out var myBands 29 | ); 30 | 31 | LoadBinary( 32 | Path.Combine(TestHelper.DATA_DIR, $"test-sigx-{version}.bin"), 33 | out var refSampleCount, 34 | out var refBands 35 | ); 36 | 37 | Assert.Equal(refSampleCount, mySampleCount + myRemainingSampleCount); 38 | 39 | var hitCount = 0; 40 | var missCount = 0; 41 | 42 | var myMagnList = new List(); 43 | var refMagnList = new List(); 44 | 45 | var myPeaks = myBands.Take(bandCount).SelectMany(i => i).ToList(); 46 | var refPeaks = refBands.SelectMany(i => i).ToList(); 47 | 48 | //var tsvMy = ToTSV(myPeaks); 49 | //var tsvRef = ToTSV(refPeaks); 50 | 51 | RemoveRefEdgePeaks(myPeaks, refPeaks); 52 | Assert.True(Math.Abs(myPeaks.Count - refPeaks.Count) < 2); 53 | 54 | foreach(var myPeak in myPeaks) { 55 | var refPeak = refPeaks.FindOne(myPeak.StripeIndex, myPeak.InterpolatedBin); 56 | 57 | if(refPeak == null) { 58 | missCount++; 59 | continue; 60 | } 61 | 62 | hitCount++; 63 | 64 | myMagnList.Add(myPeak.LogMagnitude); 65 | refMagnList.Add(refPeak.LogMagnitude); 66 | } 67 | 68 | Assert.True(1d * hitCount / myPeaks.Count > 0.93); 69 | 70 | var (magnFitIntercept, magnFitSlope) = Fit.Line(myMagnList.ToArray(), refMagnList.ToArray()); 71 | 72 | Assert.True(Math.Abs(magnFitSlope - 1) < 0.001); 73 | Assert.True(Math.Abs(magnFitIntercept) < 10); 74 | } 75 | 76 | static void CreateFromFile(string path, bool interpolation, out int sampleCount, out int remainingSampleCount, out Bands bands) { 77 | var analysis = new Analysis(); 78 | var finder = new PeakFinder(analysis, interpolation); 79 | 80 | using var captureHelper = new FileCaptureHelper(path); 81 | captureHelper.Start(); 82 | 83 | var sampleProvider = AddPadding(captureHelper.SampleProvider); 84 | var chunk = new float[Analysis.CHUNK_SIZE]; 85 | 86 | while(true) { 87 | var readCount = sampleProvider.Read(chunk, 0, chunk.Length); 88 | 89 | if(readCount < chunk.Length) { 90 | remainingSampleCount = readCount; 91 | break; 92 | } 93 | 94 | analysis.AddChunk(chunk); 95 | } 96 | 97 | var sigBytes = Sig.Write(Analysis.SAMPLE_RATE, analysis.ProcessedSamples, finder); 98 | LoadBinary(sigBytes, out sampleCount, out bands); 99 | } 100 | 101 | 102 | static void LoadBinary(string path, out int sampleCount, out Bands bands) { 103 | LoadBinary(File.ReadAllBytes(path), out sampleCount, out bands); 104 | } 105 | 106 | static void LoadBinary(byte[] data, out int sampleCount, out Bands bands) { 107 | Sig.Read( 108 | data, 109 | out var sampleRate, 110 | out sampleCount, 111 | out bands 112 | ); 113 | 114 | Assert.Equal(Analysis.SAMPLE_RATE, sampleRate); 115 | } 116 | 117 | static void RemoveRefEdgePeaks(IReadOnlyList myPeaks, List refPeaks) { 118 | // Official signature contains peaks even if there is only a single stripe 119 | // My signature requires full search distance around each peak (see Analysis_StripeAddedCallback) 120 | 121 | var myMinStripe = myPeaks.Min(i => i.StripeIndex); 122 | var myMaxStripe = myPeaks.Max(i => i.StripeIndex); 123 | 124 | refPeaks.RemoveAll(i => i.StripeIndex < myMinStripe || i.StripeIndex > myMaxStripe); 125 | } 126 | 127 | static string ToTSV(IEnumerable peaks) { 128 | return String.Join("\n", 129 | peaks 130 | .OrderBy(p => p.StripeIndex) 131 | .ThenBy(p => p.InterpolatedBin) 132 | .Select(p => String.Join("\t", new[] { 133 | p.StripeIndex, 134 | p.InterpolatedBin, 135 | //p.LogMagnitude, 136 | })) 137 | ); 138 | } 139 | 140 | static ISampleProvider AddPadding(ISampleProvider sampleProvider) { 141 | // Possible explanation for padding in official signature 142 | // start : first chunk completes the FFT window so it is immediately ready for analysis 143 | // end : to absorb remaining samples? for symmetry? 144 | 145 | var waveFormat = sampleProvider.WaveFormat; 146 | var sampleCount = Analysis.WINDOW_SIZE - Analysis.CHUNK_SIZE; 147 | 148 | return new ConcatenatingSampleProvider([ 149 | new FixedLenSilence(waveFormat, sampleCount), 150 | sampleProvider, 151 | new FixedLenSilence(waveFormat, sampleCount), 152 | ]); 153 | } 154 | 155 | class FixedLenSilence : ISampleProvider { 156 | int SamplesLeft; 157 | 158 | public FixedLenSilence(WaveFormat waveFormat, int sampleCount) { 159 | WaveFormat = waveFormat; 160 | SamplesLeft = sampleCount; 161 | } 162 | 163 | public WaveFormat WaveFormat { get; private set; } 164 | 165 | public int Read(float[] buffer, int offset, int count) { 166 | count = Math.Min(count, SamplesLeft); 167 | Array.Clear(buffer, offset, count); 168 | SamplesLeft -= count; 169 | return count; 170 | } 171 | } 172 | 173 | } 174 | 175 | } 176 | #endif 177 | -------------------------------------------------------------------------------- /Tagging/Sig.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | 6 | namespace Project; 7 | 8 | // Based on https://github.com/marin-m/SongRec/blob/0.1.0/python-version/fingerprinting/signature_format.py 9 | 10 | static class Sig { 11 | 12 | public static byte[] Write(int sampleRate, int sampleCount, PeakFinder finder) { 13 | using(var mem = new MemoryStream()) 14 | using(var writer = new BinaryWriter(mem)) { 15 | writer.Write(0xCAFE2580); // Dialling "2580" on your phone and holding it up to the music, https://www.shazam.com/company 16 | writer.Write(-1); 17 | writer.Write(-1); 18 | writer.Write(GetVersionCode()); 19 | writer.Write(0); 20 | writer.Write(0); 21 | writer.Write(0); 22 | writer.Write(GetSampleRateCode(sampleRate) << 27); 23 | writer.Write(0); 24 | writer.Write(0); 25 | writer.Write(sampleCount); 26 | writer.Write(0x007C0000); // padding + unknown, (0x007C0000 & 0xFFF80000) >> 19 == 15 27 | writer.Write(0x40000000); 28 | writer.Write(-1); 29 | 30 | var bandData = GetBandData(finder); 31 | for(var i = 0; i < bandData.Length; i++) { 32 | writer.Write(0x60030040 + i); 33 | writer.Write(bandData[i].Length); 34 | writer.Write(bandData[i]); 35 | } 36 | 37 | var totalLen = (int)mem.Length; 38 | var contentLen = totalLen - 48; 39 | 40 | foreach(var i in new[] { 2, 13 }) { 41 | mem.Position = i * 4; 42 | writer.Write(contentLen); 43 | } 44 | 45 | var crc = Force.Crc32.Crc32Algorithm.Compute(mem.GetBuffer(), 8, totalLen - 8); 46 | mem.Position = 4; 47 | writer.Write(crc); 48 | 49 | return mem.ToArray(); 50 | } 51 | } 52 | 53 | // Alternative (legacy?) format used in ShazamCore10.dll 54 | // Works with any sample rate 55 | public static byte[] Write2(int sampleRate, int sampleCount, PeakFinder finder) { 56 | using(var mem = new MemoryStream()) 57 | using(var writer = new BinaryWriter(mem)) { 58 | writer.Write(-1); 59 | writer.Write(0x789ABC05); 60 | writer.Write(0xFFFFFFFF); 61 | writer.Write(0x30000002); 62 | writer.Write(0x10); 63 | writer.Write(-1); 64 | writer.Write(-1); 65 | writer.Write(0); 66 | writer.Write(0); 67 | writer.Write(0x40000000); 68 | writer.Write(-1); 69 | writer.Write(0); 70 | writer.Write(0); 71 | writer.Write(0); 72 | writer.Write(0); 73 | writer.Write(0x50000001); 74 | writer.Write(0x18); 75 | writer.Write(0); 76 | writer.Write(0); 77 | writer.Write(0); 78 | writer.Write(0xDEADBEEF); 79 | writer.Write(Convert.ToInt32(sampleCount * 8000L / sampleRate)); 80 | writer.Write(0); 81 | writer.Write(GetVersionCode()); 82 | writer.Write(15); // padding 83 | writer.Write((float)sampleCount / sampleRate); 84 | 85 | var bandData = GetBandData(finder); 86 | for(var i = 0; i < bandData.Length; i++) { 87 | writer.Write(0); 88 | writer.Write(0x60030040 + i); 89 | writer.Write(bandData[i].Length); 90 | writer.Write(0); 91 | writer.Write(0); 92 | writer.Write(0); 93 | writer.Write(bandData[i]); 94 | } 95 | 96 | var totalLen = (int)mem.Length; 97 | var contentLen = totalLen - 32; 98 | 99 | foreach(var i in new[] { 0, 5, 10 }) { 100 | mem.Position = i * 4; 101 | writer.Write(contentLen); 102 | } 103 | 104 | mem.Position = 6 * 4; 105 | writer.Write(contentLen ^ 0x789ABC13); 106 | 107 | var buffer = mem.GetBuffer(); 108 | var checksum = (uint)0; 109 | for(var i = 0; i < totalLen / 4; i++) 110 | checksum += BitConverter.ToUInt32(buffer, 4 * i); 111 | 112 | mem.Position = 7 * 4; 113 | writer.Write(checksum); 114 | 115 | return mem.ToArray(); 116 | } 117 | } 118 | 119 | public static void Read(byte[] data, out int sampleRate, out int sampleCount, out IReadOnlyList> bands) { 120 | using var mem = new MemoryStream(data); 121 | using var reader = new BinaryReader(mem); 122 | 123 | var magicKey = reader.ReadUInt32(); 124 | var legacyFormat = false; 125 | 126 | if(magicKey == 0x25802580) { 127 | var w1 = reader.ReadInt32(); // count? 128 | var w2 = reader.ReadInt32(); // offset? 129 | if(w1 != 1 || w2 != 12) 130 | throw new NotSupportedException(); 131 | magicKey = reader.ReadUInt32(); 132 | } 133 | 134 | if(magicKey != 0xcafe2580) { 135 | if(reader.ReadUInt32() == 0x789abc05) { 136 | legacyFormat = true; 137 | } else { 138 | throw new NotSupportedException(); 139 | } 140 | } 141 | 142 | if(legacyFormat) { 143 | sampleRate = 16000; 144 | 145 | mem.Position += 19 * 4; 146 | sampleCount = 2 * reader.ReadInt32(); 147 | 148 | mem.Position += 4 * 4; 149 | } else { 150 | mem.Position += 6 * 4; 151 | sampleRate = SampleRateFromCode(reader.ReadInt32() >> 27); 152 | 153 | mem.Position += 2 * 4; 154 | sampleCount = reader.ReadInt32(); 155 | 156 | mem.Position += 3 * 4; 157 | } 158 | 159 | var writableBands = new List[4]; 160 | for(var i = 0; i < 4; i++) 161 | writableBands[i] = new(); 162 | 163 | while(mem.Position < mem.Length) { 164 | if(legacyFormat) { 165 | mem.Position += 4; 166 | } 167 | 168 | var fat = false; 169 | var bandIndex = 0; 170 | var header = reader.ReadInt32(); 171 | 172 | if(header == 0x60023e80) { 173 | fat = true; 174 | } else { 175 | bandIndex = header - 0x60030040; 176 | if(bandIndex < 0 || bandIndex > 3) 177 | throw new InvalidOperationException(); 178 | } 179 | 180 | var len = reader.ReadInt32(); 181 | 182 | if(legacyFormat) { 183 | mem.Position += 3 * 4; 184 | } 185 | 186 | var end = mem.Position + len; 187 | var stripe = 0; 188 | 189 | while(mem.Position < end) { 190 | if(end - mem.Position >= 5) { 191 | if(fat) { 192 | stripe = reader.ReadInt32(); 193 | } else { 194 | var x = reader.ReadByte(); 195 | if(x == 255) { 196 | stripe = reader.ReadInt32(); 197 | x = reader.ReadByte(); 198 | } 199 | stripe += x; 200 | } 201 | 202 | var word1 = reader.ReadUInt16(); 203 | var word2 = reader.ReadUInt16(); 204 | 205 | if(fat) { 206 | mem.Position += 4; 207 | (word1, word2) = (word2, word1); 208 | } 209 | 210 | var magn = word1; 211 | var bin = word2 / 64; 212 | 213 | if(bin == 0 || magn == 0) 214 | throw new InvalidOperationException(); 215 | 216 | writableBands[bandIndex].Add(new PeakInfo(stripe, bin, magn)); 217 | } else { 218 | if(reader.ReadByte() != 0) 219 | throw new InvalidOperationException(); 220 | } 221 | } 222 | 223 | var pad = CalcPadLen((int)mem.Position); 224 | 225 | while(pad > 0) { 226 | reader.ReadByte(); 227 | pad--; 228 | } 229 | } 230 | 231 | bands = writableBands; 232 | } 233 | 234 | static byte[][] GetBandData(PeakFinder finder) { 235 | finder.ApplyRateLimit(); 236 | return finder.EnumerateBandedPeaks().Select(GetBandData).ToArray(); 237 | } 238 | 239 | static byte[] GetBandData(IEnumerable peaks) { 240 | using(var mem = new MemoryStream()) 241 | using(var writer = new BinaryWriter(mem)) { 242 | var stripeIndex = 0; 243 | 244 | foreach(var p in peaks) { 245 | 246 | if(p.StripeIndex - stripeIndex >= 100) { 247 | stripeIndex = p.StripeIndex; 248 | writer.Write((byte)255); 249 | writer.Write(stripeIndex); 250 | } 251 | 252 | if(p.StripeIndex < stripeIndex) 253 | throw new InvalidOperationException(); 254 | 255 | writer.Write(Convert.ToByte(p.StripeIndex - stripeIndex)); 256 | writer.Write(Convert.ToUInt16(p.LogMagnitude)); 257 | writer.Write(Convert.ToUInt16(64 * p.InterpolatedBin)); 258 | 259 | stripeIndex = p.StripeIndex; 260 | } 261 | 262 | while(mem.Length % 4 != 0) 263 | writer.Write((byte)0); 264 | 265 | return mem.ToArray(); 266 | } 267 | } 268 | 269 | static int CalcPadLen(int dataLen) { 270 | var result = -dataLen % 4; 271 | if(result < 0) 272 | result += 4; 273 | return result; 274 | } 275 | 276 | static int GetSampleRateCode(int sampleRate) { 277 | switch(sampleRate) { 278 | case 8000: return 1; 279 | case 16000: return 3; 280 | case 32000: return 4; 281 | } 282 | throw new NotSupportedException(); 283 | } 284 | 285 | static int SampleRateFromCode(int code) { 286 | code &= 0xf; 287 | return code switch { 288 | 1 => 8000, 289 | 3 => 16000, 290 | 4 => 32000, 291 | _ => throw new NotSupportedException() 292 | }; 293 | } 294 | 295 | static uint GetVersionCode() { 296 | var v1 = (10, 1, 3); // extractor/generator/pipeline version 297 | var v2 = (7, 0, 0); // unpacker version 298 | 299 | var result = 0; 300 | 301 | result += v1.Item1 << 25; 302 | result += v1.Item2 << 20; 303 | result += v1.Item3 << 15; 304 | 305 | result += v2.Item1 << 10; 306 | result += v2.Item2 << 5; 307 | result += v2.Item3; 308 | 309 | return (uint)result ^ 0x80000000; 310 | } 311 | 312 | } 313 | --------------------------------------------------------------------------------