feat: added v1 Meyda.js based chord detection feature
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { detectChordsFromAudio, type AudioChordDetectionRequest } from './audioChordDetectionCore';
|
||||
import {
|
||||
DEFAULT_AUDIO_CHORD_DETECTION_OPTIONS,
|
||||
detectChordsFromAudio,
|
||||
type AudioChordDetectionOptions,
|
||||
type AudioChordDetectionRequest,
|
||||
} from './audioChordDetectionCore';
|
||||
|
||||
const SAMPLE_RATE = 44100;
|
||||
|
||||
@@ -19,12 +24,20 @@ function createSineChordPcm(frequencies: number[], durationSeconds: number): Flo
|
||||
return pcm;
|
||||
}
|
||||
|
||||
function createRequest(windows: AudioChordDetectionRequest['windows'], pcm: Float32Array): AudioChordDetectionRequest {
|
||||
function createRequest(
|
||||
windows: AudioChordDetectionRequest['windows'],
|
||||
pcm: Float32Array,
|
||||
options?: Partial<AudioChordDetectionOptions>,
|
||||
): AudioChordDetectionRequest {
|
||||
return {
|
||||
pcm,
|
||||
sampleRate: SAMPLE_RATE,
|
||||
clipStartOffsetSeconds: 0,
|
||||
windows,
|
||||
options: {
|
||||
...DEFAULT_AUDIO_CHORD_DETECTION_OPTIONS,
|
||||
...options,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -72,4 +85,62 @@ describe('audio chord detection', () => {
|
||||
|
||||
expect(results.map(result => result.symbol)).toEqual(['Am', 'Am']);
|
||||
});
|
||||
|
||||
it('uses default options to preserve current triad-first behavior', () => {
|
||||
const pcm = createSineChordPcm([329.63, 415.3, 493.88, 587.33], 2);
|
||||
const [result] = detectChordsFromAudio(createRequest([
|
||||
{ barIndex: 0, startBeat: 0, endBeat: 4, startSeconds: 0, endSeconds: 2 },
|
||||
], pcm));
|
||||
|
||||
expect(result.symbol).toBe('E');
|
||||
});
|
||||
|
||||
it('can emit a seventh label when chord detail is enabled', () => {
|
||||
const pcm = createSineChordPcm([329.63, 415.3, 493.88, 587.33], 2);
|
||||
const [result] = detectChordsFromAudio(createRequest([
|
||||
{ barIndex: 0, startBeat: 0, endBeat: 4, startSeconds: 0, endSeconds: 2 },
|
||||
], pcm, { enableSevenths: true }));
|
||||
|
||||
expect(result.symbol).toBe('E7');
|
||||
});
|
||||
|
||||
it('can suppress a weak bar when no-chord threshold is increased', () => {
|
||||
const pcm = createSineChordPcm([261.63, 329.63, 392.0], 2);
|
||||
for (let sampleIndex = 0; sampleIndex < pcm.length; sampleIndex++) {
|
||||
pcm[sampleIndex] *= 0.04;
|
||||
}
|
||||
|
||||
const [result] = detectChordsFromAudio(createRequest([
|
||||
{ barIndex: 0, startBeat: 0, endBeat: 4, startSeconds: 0, endSeconds: 2 },
|
||||
], pcm, { noChordThreshold: 95 }));
|
||||
|
||||
expect(result.symbol).toBe('N');
|
||||
});
|
||||
|
||||
it('changes smoothing behavior when stability is lowered', () => {
|
||||
const barA = createSineChordPcm([220.0, 261.63, 329.63], 2);
|
||||
const barB = createSineChordPcm([261.63, 329.63], 2);
|
||||
for (let sampleIndex = 0; sampleIndex < barB.length; sampleIndex++) {
|
||||
barB[sampleIndex] *= 0.3;
|
||||
}
|
||||
const barC = createSineChordPcm([220.0, 261.63, 329.63], 2);
|
||||
const pcm = new Float32Array(barA.length + barB.length + barC.length);
|
||||
pcm.set(barA, 0);
|
||||
pcm.set(barB, barA.length);
|
||||
pcm.set(barC, barA.length + barB.length);
|
||||
|
||||
const stableResults = detectChordsFromAudio(createRequest([
|
||||
{ barIndex: 0, startBeat: 0, endBeat: 4, startSeconds: 0, endSeconds: 2 },
|
||||
{ barIndex: 1, startBeat: 4, endBeat: 8, startSeconds: 2, endSeconds: 4 },
|
||||
{ barIndex: 2, startBeat: 8, endBeat: 12, startSeconds: 4, endSeconds: 6 },
|
||||
], pcm, { stability: 100 }));
|
||||
const unstableResults = detectChordsFromAudio(createRequest([
|
||||
{ barIndex: 0, startBeat: 0, endBeat: 4, startSeconds: 0, endSeconds: 2 },
|
||||
{ barIndex: 1, startBeat: 4, endBeat: 8, startSeconds: 2, endSeconds: 4 },
|
||||
{ barIndex: 2, startBeat: 8, endBeat: 12, startSeconds: 4, endSeconds: 6 },
|
||||
], pcm, { stability: 0 }));
|
||||
|
||||
expect(stableResults[1].symbol).toBe('Am');
|
||||
expect(unstableResults[1].symbol).not.toBe('Am');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,8 +3,10 @@ import { KGAudioRegion } from '../core/region/KGAudioRegion';
|
||||
import { beatRangeToSeconds, getAudioRegionDisplayLengthBeats } from './globalTrackUtil';
|
||||
|
||||
export {
|
||||
DEFAULT_AUDIO_CHORD_DETECTION_OPTIONS,
|
||||
detectChordsFromAudio,
|
||||
type AudioChordDetectionRequest,
|
||||
type AudioChordDetectionOptions,
|
||||
type AudioChordWindow,
|
||||
type DetectedAudioChord,
|
||||
} from './audioChordDetectionCore';
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface AudioChordDetectionRequest {
|
||||
sampleRate: number;
|
||||
clipStartOffsetSeconds: number;
|
||||
windows: AudioChordWindow[];
|
||||
options: AudioChordDetectionOptions;
|
||||
}
|
||||
|
||||
export interface DetectedAudioChord {
|
||||
@@ -39,11 +40,30 @@ export interface AudioChordDetectionProgress {
|
||||
percent: number;
|
||||
}
|
||||
|
||||
export interface AudioChordDetectionOptions {
|
||||
sensitivity: number;
|
||||
stability: number;
|
||||
noChordThreshold: number;
|
||||
enableSevenths: boolean;
|
||||
}
|
||||
|
||||
interface ScoredChord {
|
||||
symbol: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
interface TriadCandidate extends ScoredChord {
|
||||
root: number;
|
||||
quality: 'major' | 'minor';
|
||||
}
|
||||
|
||||
export const DEFAULT_AUDIO_CHORD_DETECTION_OPTIONS: AudioChordDetectionOptions = {
|
||||
sensitivity: 50,
|
||||
stability: 50,
|
||||
noChordThreshold: 0,
|
||||
enableSevenths: false,
|
||||
};
|
||||
|
||||
const HANN_WINDOW = (() => {
|
||||
const window = new Float32Array(FFT_SIZE);
|
||||
for (let i = 0; i < FFT_SIZE; i++) {
|
||||
@@ -56,13 +76,50 @@ function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function parseChordSymbol(symbol: string): { rootIndex: number; quality: 'major' | 'minor' | 'other' } | null {
|
||||
if (!symbol || symbol === 'N') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rootName = symbol.startsWith('C#') || symbol.startsWith('D#') || symbol.startsWith('F#') || symbol.startsWith('G#') || symbol.startsWith('A#')
|
||||
? symbol.slice(0, 2)
|
||||
: symbol.slice(0, 1);
|
||||
const rootIndex = ROOT_NAMES.indexOf(rootName as typeof ROOT_NAMES[number]);
|
||||
if (rootIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (symbol === `${rootName}m`) {
|
||||
return { rootIndex, quality: 'minor' };
|
||||
}
|
||||
|
||||
if (symbol === rootName) {
|
||||
return { rootIndex, quality: 'major' };
|
||||
}
|
||||
|
||||
return { rootIndex, quality: 'other' };
|
||||
}
|
||||
|
||||
function cloneWindow(window: AudioChordWindow): AudioChordWindow {
|
||||
return { ...window };
|
||||
}
|
||||
|
||||
function buildTriadCandidates(chroma: Float64Array): { best: ScoredChord; second: ScoredChord } {
|
||||
let best: ScoredChord = { symbol: 'N', score: Number.NEGATIVE_INFINITY };
|
||||
let second: ScoredChord = { symbol: 'N', score: Number.NEGATIVE_INFINITY };
|
||||
function buildChordCandidates(
|
||||
chroma: Float64Array,
|
||||
options: AudioChordDetectionOptions,
|
||||
): { best: ScoredChord; second: ScoredChord } {
|
||||
let bestTriad: TriadCandidate = {
|
||||
symbol: 'N',
|
||||
score: Number.NEGATIVE_INFINITY,
|
||||
root: 0,
|
||||
quality: 'major',
|
||||
};
|
||||
let secondTriad: TriadCandidate = {
|
||||
symbol: 'N',
|
||||
score: Number.NEGATIVE_INFINITY,
|
||||
root: 0,
|
||||
quality: 'major',
|
||||
};
|
||||
|
||||
for (let root = 0; root < ROOT_NAMES.length; root++) {
|
||||
const rootEnergy = chroma[root];
|
||||
@@ -74,21 +131,69 @@ function buildTriadCandidates(chroma: Float64Array): { best: ScoredChord; second
|
||||
const majorScore = (rootEnergy * 1.2) + (majorThird * 1.0) + (fifth * 0.8) - (outsideEnergy * 0.35) - (minorThird * 0.5);
|
||||
const minorScore = (rootEnergy * 1.2) + (minorThird * 1.0) + (fifth * 0.8) - (outsideEnergy * 0.35) - (majorThird * 0.5);
|
||||
|
||||
const candidates: ScoredChord[] = [
|
||||
{ symbol: ROOT_NAMES[root], score: majorScore },
|
||||
{ symbol: `${ROOT_NAMES[root]}m`, score: minorScore },
|
||||
const triadCandidates: TriadCandidate[] = [
|
||||
{ symbol: ROOT_NAMES[root], score: majorScore, root, quality: 'major' },
|
||||
{ symbol: `${ROOT_NAMES[root]}m`, score: minorScore, root, quality: 'minor' },
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.score > best.score) {
|
||||
second = best;
|
||||
best = candidate;
|
||||
} else if (candidate.score > second.score) {
|
||||
second = candidate;
|
||||
for (const candidate of triadCandidates) {
|
||||
if (candidate.score > bestTriad.score) {
|
||||
secondTriad = bestTriad;
|
||||
bestTriad = candidate;
|
||||
} else if (candidate.score > secondTriad.score) {
|
||||
secondTriad = candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let best: ScoredChord = { symbol: bestTriad.symbol, score: bestTriad.score };
|
||||
let second: ScoredChord = { symbol: secondTriad.symbol, score: secondTriad.score };
|
||||
|
||||
if (!options.enableSevenths || bestTriad.symbol === 'N') {
|
||||
return { best, second };
|
||||
}
|
||||
|
||||
const rootEnergy = chroma[bestTriad.root];
|
||||
const minorSeventh = chroma[(bestTriad.root + 10) % 12];
|
||||
const majorSeventh = chroma[(bestTriad.root + 11) % 12];
|
||||
const seventhCandidates: ScoredChord[] = [];
|
||||
|
||||
if (bestTriad.quality === 'major') {
|
||||
const dominantSeventhBonus = Math.max(0, minorSeventh - 0.06) * 2.5;
|
||||
const majorSeventhBonus = Math.max(0, majorSeventh - 0.2) * 1.65;
|
||||
|
||||
if (dominantSeventhBonus > 0 && minorSeventh >= rootEnergy * 0.2) {
|
||||
seventhCandidates.push({
|
||||
symbol: `${ROOT_NAMES[bestTriad.root]}7`,
|
||||
score: bestTriad.score + dominantSeventhBonus - (majorSeventh * 0.18) - 0.02,
|
||||
});
|
||||
}
|
||||
|
||||
if (majorSeventhBonus > 0 && majorSeventh >= rootEnergy * 0.42) {
|
||||
seventhCandidates.push({
|
||||
symbol: `${ROOT_NAMES[bestTriad.root]}maj7`,
|
||||
score: bestTriad.score + majorSeventhBonus - (minorSeventh * 0.2) - 0.04,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const minorSeventhBonus = Math.max(0, minorSeventh - 0.2) * 1.65;
|
||||
if (minorSeventhBonus > 0 && minorSeventh >= rootEnergy * 0.42) {
|
||||
seventhCandidates.push({
|
||||
symbol: `${ROOT_NAMES[bestTriad.root]}m7`,
|
||||
score: bestTriad.score + minorSeventhBonus - (majorSeventh * 0.14) - 0.04,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of seventhCandidates) {
|
||||
if (candidate.score > best.score) {
|
||||
second = best;
|
||||
best = candidate;
|
||||
} else if (candidate.score > second.score) {
|
||||
second = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return { best, second };
|
||||
}
|
||||
|
||||
@@ -97,6 +202,7 @@ function analyzeChordWindow(
|
||||
sampleRate: number,
|
||||
startSeconds: number,
|
||||
endSeconds: number,
|
||||
options: AudioChordDetectionOptions,
|
||||
): { symbol: string; confidence: number; rms: number } {
|
||||
const startSample = Math.max(0, Math.floor(startSeconds * sampleRate));
|
||||
const endSample = Math.min(pcm.length, Math.ceil(endSeconds * sampleRate));
|
||||
@@ -171,7 +277,7 @@ function analyzeChordWindow(
|
||||
chroma[i] /= chromaTotal;
|
||||
}
|
||||
|
||||
const { best, second } = buildTriadCandidates(chroma);
|
||||
const { best, second } = buildChordCandidates(chroma, options);
|
||||
return {
|
||||
symbol: best.symbol,
|
||||
confidence: clamp(best.score - second.score + (best.score * 0.2), 0, 1),
|
||||
@@ -179,12 +285,18 @@ function analyzeChordWindow(
|
||||
};
|
||||
}
|
||||
|
||||
function smoothDetectedChords(results: DetectedAudioChord[]): DetectedAudioChord[] {
|
||||
function smoothDetectedChords(
|
||||
results: DetectedAudioChord[],
|
||||
options: AudioChordDetectionOptions,
|
||||
): DetectedAudioChord[] {
|
||||
if (results.length < 3) {
|
||||
return results.map(result => ({ ...result }));
|
||||
}
|
||||
|
||||
const smoothed = results.map(result => ({ ...result }));
|
||||
const stability = clamp(options.stability, 0, 100);
|
||||
const smoothingThreshold = 0.6 + (0.7 * (stability / 100));
|
||||
const inheritedConfidenceFloor = 0.45 + (0.6 * (stability / 100));
|
||||
for (let i = 1; i < smoothed.length - 1; i++) {
|
||||
const previous = smoothed[i - 1];
|
||||
const current = smoothed[i];
|
||||
@@ -194,9 +306,9 @@ function smoothDetectedChords(results: DetectedAudioChord[]): DetectedAudioChord
|
||||
}
|
||||
if (previous.symbol === next.symbol && previous.symbol !== 'N' && current.symbol !== previous.symbol) {
|
||||
const surroundingConfidence = Math.max(previous.confidence, next.confidence);
|
||||
if (current.confidence < surroundingConfidence * 0.85) {
|
||||
if (current.confidence < surroundingConfidence * smoothingThreshold) {
|
||||
current.symbol = previous.symbol;
|
||||
current.confidence = Math.max(current.confidence, surroundingConfidence * 0.75);
|
||||
current.confidence = Math.max(current.confidence, surroundingConfidence * inheritedConfidenceFloor);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,6 +316,45 @@ function smoothDetectedChords(results: DetectedAudioChord[]): DetectedAudioChord
|
||||
return smoothed;
|
||||
}
|
||||
|
||||
function applySeventhContextPromotion(results: DetectedAudioChord[], options: AudioChordDetectionOptions): DetectedAudioChord[] {
|
||||
if (!options.enableSevenths || results.length < 2) {
|
||||
return results.map(result => ({ ...result }));
|
||||
}
|
||||
|
||||
const promoted = results.map(result => ({ ...result }));
|
||||
for (let i = 0; i < promoted.length - 1; i++) {
|
||||
const current = promoted[i];
|
||||
const next = promoted[i + 1];
|
||||
const previous = i > 0 ? promoted[i - 1] : null;
|
||||
const currentChord = parseChordSymbol(current.symbol);
|
||||
const nextChord = parseChordSymbol(next.symbol);
|
||||
const previousChord = previous ? parseChordSymbol(previous.symbol) : null;
|
||||
|
||||
if (!currentChord) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolvesToMinorTonic = nextChord
|
||||
&& currentChord.quality === 'major'
|
||||
&& nextChord.quality === 'minor'
|
||||
&& current.confidence >= 0.3
|
||||
&& ((nextChord.rootIndex - currentChord.rootIndex + 12) % 12) === 5;
|
||||
|
||||
const endsAfterMinorPredominant = next.symbol === 'N'
|
||||
&& previousChord
|
||||
&& currentChord.quality === 'major'
|
||||
&& previousChord.quality === 'minor'
|
||||
&& current.confidence >= 0.3
|
||||
&& ((currentChord.rootIndex - previousChord.rootIndex + 12) % 12) === 2;
|
||||
|
||||
if (resolvesToMinorTonic || endsAfterMinorPredominant) {
|
||||
current.symbol = `${ROOT_NAMES[currentChord.rootIndex]}7`;
|
||||
}
|
||||
}
|
||||
|
||||
return promoted;
|
||||
}
|
||||
|
||||
export function detectChordsFromAudio(
|
||||
request: AudioChordDetectionRequest,
|
||||
onProgress?: (progress: AudioChordDetectionProgress) => void,
|
||||
@@ -212,6 +363,10 @@ export function detectChordsFromAudio(
|
||||
if (windows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const options: AudioChordDetectionOptions = {
|
||||
...DEFAULT_AUDIO_CHORD_DETECTION_OPTIONS,
|
||||
...request.options,
|
||||
};
|
||||
|
||||
onProgress?.({
|
||||
completedWindows: 0,
|
||||
@@ -226,6 +381,7 @@ export function detectChordsFromAudio(
|
||||
request.sampleRate,
|
||||
request.clipStartOffsetSeconds + (window.startSeconds - request.clipStartOffsetSeconds),
|
||||
request.clipStartOffsetSeconds + (window.endSeconds - request.clipStartOffsetSeconds),
|
||||
options,
|
||||
);
|
||||
|
||||
rawResults.push({
|
||||
@@ -245,12 +401,18 @@ export function detectChordsFromAudio(
|
||||
});
|
||||
|
||||
const maxRms = rawResults.reduce((max, result) => Math.max(max, result.rms), 0);
|
||||
const silenceThreshold = Math.max(ABSOLUTE_SILENCE_RMS, maxRms * RELATIVE_SILENCE_RATIO);
|
||||
const sensitivityRatio = clamp((options.sensitivity - 50) / 50, -1, 1);
|
||||
const sensitivityFactor = Math.pow(2, -sensitivityRatio);
|
||||
const silenceThreshold = Math.max(
|
||||
ABSOLUTE_SILENCE_RMS * sensitivityFactor,
|
||||
maxRms * RELATIVE_SILENCE_RATIO * sensitivityFactor,
|
||||
);
|
||||
const noChordThreshold = clamp(options.noChordThreshold, 0, 100) / 100;
|
||||
const filtered = rawResults.map(result => (
|
||||
result.rms < silenceThreshold
|
||||
result.rms < silenceThreshold || result.confidence < noChordThreshold
|
||||
? { ...result, symbol: 'N', confidence: 0 }
|
||||
: result
|
||||
));
|
||||
|
||||
return smoothDetectedChords(filtered);
|
||||
return applySeventhContextPromotion(smoothDetectedChords(filtered, options), options);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,13 @@ export interface TimeSigResult {
|
||||
denominator: number;
|
||||
}
|
||||
|
||||
export interface ChordDetectionOptionsResult {
|
||||
sensitivity: number;
|
||||
stability: number;
|
||||
noChordThreshold: number;
|
||||
enableSevenths: boolean;
|
||||
}
|
||||
|
||||
export interface ChoiceOption {
|
||||
label: string;
|
||||
value: string;
|
||||
@@ -24,6 +31,7 @@ let _showConfirmFn: ((message: string, options?: ConfirmOptions) => Promise<bool
|
||||
let _showPromptFn: ((message: string, defaultValue?: string, options?: PromptOptions) => Promise<string | null>) | null = null;
|
||||
let _showTimeSigFn: ((message: string, defaultValue?: TimeSigResult) => Promise<TimeSigResult | null>) | null = null;
|
||||
let _showChoiceFn: ((message: string, choices: ChoiceOption[]) => Promise<string | null>) | null = null;
|
||||
let _showChordDetectionOptionsFn: ((message: string, defaultValue?: ChordDetectionOptionsResult) => Promise<ChordDetectionOptionsResult | null>) | null = null;
|
||||
|
||||
export function registerDialogFns(
|
||||
alertFn: (message: string) => Promise<void>,
|
||||
@@ -31,12 +39,14 @@ export function registerDialogFns(
|
||||
promptFn: (message: string, defaultValue?: string, options?: PromptOptions) => Promise<string | null>,
|
||||
timeSigFn: (message: string, defaultValue?: TimeSigResult) => Promise<TimeSigResult | null>,
|
||||
choiceFn?: (message: string, choices: ChoiceOption[]) => Promise<string | null>,
|
||||
chordDetectionOptionsFn?: (message: string, defaultValue?: ChordDetectionOptionsResult) => Promise<ChordDetectionOptionsResult | null>,
|
||||
) {
|
||||
_showAlertFn = alertFn;
|
||||
_showConfirmFn = confirmFn;
|
||||
_showPromptFn = promptFn;
|
||||
_showTimeSigFn = timeSigFn;
|
||||
if (choiceFn) _showChoiceFn = choiceFn;
|
||||
if (chordDetectionOptionsFn) _showChordDetectionOptionsFn = chordDetectionOptionsFn;
|
||||
}
|
||||
|
||||
export function showAlert(message: string): Promise<void> {
|
||||
@@ -78,3 +88,18 @@ export function showTimeSigPrompt(message: string, defaultValue?: TimeSigResult)
|
||||
}
|
||||
return _showTimeSigFn(message, defaultValue);
|
||||
}
|
||||
|
||||
export function showChordDetectionOptions(
|
||||
message: string,
|
||||
defaultValue?: ChordDetectionOptionsResult,
|
||||
): Promise<ChordDetectionOptionsResult | null> {
|
||||
if (!_showChordDetectionOptionsFn) {
|
||||
return Promise.resolve(defaultValue ?? {
|
||||
sensitivity: 50,
|
||||
stability: 50,
|
||||
noChordThreshold: 0,
|
||||
enableSevenths: false,
|
||||
});
|
||||
}
|
||||
return _showChordDetectionOptionsFn(message, defaultValue);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user