@hum2music/audio-analysis 0.0.0-stage → 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.
package/package.json CHANGED
@@ -1,6 +1,19 @@
1
1
  {
2
2
  "name": "@hum2music/audio-analysis",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "WAV decoding/encoding, YIN pitch detection, and melody transcription for Hum2Music.",
5
+ "license": "Apache-2.0",
6
+ "homepage": "https://github.com/hum2music/hum2music",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/hum2music/hum2music.git",
10
+ "directory": "packages/audio-analysis"
11
+ },
12
+ "type": "module",
13
+ "main": "./src/index.ts",
14
+ "types": "./src/index.ts",
15
+ "files": ["src"],
16
+ "dependencies": {
17
+ "@hum2music/core": "0.1.0"
18
+ }
19
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export * from "./wav";
2
+ export * from "./pitch-to-midi";
3
+ export * from "./yin";
4
+ export * from "./smoothing";
5
+ export * from "./segmentation";
6
+ export * from "./transcribe";
@@ -0,0 +1,7 @@
1
+ export function frequencyToMidiNote(frequencyHz: number): number {
2
+ return 69 + 12 * Math.log2(frequencyHz / 440);
3
+ }
4
+
5
+ export function midiNoteToFrequency(midiNote: number): number {
6
+ return 440 * Math.pow(2, (midiNote - 69) / 12);
7
+ }
@@ -0,0 +1,62 @@
1
+ import type { NoteCandidate, PitchFrame } from "@hum2music/core";
2
+ import { frequencyToMidiNote } from "./pitch-to-midi";
3
+
4
+ export interface SegmentationOptions {
5
+ hopMs: number;
6
+ pitchJumpCents?: number;
7
+ minVoicedFrames?: number;
8
+ }
9
+
10
+ /**
11
+ * Groups a cleaned pitch trajectory into note candidates. A run of voiced
12
+ * frames stays one note as long as it doesn't jump more than
13
+ * `pitchJumpCents` away from the running average of the current note;
14
+ * unvoiced frames (rests) always end the current note.
15
+ */
16
+ export function segmentNotes(frames: PitchFrame[], options: SegmentationOptions): NoteCandidate[] {
17
+ const pitchJumpCents = options.pitchJumpCents ?? 50;
18
+ const minVoicedFrames = options.minVoicedFrames ?? 2;
19
+
20
+ const candidates: NoteCandidate[] = [];
21
+ let current: PitchFrame[] = [];
22
+ let midiSum = 0;
23
+
24
+ const flush = () => {
25
+ if (current.length >= minVoicedFrames) {
26
+ candidates.push(buildCandidate(current, options.hopMs));
27
+ }
28
+ current = [];
29
+ midiSum = 0;
30
+ };
31
+
32
+ for (const frame of frames) {
33
+ if (!frame.voiced || frame.f0Hz === null) {
34
+ flush();
35
+ continue;
36
+ }
37
+
38
+ const midi = frequencyToMidiNote(frame.f0Hz);
39
+
40
+ if (current.length > 0) {
41
+ const runningAverage = midiSum / current.length;
42
+ const centsDelta = (midi - runningAverage) * 100;
43
+ if (Math.abs(centsDelta) > pitchJumpCents) flush();
44
+ }
45
+
46
+ current.push(frame);
47
+ midiSum += midi;
48
+ }
49
+ flush();
50
+
51
+ return candidates;
52
+ }
53
+
54
+ function buildCandidate(frames: PitchFrame[], hopMs: number): NoteCandidate {
55
+ const startMs = frames[0].timeMs;
56
+ const durationMs = frames[frames.length - 1].timeMs - startMs + hopMs;
57
+ const midiValues = frames.map((frame) => frequencyToMidiNote(frame.f0Hz as number));
58
+ const rawPitch = midiValues.reduce((sum, value) => sum + value, 0) / midiValues.length;
59
+ const onsetConfidence = frames.reduce((sum, frame) => sum + frame.confidence, 0) / frames.length;
60
+
61
+ return { startMs, durationMs, rawPitch, pitchTrajectory: frames, onsetConfidence };
62
+ }
@@ -0,0 +1,64 @@
1
+ import type { PitchFrame } from "@hum2music/core";
2
+
3
+ export interface OutlierOptions {
4
+ neighborRadius?: number;
5
+ centsThreshold?: number;
6
+ }
7
+
8
+ /** Nulls out isolated pitch spikes that deviate too far from their local median. */
9
+ export function removePitchOutliers(frames: PitchFrame[], options: OutlierOptions = {}): PitchFrame[] {
10
+ const neighborRadius = options.neighborRadius ?? 3;
11
+ const centsThreshold = options.centsThreshold ?? 80;
12
+
13
+ const result = frames.map((frame) => ({ ...frame }));
14
+
15
+ for (let i = 0; i < result.length; i++) {
16
+ const frame = result[i];
17
+ if (!frame.voiced || frame.f0Hz === null) continue;
18
+
19
+ const neighborFrequencies: number[] = [];
20
+ for (let offset = -neighborRadius; offset <= neighborRadius; offset++) {
21
+ if (offset === 0) continue;
22
+ const neighbor = result[i + offset];
23
+ if (neighbor?.voiced && neighbor.f0Hz !== null) neighborFrequencies.push(neighbor.f0Hz);
24
+ }
25
+ if (neighborFrequencies.length === 0) continue;
26
+
27
+ const medianNeighbor = median(neighborFrequencies);
28
+ const cents = 1200 * Math.log2(frame.f0Hz / medianNeighbor);
29
+ if (Math.abs(cents) > centsThreshold) {
30
+ frame.f0Hz = null;
31
+ frame.voiced = false;
32
+ frame.confidence = 0;
33
+ }
34
+ }
35
+
36
+ return result;
37
+ }
38
+
39
+ /** Moving-average smoothing of the voiced pitch trajectory; gaps (rests) are left untouched. */
40
+ export function smoothPitchTrajectory(frames: PitchFrame[], windowSize = 3): PitchFrame[] {
41
+ const half = Math.floor(windowSize / 2);
42
+ const result = frames.map((frame) => ({ ...frame }));
43
+
44
+ for (let i = 0; i < result.length; i++) {
45
+ if (!frames[i].voiced || frames[i].f0Hz === null) continue;
46
+
47
+ const windowValues: number[] = [];
48
+ for (let offset = -half; offset <= half; offset++) {
49
+ const neighbor = frames[i + offset];
50
+ if (neighbor?.voiced && neighbor.f0Hz !== null) windowValues.push(neighbor.f0Hz);
51
+ }
52
+ if (windowValues.length > 0) {
53
+ result[i].f0Hz = windowValues.reduce((sum, value) => sum + value, 0) / windowValues.length;
54
+ }
55
+ }
56
+
57
+ return result;
58
+ }
59
+
60
+ function median(values: number[]): number {
61
+ const sorted = [...values].sort((a, b) => a - b);
62
+ const mid = Math.floor(sorted.length / 2);
63
+ return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
64
+ }
@@ -0,0 +1,58 @@
1
+ import { DEFAULT_HOP_SIZE, detectPitchYin } from "./yin";
2
+ import { removePitchOutliers, smoothPitchTrajectory } from "./smoothing";
3
+ import { segmentNotes } from "./segmentation";
4
+
5
+ export interface TranscribedNote {
6
+ midiPitch: number;
7
+ rawPitch: number;
8
+ startMs: number;
9
+ durationMs: number;
10
+ velocity: number;
11
+ confidence: number;
12
+ }
13
+
14
+ /**
15
+ * Audio-to-melody transcription pipeline: raw samples -> YIN pitch track ->
16
+ * outlier removal and smoothing -> note segmentation -> integer MIDI notes
17
+ * ready for export. There is no tempo/beat model yet, so timing stays in ms.
18
+ */
19
+ export function transcribeMelody(samples: Float32Array, sampleRate: number): TranscribedNote[] {
20
+ const rawFrames = detectPitchYin(samples, sampleRate);
21
+ const cleaned = smoothPitchTrajectory(removePitchOutliers(rawFrames));
22
+ const hopMs = (DEFAULT_HOP_SIZE / sampleRate) * 1000;
23
+ const candidates = segmentNotes(cleaned, { hopMs });
24
+
25
+ return candidates.map((candidate) => ({
26
+ midiPitch: clampMidiPitch(Math.round(candidate.rawPitch)),
27
+ rawPitch: candidate.rawPitch,
28
+ startMs: candidate.startMs,
29
+ durationMs: candidate.durationMs,
30
+ velocity: estimateVelocity(samples, sampleRate, candidate.startMs, candidate.durationMs),
31
+ confidence: candidate.onsetConfidence,
32
+ }));
33
+ }
34
+
35
+ function clampMidiPitch(pitch: number): number {
36
+ return Math.min(127, Math.max(0, pitch));
37
+ }
38
+
39
+ // Scaling factor chosen so a full-scale sine (RMS ~0.707) maps near the top
40
+ // of the MIDI velocity range; proposed value, to be revisited with real
41
+ // humming recordings (see docs/specs/audio-analysis.md).
42
+ const VELOCITY_RMS_SCALE = 170;
43
+
44
+ function estimateVelocity(samples: Float32Array, sampleRate: number, startMs: number, durationMs: number): number {
45
+ const startSample = Math.max(0, Math.floor((startMs / 1000) * sampleRate));
46
+ const endSample = Math.min(samples.length, Math.floor(((startMs + durationMs) / 1000) * sampleRate));
47
+
48
+ let sumOfSquares = 0;
49
+ let count = 0;
50
+ for (let i = startSample; i < endSample; i++) {
51
+ sumOfSquares += samples[i] * samples[i];
52
+ count++;
53
+ }
54
+ if (count === 0) return 64;
55
+
56
+ const rms = Math.sqrt(sumOfSquares / count);
57
+ return Math.min(127, Math.max(1, Math.round(rms * VELOCITY_RMS_SCALE)));
58
+ }
package/src/wav.ts ADDED
@@ -0,0 +1,103 @@
1
+ export interface DecodedAudio {
2
+ sampleRate: number;
3
+ samples: Float32Array;
4
+ }
5
+
6
+ export function decodeWav(buffer: ArrayBuffer): DecodedAudio {
7
+ const view = new DataView(buffer);
8
+
9
+ if (readAscii(view, 0, 4) !== "RIFF") throw new Error("Not a RIFF file");
10
+ if (readAscii(view, 8, 4) !== "WAVE") throw new Error("Not a WAVE file");
11
+
12
+ let offset = 12;
13
+ let fmt: { audioFormat: number; numChannels: number; sampleRate: number; bitsPerSample: number } | null = null;
14
+ let dataOffset = -1;
15
+ let dataLength = 0;
16
+
17
+ while (offset + 8 <= view.byteLength) {
18
+ const chunkId = readAscii(view, offset, 4);
19
+ const chunkSize = view.getUint32(offset + 4, true);
20
+ const chunkStart = offset + 8;
21
+
22
+ if (chunkId === "fmt ") {
23
+ fmt = {
24
+ audioFormat: view.getUint16(chunkStart, true),
25
+ numChannels: view.getUint16(chunkStart + 2, true),
26
+ sampleRate: view.getUint32(chunkStart + 4, true),
27
+ bitsPerSample: view.getUint16(chunkStart + 14, true),
28
+ };
29
+ } else if (chunkId === "data") {
30
+ dataOffset = chunkStart;
31
+ dataLength = chunkSize;
32
+ }
33
+
34
+ offset = chunkStart + chunkSize + (chunkSize % 2);
35
+ }
36
+
37
+ if (!fmt) throw new Error("Malformed WAV file: missing fmt chunk");
38
+ if (dataOffset < 0) throw new Error("Malformed WAV file: missing data chunk");
39
+ if (fmt.audioFormat !== 1) {
40
+ throw new Error(`Unsupported WAV encoding (audioFormat=${fmt.audioFormat}); only PCM is supported`);
41
+ }
42
+ if (fmt.bitsPerSample !== 16) {
43
+ throw new Error(`Unsupported bit depth (${fmt.bitsPerSample}-bit); only 16-bit PCM is supported`);
44
+ }
45
+
46
+ const bytesPerSample = fmt.bitsPerSample / 8;
47
+ const frameCount = Math.floor(dataLength / (bytesPerSample * fmt.numChannels));
48
+ const mono = new Float32Array(frameCount);
49
+
50
+ for (let i = 0; i < frameCount; i++) {
51
+ let sum = 0;
52
+ for (let channel = 0; channel < fmt.numChannels; channel++) {
53
+ const sampleOffset = dataOffset + (i * fmt.numChannels + channel) * bytesPerSample;
54
+ sum += view.getInt16(sampleOffset, true) / 32768;
55
+ }
56
+ mono[i] = sum / fmt.numChannels;
57
+ }
58
+
59
+ return { sampleRate: fmt.sampleRate, samples: mono };
60
+ }
61
+
62
+ function readAscii(view: DataView, offset: number, length: number): string {
63
+ let text = "";
64
+ for (let i = 0; i < length; i++) text += String.fromCharCode(view.getUint8(offset + i));
65
+ return text;
66
+ }
67
+
68
+ /** Counterpart to decodeWav: encodes mono samples as a 16-bit PCM WAV file. */
69
+ export function encodeWavPcm16(samples: Float32Array, sampleRate: number): ArrayBuffer {
70
+ const numChannels = 1;
71
+ const bitsPerSample = 16;
72
+ const blockAlign = (numChannels * bitsPerSample) / 8;
73
+ const byteRate = sampleRate * blockAlign;
74
+ const dataSize = samples.length * blockAlign;
75
+
76
+ const buffer = new ArrayBuffer(44 + dataSize);
77
+ const view = new DataView(buffer);
78
+
79
+ writeAscii(view, 0, "RIFF");
80
+ view.setUint32(4, 36 + dataSize, true);
81
+ writeAscii(view, 8, "WAVE");
82
+ writeAscii(view, 12, "fmt ");
83
+ view.setUint32(16, 16, true);
84
+ view.setUint16(20, 1, true);
85
+ view.setUint16(22, numChannels, true);
86
+ view.setUint32(24, sampleRate, true);
87
+ view.setUint32(28, byteRate, true);
88
+ view.setUint16(32, blockAlign, true);
89
+ view.setUint16(34, bitsPerSample, true);
90
+ writeAscii(view, 36, "data");
91
+ view.setUint32(40, dataSize, true);
92
+
93
+ for (let i = 0; i < samples.length; i++) {
94
+ const clamped = Math.max(-1, Math.min(1, samples[i]));
95
+ view.setInt16(44 + i * 2, Math.round(clamped * 32767), true);
96
+ }
97
+
98
+ return buffer;
99
+ }
100
+
101
+ function writeAscii(view: DataView, offset: number, text: string): void {
102
+ for (let i = 0; i < text.length; i++) view.setUint8(offset + i, text.charCodeAt(i));
103
+ }
package/src/yin.ts ADDED
@@ -0,0 +1,84 @@
1
+ import type { PitchFrame } from "@hum2music/core";
2
+
3
+ export const DEFAULT_FRAME_SIZE = 1024;
4
+ export const DEFAULT_HOP_SIZE = 512;
5
+
6
+ export interface YinOptions {
7
+ frameSize?: number;
8
+ hopSize?: number;
9
+ threshold?: number;
10
+ minFrequencyHz?: number;
11
+ maxFrequencyHz?: number;
12
+ }
13
+
14
+ /**
15
+ * YIN pitch detection (de Cheveigne & Kawahara, 2002), evaluated per sliding
16
+ * frame over the whole signal. Frames where no period passes the absolute
17
+ * threshold are reported as unvoiced rather than forced to a guess.
18
+ */
19
+ export function detectPitchYin(samples: Float32Array, sampleRate: number, options: YinOptions = {}): PitchFrame[] {
20
+ const frameSize = options.frameSize ?? DEFAULT_FRAME_SIZE;
21
+ const hopSize = options.hopSize ?? DEFAULT_HOP_SIZE;
22
+ const threshold = options.threshold ?? 0.12;
23
+ const minFrequencyHz = options.minFrequencyHz ?? 80;
24
+ const maxFrequencyHz = options.maxFrequencyHz ?? 1000;
25
+
26
+ const tauMin = Math.max(2, Math.floor(sampleRate / maxFrequencyHz));
27
+ const tauMax = Math.min(Math.floor(frameSize / 2) - 1, Math.floor(sampleRate / minFrequencyHz));
28
+
29
+ const frames: PitchFrame[] = [];
30
+ const diff = new Float64Array(tauMax + 1);
31
+ const cmndf = new Float64Array(tauMax + 1);
32
+
33
+ for (let start = 0; start + frameSize <= samples.length; start += hopSize) {
34
+ for (let tau = 1; tau <= tauMax; tau++) {
35
+ let sum = 0;
36
+ for (let j = 0; j < frameSize - tau; j++) {
37
+ const delta = samples[start + j] - samples[start + j + tau];
38
+ sum += delta * delta;
39
+ }
40
+ diff[tau] = sum;
41
+ }
42
+
43
+ cmndf[0] = 1;
44
+ let runningSum = 0;
45
+ for (let tau = 1; tau <= tauMax; tau++) {
46
+ runningSum += diff[tau];
47
+ cmndf[tau] = runningSum === 0 ? 1 : (diff[tau] * tau) / runningSum;
48
+ }
49
+
50
+ let tauEstimate = -1;
51
+ for (let tau = tauMin; tau <= tauMax; tau++) {
52
+ if (cmndf[tau] < threshold) {
53
+ while (tau + 1 <= tauMax && cmndf[tau + 1] < cmndf[tau]) tau++;
54
+ tauEstimate = tau;
55
+ break;
56
+ }
57
+ }
58
+
59
+ const timeMs = (start / sampleRate) * 1000;
60
+
61
+ if (tauEstimate === -1) {
62
+ frames.push({ timeMs, f0Hz: null, confidence: 0, voiced: false });
63
+ continue;
64
+ }
65
+
66
+ const refinedTau = parabolicInterpolate(cmndf, tauEstimate, tauMax);
67
+ const f0Hz = sampleRate / refinedTau;
68
+ const confidence = Math.max(0, 1 - cmndf[tauEstimate]);
69
+
70
+ frames.push({ timeMs, f0Hz, confidence, voiced: true });
71
+ }
72
+
73
+ return frames;
74
+ }
75
+
76
+ function parabolicInterpolate(cmndf: Float64Array, tau: number, tauMax: number): number {
77
+ if (tau <= 0 || tau >= tauMax) return tau;
78
+ const s0 = cmndf[tau - 1];
79
+ const s1 = cmndf[tau];
80
+ const s2 = cmndf[tau + 1];
81
+ const denominator = s0 + s2 - 2 * s1;
82
+ if (denominator === 0) return tau;
83
+ return tau + (0.5 * (s0 - s2)) / denominator;
84
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.