From 2dfae80f6ba1dc0cefc525a5e01fceacdc2d46bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ju=CC=88rgen=20Hock?= Date: Fri, 29 Mar 2024 22:49:27 +0100 Subject: [PATCH] Add tuning frequency estimation prototype #1 --- src/sandbox/synth.py | 52 +++++++++++++++------------- src/sandbox/tuning.py | 80 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 24 deletions(-) create mode 100644 src/sandbox/tuning.py diff --git a/src/sandbox/synth.py b/src/sandbox/synth.py index 8a95a64..a1df599 100644 --- a/src/sandbox/synth.py +++ b/src/sandbox/synth.py @@ -12,28 +12,6 @@ import click -@click.command( - context_settings={'help_option_names': ['-h', '--help']}, - no_args_is_help=True) -@click.argument('file', nargs=1, - required=True, - type=click.Path(exists=False, file_okay=True, dir_okay=False, path_type=Path)) -@click.option('-a', '--a4', - default=440, - show_default=True, - help='Tuning frequency.') -@click.option('-b', '--bpm', - default=120, - show_default=True, - help='Beats per minute.') -@click.option('-g', '--prog', - default=0, - show_default=True, - help='MIDI program number.') -@click.option('-p', '--play', - default=False, - is_flag=True, - help='Play generated file.') def synth(file: Union[str, PathLike], *, a4: int = 440, bpm: int = 120, prog: int = 0, @@ -59,7 +37,6 @@ def synth(file: Union[str, PathLike], *, a4: int = 440, track.append(Message('note_off', note=note, velocity=100, time=240)) track.append(MetaMessage('end_of_track')) - midi.save(file.with_suffix('.mid')) scale = create_edo_scale(12) @@ -84,6 +61,33 @@ def synth(file: Union[str, PathLike], *, a4: int = 440, run(['play', file], check=True) +@click.command( + context_settings={'help_option_names': ['-h', '--help']}, + no_args_is_help=True) +@click.argument('file', nargs=1, + required=True, + type=click.Path(exists=False, file_okay=True, dir_okay=False, path_type=Path)) +@click.option('-a', '--a4', + default=440, + show_default=True, + help='Tuning frequency.') +@click.option('-b', '--bpm', + default=120, + show_default=True, + help='Beats per minute.') +@click.option('-p', '--prog', + default=0, + show_default=True, + help='MIDI program number.') +@click.option('-y', '--play', + default=False, + is_flag=True, + help='Play generated file.') +def main(file, a4, bpm, prog, play): + + synth(file, a4=a4, bpm=bpm, prog=prog, play=play) + + if __name__ == '__main__': - synth() # pylint: disable=no-value-for-parameter + main() # pylint: disable=no-value-for-parameter diff --git a/src/sandbox/tuning.py b/src/sandbox/tuning.py new file mode 100644 index 0000000..4dd3fc2 --- /dev/null +++ b/src/sandbox/tuning.py @@ -0,0 +1,80 @@ +# pylint: disable=import-error +# pylint: disable=fixme + +import matplotlib.pyplot as plot +import numpy as np +import numpy.lib.stride_tricks as tricks +import soundfile + +from qdft import Chroma +from synth import synth + +CP = 440 +test = f'test.{CP}.wav' +synth(test, a4=CP) + +samples, samplerate = soundfile.read(test) + +samples = np.mean(samples, axis=-1) \ + if len(np.shape(samples)) > 1 \ + else np.asarray(samples) + +print(f'samples {len(samples)} {len(samples)/samplerate}s') +length = int(np.ceil(samples.size / samplerate) * samplerate) +samples.resize(length) +print(f'samples {len(samples)} {len(samples)/samplerate}s') + +chunks = tricks.sliding_window_view(samples, samplerate)[::samplerate] +chroma = Chroma(samplerate, feature='hz') + +chromagram = np.empty((0, chroma.size)) + +for i, chunk in enumerate(chunks): + + if not i: + print('0%') + + chromagram = np.vstack((chromagram, chroma.chroma(chunk))) + + print(f'{int(100 * (i + 1) / len(chunks))}%') + +# TODO chroma.qdft.latencies in the next release +latency = int(np.max(chroma.qdft.periods[0] - chroma.qdft.offsets)) +print(f'latency {latency}') + +print(f'old shape {chromagram.shape}') +chromagram = chromagram[latency:] +print(f'new shape {chromagram.shape}') + +cp0 = chroma.concertpitch +cp1 = np.full(len(chromagram), cp0, float) + +r = np.real(chromagram) +f = np.imag(chromagram) + +i = np.arange(len(chromagram)) +j = np.argmax(r, axis=-1) + +for n, m in zip(i, j): + + # TODO peak picking + s = np.round(12 * np.log2(f[n, m] / cp1[n-1])) + + cp1[n] = (f[n, m] * 2**(s/12)) / (2**(s/6)) + cp1[n] = cp1[n-1] if np.isnan(cp1[n]) else cp1[n] + +# TODO better estimation precision +stats = np.ceil([ + cp1[0], + cp1[-1], + np.mean(cp1), + np.median(cp1) +]) + +print(f'fist {stats[0]} last {stats[1]} avg {stats[2]} med {stats[3]}') + +plot.figure(test) +plot.plot(cp1) +plot.show() + +assert stats[-1] == CP