fingerprintjs
259 строк · 9.4 Кб
1import { isDesktopWebKit, isWebKit, isWebKit606OrNewer } from '../utils/browser'
2import { isPromise, suppressUnhandledRejectionWarning, wait } from '../utils/async'
3import { whenDocumentVisible } from '../utils/dom'
4
5export const enum SpecialFingerprint {
6/** The browser is known for always suspending audio context, thus making fingerprinting impossible */
7KnownForSuspending = -1,
8/** The browser doesn't support audio context */
9NotSupported = -2,
10/** An unexpected timeout has happened */
11Timeout = -3,
12}
13
14const sampleRate = 44100
15const cloneCount = 40000
16const stabilizationPrecision = 6.2
17
18/**
19* A version of the entropy source with stabilization to make it suitable for static fingerprinting.
20* Audio signal is noised in private mode of Safari 17.
21*/
22export default async function getAudioFingerprint(): Promise<() => number> {
23const finish = await getUnstableAudioFingerprint()
24return () => {
25const rawFingerprint = finish()
26return stabilize(rawFingerprint, stabilizationPrecision)
27}
28}
29
30/**
31* A version of the entropy source without stabilization.
32*
33* Warning for package users:
34* This function is out of Semantic Versioning, i.e. can change unexpectedly. Usage is at your own risk.
35*/
36export async function getUnstableAudioFingerprint(): Promise<() => number> {
37let fingerprintResult: [true, number] | [false, unknown] | undefined
38
39// The timeout is not started until the browser tab becomes visible because some browsers may not want to render
40// an audio context in background.
41const timeoutPromise = whenDocumentVisible().then(() => wait(500))
42const fingerprintPromise = getBaseAudioFingerprint().then(
43(result) => (fingerprintResult = [true, result]),
44(error) => (fingerprintResult = [false, error]),
45)
46await Promise.race([timeoutPromise, fingerprintPromise])
47
48return () => {
49if (!fingerprintResult) {
50return SpecialFingerprint.Timeout
51}
52if (!fingerprintResult[0]) {
53throw fingerprintResult[1]
54}
55return fingerprintResult[1]
56}
57}
58
59async function getBaseAudioFingerprint(): Promise<number> {
60const w = window
61const AudioContext = w.OfflineAudioContext || w.webkitOfflineAudioContext
62if (!AudioContext) {
63return SpecialFingerprint.NotSupported
64}
65
66// In some browsers, audio context always stays suspended unless the context is started in response to a user action
67// (e.g. a click or a tap). It prevents audio fingerprint from being taken at an arbitrary moment of time.
68// Such browsers are old and unpopular, so the audio fingerprinting is just skipped in them.
69// See a similar case explanation at https://stackoverflow.com/questions/46363048/onaudioprocess-not-called-on-ios11#46534088
70if (doesBrowserSuspendAudioContext()) {
71return SpecialFingerprint.KnownForSuspending
72}
73
74const baseSignal = await getBaseSignal(AudioContext)
75if (!baseSignal) {
76return SpecialFingerprint.Timeout
77}
78
79// This context copies the last sample of the base signal many times.
80// The array of copies helps to cancel the noise.
81const context = new AudioContext(1, baseSignal.length - 1 + cloneCount, sampleRate)
82const sourceNode = context.createBufferSource()
83sourceNode.buffer = baseSignal
84sourceNode.loop = true
85sourceNode.loopStart = (baseSignal.length - 1) / sampleRate
86sourceNode.loopEnd = baseSignal.length / sampleRate
87sourceNode.connect(context.destination)
88sourceNode.start()
89
90const clonedSignal = await renderAudio(context)
91if (!clonedSignal) {
92return SpecialFingerprint.Timeout
93}
94const fingerprint = extractFingerprint(baseSignal, clonedSignal.getChannelData(0).subarray(baseSignal.length - 1))
95return Math.abs(fingerprint) // The fingerprint is made positive to avoid confusion with the special fingerprints
96}
97
98/**
99* Checks if the current browser is known for always suspending audio context.
100*
101* Warning for package users:
102* This function is out of Semantic Versioning, i.e. can change unexpectedly. Usage is at your own risk.
103*/
104export function doesBrowserSuspendAudioContext() {
105// Mobile Safari 11 and older
106return isWebKit() && !isDesktopWebKit() && !isWebKit606OrNewer()
107}
108
109/**
110* Produces an arbitrary audio signal
111*/
112async function getBaseSignal(AudioContext: typeof OfflineAudioContext) {
113const targetSampleIndex = 3395
114const context = new AudioContext(1, targetSampleIndex + 1, sampleRate)
115
116const oscillator = context.createOscillator()
117oscillator.type = 'square'
118oscillator.frequency.value = 1000
119
120const compressor = context.createDynamicsCompressor()
121compressor.threshold.value = -70
122compressor.knee.value = 40
123compressor.ratio.value = 12
124compressor.attack.value = 0
125compressor.release.value = 0.25
126
127const filter = context.createBiquadFilter()
128filter.type = 'allpass'
129filter.frequency.value = 5.239622852977861
130filter.Q.value = 0.1
131
132oscillator.connect(compressor)
133compressor.connect(filter)
134filter.connect(context.destination)
135oscillator.start(0)
136
137return await renderAudio(context)
138}
139
140/**
141* Renders the given audio context with configured nodes.
142* Returns `null` when the rendering runs out of attempts.
143*
144* Warning for package users:
145* This function is out of Semantic Versioning, i.e. can change unexpectedly. Usage is at your own risk.
146*/
147export function renderAudio(context: OfflineAudioContext) {
148return new Promise<AudioBuffer | null>((resolve, reject) => {
149const retryDelay = 200
150let attemptsLeft = 25
151
152context.oncomplete = (event) => resolve(event.renderedBuffer)
153
154const tryRender = () => {
155try {
156const renderingPromise = context.startRendering()
157
158// `context.startRendering` has two APIs: Promise and callback, we check that it's really a promise just in case
159if (isPromise(renderingPromise)) {
160// Suppresses all unhandled rejections in case of scheduled redundant retries after successful rendering
161suppressUnhandledRejectionWarning(renderingPromise)
162}
163
164// Sometimes the audio context doesn't start after calling `startRendering` (in addition to the cases where
165// audio context doesn't start at all). A known case is starting an audio context when the browser tab is in
166// background on iPhone. Retries usually help in this case.
167if (context.state === 'suspended') {
168// The audio context can reject starting until the tab is in foreground. Long fingerprint duration
169// in background isn't a problem, therefore the retry attempts don't count in background. It can lead to
170// a situation when a fingerprint takes very long time and finishes successfully. FYI, the audio context
171// can be suspended when `document.hidden === false` and start running after a retry.
172if (!document.hidden) {
173attemptsLeft--
174}
175if (attemptsLeft > 0) {
176setTimeout(tryRender, retryDelay)
177} else {
178resolve(null)
179}
180}
181} catch (error) {
182reject(error)
183}
184}
185
186tryRender()
187})
188}
189
190function extractFingerprint(baseSignal: AudioBuffer, clonedSample: Float32Array): number {
191let fingerprint = undefined
192let needsDenoising = false
193
194for (let i = 0; i < clonedSample.length; i += Math.floor(clonedSample.length / 10)) {
195if (clonedSample[i] === 0) {
196// In some cases the signal is 0 on a short range for some reason. Ignoring such samples.
197} else if (fingerprint === undefined) {
198fingerprint = clonedSample[i]
199} else if (fingerprint !== clonedSample[i]) {
200needsDenoising = true
201break
202}
203}
204
205// The looped buffer source works incorrectly in old Safari versions (>14 desktop, >15 mobile).
206// The looped signal contains only 0s. To fix it, the loop start should be `baseSignal.length - 1.00000000001` and
207// the loop end should be `baseSignal.length + 0.00000000001` (there can be 10 or 11 0s after the point). But this
208// solution breaks the looped signal in other browsers. Instead of checking the browser version, we check that the
209// looped signals comprises only 0s, and if it does, we return the last value of the base signal, because old Safari
210// versions don't add noise that we want to cancel.
211if (fingerprint === undefined) {
212fingerprint = baseSignal.getChannelData(0)[baseSignal.length - 1]
213} else if (needsDenoising) {
214fingerprint = getMiddle(clonedSample)
215}
216
217return fingerprint
218}
219
220/**
221* Calculates the middle between the minimum and the maximum array item
222*/
223export function getMiddle(signal: ArrayLike<number>): number {
224let min = Infinity
225let max = -Infinity
226
227for (let i = 0; i < signal.length; i++) {
228const value = signal[i]
229// In very rare cases the signal is 0 on a short range for some reason. Ignoring such samples.
230if (value === 0) {
231continue
232}
233if (value < min) {
234min = value
235}
236if (value > max) {
237max = value
238}
239}
240
241return (min + max) / 2
242}
243
244/**
245* Truncates some digits of the number to make it stable.
246* `precision` is the number of significant digits to keep. The number may be not integer:
247* - If it ends with `.2`, the last digit is rounded to the nearest multiple of 5;
248* - If it ends with `.5`, the last digit is rounded to the nearest even number;
249*/
250export function stabilize(value: number, precision: number): number {
251if (value === 0) {
252return value
253}
254
255const power = Math.floor(Math.log10(Math.abs(value)))
256const precisionPower = power - Math.floor(precision) + 1
257const precisionBase = 10 ** -precisionPower * ((precision * 10) % 10 || 1)
258return Math.round(value * precisionBase) / precisionBase
259}
260