pitch-transcription 4.2.0 → 4.3.3

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.
package/NOTICE CHANGED
@@ -4,4 +4,5 @@ Copyright Jimmy Chen
4
4
  This package is derived from basic-pitch-ts (https://github.com/spotify/basic-pitch-ts),
5
5
  Copyright 2022 Spotify AB, licensed under the Apache License, Version 2.0. Its note
6
6
  creation, audio preparation and model call are rewritten from that source, and the
7
- trained model under `model/` is Spotify's Basic Pitch model, distributed unmodified.
7
+ trained model under `model/` is Spotify's Basic Pitch model, its ONNX export `nmp.onnx` from
8
+ `basic_pitch/saved_models/icassp_2022` in basic-pitch's repository, distributed unmodified.
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![NPM downloads][badge-npm-downloads]][url-npm]
6
6
  [![NPM Unpacked Size (with version)][badge-npm-unpacked-size]][url-npm]
7
7
 
8
- The notes played in a recording: each note's pitch, start, length, loudness and pitch bend, heard by Spotify's Basic Pitch model on the current TensorFlow.js and written as MIDI if you want a file. It hears several notes at once, on any instrument. The trained model ships with the package, and the step that turns the model's readings into notes imports without TensorFlow.js.
8
+ The notes played in a recording: each note's pitch, start, length, loudness and pitch bend, heard by Spotify's Basic Pitch model on ONNX Runtime and written as MIDI if you want a file. It hears several notes at once, on any instrument. The trained model ships with the package, and the step that turns the model's readings into notes imports without ONNX Runtime.
9
9
 
10
10
  ## Table of Contents
11
11
 
@@ -18,50 +18,49 @@ The notes played in a recording: each note's pitch, start, length, loudness and
18
18
  ## <a name="getting-started">🚀 Getting Started</a>
19
19
 
20
20
  ```bash
21
- pnpm i pitch-transcription @tensorflow/tfjs
21
+ pnpm i pitch-transcription onnxruntime-common onnxruntime-node
22
22
  ```
23
23
 
24
24
  ```ts
25
- import { loadGraphModelSync } from "@tensorflow/tfjs";
26
- import { readFile } from "node:fs/promises";
25
+ import { InferenceSession } from "onnxruntime-node";
26
+ import { fileURLToPath } from "node:url";
27
27
  import {
28
28
  addPitchBends,
29
29
  convertNotesToSeconds,
30
30
  createNotes,
31
31
  MODEL_URL,
32
- MODEL_WEIGHTS_URL,
33
32
  readModel,
34
33
  writeMidi,
35
34
  } from "pitch-transcription";
36
35
 
37
- // Under Node, the model is built from its two files; in a browser, `await loadGraphModel(servedModelUrl)` instead
38
- const [modelJson, weights] = await Promise.all([readFile(MODEL_URL, "utf8"), readFile(MODEL_WEIGHTS_URL)]);
39
- const model = loadGraphModelSync([JSON.parse(modelJson), new Uint8Array(weights).buffer]);
36
+ // Under Node, the session reads the shipped model file; in a browser, `InferenceSession.create(servedModelUrl)` from `onnxruntime-web` instead
37
+ const session = await InferenceSession.create(fileURLToPath(MODEL_URL));
40
38
  // One channel at 22050 Hz as a Float32Array, or an AudioBuffer at 22050 Hz with any number of channels
41
- const readings = await readModel(model, samples, (_windowReadings, progress) => console.log(progress));
39
+ const readings = await readModel(session, samples, (_windowReadings, progress) => console.log(progress));
42
40
  const notes = createNotes(readings, { minNoteLength: 11 });
43
41
  const bentNotes = addPitchBends(readings.contours, notes);
44
42
  const timedNotes = convertNotesToSeconds(bentNotes);
45
43
  // Each note's startTimeSeconds, durationSeconds, pitchMidi, amplitude and pitchBends
46
44
  const midiBytes = writeMidi(timedNotes);
45
+ await session.release();
47
46
  ```
48
47
 
49
- A worker or a server that already holds the readings imports note creation from `pitch-transcription/notes`, which never loads TensorFlow.js.
48
+ A worker or a server that already holds the readings imports note creation from `pitch-transcription/notes`, which never loads ONNX Runtime. `pitch-transcription` takes `onnxruntime-common` as a peer, and the runtime the session runs on is yours to choose: `onnxruntime-node` under Node, `onnxruntime-web` in a browser.
50
49
 
51
50
  ## <a name="documentation">📖 Documentation</a>
52
51
 
