shazamio-rb 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +112 -0
- data/lib/shazamio/algorithm.rb +221 -0
- data/lib/shazamio/audio_loader.rb +75 -0
- data/lib/shazamio/converter.rb +18 -0
- data/lib/shazamio/enums.rb +41 -0
- data/lib/shazamio/exceptions.rb +8 -0
- data/lib/shazamio/fft.rb +76 -0
- data/lib/shazamio/http_client.rb +149 -0
- data/lib/shazamio/request.rb +33 -0
- data/lib/shazamio/serialize.rb +63 -0
- data/lib/shazamio/shazam.rb +146 -0
- data/lib/shazamio/signature.rb +180 -0
- data/lib/shazamio/urls.rb +41 -0
- data/lib/shazamio/user_agent.rb +29 -0
- data/lib/shazamio/version.rb +5 -0
- data/lib/shazamio.rb +19 -0
- metadata +85 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 169b00c56b72334bc204042b05232115f40e0cb57771797cfdcdabfeee8e389d
|
|
4
|
+
data.tar.gz: 644afd665ddfa4414013de25f01455cca6c37e871f9cb3f3a560e86fee9b6900
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: e54e444b58c06cbdfdb3eb01d72763aa6fa8b85e4fbe7699ebb366059b7a0e8682c26b5b24e5b888243e76f70a60696f5a78ae08c9611f9303d533925d583d27
|
|
7
|
+
data.tar.gz: 3ac1c8b7ac66f09d8ec5a958e41a42b2291f154ff5dfe1f1dc8e27e797ad7a301f71a20d86ce81f24f94d5dce7a51c61e432e91d2a8f08dfd6727b4564989d27
|
data/README.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# shazamio-rb
|
|
2
|
+
|
|
3
|
+
A Ruby port of [ShazamIO](https://github.com/shazamio/ShazamIO): a client for
|
|
4
|
+
the reverse engineered Shazam API. Recognize songs from an audio file,
|
|
5
|
+
search tracks/artists, and fetch track metadata.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
Copy this directory into your project, or build the gem locally:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
gem build shazamio.gemspec
|
|
13
|
+
gem install ./shazamio-rb-0.1.0.gem
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`shazamio-rb` is stdlib-only (`net/http`, `json`, `zlib`, `base64`, ...) —
|
|
17
|
+
no gems are required at runtime. `recognize` additionally shells out to the
|
|
18
|
+
`ffmpeg` binary to decode audio files, exactly like the Python project does
|
|
19
|
+
under the hood via `pydub`.
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
require "shazamio"
|
|
25
|
+
|
|
26
|
+
shazam = Shazamio::Shazam.new
|
|
27
|
+
|
|
28
|
+
# Recognize a song from a file (mp3, wav, ogg, ... anything ffmpeg reads).
|
|
29
|
+
# Confirmed working end-to-end: recognized a real track against the live API.
|
|
30
|
+
result = shazam.recognize("song.mp3")
|
|
31
|
+
puts result.dig("track", "title")
|
|
32
|
+
|
|
33
|
+
# Track info
|
|
34
|
+
shazam.track_about(552_406_075)
|
|
35
|
+
|
|
36
|
+
# Search
|
|
37
|
+
shazam.search_track("Alan Walker", limit: 5)
|
|
38
|
+
shazam.search_artist("Alan Walker", limit: 5)
|
|
39
|
+
|
|
40
|
+
# Related tracks / listening counters
|
|
41
|
+
shazam.related_tracks(559_284_007, limit: 10)
|
|
42
|
+
shazam.listening_counter(559_284_007)
|
|
43
|
+
shazam.listening_counter_many([559_284_007, 552_406_075])
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Every method returns the raw Shazam JSON response as a Ruby `Hash`. For the
|
|
47
|
+
handful of fields most people actually want off a track payload, there's a
|
|
48
|
+
small helper:
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
track_hash = shazam.track_about(552_406_075)
|
|
52
|
+
info = Shazamio::Serialize.track(track_hash["track"] || track_hash)
|
|
53
|
+
info.title #=> "Ale Jazz"
|
|
54
|
+
info.spotify_url
|
|
55
|
+
info.apple_music_url
|
|
56
|
+
info.raw # the untouched Hash, for anything not mapped above
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
More runnable examples live in [`examples/`](examples).
|
|
60
|
+
|
|
61
|
+
## Not included (broken upstream, not a porting bug)
|
|
62
|
+
|
|
63
|
+
The Python project also has `artist_about`, `artist_albums`, `search_album`,
|
|
64
|
+
and every "top tracks" chart method (`top_world_tracks`,
|
|
65
|
+
`top_country_tracks`, `top_city_tracks`, `top_world_genre_tracks`,
|
|
66
|
+
`top_country_genre_tracks`). All of these hit URLs under
|
|
67
|
+
`https://www.shazam.com/services/amapi/v1/catalog/...`.
|
|
68
|
+
|
|
69
|
+
That whole path is currently returning **405 Method Not Allowed** for GET
|
|
70
|
+
requests on Shazam's own servers — confirmed by a report of the exact same
|
|
71
|
+
405 on the exact same path, hit by Shazam's own web app in a browser (not
|
|
72
|
+
by any third-party client): see [this Apple Community
|
|
73
|
+
thread](https://discussions.apple.com/thread/256185871).
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
## Porting notes
|
|
77
|
+
|
|
78
|
+
A few things a straight Python→Ruby translation can't carry over 1:1:
|
|
79
|
+
|
|
80
|
+
- **No async runtime.** The Python client is built on `asyncio`/`aiohttp`.
|
|
81
|
+
Ruby's standard library has no equivalent, so `HTTPClient` is synchronous
|
|
82
|
+
(`Net::HTTP` under the hood, with the same exponential-backoff retry on
|
|
83
|
+
429/500/502/503/504 as the Python `aiohttp_retry.ExponentialRetry`, and
|
|
84
|
+
redirect-following, which `Net::HTTP` doesn't do by default but `aiohttp`
|
|
85
|
+
does). Wrap calls in threads, or use the `async`/`async-http` gems, if you
|
|
86
|
+
need concurrency.
|
|
87
|
+
- **No Rust extension for recognition.** Python's `recognize()` calls into a
|
|
88
|
+
compiled Rust library (`shazamio_core`) for speed; there's no Ruby
|
|
89
|
+
equivalent available offline. This port always uses the algorithm from the
|
|
90
|
+
Python project's *pure-Python* fallback (`algorithm.py`, what backs the
|
|
91
|
+
deprecated `recognize_song`), translated line-for-line into
|
|
92
|
+
`Shazamio::SignatureGenerator`. It produces the same signatures, just
|
|
93
|
+
slower — expect a handful of seconds of Ruby-side CPU work per ~10s audio
|
|
94
|
+
clip, since there's no NumPy/FFTW here either (see next point). It has
|
|
95
|
+
been confirmed to work end-to-end against the live API.
|
|
96
|
+
- **Pure-Ruby FFT.** `Shazamio::FFT` is a small iterative Cooley-Tukey FFT
|
|
97
|
+
(power-of-two sizes only, which is all the algorithm ever needs). It's
|
|
98
|
+
plain Ruby, not vectorized/SIMD like NumPy. If you process a lot of audio,
|
|
99
|
+
dropping in a binding to FFTW (e.g. a `fftw3` gem) or `Numo::NArray`
|
|
100
|
+
instead is a drop-in swap: only `FFT.rfft` needs to change.
|
|
101
|
+
- **Audio decoding via `ffmpeg` directly**, instead of `pydub` (which itself
|
|
102
|
+
just wraps `ffmpeg`). Same external dependency, one less layer. `recognize`
|
|
103
|
+
treats a `String` argument as a file path; to pass raw audio bytes instead
|
|
104
|
+
of a path, wrap them (e.g. `StringIO.new(bytes)`) so there's no ambiguity
|
|
105
|
+
between "a path" and "some bytes that happen to be a String".
|
|
106
|
+
- **Schemas simplified.** The Python project maps every response onto a deep
|
|
107
|
+
tree of Pydantic models (`shazamio/schemas/**`, thousands of lines).
|
|
108
|
+
This port keeps the ergonomics people actually use — `Shazamio::Serialize`
|
|
109
|
+
exposes the common fields as plain Ruby `Struct`s — but doesn't reproduce
|
|
110
|
+
the full model tree. `result.raw` (or just using the returned `Hash`
|
|
111
|
+
directly) always gives you everything Shazam sent back.
|
|
112
|
+
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "enums"
|
|
4
|
+
require_relative "signature"
|
|
5
|
+
require_relative "fft"
|
|
6
|
+
|
|
7
|
+
module Shazamio
|
|
8
|
+
# Fixed-size circular buffer, ported from algorithm.py's RingBuffer(list).
|
|
9
|
+
class RingBuffer
|
|
10
|
+
attr_reader :buffer_size
|
|
11
|
+
attr_accessor :position, :num_written
|
|
12
|
+
|
|
13
|
+
def initialize(buffer_size, default_value: nil)
|
|
14
|
+
@buffer_size = buffer_size
|
|
15
|
+
@data = Array.new(buffer_size) { default_value.nil? ? nil : default_value.dup }
|
|
16
|
+
@position = 0
|
|
17
|
+
@num_written = 0
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def [](index)
|
|
21
|
+
@data[index % @buffer_size]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def []=(index, value)
|
|
25
|
+
@data[index % @buffer_size] = value
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def append(value)
|
|
29
|
+
@data[@position] = value
|
|
30
|
+
@position = (@position + 1) % @buffer_size
|
|
31
|
+
@num_written += 1
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Precomputed Hanning window (matches numpy's np.hanning(2050)[1:-1]).
|
|
36
|
+
def self.hanning(size)
|
|
37
|
+
return [] if size <= 0
|
|
38
|
+
return [1.0] if size == 1
|
|
39
|
+
|
|
40
|
+
Array.new(size) { |n| 0.5 - 0.5 * Math.cos(2 * Math::PI * n / (size - 1)) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
HANNING_MATRIX = hanning(2050)[1..-2].freeze # 2048 values, trims the leading/trailing zero
|
|
44
|
+
|
|
45
|
+
# Pure-Ruby reimplementation of Shazam's signature (audio fingerprint)
|
|
46
|
+
# generator. This is the algorithm the Python project calls "legacy" /
|
|
47
|
+
# pure-Python, used by the deprecated `recognize_song`; the newer
|
|
48
|
+
# `recognize` in Python instead calls into a compiled Rust extension
|
|
49
|
+
# (`shazamio_core`) for speed. There is no Ruby equivalent of that native
|
|
50
|
+
# extension here, so this pure-Ruby path (translated line-for-line from
|
|
51
|
+
# algorithm.py) is the only recognizer available in this port. It is
|
|
52
|
+
# correct but noticeably slower than the Rust version on long clips.
|
|
53
|
+
class SignatureGenerator
|
|
54
|
+
MAX_PEAKS = 255
|
|
55
|
+
|
|
56
|
+
attr_accessor :max_time_seconds, :samples_processed
|
|
57
|
+
attr_reader :next_signature
|
|
58
|
+
|
|
59
|
+
def initialize
|
|
60
|
+
@input_pending_processing = []
|
|
61
|
+
@samples_processed = 0
|
|
62
|
+
|
|
63
|
+
@ring_buffer_of_samples = RingBuffer.new(2048, default_value: 0)
|
|
64
|
+
@fft_outputs = RingBuffer.new(256, default_value: Array.new(1025, 0.0))
|
|
65
|
+
@spread_fft_output = RingBuffer.new(256, default_value: Array.new(1025, 0.0))
|
|
66
|
+
|
|
67
|
+
@max_time_seconds = 3.1
|
|
68
|
+
|
|
69
|
+
@next_signature = DecodedMessage.new
|
|
70
|
+
@next_signature.sample_rate_hz = 16_000
|
|
71
|
+
@next_signature.number_samples = 0
|
|
72
|
+
@next_signature.frequency_band_to_sound_peaks = {}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# samples: Array of signed 16-bit, 16kHz mono PCM integers.
|
|
76
|
+
def feed_input(samples)
|
|
77
|
+
@input_pending_processing.concat(samples)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def get_next_signature
|
|
81
|
+
return nil if @input_pending_processing.length - @samples_processed < 128
|
|
82
|
+
|
|
83
|
+
while (@input_pending_processing.length - @samples_processed >= 128) &&
|
|
84
|
+
((@next_signature.number_samples.to_f / @next_signature.sample_rate_hz < @max_time_seconds) ||
|
|
85
|
+
(@next_signature.frequency_band_to_sound_peaks.values.sum(&:length) < MAX_PEAKS))
|
|
86
|
+
chunk = @input_pending_processing[@samples_processed, 128]
|
|
87
|
+
process_input(chunk)
|
|
88
|
+
@samples_processed += 128
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
@ring_buffer_of_samples = RingBuffer.new(2048, default_value: 0)
|
|
92
|
+
@fft_outputs = RingBuffer.new(256, default_value: Array.new(1025, 0.0))
|
|
93
|
+
@spread_fft_output = RingBuffer.new(256, default_value: Array.new(1025, 0.0))
|
|
94
|
+
|
|
95
|
+
@next_signature
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def process_input(samples)
|
|
101
|
+
@next_signature.number_samples += samples.length
|
|
102
|
+
position = 0
|
|
103
|
+
while position < samples.length
|
|
104
|
+
do_fft(samples[position, 128])
|
|
105
|
+
do_peak_spreading_and_recognition
|
|
106
|
+
position += 128
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def do_fft(batch_of_128)
|
|
111
|
+
start = @ring_buffer_of_samples.position
|
|
112
|
+
batch_of_128.each_with_index { |v, i| @ring_buffer_of_samples[start + i] = v }
|
|
113
|
+
@ring_buffer_of_samples.position = (start + batch_of_128.length) % 2048
|
|
114
|
+
@ring_buffer_of_samples.num_written += batch_of_128.length
|
|
115
|
+
|
|
116
|
+
pos = @ring_buffer_of_samples.position
|
|
117
|
+
excerpt = (pos...(pos + 2048)).map { |i| @ring_buffer_of_samples[i] }
|
|
118
|
+
|
|
119
|
+
windowed = excerpt.each_with_index.map { |v, i| v * HANNING_MATRIX[i] }
|
|
120
|
+
fft_bins = FFT.rfft(windowed)
|
|
121
|
+
|
|
122
|
+
fft_results = fft_bins.map do |c|
|
|
123
|
+
v = (c.real**2 + c.imaginary**2) / (1 << 17)
|
|
124
|
+
v < 0.0000000001 ? 0.0000000001 : v
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
@fft_outputs.append(fft_results)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def do_peak_spreading_and_recognition
|
|
131
|
+
do_peak_spreading
|
|
132
|
+
do_peak_recognition if @spread_fft_output.num_written >= 46
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def do_peak_spreading
|
|
136
|
+
origin_last_fft = @fft_outputs[@fft_outputs.position - 1]
|
|
137
|
+
n = origin_last_fft.length
|
|
138
|
+
|
|
139
|
+
spread = origin_last_fft.dup
|
|
140
|
+
(0...(n - 3)).each do |i|
|
|
141
|
+
spread[i] = [origin_last_fft[i], origin_last_fft[i + 1], origin_last_fft[i + 2]].max
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Cumulative max chained backwards into the last 1/3/6 spread frames,
|
|
145
|
+
# matching the (redundant but equivalent) vstack+max done in Python.
|
|
146
|
+
pos = @spread_fft_output.position
|
|
147
|
+
carry = spread
|
|
148
|
+
[-1, -3, -6].each do |offset|
|
|
149
|
+
idx = pos + offset
|
|
150
|
+
prev = @spread_fft_output[idx]
|
|
151
|
+
merged = prev.each_index.map { |i| [prev[i], carry[i]].max }
|
|
152
|
+
@spread_fft_output[idx] = merged
|
|
153
|
+
carry = merged
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
@spread_fft_output.append(spread)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def do_peak_recognition
|
|
160
|
+
fft_minus_46 = @fft_outputs[@fft_outputs.position - 46]
|
|
161
|
+
fft_minus_49 = @spread_fft_output[@spread_fft_output.position - 49]
|
|
162
|
+
|
|
163
|
+
(10...1015).each do |bin_position|
|
|
164
|
+
next unless fft_minus_46[bin_position] >= (1.0 / 64) &&
|
|
165
|
+
fft_minus_46[bin_position] >= fft_minus_49[bin_position - 1]
|
|
166
|
+
|
|
167
|
+
max_neighbor_49 = 0
|
|
168
|
+
([*(-10..-4).step(3), -3, 1, *(2..8).step(3)]).each do |offset|
|
|
169
|
+
max_neighbor_49 = [fft_minus_49[bin_position + offset], max_neighbor_49].max
|
|
170
|
+
end
|
|
171
|
+
next unless fft_minus_46[bin_position] > max_neighbor_49
|
|
172
|
+
|
|
173
|
+
max_neighbor_other = max_neighbor_49
|
|
174
|
+
([-53, -45, *(165..200).step(7), *(214..249).step(7)]).each do |offset|
|
|
175
|
+
idx = @spread_fft_output.position + offset
|
|
176
|
+
max_neighbor_other = [@spread_fft_output[idx][bin_position - 1], max_neighbor_other].max
|
|
177
|
+
end
|
|
178
|
+
next unless fft_minus_46[bin_position] > max_neighbor_other
|
|
179
|
+
|
|
180
|
+
record_peak(bin_position, fft_minus_46)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def record_peak(bin_position, fft_minus_46)
|
|
185
|
+
fft_number = @spread_fft_output.num_written - 46
|
|
186
|
+
|
|
187
|
+
peak_magnitude = Math.log([1.0 / 64, fft_minus_46[bin_position]].max) * 1477.3 + 6144
|
|
188
|
+
peak_magnitude_before = Math.log([1.0 / 64, fft_minus_46[bin_position - 1]].max) * 1477.3 + 6144
|
|
189
|
+
peak_magnitude_after = Math.log([1.0 / 64, fft_minus_46[bin_position + 1]].max) * 1477.3 + 6144
|
|
190
|
+
|
|
191
|
+
peak_variation_1 = peak_magnitude * 2 - peak_magnitude_before - peak_magnitude_after
|
|
192
|
+
return unless peak_variation_1 > 0
|
|
193
|
+
|
|
194
|
+
peak_variation_2 = (peak_magnitude_after - peak_magnitude_before) * 32 / peak_variation_1
|
|
195
|
+
corrected_peak_frequency_bin = bin_position * 64 + peak_variation_2
|
|
196
|
+
|
|
197
|
+
frequency_hz = corrected_peak_frequency_bin * (16_000 / 2.0 / 1024 / 64)
|
|
198
|
+
|
|
199
|
+
# NOTE: the last branch below (5500 < hz <= 5500) is unreachable by
|
|
200
|
+
# construction. That mirrors a quirk in the upstream Python source
|
|
201
|
+
# (algorithm.py) verbatim -- HZ_3500_5500 peaks are effectively never
|
|
202
|
+
# emitted there either. Kept as-is for behavioural parity; treat it as
|
|
203
|
+
# a known upstream oddity rather than a porting mistake.
|
|
204
|
+
band =
|
|
205
|
+
if frequency_hz > 250 && frequency_hz < 520
|
|
206
|
+
FrequencyBand::HZ_250_520
|
|
207
|
+
elsif frequency_hz > 520 && frequency_hz < 1450
|
|
208
|
+
FrequencyBand::HZ_520_1450
|
|
209
|
+
elsif frequency_hz > 1450 && frequency_hz < 3500
|
|
210
|
+
FrequencyBand::HZ_1450_3500
|
|
211
|
+
elsif frequency_hz > 5500 && frequency_hz <= 5500
|
|
212
|
+
FrequencyBand::HZ_3500_5500
|
|
213
|
+
end
|
|
214
|
+
return unless band
|
|
215
|
+
|
|
216
|
+
(@next_signature.frequency_band_to_sound_peaks[band] ||= []) << FrequencyPeak.new(
|
|
217
|
+
fft_number, peak_magnitude.to_i, corrected_peak_frequency_bin.to_i, 16_000
|
|
218
|
+
)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "tempfile"
|
|
5
|
+
|
|
6
|
+
module Shazamio
|
|
7
|
+
# The Python project decodes arbitrary audio formats (mp3, ogg, m4a, ...)
|
|
8
|
+
# via `pydub`, which itself just shells out to `ffmpeg`. Ruby has no
|
|
9
|
+
# standard-library audio decoder either, so this does the same thing
|
|
10
|
+
# directly: pipe the input through `ffmpeg` and ask for raw signed 16-bit
|
|
11
|
+
# little-endian mono 16kHz PCM, which is exactly what the signature
|
|
12
|
+
# algorithm expects.
|
|
13
|
+
#
|
|
14
|
+
# Requires `ffmpeg` to be installed and on PATH (same real-world
|
|
15
|
+
# requirement the Python library has via pydub).
|
|
16
|
+
module AudioLoader
|
|
17
|
+
class FFmpegNotFound < StandardError; end
|
|
18
|
+
class DecodeError < StandardError; end
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# @param source [String, Pathname, StringIO, #read] a file path, or an
|
|
23
|
+
# object holding raw audio bytes (e.g. a String of bytes, or anything
|
|
24
|
+
# responding to #read).
|
|
25
|
+
# @return [Array<Integer>] signed 16-bit mono samples at 16kHz
|
|
26
|
+
def pcm_s16le_mono_16k(source)
|
|
27
|
+
ensure_ffmpeg!
|
|
28
|
+
|
|
29
|
+
path, cleanup = resolve_path(source)
|
|
30
|
+
begin
|
|
31
|
+
stdout, stderr, status = Open3.capture3(
|
|
32
|
+
"ffmpeg", "-v", "error", "-y",
|
|
33
|
+
"-i", path,
|
|
34
|
+
"-ac", "1",
|
|
35
|
+
"-ar", "16000",
|
|
36
|
+
"-f", "s16le",
|
|
37
|
+
"pipe:1"
|
|
38
|
+
)
|
|
39
|
+
raise DecodeError, "ffmpeg failed: #{stderr}" unless status.success?
|
|
40
|
+
|
|
41
|
+
stdout.unpack("s<*")
|
|
42
|
+
ensure
|
|
43
|
+
cleanup&.call
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def ensure_ffmpeg!
|
|
48
|
+
return if system("ffmpeg", "-version", out: File::NULL, err: File::NULL)
|
|
49
|
+
|
|
50
|
+
raise FFmpegNotFound, "ffmpeg was not found on PATH. Install it (e.g. `apt install ffmpeg` " \
|
|
51
|
+
"or `brew install ffmpeg`) to decode audio files."
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def resolve_path(source)
|
|
55
|
+
case source
|
|
56
|
+
when String
|
|
57
|
+
if File.exist?(source)
|
|
58
|
+
[source, nil]
|
|
59
|
+
else
|
|
60
|
+
raise DecodeError,
|
|
61
|
+
"No such file: #{source.inspect}. `recognize`/`pcm_s16le_mono_16k` treats a String " \
|
|
62
|
+
"as a file path. If you meant to pass raw audio bytes instead of a path, wrap them " \
|
|
63
|
+
"first, e.g. StringIO.new(bytes)."
|
|
64
|
+
end
|
|
65
|
+
else
|
|
66
|
+
bytes = source.respond_to?(:read) ? source.read : source.to_s
|
|
67
|
+
tmp = Tempfile.new(["shazamio", ".audio"])
|
|
68
|
+
tmp.binmode
|
|
69
|
+
tmp.write(bytes)
|
|
70
|
+
tmp.close
|
|
71
|
+
[tmp.path, -> { tmp.unlink }]
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shazamio
|
|
4
|
+
module Converter
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
# Builds the JSON payload sent to Shazam's /tag endpoint.
|
|
8
|
+
def data_search(timezone, uri, samplems, timestamp)
|
|
9
|
+
{
|
|
10
|
+
"timezone" => timezone,
|
|
11
|
+
"signature" => { "uri" => uri, "samplems" => samplems },
|
|
12
|
+
"timestamp" => timestamp,
|
|
13
|
+
"context" => {},
|
|
14
|
+
"geolocation" => {}
|
|
15
|
+
}
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shazamio
|
|
4
|
+
# Enum keys are sample rates in Hz. Used by the signature format/algorithm.
|
|
5
|
+
module SampleRate
|
|
6
|
+
NAME_BY_VALUE = {
|
|
7
|
+
1 => "8000",
|
|
8
|
+
2 => "11025",
|
|
9
|
+
3 => "16000",
|
|
10
|
+
4 => "32000",
|
|
11
|
+
5 => "44100",
|
|
12
|
+
6 => "48000"
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
VALUE_BY_HZ = {
|
|
16
|
+
8000 => 1,
|
|
17
|
+
11_025 => 2,
|
|
18
|
+
16_000 => 3,
|
|
19
|
+
32_000 => 4,
|
|
20
|
+
44_100 => 5,
|
|
21
|
+
48_000 => 6
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
def self.hz_for(value)
|
|
25
|
+
NAME_BY_VALUE.fetch(value).to_i
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.value_for_hz(hz)
|
|
29
|
+
VALUE_BY_HZ.fetch(hz)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Enum keys are frequency ranges in Hz. Used by the signature algorithm.
|
|
34
|
+
module FrequencyBand
|
|
35
|
+
HZ_0_250 = -1 # Nothing above 250 Hz is actually stored
|
|
36
|
+
HZ_250_520 = 0
|
|
37
|
+
HZ_520_1450 = 1
|
|
38
|
+
HZ_1450_3500 = 2
|
|
39
|
+
HZ_3500_5500 = 3 # Should not be used in legacy mode
|
|
40
|
+
end
|
|
41
|
+
end
|
data/lib/shazamio/fft.rb
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shazamio
|
|
4
|
+
# A small, dependency-free FFT used to reimplement numpy's `np.fft.rfft`
|
|
5
|
+
# for the signature algorithm. Only handles power-of-two sizes, which is
|
|
6
|
+
# all the algorithm ever needs (a 2048-sample window).
|
|
7
|
+
#
|
|
8
|
+
# This is plain Ruby, so it is meaningfully slower than numpy/FFTW. If you
|
|
9
|
+
# process a lot of audio and have network access to rubygems.org, swap
|
|
10
|
+
# `FFT.rfft` out for a binding to FFTW (e.g. the `fftw3` gem) or Numo::NArray
|
|
11
|
+
# without changing any other file — `Algorithm::SignatureGenerator` only
|
|
12
|
+
# calls `FFT.rfft`.
|
|
13
|
+
module FFT
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# Real FFT: takes N real samples, returns N/2+1 complex bins, matching
|
|
17
|
+
# numpy's np.fft.rfft for real input.
|
|
18
|
+
def rfft(real_samples)
|
|
19
|
+
n = real_samples.length
|
|
20
|
+
raise ArgumentError, "size must be a power of two" unless (n & (n - 1)).zero?
|
|
21
|
+
|
|
22
|
+
spectrum = fft(real_samples.map { |s| Complex(s, 0) })
|
|
23
|
+
spectrum[0..(n / 2)]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# In-place-conceptual iterative Cooley-Tukey FFT (returns a new array).
|
|
27
|
+
def fft(complex_samples)
|
|
28
|
+
n = complex_samples.length
|
|
29
|
+
return complex_samples.dup if n <= 1
|
|
30
|
+
|
|
31
|
+
a = bit_reverse_copy(complex_samples)
|
|
32
|
+
|
|
33
|
+
len = 2
|
|
34
|
+
while len <= n
|
|
35
|
+
ang = -2 * Math::PI / len
|
|
36
|
+
wlen = Complex(Math.cos(ang), Math.sin(ang))
|
|
37
|
+
i = 0
|
|
38
|
+
while i < n
|
|
39
|
+
w = Complex(1, 0)
|
|
40
|
+
(len / 2).times do |j|
|
|
41
|
+
u = a[i + j]
|
|
42
|
+
v = a[i + j + len / 2] * w
|
|
43
|
+
a[i + j] = u + v
|
|
44
|
+
a[i + j + len / 2] = u - v
|
|
45
|
+
w *= wlen
|
|
46
|
+
end
|
|
47
|
+
i += len
|
|
48
|
+
end
|
|
49
|
+
len <<= 1
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
a
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def bit_reverse_copy(a)
|
|
56
|
+
n = a.length
|
|
57
|
+
bits = Math.log2(n).round
|
|
58
|
+
out = Array.new(n)
|
|
59
|
+
n.times do |i|
|
|
60
|
+
out[reverse_bits(i, bits)] = a[i]
|
|
61
|
+
end
|
|
62
|
+
out
|
|
63
|
+
end
|
|
64
|
+
private_class_method :bit_reverse_copy
|
|
65
|
+
|
|
66
|
+
def reverse_bits(value, bits)
|
|
67
|
+
result = 0
|
|
68
|
+
bits.times do
|
|
69
|
+
result = (result << 1) | (value & 1)
|
|
70
|
+
value >>= 1
|
|
71
|
+
end
|
|
72
|
+
result
|
|
73
|
+
end
|
|
74
|
+
private_class_method :reverse_bits
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
require "zlib"
|
|
7
|
+
require "stringio"
|
|
8
|
+
require_relative "exceptions"
|
|
9
|
+
|
|
10
|
+
module Shazamio
|
|
11
|
+
# Thin Net::HTTP wrapper with exponential-backoff retries, mirroring the
|
|
12
|
+
# Python client's use of aiohttp_retry.ExponentialRetry(attempts: 20,
|
|
13
|
+
# max_timeout: 60, statuses: {500, 502, 503, 504, 429}).
|
|
14
|
+
#
|
|
15
|
+
# Ruby has no first-class async story equivalent to asyncio/aiohttp in the
|
|
16
|
+
# standard library, so this client is synchronous. Wrap calls in threads or
|
|
17
|
+
# fibers (or use the `async`/`async-http` gems) if you need concurrency.
|
|
18
|
+
class HTTPClient
|
|
19
|
+
RETRY_STATUSES = [429, 500, 502, 503, 504].freeze
|
|
20
|
+
|
|
21
|
+
def initialize(attempts: 20, max_timeout: 60, open_timeout: 10, read_timeout: 30)
|
|
22
|
+
@attempts = attempts
|
|
23
|
+
@max_timeout = max_timeout
|
|
24
|
+
@open_timeout = open_timeout
|
|
25
|
+
@read_timeout = read_timeout
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param method [String] "GET" or "POST"
|
|
29
|
+
# @param url [String]
|
|
30
|
+
# @param headers [Hash]
|
|
31
|
+
# @param params [Hash] query string params (GET only)
|
|
32
|
+
# @param json [Hash] JSON body (POST only)
|
|
33
|
+
# @param proxy [String, nil] e.g. "http://user:pass@host:port"
|
|
34
|
+
# @param content_type [String] expected response content type for JSON validation
|
|
35
|
+
def request(method, url, headers: {}, params: nil, json: nil, proxy: nil, content_type: "application/json")
|
|
36
|
+
uri = build_uri(url, params)
|
|
37
|
+
|
|
38
|
+
attempt = 0
|
|
39
|
+
backoff = 1.0
|
|
40
|
+
|
|
41
|
+
begin
|
|
42
|
+
attempt += 1
|
|
43
|
+
response = perform(method, uri, headers, json, proxy)
|
|
44
|
+
|
|
45
|
+
if RETRY_STATUSES.include?(response.code.to_i) && attempt < @attempts
|
|
46
|
+
sleep([backoff, @max_timeout].min)
|
|
47
|
+
backoff *= 2
|
|
48
|
+
raise Retryable
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
parse_json(response, content_type)
|
|
52
|
+
rescue Retryable
|
|
53
|
+
retry
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
class Retryable < StandardError; end
|
|
60
|
+
|
|
61
|
+
def build_uri(url, params)
|
|
62
|
+
uri = URI.parse(url)
|
|
63
|
+
if params && !params.empty?
|
|
64
|
+
existing = URI.decode_www_form(uri.query || "")
|
|
65
|
+
added = params.flat_map { |k, v| Array(v).map { |vv| [k.to_s, vv.to_s] } }
|
|
66
|
+
uri.query = URI.encode_www_form(existing + added)
|
|
67
|
+
end
|
|
68
|
+
uri
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
MAX_REDIRECTS = 10
|
|
72
|
+
|
|
73
|
+
def perform(method, uri, headers, json, proxy, redirects_left: MAX_REDIRECTS)
|
|
74
|
+
http_class = proxy_class(proxy)
|
|
75
|
+
http = http_class.new(uri.host, uri.port)
|
|
76
|
+
http.use_ssl = uri.scheme == "https"
|
|
77
|
+
http.open_timeout = @open_timeout
|
|
78
|
+
http.read_timeout = @read_timeout
|
|
79
|
+
|
|
80
|
+
request = build_request(method, uri, headers, json)
|
|
81
|
+
|
|
82
|
+
response = http.start { |conn| conn.request(request) }
|
|
83
|
+
|
|
84
|
+
# Net::HTTP does not follow redirects on its own (unlike aiohttp, which
|
|
85
|
+
# does by default) -- without this, a 301/302 comes back as an HTML
|
|
86
|
+
# redirect page instead of Shazam's JSON.
|
|
87
|
+
if response.is_a?(Net::HTTPRedirection) && redirects_left.positive?
|
|
88
|
+
location = URI.join(uri, response["location"])
|
|
89
|
+
return perform(method, location, headers, json, proxy, redirects_left: redirects_left - 1)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
response
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def proxy_class(proxy)
|
|
96
|
+
return Net::HTTP unless proxy
|
|
97
|
+
|
|
98
|
+
proxy_uri = URI.parse(proxy)
|
|
99
|
+
Net::HTTP::Proxy(proxy_uri.host, proxy_uri.port, proxy_uri.user, proxy_uri.password)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def build_request(method, uri, headers, json)
|
|
103
|
+
request =
|
|
104
|
+
case method.to_s.upcase
|
|
105
|
+
when "GET"
|
|
106
|
+
Net::HTTP::Get.new(uri)
|
|
107
|
+
when "POST"
|
|
108
|
+
Net::HTTP::Post.new(uri)
|
|
109
|
+
else
|
|
110
|
+
raise BadMethod, "Accept only GET/POST"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
headers.each { |k, v| request[k] = v }
|
|
114
|
+
|
|
115
|
+
if json
|
|
116
|
+
request["Content-Type"] = "application/json"
|
|
117
|
+
request.body = JSON.generate(json)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
request
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def parse_json(response, content_type)
|
|
124
|
+
body = decode_body(response)
|
|
125
|
+
|
|
126
|
+
actual_type = response["Content-Type"].to_s.split(";").first
|
|
127
|
+
if content_type && actual_type && !actual_type.empty? && actual_type != content_type
|
|
128
|
+
raise FailedDecodeJson,
|
|
129
|
+
"Failed to decode json (status #{response.code}, content-type was #{actual_type}). " \
|
|
130
|
+
"Body started with: #{body.to_s[0, 200].inspect}"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
JSON.parse(body)
|
|
134
|
+
rescue JSON::ParserError => e
|
|
135
|
+
raise FailedDecodeJson, "Failed to decode json: #{e.message}. Body started with: #{body.to_s[0, 200].inspect}"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def decode_body(response)
|
|
139
|
+
case response["Content-Encoding"]
|
|
140
|
+
when "gzip"
|
|
141
|
+
Zlib::GzipReader.new(StringIO.new(response.body)).read
|
|
142
|
+
when "deflate"
|
|
143
|
+
Zlib::Inflate.inflate(response.body)
|
|
144
|
+
else
|
|
145
|
+
response.body
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "user_agent"
|
|
4
|
+
|
|
5
|
+
module Shazamio
|
|
6
|
+
# Mirrors the Python `Device` enum: a client platform Shazam is spoofed as.
|
|
7
|
+
module Device
|
|
8
|
+
IPHONE = "iphone"
|
|
9
|
+
ANDROID = "android"
|
|
10
|
+
WEB = "web"
|
|
11
|
+
ALL = [IPHONE, ANDROID, WEB].freeze
|
|
12
|
+
|
|
13
|
+
def self.random
|
|
14
|
+
ALL.sample
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Shared request concerns: timezone + Shazam-flavoured headers.
|
|
19
|
+
module Request
|
|
20
|
+
TIME_ZONE = "Europe/Moscow"
|
|
21
|
+
|
|
22
|
+
def headers
|
|
23
|
+
{
|
|
24
|
+
"X-Shazam-Platform" => "IPHONE",
|
|
25
|
+
"X-Shazam-AppVersion" => "14.1.0",
|
|
26
|
+
"Accept" => "*/*",
|
|
27
|
+
"Accept-Language" => language,
|
|
28
|
+
"Accept-Encoding" => "gzip, deflate",
|
|
29
|
+
"User-Agent" => USER_AGENTS.sample
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shazamio
|
|
4
|
+
# The Python project uses Pydantic + dataclass-factory to map Shazam's raw
|
|
5
|
+
# JSON onto a large tree of typed models (see shazamio/schemas/**). Porting
|
|
6
|
+
# that whole schema tree 1:1 would be a lot of low-value boilerplate, so
|
|
7
|
+
# this Ruby port instead exposes the handful of fields people actually use
|
|
8
|
+
# (mirroring factory.py's `name_mapping`s) as plain structs, while always
|
|
9
|
+
# keeping the full raw Hash available too via `.raw`.
|
|
10
|
+
module Serialize
|
|
11
|
+
Track = Struct.new(
|
|
12
|
+
:title, :subtitle, :artist_id, :photo_url, :ringtone,
|
|
13
|
+
:apple_music_url, :spotify_url, :spotify_uri, :shazam_url, :raw,
|
|
14
|
+
keyword_init: true
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
Artist = Struct.new(
|
|
18
|
+
:adam_id, :name, :avatar, :url, :genres_primary, :genres, :raw,
|
|
19
|
+
keyword_init: true
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
# @param data [Hash] one "track" object as returned by e.g. #track_about,
|
|
25
|
+
# #search_track, #top_world_tracks, etc.
|
|
26
|
+
def track(data)
|
|
27
|
+
hub = data["hub"] || {}
|
|
28
|
+
options = (hub["options"] || [])[0] || {}
|
|
29
|
+
providers = (hub["providers"] || [])[0] || {}
|
|
30
|
+
actions = hub["actions"] || []
|
|
31
|
+
|
|
32
|
+
Track.new(
|
|
33
|
+
title: data["title"],
|
|
34
|
+
subtitle: data["subtitle"],
|
|
35
|
+
artist_id: data.dig("artists", 0, "id"),
|
|
36
|
+
photo_url: data.dig("images", "coverarthq"),
|
|
37
|
+
ringtone: actions.dig(1, "uri"),
|
|
38
|
+
apple_music_url: options.dig("actions", 0, "uri"),
|
|
39
|
+
spotify_url: providers.dig("actions", 0, "uri"),
|
|
40
|
+
spotify_uri: providers.dig("actions", 1, "uri"),
|
|
41
|
+
shazam_url: data["url"],
|
|
42
|
+
raw: data
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @param data [Hash] one "artist" object, e.g. from #search_artist hits,
|
|
47
|
+
# or the top-level result of #artist_about (legacy v1 shape).
|
|
48
|
+
def artist(data)
|
|
49
|
+
attributes = data["attributes"] || data
|
|
50
|
+
genres = data["genres"] || {}
|
|
51
|
+
|
|
52
|
+
Artist.new(
|
|
53
|
+
adam_id: data["adamid"] || data["id"],
|
|
54
|
+
name: attributes["name"] || data["name"],
|
|
55
|
+
avatar: data["avatar"],
|
|
56
|
+
url: attributes["url"] || data["weburl"],
|
|
57
|
+
genres_primary: genres["primary"],
|
|
58
|
+
genres: genres["secondaries"],
|
|
59
|
+
raw: data
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
require_relative "request"
|
|
6
|
+
require_relative "urls"
|
|
7
|
+
require_relative "http_client"
|
|
8
|
+
require_relative "converter"
|
|
9
|
+
require_relative "algorithm"
|
|
10
|
+
require_relative "audio_loader"
|
|
11
|
+
require_relative "enums"
|
|
12
|
+
|
|
13
|
+
module Shazamio
|
|
14
|
+
# Ruby port of shazamio.api.Shazam. Synchronous (see HTTPClient for why),
|
|
15
|
+
# so no asyncio/aiohttp equivalent is needed -- just call methods directly.
|
|
16
|
+
#
|
|
17
|
+
# shazam = Shazamio::Shazam.new
|
|
18
|
+
# result = shazam.recognize("song.mp3")
|
|
19
|
+
# puts result.dig("track", "title")
|
|
20
|
+
#
|
|
21
|
+
# NOTE: this only exposes the endpoints that are currently reachable. A
|
|
22
|
+
# separate group of endpoints (artist info, artist albums, album info, and
|
|
23
|
+
# every "top tracks" chart) is broken on Shazam's side right now -- their
|
|
24
|
+
# own web app hits the same error -- and was removed from here rather than
|
|
25
|
+
# shipped broken. See the "Not included" section of README.md for details,
|
|
26
|
+
# including how to bring them back if/when Apple fixes it upstream.
|
|
27
|
+
class Shazam
|
|
28
|
+
include Request
|
|
29
|
+
|
|
30
|
+
attr_reader :language, :endpoint_country, :http_client
|
|
31
|
+
|
|
32
|
+
def initialize(language: "en-US", endpoint_country: "GB", http_client: nil)
|
|
33
|
+
@language = language
|
|
34
|
+
@endpoint_country = endpoint_country
|
|
35
|
+
@http_client = http_client || HTTPClient.new
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Recognize a track from an audio file path, raw audio bytes (wrapped in
|
|
39
|
+
# e.g. StringIO), or an Array of signed 16-bit PCM samples already at
|
|
40
|
+
# 16kHz mono.
|
|
41
|
+
#
|
|
42
|
+
# There is no Ruby equivalent of the Python project's compiled Rust
|
|
43
|
+
# recognizer (`shazamio_core`); this always uses the pure-Ruby signature
|
|
44
|
+
# algorithm (see Algorithm::SignatureGenerator), which is Python's
|
|
45
|
+
# "legacy" path (`recognize_song`). It works the same way, just slower.
|
|
46
|
+
#
|
|
47
|
+
# @param data [String, Array<Integer>, #read] file path, audio bytes (via
|
|
48
|
+
# an IO-like object), or PCM samples
|
|
49
|
+
# @param proxy [String, nil]
|
|
50
|
+
# @return [Hash] Shazam's raw JSON response
|
|
51
|
+
def recognize(data, proxy: nil)
|
|
52
|
+
samples =
|
|
53
|
+
if data.is_a?(Array)
|
|
54
|
+
data
|
|
55
|
+
else
|
|
56
|
+
AudioLoader.pcm_s16le_mono_16k(data)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
generator = SignatureGenerator.new
|
|
60
|
+
generator.feed_input(samples)
|
|
61
|
+
generator.max_time_seconds = 12
|
|
62
|
+
|
|
63
|
+
duration_seconds = samples.length / 16_000.0
|
|
64
|
+
if duration_seconds > 12 * 3
|
|
65
|
+
generator.samples_processed += 16_000 * ((duration_seconds / 2).to_i - 6)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
signature = generator.get_next_signature
|
|
69
|
+
return { "matches" => [] } if signature.nil?
|
|
70
|
+
|
|
71
|
+
send_recognize_request(signature, proxy: proxy)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def send_recognize_request(signature, proxy: nil)
|
|
75
|
+
body = Converter.data_search(
|
|
76
|
+
Request::TIME_ZONE,
|
|
77
|
+
signature.encode_to_uri,
|
|
78
|
+
(signature.number_samples / signature.sample_rate_hz.to_f * 1000).to_i,
|
|
79
|
+
(Time.now.to_f * 1000).to_i
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
url = format(
|
|
83
|
+
ShazamUrl::SEARCH_FROM_FILE,
|
|
84
|
+
language: language,
|
|
85
|
+
device: Device.random,
|
|
86
|
+
endpoint_country: endpoint_country,
|
|
87
|
+
uuid_1: SecureRandom.uuid.upcase,
|
|
88
|
+
uuid_2: SecureRandom.uuid.upcase
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
http_client.request("POST", url, headers: headers, json: body, proxy: proxy)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def track_about(track_id, proxy: nil)
|
|
95
|
+
url = format(ShazamUrl::ABOUT_TRACK, language: language, endpoint_country: endpoint_country, track_id: track_id)
|
|
96
|
+
http_client.request("GET", url, headers: headers, proxy: proxy)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def search_artist(query, limit: 10, offset: 0, proxy: nil)
|
|
100
|
+
url = format(
|
|
101
|
+
ShazamUrl::SEARCH_ARTIST, language: language, endpoint_country: endpoint_country,
|
|
102
|
+
limit: limit, offset: offset, query: cgi_escape(query)
|
|
103
|
+
)
|
|
104
|
+
http_client.request("GET", url, headers: headers, proxy: proxy)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def search_track(query, limit: 10, offset: 0, proxy: nil)
|
|
108
|
+
url = format(
|
|
109
|
+
ShazamUrl::SEARCH_MUSIC, language: language, endpoint_country: endpoint_country,
|
|
110
|
+
limit: limit, offset: offset, query: cgi_escape(query)
|
|
111
|
+
)
|
|
112
|
+
http_client.request("GET", url, headers: headers, proxy: proxy)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def related_tracks(track_id, limit: 20, offset: 0, proxy: nil)
|
|
116
|
+
url = format(
|
|
117
|
+
ShazamUrl::RELATED_SONGS, language: language, endpoint_country: endpoint_country,
|
|
118
|
+
limit: limit, offset: offset, track_id: track_id
|
|
119
|
+
)
|
|
120
|
+
http_client.request("GET", url, headers: headers, proxy: proxy)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def listening_counter(track_id, proxy: nil)
|
|
124
|
+
url = format(ShazamUrl::LISTENING_COUNTER, track_id: track_id)
|
|
125
|
+
http_client.request("GET", url, headers: headers, proxy: proxy)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def listening_counter_many(track_ids, proxy: nil)
|
|
129
|
+
http_client.request(
|
|
130
|
+
"GET", ShazamUrl::LISTENING_COUNTER_MANY,
|
|
131
|
+
headers: headers, params: { "id" => track_ids }, proxy: proxy
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def get_youtube_data(link, proxy: nil)
|
|
136
|
+
http_client.request("GET", link, headers: headers, proxy: proxy)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
def cgi_escape(str)
|
|
142
|
+
require "cgi"
|
|
143
|
+
CGI.escape(str.to_s)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "zlib"
|
|
5
|
+
require "stringio"
|
|
6
|
+
require_relative "enums"
|
|
7
|
+
|
|
8
|
+
module Shazamio
|
|
9
|
+
DATA_URI_PREFIX = "data:audio/vnd.shazam.sig;base64,"
|
|
10
|
+
|
|
11
|
+
# A single detected FFT peak: which frequency band, at what time offset,
|
|
12
|
+
# magnitude and (corrected) frequency bin. Ported from signature.py.
|
|
13
|
+
class FrequencyPeak
|
|
14
|
+
attr_accessor :fft_pass_number, :peak_magnitude, :corrected_peak_frequency_bin, :sample_rate_hz
|
|
15
|
+
|
|
16
|
+
def initialize(fft_pass_number, peak_magnitude, corrected_peak_frequency_bin, sample_rate_hz)
|
|
17
|
+
@fft_pass_number = fft_pass_number
|
|
18
|
+
@peak_magnitude = peak_magnitude
|
|
19
|
+
@corrected_peak_frequency_bin = corrected_peak_frequency_bin
|
|
20
|
+
@sample_rate_hz = sample_rate_hz
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def frequency_hz
|
|
24
|
+
corrected_peak_frequency_bin * (sample_rate_hz / 2.0 / 1024 / 64)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def amplitude_pcm
|
|
28
|
+
Math.sqrt(Math.exp((peak_magnitude - 6144) / 1477.3) * (1 << 17) / 2) / 1024
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def seconds
|
|
32
|
+
(fft_pass_number * 128).to_f / sample_rate_hz
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Shazam's raw binary "signature" format: a 48-byte header (magic numbers,
|
|
37
|
+
# CRC32, sample count) followed by a TLV list of frequency-band peaks.
|
|
38
|
+
# Ported field-for-field from signature.py's RawSignatureHeader /
|
|
39
|
+
# DecodedMessage, replacing ctypes.Structure with Array#pack/#unpack.
|
|
40
|
+
class DecodedMessage
|
|
41
|
+
HEADER_FORMAT = "V12" # 12 x uint32 little-endian = 48 bytes
|
|
42
|
+
MAGIC1 = 0xCAFE2580
|
|
43
|
+
MAGIC2 = 0x94119C00
|
|
44
|
+
|
|
45
|
+
attr_accessor :sample_rate_hz, :number_samples, :frequency_band_to_sound_peaks
|
|
46
|
+
|
|
47
|
+
def initialize
|
|
48
|
+
@frequency_band_to_sound_peaks = {}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.decode_from_binary(data)
|
|
52
|
+
msg = new
|
|
53
|
+
buf = StringIO.new(data)
|
|
54
|
+
buf.binmode
|
|
55
|
+
|
|
56
|
+
buf.seek(8)
|
|
57
|
+
check_summable_data = buf.read
|
|
58
|
+
buf.seek(0)
|
|
59
|
+
|
|
60
|
+
header_bytes = buf.read(48)
|
|
61
|
+
fields = header_bytes.unpack(HEADER_FORMAT)
|
|
62
|
+
magic1, crc32, size_minus_header, magic2, _v1a, _v1b, _v1c,
|
|
63
|
+
shifted_sample_rate_id, _v2a, _v2b, number_samples_plus_divided_sample_rate, _fixed_value = fields
|
|
64
|
+
|
|
65
|
+
raise "bad magic1" unless magic1 == MAGIC1
|
|
66
|
+
raise "bad size" unless size_minus_header == data.bytesize - 48
|
|
67
|
+
raise "bad crc32" unless (Zlib.crc32(check_summable_data) & 0xFFFFFFFF) == crc32
|
|
68
|
+
raise "bad magic2" unless magic2 == MAGIC2
|
|
69
|
+
|
|
70
|
+
msg.sample_rate_hz = SampleRate.hz_for(shifted_sample_rate_id >> 27)
|
|
71
|
+
msg.number_samples = (number_samples_plus_divided_sample_rate - msg.sample_rate_hz * 0.24).to_i
|
|
72
|
+
|
|
73
|
+
raise "bad tlv preamble" unless buf.read(4).unpack1("V") == 0x40000000
|
|
74
|
+
raise "bad tlv size" unless buf.read(4).unpack1("V") == data.bytesize - 48
|
|
75
|
+
|
|
76
|
+
msg.frequency_band_to_sound_peaks = {}
|
|
77
|
+
|
|
78
|
+
until buf.eof?
|
|
79
|
+
tlv_header = buf.read(8)
|
|
80
|
+
break if tlv_header.nil? || tlv_header.empty?
|
|
81
|
+
|
|
82
|
+
frequency_band_id, frequency_peaks_size = tlv_header.unpack("VV")
|
|
83
|
+
padding = (-frequency_peaks_size) % 4
|
|
84
|
+
|
|
85
|
+
peaks_buf = StringIO.new(buf.read(frequency_peaks_size))
|
|
86
|
+
buf.read(padding)
|
|
87
|
+
|
|
88
|
+
band = frequency_band_id - 0x60030040
|
|
89
|
+
fft_pass_number = 0
|
|
90
|
+
msg.frequency_band_to_sound_peaks[band] = []
|
|
91
|
+
|
|
92
|
+
until peaks_buf.eof?
|
|
93
|
+
raw_offset = peaks_buf.read(1)
|
|
94
|
+
break if raw_offset.nil? || raw_offset.empty?
|
|
95
|
+
|
|
96
|
+
offset = raw_offset.unpack1("C")
|
|
97
|
+
if offset == 0xFF
|
|
98
|
+
fft_pass_number = peaks_buf.read(4).unpack1("V")
|
|
99
|
+
next
|
|
100
|
+
else
|
|
101
|
+
fft_pass_number += offset
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
peak_magnitude = peaks_buf.read(2).unpack1("v")
|
|
105
|
+
corrected_peak_frequency_bin = peaks_buf.read(2).unpack1("v")
|
|
106
|
+
|
|
107
|
+
msg.frequency_band_to_sound_peaks[band] << FrequencyPeak.new(
|
|
108
|
+
fft_pass_number, peak_magnitude, corrected_peak_frequency_bin, msg.sample_rate_hz
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
msg
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def encode_to_binary
|
|
117
|
+
contents_buf = StringIO.new
|
|
118
|
+
contents_buf.binmode
|
|
119
|
+
|
|
120
|
+
frequency_band_to_sound_peaks.sort.each do |frequency_band, frequency_peaks|
|
|
121
|
+
peaks_buf = StringIO.new
|
|
122
|
+
peaks_buf.binmode
|
|
123
|
+
fft_pass_number = 0
|
|
124
|
+
|
|
125
|
+
frequency_peaks.each do |peak|
|
|
126
|
+
raise "peaks must be sorted by fft_pass_number" if peak.fft_pass_number < fft_pass_number
|
|
127
|
+
|
|
128
|
+
if peak.fft_pass_number - fft_pass_number >= 255
|
|
129
|
+
peaks_buf.write([0xFF].pack("C"))
|
|
130
|
+
peaks_buf.write([peak.fft_pass_number].pack("V"))
|
|
131
|
+
fft_pass_number = peak.fft_pass_number
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
peaks_buf.write([peak.fft_pass_number - fft_pass_number].pack("C"))
|
|
135
|
+
peaks_buf.write([peak.peak_magnitude].pack("v"))
|
|
136
|
+
peaks_buf.write([peak.corrected_peak_frequency_bin].pack("v"))
|
|
137
|
+
|
|
138
|
+
fft_pass_number = peak.fft_pass_number
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
peaks_bytes = peaks_buf.string
|
|
142
|
+
contents_buf.write([0x60030040 + frequency_band].pack("V"))
|
|
143
|
+
contents_buf.write([peaks_bytes.bytesize].pack("V"))
|
|
144
|
+
contents_buf.write(peaks_bytes)
|
|
145
|
+
contents_buf.write("\x00" * ((-peaks_bytes.bytesize) % 4))
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
contents = contents_buf.string
|
|
149
|
+
size_minus_header = contents.bytesize + 8
|
|
150
|
+
shifted_sample_rate_id = SampleRate.value_for_hz(sample_rate_hz) << 27
|
|
151
|
+
fixed_value = (15 << 19) + 0x40000
|
|
152
|
+
number_samples_plus_divided_sample_rate = (number_samples + sample_rate_hz * 0.24).to_i
|
|
153
|
+
|
|
154
|
+
# First pass: header with crc32 = 0, to compute the real crc32 over it.
|
|
155
|
+
header_fields = [
|
|
156
|
+
MAGIC1, 0, size_minus_header, MAGIC2,
|
|
157
|
+
0, 0, 0,
|
|
158
|
+
shifted_sample_rate_id,
|
|
159
|
+
0, 0,
|
|
160
|
+
number_samples_plus_divided_sample_rate,
|
|
161
|
+
fixed_value
|
|
162
|
+
]
|
|
163
|
+
|
|
164
|
+
body = StringIO.new
|
|
165
|
+
body.binmode
|
|
166
|
+
body.write([0x40000000].pack("V"))
|
|
167
|
+
body.write([contents.bytesize + 8].pack("V"))
|
|
168
|
+
body.write(contents)
|
|
169
|
+
|
|
170
|
+
crc = Zlib.crc32(header_fields.pack(HEADER_FORMAT)[8..] + body.string) & 0xFFFFFFFF
|
|
171
|
+
header_fields[1] = crc
|
|
172
|
+
|
|
173
|
+
header_fields.pack(HEADER_FORMAT) + body.string
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def encode_to_uri
|
|
177
|
+
DATA_URI_PREFIX + Base64.strict_encode64(encode_to_binary)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shazamio
|
|
4
|
+
# Endpoint URL templates, ported from the Python client's ShazamUrl.
|
|
5
|
+
# `%{...}` placeholders are filled in with String#% at call sites.
|
|
6
|
+
#
|
|
7
|
+
# NOTE: the Python source also defines TOP_TRACKS_PLAYLIST, LOCATIONS,
|
|
8
|
+
# SEARCH_ARTIST_V2 (artist info), ARTIST_ALBUMS, and ARTIST_ALBUM_INFO --
|
|
9
|
+
# all under https://www.shazam.com/services/amapi/v1/catalog/... . That
|
|
10
|
+
# whole `amapi` catalog path is currently rejecting GET requests with a
|
|
11
|
+
# 405 on Shazam's own servers (confirmed: shazam.com's own web app hits
|
|
12
|
+
# the same 405 on this path). Since there's nothing a client can do about
|
|
13
|
+
# a server-side 405, those routes were left out here rather than shipped
|
|
14
|
+
# broken. See README.md ("Not included") for the exact URLs, in case
|
|
15
|
+
# Apple fixes this and someone wants to add them back.
|
|
16
|
+
module ShazamUrl
|
|
17
|
+
SEARCH_FROM_FILE =
|
|
18
|
+
"https://amp.shazam.com/discovery/v5/%<language>s/%<endpoint_country>s/%<device>s/-/tag" \
|
|
19
|
+
"/%<uuid_1>s/%<uuid_2>s?sync=true&webv3=true&sampling=true" \
|
|
20
|
+
"&connected=&shazamapiversion=v3&sharehub=true&hubv5minorversion=v5.1&hidelb=true&video=v3"
|
|
21
|
+
|
|
22
|
+
ABOUT_TRACK =
|
|
23
|
+
"https://www.shazam.com/discovery/v5/%<language>s/%<endpoint_country>s/web/-/track" \
|
|
24
|
+
"/%<track_id>s?shazamapiversion=v3&video=v3"
|
|
25
|
+
|
|
26
|
+
RELATED_SONGS =
|
|
27
|
+
"https://cdn.shazam.com/shazam/v3/%<language>s/%<endpoint_country>s/web/-/tracks" \
|
|
28
|
+
"/track-similarities-id-%<track_id>s?startFrom=%<offset>s&pageSize=%<limit>s&connected=&channel="
|
|
29
|
+
|
|
30
|
+
SEARCH_ARTIST =
|
|
31
|
+
"https://www.shazam.com/services/search/v4/%<language>s/%<endpoint_country>s/web" \
|
|
32
|
+
"/search?term=%<query>s&limit=%<limit>s&offset=%<offset>s&types=artists"
|
|
33
|
+
|
|
34
|
+
SEARCH_MUSIC =
|
|
35
|
+
"https://www.shazam.com/services/search/v3/%<language>s/%<endpoint_country>s/web" \
|
|
36
|
+
"/search?query=%<query>s&numResults=%<limit>s&offset=%<offset>s&types=songs"
|
|
37
|
+
|
|
38
|
+
LISTENING_COUNTER = "https://www.shazam.com/services/count/v2/web/track/%<track_id>s"
|
|
39
|
+
LISTENING_COUNTER_MANY = "https://www.shazam.com/services/count/v2/web/track"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shazamio
|
|
4
|
+
# A pool of realistic desktop/mobile User-Agent strings. A random one is
|
|
5
|
+
# attached to every outgoing request, mirroring the Python client's
|
|
6
|
+
# behaviour of rotating user agents.
|
|
7
|
+
USER_AGENTS = [
|
|
8
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
|
9
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15",
|
|
10
|
+
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
|
11
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:125.0) Gecko/20100101 Firefox/125.0",
|
|
12
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
|
13
|
+
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1",
|
|
14
|
+
"Mozilla/5.0 (iPad; CPU OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1",
|
|
15
|
+
"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36",
|
|
16
|
+
"Mozilla/5.0 (Linux; Android 13; SM-S911B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36",
|
|
17
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Edg/124.0.0.0 Safari/537.36",
|
|
18
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
|
|
19
|
+
"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0",
|
|
20
|
+
"Mozilla/5.0 (Windows NT 11.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
|
21
|
+
"Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1",
|
|
22
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Safari/605.1.15",
|
|
23
|
+
"Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
24
|
+
"Mozilla/5.0 (Linux; Android 14; SM-A536E) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36",
|
|
25
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 OPR/108.0.0.0",
|
|
26
|
+
"Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; rv:11.0) like Gecko",
|
|
27
|
+
"Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Trident/5.0)"
|
|
28
|
+
].freeze
|
|
29
|
+
end
|
data/lib/shazamio.rb
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "shazamio/version"
|
|
4
|
+
require_relative "shazamio/exceptions"
|
|
5
|
+
require_relative "shazamio/enums"
|
|
6
|
+
require_relative "shazamio/user_agent"
|
|
7
|
+
require_relative "shazamio/urls"
|
|
8
|
+
require_relative "shazamio/request"
|
|
9
|
+
require_relative "shazamio/http_client"
|
|
10
|
+
require_relative "shazamio/converter"
|
|
11
|
+
require_relative "shazamio/fft"
|
|
12
|
+
require_relative "shazamio/signature"
|
|
13
|
+
require_relative "shazamio/algorithm"
|
|
14
|
+
require_relative "shazamio/audio_loader"
|
|
15
|
+
require_relative "shazamio/serialize"
|
|
16
|
+
require_relative "shazamio/shazam"
|
|
17
|
+
|
|
18
|
+
module Shazamio
|
|
19
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: shazamio-rb
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- ShazamIO Ruby port contributors
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rspec
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '3.13'
|
|
19
|
+
type: :development
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '3.13'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: webmock
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '3.23'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '3.23'
|
|
40
|
+
description: |
|
|
41
|
+
A free Ruby library for the reverse engineered Shazam API. Search tracks and
|
|
42
|
+
artists, fetch charts, and recognize songs from an audio file using a pure
|
|
43
|
+
Ruby port of Shazam's audio fingerprinting algorithm.
|
|
44
|
+
executables: []
|
|
45
|
+
extensions: []
|
|
46
|
+
extra_rdoc_files: []
|
|
47
|
+
files:
|
|
48
|
+
- README.md
|
|
49
|
+
- lib/shazamio.rb
|
|
50
|
+
- lib/shazamio/algorithm.rb
|
|
51
|
+
- lib/shazamio/audio_loader.rb
|
|
52
|
+
- lib/shazamio/converter.rb
|
|
53
|
+
- lib/shazamio/enums.rb
|
|
54
|
+
- lib/shazamio/exceptions.rb
|
|
55
|
+
- lib/shazamio/fft.rb
|
|
56
|
+
- lib/shazamio/http_client.rb
|
|
57
|
+
- lib/shazamio/request.rb
|
|
58
|
+
- lib/shazamio/serialize.rb
|
|
59
|
+
- lib/shazamio/shazam.rb
|
|
60
|
+
- lib/shazamio/signature.rb
|
|
61
|
+
- lib/shazamio/urls.rb
|
|
62
|
+
- lib/shazamio/user_agent.rb
|
|
63
|
+
- lib/shazamio/version.rb
|
|
64
|
+
homepage: https://github.com/shazamio/ShazamIO
|
|
65
|
+
licenses:
|
|
66
|
+
- MIT
|
|
67
|
+
metadata: {}
|
|
68
|
+
rdoc_options: []
|
|
69
|
+
require_paths:
|
|
70
|
+
- lib
|
|
71
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - ">="
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '3.0'
|
|
76
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
77
|
+
requirements:
|
|
78
|
+
- - ">="
|
|
79
|
+
- !ruby/object:Gem::Version
|
|
80
|
+
version: '0'
|
|
81
|
+
requirements: []
|
|
82
|
+
rubygems_version: 3.6.9
|
|
83
|
+
specification_version: 4
|
|
84
|
+
summary: Ruby client for the reverse engineered Shazam API (port of Python's shazamio)
|
|
85
|
+
test_files: []
|