53
- We highly recommend you take a look at the [documentation](https://esposter.com/docs/api/modules/pitch-transcription.html) to level up, and the [package's page](https://esposter.com/docs/pitch-transcription) for how it works and the absorption of `@spotify/basic-pitch` it replaced.
52
+ We highly recommend you take a look at the [documentation](https://github.com/Esposter/Esposter/tree/main/openspec/specs/pitch-transcription) to level up. It explains how the package works and how it absorbed `@spotify/basic-pitch`.
54
53
 
55
54
  ### Key exports
56
55
 
57
- | Export | Role |
58
- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
59
- | `readModel` | The model's frame, onset and contour readings over a recording, a window at a time, each window's tensors freed |
60
- | `createNotes` | The readings turned into notes, with Basic Pitch's thresholds, inferred onsets, frequency range and melodia trick |
61
- | `addPitchBends` | Each note's bend at every frame, in contour bins of a third of a semitone |
62
- | `convertNotesToSeconds` | Notes timed in seconds rather than the model's frames |
63
- | `writeMidi` | Notes as a MIDI file's bytes, each bend written as its share of the bend's range |
64
- | `MODEL_URL`, `MODEL_WEIGHTS_URL` | The shipped model's two files |
56
+ | Export | Role |
57
+ | ----------------------- | ------------------------------------------------------------------------------------------------------------------ |
58
+ | `readModel` | The model's frame, onset and contour readings over a recording, a window at a time, each output disposed once read |
59
+ | `createNotes` | The readings turned into notes, with Basic Pitch's thresholds, inferred onsets, frequency range and melodia trick |
60
+ | `addPitchBends` | Each note's bend at every frame, in contour bins of a third of a semitone |
61
+ | `convertNotesToSeconds` | Notes timed in seconds rather than the model's frames |
62
+ | `writeMidi` | Notes as a MIDI file's bytes, each bend written as its share of the bend's range |
63
+ | `MODEL_URL` | The shipped model file, Basic Pitch's ONNX export |
65
64
 
66
65
  ### Commands
67
66
 
@@ -0,0 +1,21 @@
1
+ import { ANNOTATIONS_FPS, ANNOTATIONS_FRAMES_PER_WINDOW, ANNOTATIONS_SEMITONES, AUDIO_SAMPLE_RATE, AUDIO_WINDOW_SAMPLES, CONTOURS_TENSOR, CONTOUR_BINS, CONTOUR_BINS_PER_SEMITONE, ENERGY_TOLERANCE, FFT_HOP, FRAMES_TENSOR, FRAME_THRESHOLD, INPUT_TENSOR, MIDI_MAX_VELOCITY, MIDI_MIN_VELOCITY, MIDI_OFFSET, MIDI_PITCH_BEND_SEMITONES, MIDI_PITCH_BEND_STEPS, MIN_NOTE_LENGTH, MODEL_URL, ModelReadings, NoteCreationOptions, NoteEvent, NoteEventTime, ONSETS_TENSOR, ONSET_THRESHOLD, OVERLAPPING_FRAMES, OVERLAP_SAMPLES, PITCH_BEND_STANDARD_DEVIATION, PITCH_BEND_TOLERANCE, WINDOW_HOP_SAMPLES, WINDOW_OFFSET_SECONDS, addPitchBends, convertNotesToSeconds, createNotes, writeMidi } from "./notes.js";
2
+ import { InferenceSession, Tensor } from "onnxruntime-common";
3
+ export interface AudioChannels {
4
+ getChannelData: (channel: number) => Float32Array;
5
+ numberOfChannels: number;
6
+ sampleRate: number;
7
+ }
8
+ export declare const clearPitch: (energy: number[][], frame: number, pitch: number) => void;
9
+ export declare const constrainFrequency: (readings: number[][], maxFrequency?: number, minFrequency?: number) => number[][];
10
+ export declare const findLargestReading: (readings: number[][]) => number;
11
+ export declare const findOnsetPeaks: (onsets: number[][], threshold: number) => [number, number][];
12
+ export declare const inferOnsets: (onsets: number[][], frames: number[][]) => number[][];
13
+ export declare const mixDownChannels: (audio: AudioChannels | Float32Array) => Float32Array;
14
+ export interface AudioWindows {
15
+ count: number;
16
+ getWindow: (index: number) => Tensor;
17
+ }
18
+ export declare const prepareAudioWindows: (samples: Float32Array) => AudioWindows;
19
+ export declare const readModel: (session: Pick<InferenceSession, "run">, audio: AudioChannels | Float32Array, onWindow?: (readings: ModelReadings, progress: number) => void) => Promise<ModelReadings>;
20
+ export declare const unwrapWindowReadings: (readings: Float32Array, binCount: number) => number[][];
21
+ export { ANNOTATIONS_FPS, ANNOTATIONS_FRAMES_PER_WINDOW, ANNOTATIONS_SEMITONES, AUDIO_SAMPLE_RATE, AUDIO_WINDOW_SAMPLES, CONTOURS_TENSOR, CONTOUR_BINS, CONTOUR_BINS_PER_SEMITONE, ENERGY_TOLERANCE, FFT_HOP, FRAMES_TENSOR, FRAME_THRESHOLD, INPUT_TENSOR, MIDI_MAX_VELOCITY, MIDI_MIN_VELOCITY, MIDI_OFFSET, MIDI_PITCH_BEND_SEMITONES, MIDI_PITCH_BEND_STEPS, MIN_NOTE_LENGTH, MODEL_URL, ModelReadings, NoteCreationOptions, NoteEvent, NoteEventTime, ONSETS_TENSOR, ONSET_THRESHOLD, OVERLAPPING_FRAMES, OVERLAP_SAMPLES, PITCH_BEND_STANDARD_DEVIATION, PITCH_BEND_TOLERANCE, WINDOW_HOP_SAMPLES, WINDOW_OFFSET_SECONDS, addPitchBends, convertNotesToSeconds, createNotes, writeMidi };
package/dist/index.js ADDED
@@ -0,0 +1,85 @@
1
+ import { A as ONSETS_TENSOR, C as MIDI_MAX_VELOCITY, D as MIDI_PITCH_BEND_STEPS, E as MIDI_PITCH_BEND_SEMITONES, F as PITCH_BEND_TOLERANCE, I as WINDOW_HOP_SAMPLES, L as WINDOW_OFFSET_SECONDS, M as OVERLAPPING_FRAMES, N as OVERLAP_SAMPLES, O as MIN_NOTE_LENGTH, P as PITCH_BEND_STANDARD_DEVIATION, S as INPUT_TENSOR, T as MIDI_OFFSET, _ as CONTOUR_BINS_PER_SEMITONE, a as findOnsetPeaks, b as FRAMES_TENSOR, c as convertNotesToSeconds, d as ANNOTATIONS_FRAMES_PER_WINDOW, f as ANNOTATIONS_SEMITONES, g as CONTOUR_BINS, h as CONTOURS_TENSOR, i as findLargestReading, j as ONSET_THRESHOLD, k as MODEL_URL, l as addPitchBends, m as AUDIO_WINDOW_SAMPLES, n as createNotes, o as constrainFrequency, p as AUDIO_SAMPLE_RATE, r as inferOnsets, s as clearPitch, t as writeMidi, u as ANNOTATIONS_FPS, v as ENERGY_TOLERANCE, w as MIDI_MIN_VELOCITY, x as FRAME_THRESHOLD, y as FFT_HOP } from "./notes-D6NakKKJ.js";
2
+ import { InvalidOperationError, Operation, takeOne } from "@esposter/shared";
3
+ import { Tensor } from "onnxruntime-common";
4
+ // src/services/mixDownChannels.ts
5
+ const mixDownChannels = (audio) => {
6
+ if (audio instanceof Float32Array) return audio;
7
+ if (audio.sampleRate !== 22050) throw new InvalidOperationError(Operation.Read, mixDownChannels.name, `audio at ${audio.sampleRate} Hz, the model reads ${AUDIO_SAMPLE_RATE} Hz`);
8
+ if (audio.numberOfChannels === 1) return audio.getChannelData(0);
9
+ const mixed = new Float32Array(audio.getChannelData(0).length);
10
+ for (let channel = 0; channel < audio.numberOfChannels; channel++) {
11
+ const channelData = audio.getChannelData(channel);
12
+ for (let index = 0; index < channelData.length; index++) mixed[index] = (mixed[index] ?? 0) + (channelData[index] ?? 0) / audio.numberOfChannels;
13
+ }
14
+ return mixed;
15
+ };
16
+ // src/services/prepareAudioWindows.ts
17
+ const prepareAudioWindows = (samples) => {
18
+ const paddedLength = OVERLAP_SAMPLES / 2 + samples.length;
19
+ const count = Math.ceil(paddedLength / WINDOW_HOP_SAMPLES);
20
+ const padded = new Float32Array((count - 1) * WINDOW_HOP_SAMPLES + AUDIO_WINDOW_SAMPLES);
21
+ padded.set(samples, OVERLAP_SAMPLES / 2);
22
+ return {
23
+ count,
24
+ getWindow: (index) => {
25
+ const start = index * WINDOW_HOP_SAMPLES;
26
+ return new Tensor("float32", padded.subarray(start, start + AUDIO_WINDOW_SAMPLES), [
27
+ 1,
28
+ AUDIO_WINDOW_SAMPLES,
29
+ 1
30
+ ]);
31
+ }
32
+ };
33
+ };
34
+ // src/services/unwrapWindowReadings.ts
35
+ const unwrapWindowReadings = (readings, binCount) => {
36
+ const frameCount = readings.length / binCount;
37
+ const rows = [];
38
+ for (let frame = 15; frame < frameCount - 15; frame++) rows.push([...readings.subarray(frame * binCount, (frame + 1) * binCount)]);
39
+ return rows;
40
+ };
41
+ // src/services/readModel.ts
42
+ const readOutput = async (outputs, name, binCount) => {
43
+ const output = takeOne(outputs, name);
44
+ const data = await output.getData();
45
+ output.dispose();
46
+ if (!(data instanceof Float32Array)) throw new InvalidOperationError(Operation.Read, readOutput.name, `${name} as ${output.type}, the model writes float32`);
47
+ return unwrapWindowReadings(data, binCount);
48
+ };
49
+ const readWindow = async (session, window, remainingFrames) => {
50
+ const outputs = await session.run({ [INPUT_TENSOR]: window }, [
51
+ FRAMES_TENSOR,
52
+ ONSETS_TENSOR,
53
+ CONTOURS_TENSOR
54
+ ]);
55
+ const [frames, onsets, contours] = await Promise.all([
56
+ readOutput(outputs, FRAMES_TENSOR, 88),
57
+ readOutput(outputs, ONSETS_TENSOR, 88),
58
+ readOutput(outputs, CONTOURS_TENSOR, 264)
59
+ ]);
60
+ return {
61
+ contours: contours.slice(0, remainingFrames),
62
+ frames: frames.slice(0, remainingFrames),
63
+ onsets: onsets.slice(0, remainingFrames)
64
+ };
65
+ };
66
+ const readModel = async (session, audio, onWindow) => {
67
+ const samples = mixDownChannels(audio);
68
+ const frameCount = Math.floor(samples.length * 86 / AUDIO_SAMPLE_RATE);
69
+ const windows = prepareAudioWindows(samples);
70
+ const readings = {
71
+ contours: [],
72
+ frames: [],
73
+ onsets: []
74
+ };
75
+ for (let index = 0; index < windows.count && readings.frames.length < frameCount; index++) {
76
+ const remainingFrames = frameCount - readings.frames.length;
77
+ const windowReadings = await readWindow(session, windows.getWindow(index), remainingFrames);
78
+ readings.frames.push(...windowReadings.frames);
79
+ readings.onsets.push(...windowReadings.onsets);
80
+ readings.contours.push(...windowReadings.contours);
81
+ onWindow?.(windowReadings, readings.frames.length / frameCount);
82
+ }
83
+ return readings;
84
+ };
85
+ export { ANNOTATIONS_FPS, ANNOTATIONS_FRAMES_PER_WINDOW, ANNOTATIONS_SEMITONES, AUDIO_SAMPLE_RATE, AUDIO_WINDOW_SAMPLES, CONTOURS_TENSOR, CONTOUR_BINS, CONTOUR_BINS_PER_SEMITONE, ENERGY_TOLERANCE, FFT_HOP, FRAMES_TENSOR, FRAME_THRESHOLD, INPUT_TENSOR, MIDI_MAX_VELOCITY, MIDI_MIN_VELOCITY, MIDI_OFFSET, MIDI_PITCH_BEND_SEMITONES, MIDI_PITCH_BEND_STEPS, MIN_NOTE_LENGTH, MODEL_URL, ONSETS_TENSOR, ONSET_THRESHOLD, OVERLAPPING_FRAMES, OVERLAP_SAMPLES, PITCH_BEND_STANDARD_DEVIATION, PITCH_BEND_TOLERANCE, WINDOW_HOP_SAMPLES, WINDOW_OFFSET_SECONDS, addPitchBends, clearPitch, constrainFrequency, convertNotesToSeconds, createNotes, findLargestReading, findOnsetPeaks, inferOnsets, mixDownChannels, prepareAudioWindows, readModel, unwrapWindowReadings, writeMidi };
@@ -0,0 +1,307 @@
1
+ import { takeOne } from "@esposter/shared";
2
+ // src/constants.ts
3
+ const AUDIO_SAMPLE_RATE = 22050;
4
+ const FFT_HOP = 256;
5
+ const AUDIO_WINDOW_SAMPLES = AUDIO_SAMPLE_RATE * 2 - 256;
6
+ const ANNOTATIONS_FPS = Math.floor(AUDIO_SAMPLE_RATE / 256);
7
+ const ANNOTATIONS_FRAMES_PER_WINDOW = 172;
8
+ const OVERLAPPING_FRAMES = 30;
9
+ const OVERLAP_SAMPLES = 7680;
10
+ const WINDOW_HOP_SAMPLES = 36164;
11
+ const WINDOW_OFFSET_SECONDS = .010326077097505668;
12
+ const MIDI_OFFSET = 21;
13
+ const ANNOTATIONS_SEMITONES = 88;
14
+ const CONTOUR_BINS_PER_SEMITONE = 3;
15
+ const CONTOUR_BINS = 264;
16
+ const INPUT_TENSOR = "serving_default_input_2:0";
17
+ const FRAMES_TENSOR = "StatefulPartitionedCall:1";
18
+ const ONSETS_TENSOR = "StatefulPartitionedCall:2";
19
+ const CONTOURS_TENSOR = "StatefulPartitionedCall:0";
20
+ const MODEL_URL = new URL("../model/nmp.onnx", import.meta.url);
21
+ const ONSET_THRESHOLD = .5;
22
+ const FRAME_THRESHOLD = .3;
23
+ const MIN_NOTE_LENGTH = 11;
24
+ const ENERGY_TOLERANCE = 11;
25
+ const PITCH_BEND_TOLERANCE = 25;
26
+ const PITCH_BEND_STANDARD_DEVIATION = 5;
27
+ const MIDI_PITCH_BEND_SEMITONES = 2;
28
+ const MIDI_PITCH_BEND_STEPS = 8192;
29
+ const MIDI_MIN_VELOCITY = 1;
30
+ const MIDI_MAX_VELOCITY = 127;
31
+ // src/services/addPitchBends.ts
32
+ const PITCH_BEND_WEIGHTS = Array.from({ length: 51 }, (_value, offset) => Math.exp(-((offset - 25) ** 2) / 50));
33
+ const addPitchBends = (contours, notes) => notes.map((note) => {
34
+ const bin = (note.pitchMidi - 21) * 3;
35
+ const start = Math.max(bin - 25, 0);
36
+ const end = Math.min(bin + 25 + 1, 264);
37
+ const pitchBends = contours.slice(note.startFrame, note.startFrame + note.durationFrames).map((row) => {
38
+ let bestBin = start;
39
+ let bestReading = -Infinity;
40
+ for (let contourBin = start; contourBin < end; contourBin++) {
41
+ const reading = takeOne(row, contourBin) * takeOne(PITCH_BEND_WEIGHTS, contourBin - bin + 25);
42
+ if (reading <= bestReading) continue;
43
+ bestBin = contourBin;
44
+ bestReading = reading;
45
+ }
46
+ return bestBin - bin;
47
+ });
48
+ return {
49
+ ...note,
50
+ pitchBends
51
+ };
52
+ });
53
+ // src/services/convertNotesToSeconds.ts
54
+ const toSeconds = (frame) => frame * 256 / AUDIO_SAMPLE_RATE - WINDOW_OFFSET_SECONDS * Math.floor(frame / 172);
55
+ const convertNotesToSeconds = (notes) => notes.map(({ amplitude, durationFrames, pitchBends, pitchMidi, startFrame }) => {
56
+ const startTimeSeconds = toSeconds(startFrame);
57
+ return {
58
+ amplitude,
59
+ durationSeconds: toSeconds(startFrame + durationFrames) - startTimeSeconds,
60
+ pitchBends,
61
+ pitchMidi,
62
+ startTimeSeconds
63
+ };
64
+ });
65
+ // src/services/clearPitch.ts
66
+ const clearPitch = (energy, frame, pitch) => {
67
+ const row = takeOne(energy, frame);
68
+ row[pitch] = 0;
69
+ if (pitch + 1 < row.length) row[pitch + 1] = 0;
70
+ if (pitch > 0) row[pitch - 1] = 0;
71
+ };
72
+ // src/services/constrainFrequency.ts
73
+ const toPitchIndex = (frequency) => Math.round(12 * Math.log2(frequency / 440) + 69 - 21);
74
+ const constrainFrequency = (readings, maxFrequency, minFrequency) => {
75
+ if (maxFrequency === void 0 && minFrequency === void 0) return readings;
76
+ const end = maxFrequency === void 0 ? Infinity : toPitchIndex(maxFrequency);
77
+ const start = minFrequency === void 0 ? 0 : toPitchIndex(minFrequency);
78
+ return readings.map((row) => row.map((reading, pitch) => pitch >= start && pitch < end ? reading : 0));
79
+ };
80
+ // src/services/findOnsetPeaks.ts
81
+ const findOnsetPeaks = (onsets, threshold) => {
82
+ const peaks = [];
83
+ for (let frame = 1; frame < onsets.length - 1; frame++) {
84
+ const previous = takeOne(onsets, frame - 1);
85
+ const row = takeOne(onsets, frame);
86
+ const next = takeOne(onsets, frame + 1);
87
+ for (let pitch = 0; pitch < row.length; pitch++) {
88
+ const reading = takeOne(row, pitch);
89
+ if (reading > threshold && reading > takeOne(previous, pitch) && reading > takeOne(next, pitch)) peaks.push([frame, pitch]);
90
+ }
91
+ }
92
+ return peaks;
93
+ };
94
+ // src/services/findLargestReading.ts
95
+ const findLargestReading = (readings) => {
96
+ let largest = 0;
97
+ for (const row of readings) for (const reading of row) largest = Math.max(largest, reading);
98
+ return largest;
99
+ };
100
+ // src/services/inferOnsets.ts
101
+ const getJump = (row, previous, beforePrevious, pitch) => {
102
+ const reading = takeOne(row, pitch);
103
+ return Math.max(0, Math.min(reading - takeOne(previous, pitch), reading - takeOne(beforePrevious, pitch)));
104
+ };
105
+ const inferOnsets = (onsets, frames) => {
106
+ let largestJump = 0;
107
+ for (let frame = 2; frame < frames.length; frame++) {
108
+ const row = takeOne(frames, frame);
109
+ const previous = takeOne(frames, frame - 1);
110
+ const beforePrevious = takeOne(frames, frame - 2);
111
+ for (let pitch = 0; pitch < row.length; pitch++) largestJump = Math.max(largestJump, getJump(row, previous, beforePrevious, pitch));
112
+ }
113
+ if (largestJump === 0) return onsets;
114
+ const scale = findLargestReading(onsets) / largestJump;
115
+ return onsets.map((onsetRow, frame) => {
116
+ const row = takeOne(frames, frame);
117
+ const previous = frame < 2 ? row : takeOne(frames, frame - 1);
118
+ const beforePrevious = frame < 2 ? row : takeOne(frames, frame - 2);
119
+ return onsetRow.map((reading, pitch) => Math.max(reading, scale * getJump(row, previous, beforePrevious, pitch)));
120
+ });
121
+ };
122
+ // src/services/createNotes.ts
123
+ const getMeanReading = (frames, start, end, pitch) => {
124
+ let sum = 0;
125
+ for (let frame = start; frame < end; frame++) sum += takeOne(takeOne(frames, frame), pitch);
126
+ return sum / (end - start);
127
+ };
128
+ const createNotes = ({ frames, onsets }, { energyTolerance = 11, frameThreshold = FRAME_THRESHOLD, isInferringOnsets = true, isMelodiaTrick = true, maxFrequency, minFrequency, minNoteLength = 11, onsetThreshold = ONSET_THRESHOLD } = {}) => {
129
+ const frameCount = frames.length;
130
+ const constrainedFrames = constrainFrequency(frames, maxFrequency, minFrequency);
131
+ const constrainedOnsets = constrainFrequency(onsets, maxFrequency, minFrequency);
132
+ const peakOnsets = isInferringOnsets ? inferOnsets(constrainedOnsets, constrainedFrames) : constrainedOnsets;
133
+ const energy = constrainedFrames.map((row) => [...row]);
134
+ const notes = [];
135
+ for (const [startFrame, pitch] of findOnsetPeaks(peakOnsets, onsetThreshold).toReversed()) {
136
+ if (startFrame >= frameCount - 1) continue;
137
+ let end = startFrame + 1;
138
+ let quietFrames = 0;
139
+ for (; end < frameCount - 1 && quietFrames < energyTolerance; end++) quietFrames = takeOne(takeOne(energy, end), pitch) < frameThreshold ? quietFrames + 1 : 0;
140
+ end -= quietFrames;
141
+ if (end - startFrame <= minNoteLength) continue;
142
+ for (let frame = startFrame; frame < end; frame++) clearPitch(energy, frame, pitch);
143
+ notes.push({
144
+ amplitude: getMeanReading(constrainedFrames, startFrame, end, pitch),
145
+ durationFrames: end - startFrame,
146
+ pitchMidi: pitch + 21,
147
+ startFrame
148
+ });
149
+ }
150
+ if (!isMelodiaTrick) return notes;
151
+ const candidates = [];
152
+ for (const [frame, row] of energy.entries()) for (let pitch = 0; pitch < row.length; pitch++) {
153
+ const reading = takeOne(row, pitch);
154
+ if (reading > frameThreshold) candidates.push([
155
+ reading,
156
+ frame,
157
+ pitch
158
+ ]);
159
+ }
160
+ const sortedCandidates = candidates.toSorted(([firstReading, firstFrame, firstPitch], [secondReading, secondFrame, secondPitch]) => secondReading - firstReading || firstFrame - secondFrame || firstPitch - secondPitch);
161
+ for (const [reading, middle, pitch] of sortedCandidates) {
162
+ const middleRow = takeOne(energy, middle);
163
+ if (takeOne(middleRow, pitch) !== reading) continue;
164
+ middleRow[pitch] = 0;
165
+ let frame = middle + 1;
166
+ let quietFrames = 0;
167
+ for (; frame < frameCount - 1 && quietFrames < energyTolerance; frame++) {
168
+ quietFrames = takeOne(takeOne(energy, frame), pitch) < frameThreshold ? quietFrames + 1 : 0;
169
+ clearPitch(energy, frame, pitch);
170
+ }
171
+ const end = frame - 1 - quietFrames;
172
+ frame = middle - 1;
173
+ quietFrames = 0;
174
+ for (; frame > 0 && quietFrames < energyTolerance; frame--) {
175
+ quietFrames = takeOne(takeOne(energy, frame), pitch) < frameThreshold ? quietFrames + 1 : 0;
176
+ clearPitch(energy, frame, pitch);
177
+ }
178
+ const start = frame + 1 + quietFrames;
179
+ if (end - start <= minNoteLength) continue;
180
+ notes.push({
181
+ amplitude: getMeanReading(constrainedFrames, start, end, pitch),
182
+ durationFrames: end - start,
183
+ pitchMidi: pitch + 21,
184
+ startFrame: start
185
+ });
186
+ }
187
+ return notes;
188
+ };
189
+ // src/services/writeMidi.ts
190
+ const MIDI_TICKS_PER_BEAT = 480;
191
+ const MIDI_TICKS_PER_SECOND = MIDI_TICKS_PER_BEAT * 2;
192
+ const writeVariableLength = (value) => {
193
+ const bytes = [value & 127];
194
+ for (let rest = value >> 7; rest > 0; rest >>= 7) bytes.unshift(rest & 127 | 128);
195
+ return bytes;
196
+ };
197
+ const toTicks = (seconds) => Math.round(seconds * MIDI_TICKS_PER_SECOND);
198
+ const toVelocity = (amplitude) => {
199
+ const velocity = Math.floor(amplitude * 127);
200
+ return Math.min(127, Math.max(1, velocity));
201
+ };
202
+ const toUint16 = (value) => [value >> 8 & 255, value & 255];
203
+ const toUint32 = (value) => [
204
+ value >>> 24 & 255,
205
+ value >> 16 & 255,
206
+ value >> 8 & 255,
207
+ value & 255
208
+ ];
209
+ const writeChunk = (type, data) => [
210
+ ...type,
211
+ ...toUint32(data.length),
212
+ ...data
213
+ ];
214
+ const HEADER_CHUNK_TYPE = [
215
+ 77,
216
+ 84,
217
+ 104,
218
+ 100
219
+ ];
220
+ const TRACK_CHUNK_TYPE = [
221
+ 77,
222
+ 84,
223
+ 114,
224
+ 107
225
+ ];
226
+ const EMPTY_TRACK_NAME = [
227
+ 255,
228
+ 3,
229
+ 0
230
+ ];
231
+ const END_OF_TRACK = [
232
+ 255,
233
+ 47,
234
+ 0
235
+ ];
236
+ const PIANO_PROGRAM_CHANGE = [192, 0];
237
+ const writeTrack = (events) => {
238
+ const data = [];
239
+ let lastTick = 0;
240
+ for (const { bytes, tick } of events.toSorted((firstEvent, secondEvent) => firstEvent.tick - secondEvent.tick)) {
241
+ data.push(...writeVariableLength(tick - lastTick), ...bytes);
242
+ lastTick = tick;
243
+ }
244
+ data.push(0, ...END_OF_TRACK);
245
+ return writeChunk(TRACK_CHUNK_TYPE, data);
246
+ };
247
+ const writeNoteEvents = (notes) => notes.map(({ amplitude, durationSeconds, pitchMidi, startTimeSeconds }) => ({
248
+ amplitude,
249
+ offTick: toTicks(startTimeSeconds) + toTicks(durationSeconds),
250
+ onTick: toTicks(startTimeSeconds),
251
+ pitchMidi
252
+ })).toSorted((firstNote, secondNote) => firstNote.onTick - secondNote.onTick).flatMap(({ amplitude, offTick, onTick, pitchMidi }) => [{
253
+ bytes: [
254
+ 144,
255
+ pitchMidi,
256
+ toVelocity(amplitude)
257
+ ],
258
+ tick: onTick
259
+ }, {
260
+ bytes: [
261
+ 128,
262
+ pitchMidi,
263
+ 0
264
+ ],
265
+ tick: offTick
266
+ }]);
267
+ const writePitchBendEvents = (notes) => notes.flatMap(({ durationSeconds, pitchBends = [], startTimeSeconds }) => pitchBends.map((bend, index) => {
268
+ const share = bend / 3 / 2;
269
+ const value = MIDI_PITCH_BEND_STEPS + Math.max(-8192, Math.min(8191, Math.round(share * MIDI_PITCH_BEND_STEPS)));
270
+ return {
271
+ bytes: [
272
+ 224,
273
+ value & 127,
274
+ value >> 7 & 127
275
+ ],
276
+ tick: toTicks(startTimeSeconds + index * durationSeconds / pitchBends.length)
277
+ };
278
+ }));
279
+ const writeMidi = (notes) => {
280
+ const header = writeChunk(HEADER_CHUNK_TYPE, [
281
+ ...toUint16(1),
282
+ ...toUint16(2),
283
+ ...toUint16(MIDI_TICKS_PER_BEAT)
284
+ ]);
285
+ const tempoTrack = writeTrack([{
286
+ bytes: EMPTY_TRACK_NAME,
287
+ tick: 0
288
+ }]);
289
+ const noteTrack = writeTrack([
290
+ {
291
+ bytes: EMPTY_TRACK_NAME,
292
+ tick: 0
293
+ },
294
+ {
295
+ bytes: PIANO_PROGRAM_CHANGE,
296
+ tick: 0
297
+ },
298
+ ...writeNoteEvents(notes),
299
+ ...writePitchBendEvents(notes)
300
+ ]);
301
+ return new Uint8Array([
302
+ ...header,
303
+ ...tempoTrack,
304
+ ...noteTrack
305
+ ]);
306
+ };
307
+ export { ONSETS_TENSOR as A, MIDI_MAX_VELOCITY as C, MIDI_PITCH_BEND_STEPS as D, MIDI_PITCH_BEND_SEMITONES as E, PITCH_BEND_TOLERANCE as F, WINDOW_HOP_SAMPLES as I, WINDOW_OFFSET_SECONDS as L, OVERLAPPING_FRAMES as M, OVERLAP_SAMPLES as N, MIN_NOTE_LENGTH as O, PITCH_BEND_STANDARD_DEVIATION as P, INPUT_TENSOR as S, MIDI_OFFSET as T, CONTOUR_BINS_PER_SEMITONE as _, findOnsetPeaks as a, FRAMES_TENSOR as b, convertNotesToSeconds as c, ANNOTATIONS_FRAMES_PER_WINDOW as d, ANNOTATIONS_SEMITONES as f, CONTOUR_BINS as g, CONTOURS_TENSOR as h, findLargestReading as i, ONSET_THRESHOLD as j, MODEL_URL as k, addPitchBends as l, AUDIO_WINDOW_SAMPLES as m, createNotes as n, constrainFrequency as o, AUDIO_SAMPLE_RATE as p, inferOnsets as r, clearPitch as s, writeMidi as t, ANNOTATIONS_FPS as u, ENERGY_TOLERANCE as v, MIDI_MIN_VELOCITY as w, FRAME_THRESHOLD as x, FFT_HOP as y };
@@ -0,0 +1,62 @@
1
+ export declare const AUDIO_SAMPLE_RATE = 22050;
2
+ export declare const FFT_HOP = 256;
3
+ export declare const AUDIO_WINDOW_SAMPLES: number;
4
+ export declare const ANNOTATIONS_FPS: number;
5
+ export declare const ANNOTATIONS_FRAMES_PER_WINDOW: number;
6
+ export declare const OVERLAPPING_FRAMES = 30;
7
+ export declare const OVERLAP_SAMPLES: number;
8
+ export declare const WINDOW_HOP_SAMPLES: number;
9
+ export declare const WINDOW_OFFSET_SECONDS: number;
10
+ export declare const MIDI_OFFSET = 21;
11
+ export declare const ANNOTATIONS_SEMITONES = 88;
12
+ export declare const CONTOUR_BINS_PER_SEMITONE = 3;
13
+ export declare const CONTOUR_BINS: number;
14
+ export declare const INPUT_TENSOR = "serving_default_input_2:0";
15
+ export declare const FRAMES_TENSOR = "StatefulPartitionedCall:1";
16
+ export declare const ONSETS_TENSOR = "StatefulPartitionedCall:2";
17
+ export declare const CONTOURS_TENSOR = "StatefulPartitionedCall:0";
18
+ export declare const MODEL_URL: URL;
19
+ export declare const ONSET_THRESHOLD = .5;
20
+ export declare const FRAME_THRESHOLD = .3;
21
+ export declare const MIN_NOTE_LENGTH = 11;
22
+ export declare const ENERGY_TOLERANCE = 11;
23
+ export declare const PITCH_BEND_TOLERANCE = 25;
24
+ export declare const PITCH_BEND_STANDARD_DEVIATION = 5;
25
+ export declare const MIDI_PITCH_BEND_SEMITONES = 2;
26
+ export declare const MIDI_PITCH_BEND_STEPS: number;
27
+ export declare const MIDI_MIN_VELOCITY = 1;
28
+ export declare const MIDI_MAX_VELOCITY = 127;
29
+ interface ModelReadings {
30
+ contours: number[][];
31
+ frames: number[][];
32
+ onsets: number[][];
33
+ }
34
+ interface NoteCreationOptions {
35
+ energyTolerance?: number;
36
+ frameThreshold?: number;
37
+ isInferringOnsets?: boolean;
38
+ isMelodiaTrick?: boolean;
39
+ maxFrequency?: number;
40
+ minFrequency?: number;
41
+ minNoteLength?: number;
42
+ onsetThreshold?: number;
43
+ }
44
+ interface NoteEvent {
45
+ amplitude: number;
46
+ durationFrames: number;
47
+ pitchBends?: number[];
48
+ pitchMidi: number;
49
+ startFrame: number;
50
+ }
51
+ interface NoteEventTime {
52
+ amplitude: number;
53
+ durationSeconds: number;
54
+ pitchBends?: number[];
55
+ pitchMidi: number;
56
+ startTimeSeconds: number;
57
+ }
58
+ export declare const addPitchBends: (contours: number[][], notes: NoteEvent[]) => NoteEvent[];
59
+ export declare const convertNotesToSeconds: (notes: NoteEvent[]) => NoteEventTime[];
60
+ export declare const createNotes: ({ frames, onsets }: Pick<ModelReadings, "frames" | "onsets">, { energyTolerance, frameThreshold, isInferringOnsets, isMelodiaTrick, maxFrequency, minFrequency, minNoteLength, onsetThreshold }?: NoteCreationOptions) => NoteEvent[];
61
+ export declare const writeMidi: (notes: NoteEventTime[]) => Uint8Array;
62
+ export type { ModelReadings, NoteCreationOptions, NoteEvent, NoteEventTime };
package/dist/notes.js ADDED
@@ -0,0 +1 @@
1
+ export { u as ANNOTATIONS_FPS, d as ANNOTATIONS_FRAMES_PER_WINDOW, f as ANNOTATIONS_SEMITONES, p as AUDIO_SAMPLE_RATE, m as AUDIO_WINDOW_SAMPLES, h as CONTOURS_TENSOR, g as CONTOUR_BINS, _ as CONTOUR_BINS_PER_SEMITONE, v as ENERGY_TOLERANCE, y as FFT_HOP, b as FRAMES_TENSOR, x as FRAME_THRESHOLD, S as INPUT_TENSOR, C as MIDI_MAX_VELOCITY, w as MIDI_MIN_VELOCITY, T as MIDI_OFFSET, E as MIDI_PITCH_BEND_SEMITONES, D as MIDI_PITCH_BEND_STEPS, O as MIN_NOTE_LENGTH, k as MODEL_URL, A as ONSETS_TENSOR, j as ONSET_THRESHOLD, M as OVERLAPPING_FRAMES, N as OVERLAP_SAMPLES, P as PITCH_BEND_STANDARD_DEVIATION, F as PITCH_BEND_TOLERANCE, I as WINDOW_HOP_SAMPLES, L as WINDOW_OFFSET_SECONDS, l as addPitchBends, c as convertNotesToSeconds, n as createNotes, t as writeMidi } from "./notes-D6NakKKJ.js";
package/model/nmp.onnx ADDED
Binary file
package/package.json CHANGED
@@ -1,15 +1,16 @@
1
1
  {
2
2
  "name": "pitch-transcription",
3
- "version": "4.2.0",
4
- "description": "The pitched notes in a recording — Spotify's Basic Pitch model on current TensorFlow.js, its readings turned into notes with pitch bends and written as MIDI.",
3
+ "version": "4.3.3",
4
+ "description": "The pitched notes in a recording — Spotify's Basic Pitch model on ONNX Runtime, its readings turned into notes with pitch bends and written as MIDI.",
5
5
  "keywords": [
6
6
  "audio",
7
7
  "basic-pitch",
8
8
  "midi",
9
9
  "music",
10
+ "onnx",
11
+ "onnxruntime",
10
12
  "pitch",
11
13
  "polyphonic",
12
- "tensorflow",
13
14
  "transcription",
14
15
  "typescript"
15
16
  ],
@@ -45,28 +46,23 @@
45
46
  "scripts": {
46
47
  "bench": "vitest bench --run",
47
48
  "build": "tsdown",
48
- "format": "oxfmt",
49
- "format:check": "oxfmt --check",
50
49
  "lint": "eslint .",
51
50
  "lint:fix": "eslint --fix .",
52
51
  "test": "vitest",
53
52
  "typecheck": "tsc"
54
53
  },
55
54
  "dependencies": {
56
- "@esposter/shared": "4.2.0"
55
+ "@esposter/shared": "4.3.3"
57
56
  },
58
57
  "devDependencies": {
59
- "@arethetypeswrong/core": "^0.18.5",
60
- "@esposter/configuration": "4.2.0",
61
- "@esposter/shared-node": "4.2.0",
62
- "@tensorflow/tfjs": "^4.22.0",
58
+ "@esposter/configuration": "4.3.3",
59
+ "@esposter/shared-node": "4.3.3",
63
60
  "@types/node": "^26.6.5",
64
- "publint": "^0.3.25",
65
- "tsdown": "^0.23.1",
66
- "vitest": "^5.0.4"
61
+ "onnxruntime-node": "^1.30.0",
62
+ "tsdown": "^0.23.1"
67
63
  },
68
64
  "peerDependencies": {
69
- "@tensorflow/tfjs": "^4.22.0"
65
+ "onnxruntime-common": "^1.30.0"
70
66
  },
71
- "gitHead": "fef4971cf6a621a2c1850f32fcec19ee4ec0de99"
67
+ "gitHead": "955bbc130ad9b28877fd4ae8efc73a045141ce01"
72
68
  }
Binary file