From 30d4baeebc0d5cb0016b527d4caca80c361f4996 Mon Sep 17 00:00:00 2001 From: Mioriarty Date: Wed, 11 Mar 2026 09:45:43 +0100 Subject: [PATCH 1/7] add docstring comments and streamline the format of all existing docstrings --- include/pytotune/algorithms/fft.h | 6 +- .../algorithms/pitch_correction_pipeline.h | 49 +++++++- include/pytotune/algorithms/pitch_shifter.h | 23 ++++ .../pytotune/algorithms/yin_pitch_detector.h | 30 +++-- include/pytotune/data-structures/mode.h | 4 - include/pytotune/data-structures/scale.h | 4 - include/pytotune/data-structures/windowing.h | 44 ++++++- include/pytotune/io/midi_file.h | 111 +++++++++++++----- include/pytotune/io/wav_file.h | 83 +++++-------- 9 files changed, 244 insertions(+), 110 deletions(-) diff --git a/include/pytotune/algorithms/fft.h b/include/pytotune/algorithms/fft.h index 0a82b9f..6013813 100644 --- a/include/pytotune/algorithms/fft.h +++ b/include/pytotune/algorithms/fft.h @@ -1,7 +1,3 @@ -// -// Created by Moritz Seppelt on 19.11.25. -// - #ifndef PYTOTUNE_FFT_H #define PYTOTUNE_FFT_H @@ -9,7 +5,7 @@ namespace p2t { /** - * @brief Performs an in-place FFT or inverse FFT on a complex buffer. + * Performs an in-place FFT or inverse FFT on a complex buffer. * * The fftBuffer is a vector of floats where real and imaginary parts are * interleaved: [Re0, Im0, Re1, Im1, ...]. diff --git a/include/pytotune/algorithms/pitch_correction_pipeline.h b/include/pytotune/algorithms/pitch_correction_pipeline.h index b32b33a..ef5dfc4 100644 --- a/include/pytotune/algorithms/pitch_correction_pipeline.h +++ b/include/pytotune/algorithms/pitch_correction_pipeline.h @@ -9,25 +9,70 @@ namespace p2t { #define DEFAULT_WINDOWING Windowing(4096, 4096 / 4) + /** + * End-to-end pitch correction pipeline for WAV input. + * + * The pipeline can either align detected pitch to MIDI notes or quantize + * pitch to a given musical scale. + */ class PitchCorrectionPipeline { public: - /// detect pitch per window. Exposed for benchmarking. + /** + * Detect pitch per analysis window. + * + * This method is exposed separately for benchmarking and profiling. + * + * @param src Source WAV audio. + * @param windowing Analysis window size and hop size. + * @param pitchRange Minimum and maximum detectable pitch in Hz. + * @param threshold YIN confidence threshold. + * @param decimationFactor Downsampling factor used during detection. + * @return Window-aligned detected pitch values in Hz. + */ WindowedData detectPitch(const WavFile &src, Windowing windowing, PitchRange pitchRange, float threshold = DEFAULT_THRESHOLD, int decimationFactor = DEFAULT_DECIMATION_FACTOR) const; - /// apply correction factors and return the shifted audio. Exposed for benchmarking. + /** + * Apply per-window correction factors and return pitch-shifted audio. + * + * This method is exposed separately for benchmarking and profiling. + * + * @param src Source WAV audio. + * @param windowing Analysis window size and hop size. + * @param correctionFactors Multiplicative pitch factors per window. + * @return New WAV file with applied pitch shifts. + */ WavFile shiftPitch(const WavFile &src, Windowing windowing, const std::vector &correctionFactors) const; + /** + * Tune audio to notes from a MIDI reference. + * + * @param src Source WAV audio. + * @param midiFile MIDI note reference used as target pitch. + * @param windowing Analysis window size and hop size. + * @param tuning Reference tuning for A4 in Hz. + * @param pitchRange Minimum and maximum detectable pitch in Hz. + * @return New WAV file tuned to the MIDI reference. + */ WavFile matchMidi(const WavFile &src, const MidiFile &midiFile, Windowing windowing = DEFAULT_WINDOWING, float tuning = DEFAULT_A4, PitchRange pitchRange = VoiceRanges::HUMAN) const; + /** + * Quantize detected pitch to the nearest notes of a musical scale. + * + * @param src Source WAV audio. + * @param scale Target scale used for quantization. + * @param windowing Analysis window size and hop size. + * @param pitchRange Minimum and maximum detectable pitch in Hz. + * @return New WAV file tuned to the provided scale. + */ WavFile roundToScale(const WavFile &src, const Scale &scale, Windowing windowing = DEFAULT_WINDOWING, PitchRange pitchRange = VoiceRanges::HUMAN) const; diff --git a/include/pytotune/algorithms/pitch_shifter.h b/include/pytotune/algorithms/pitch_shifter.h index eb3ca0e..9b816ba 100644 --- a/include/pytotune/algorithms/pitch_shifter.h +++ b/include/pytotune/algorithms/pitch_shifter.h @@ -6,16 +6,39 @@ #include "pytotune/data-structures/windowing.h" namespace p2t { + /** + * Time-domain pitch shifting for windowed audio. + * + * Applies multiplicative pitch factors to audio samples using the configured + * windowing and sample rate. + */ class PitchShifter { Windowing windowing; float sampleRate; public: + /** + * Construct a pitch shifter with fixed processing parameters. + * @param windowing Window size and hop size used during processing. + * @param sampleRate Audio sample rate in Hz. + */ explicit PitchShifter(Windowing windowing, float sampleRate) : windowing(windowing), sampleRate(sampleRate) { } + /** + * Shift pitch with per-window factors. + * @param samples Input mono audio samples. + * @param pitchFactors Multiplicative pitch factors for each analysis window. + * @return Pitch-shifted audio samples. + */ std::vector run(const std::vector &samples, const WindowedData &pitchFactors) const; + /** + * Shift pitch with one global factor. + * @param samples Input mono audio samples. + * @param pitchFactor Global multiplicative pitch factor. + * @return Pitch-shifted audio samples. + */ std::vector run(const std::vector &samples, float pitchFactor) const; }; } // namespace p2t diff --git a/include/pytotune/algorithms/yin_pitch_detector.h b/include/pytotune/algorithms/yin_pitch_detector.h index 8527071..25bbc20 100644 --- a/include/pytotune/algorithms/yin_pitch_detector.h +++ b/include/pytotune/algorithms/yin_pitch_detector.h @@ -9,10 +9,21 @@ #define DEFAULT_DECIMATION_FACTOR 4 namespace p2t { + /** + * Frequency interval used to constrain pitch detection. + */ struct PitchRange { + /// Lower bound in Hz. float min; + /// Upper bound in Hz. float max; + /** + * Construct a validated pitch range. + * @param minValue Lower bound in Hz (must be > 0). + * @param maxValue Upper bound in Hz (must be greater than minValue). + * @throws std::invalid_argument If bounds are invalid. + */ constexpr PitchRange(const float minValue, const float maxValue) : min(minValue), max(maxValue) { if (!(minValue > 0.0f && minValue < maxValue)) { @@ -21,6 +32,9 @@ namespace p2t { } }; + /** + * Common predefined frequency ranges for pitch detection. + */ namespace VoiceRanges { // Basic categories constexpr PitchRange HEARABLE = {20.f, 20000.f}; @@ -43,19 +57,19 @@ namespace p2t { class YINPitchDetector { public: /** - * Detects the pitch of the given audio buffer using the YIN algorithm with preset parameters. - * @param audioBuffer The audio buffer containing the WavData. - * @param pitchRange Minimum and maximum frequency to consider (in Hz). - * @param threshold Threshold for pitch detection confidence (between 0 and 1). - * @param decimationFactor Factor by which to downsample the audio for faster processing (default is 2). - * @return A PitchDetection struct containing the detected pitch information. + * Detect pitch per analysis window using the YIN algorithm. + * @param audioBuffer Input audio samples. + * @param pitchRange Minimum and maximum frequency to consider (Hz). + * @param threshold Detection confidence threshold (typically between 0 and 1). + * @param decimationFactor Downsampling factor used during preprocessing (default is 4). + * @return Window-aligned detected pitch values in Hz. */ [[nodiscard]] WindowedData detectPitch(const WavData &audioBuffer, PitchRange pitchRange, float threshold = DEFAULT_THRESHOLD, int decimationFactor = DEFAULT_DECIMATION_FACTOR) const; /** - * Constructor of the PitchDetector class. - * @param windowing The windowing of all future pitch detection runs + * Construct a detector with fixed analysis windowing. + * @param windowing Window size and hop size used for all detection runs. */ explicit YINPitchDetector(Windowing windowing); diff --git a/include/pytotune/data-structures/mode.h b/include/pytotune/data-structures/mode.h index bcfea7b..1ebf768 100644 --- a/include/pytotune/data-structures/mode.h +++ b/include/pytotune/data-structures/mode.h @@ -1,7 +1,3 @@ -// -// Created by Moritz Seppelt on 12.11.25. -// - #ifndef PYTOTUNE_MODE_H #define PYTOTUNE_MODE_H #include diff --git a/include/pytotune/data-structures/scale.h b/include/pytotune/data-structures/scale.h index fae3602..c18b4c8 100644 --- a/include/pytotune/data-structures/scale.h +++ b/include/pytotune/data-structures/scale.h @@ -1,7 +1,3 @@ -// -// Created by Moritz Seppelt on 12.11.25. -// - #ifndef PYTOTUNE_SCALE_H #define PYTOTUNE_SCALE_H diff --git a/include/pytotune/data-structures/windowing.h b/include/pytotune/data-structures/windowing.h index e482e36..2c6f141 100644 --- a/include/pytotune/data-structures/windowing.h +++ b/include/pytotune/data-structures/windowing.h @@ -1,37 +1,73 @@ -// -// Created by Moritz Seppelt on 02.12.25. -// - #ifndef PYTOTUNE_WINDOWING_H #define PYTOTUNE_WINDOWING_H #include #include namespace p2t { + /** + * Window configuration for frame-based audio processing. + */ struct Windowing { + /// Number of samples per analysis window. int windowSize; + /// Hop size in samples between consecutive windows. int stride; + /** + * Construct windowing using explicit window size and stride. + * @param windowSize Number of samples per analysis window. + * @param stride Hop size in samples between consecutive windows. + */ Windowing(int windowSize, int stride); + /** + * Construct windowing from window size and overlap percentage. + * @param windowSize Number of samples per analysis window. + * @param overlapPercentage Overlap fraction between 0 and 1. + */ Windowing(int windowSize, float overlapPercentage); + /** + * Get oversampling factor used by phase-vocoder style processing. + * @return Oversampling factor, computed as windowSize / stride. + */ int getOsamp() const { return windowSize / stride; } }; + /** + * Data series aligned to a specific windowing configuration. + * @tparam T Element type stored per window. + */ template struct WindowedData { Windowing windowing; std::vector data; + /** + * Construct an empty windowed data container. + * @param windowing Windowing metadata for this container. + */ explicit WindowedData(Windowing windowing) : windowing(windowing) { } + /** + * Construct windowed data from an existing value vector. + * @param windowing Windowing metadata for this container. + * @param data Values associated with each analysis window. + */ WindowedData(Windowing windowing, std::vector data) : windowing(windowing), data(data) { } + /** + * Generate windowed data by sampling a function at window start times. + * @param windowing Windowing metadata for the generated data. + * @param dataSize Number of window values to generate. + * @param sampleRate Audio sample rate in Hz. + * @param lambda Function mapping time in seconds to a value. + * @return Generated windowed data. + */ static WindowedData fromLambda(Windowing windowing, int dataSize, float sampleRate, std::function lambda) { std::vector data(dataSize); diff --git a/include/pytotune/io/midi_file.h b/include/pytotune/io/midi_file.h index b571d0b..9f981d7 100644 --- a/include/pytotune/io/midi_file.h +++ b/include/pytotune/io/midi_file.h @@ -1,7 +1,3 @@ -// -// Created by Moritz Seppelt on 13.11.25. -// - #ifndef PYTOTUNE_MIDIFILE_H #define PYTOTUNE_MIDIFILE_H #include @@ -12,50 +8,86 @@ #include "pytotune/data-structures/scale.h" #include "pytotune/data-structures/windowing.h" -/// Represents a single note event on the flattened timeline. -/// Track information is intentionally discarded: all tracks are merged. -/// -/// - `note` : MIDI note number (0–127) -/// - `start` : start time in seconds -/// - `end` : end time in seconds -/// -/// Important: -/// * Tempo changes affect **all tracks globally**. -/// * Notes from all tracks are **merged into one track** (track identity is not preserved). +/** + * Represents a single note event on the flattened MIDI timeline. + * + * Track information is intentionally discarded after parsing: notes from all + * tracks are merged and interpreted on one global timeline. + */ struct NoteEvent { + /// MIDI note number in the range [0, 127]. uint8_t note; + /// Note start time in seconds. float start; + /// Note end time in seconds. float end; }; namespace p2t { class MidiFile { public: - /// Load and parse a MIDI file into a flattened list of NoteEvents. - /// - /// Behaviour notes: - /// * All track events are combined into a single timeline. - /// * Tempo changes apply globally—no per-track tempo handling exists. - /// * Track numbers from the source file are read but ultimately ignored - /// once noteEvents are created. + /** + * Load and parse a MIDI file into a flattened list of note events. + * + * All track events are combined into one timeline. Tempo changes are + * interpreted globally, matching standard MIDI tempo semantics. + * + * @param filename Path to the input MIDI file. + * @return Parsed MIDI object with flattened note events. + */ static MidiFile load(const std::string &filename); - /// Returns all notes active at the given time (seconds). - /// - /// Since track information is discarded, this checks activity across - /// the globally-merged timeline. + /** + * Get all notes active at the given time. + * @param time Query time in seconds. + * @return MIDI note numbers active at the query time. + */ std::vector getActiveNotesAt(float time) const; + /** + * Get all active pitches (Hz) at the given time. + * @param time Query time in seconds. + * @param tuning Reference tuning for A4 in Hz. + * @return Active pitches in Hz at the query time. + */ std::vector getActivePitchesAt(float time, float tuning = DEFAULT_A4) const; + /** + * Sample active notes at window start times. + * @param windowing Window size and hop size used for sampling. + * @param sampleRate Audio sample rate in Hz. + * @return Windowed active-note vectors. + */ WindowedData > getWindowedNotes(const Windowing &windowing, float sampleRate) const; + /** + * Sample the highest active note at window start times. + * @param windowing Window size and hop size used for sampling. + * @param sampleRate Audio sample rate in Hz. + * @param defaultNote Value used when no note is active in a window. + * @return Windowed highest-note values. + */ WindowedData getWindowedHighestNotes(const Windowing &windowing, float sampleRate, int defaultNote = 0) const; + /** + * Sample active pitches at window start times. + * @param windowing Window size and hop size used for sampling. + * @param sampleRate Audio sample rate in Hz. + * @param tuning Reference tuning for A4 in Hz. + * @return Windowed active-pitch vectors in Hz. + */ WindowedData > getWindowedPitches(const Windowing &windowing, float sampleRate, float tuning = DEFAULT_A4) const; + /** + * Sample the highest active pitch at window start times. + * @param windowing Window size and hop size used for sampling. + * @param sampleRate Audio sample rate in Hz. + * @param defaultPitch Value used when no note is active in a window. + * @param tuning Reference tuning for A4 in Hz. + * @return Windowed highest-pitch values in Hz. + */ WindowedData getWindowedHighestPitches(const Windowing &windowing, float sampleRate, float defaultPitch = 0.0f, float tuning = DEFAULT_A4) const; @@ -73,9 +105,18 @@ namespace p2t { float sampleRate, float tuning = DEFAULT_A4) const; + /** + * Convert a MIDI note number to frequency in Hz. + * @param note MIDI note number. + * @param tuning Reference tuning for A4 in Hz. + * @return Frequency in Hz. + */ inline static float noteToPitch(int note, float tuning = DEFAULT_A4); - /// Total duration of the flattened MIDI in seconds. + /** + * Get total duration of the flattened MIDI timeline. + * @return Duration in seconds. + */ float getLength() const { return lengthSeconds; } private: @@ -94,9 +135,12 @@ namespace p2t { Other }; - /// Internal representation of an event before flattening. - /// Track number is preserved here for parsing, but is not used - /// in the final noteEvents representation. + /** + * Internal event representation used during MIDI parsing. + * + * Track number is preserved at this stage and discarded after + * flattening to `NoteEvent`. + */ struct MidiEvent { uint32_t absTicks; // absolute tick time EventType type; @@ -119,8 +163,13 @@ namespace p2t { static MidiHeader readHeader(std::ifstream &f); - /// Parse all events in a single MIDI track. - /// Track index is provided so events can be tagged before merging. + /** + * Parse all events from a single MIDI track chunk. + * @param f Input stream positioned at the track chunk payload. + * @param header Parsed MIDI header. + * @param track Track index used to tag parsed events. + * @return Parsed events for the given track. + */ static std::vector readTrackEvents(std::ifstream &f, const MidiHeader &header, uint16_t track); diff --git a/include/pytotune/io/wav_file.h b/include/pytotune/io/wav_file.h index f381729..94c4822 100644 --- a/include/pytotune/io/wav_file.h +++ b/include/pytotune/io/wav_file.h @@ -1,16 +1,3 @@ -/** - * @file wav_file.h - * @brief Lightweight WAV reader declarations. - * - * Provides WavData and WavFile for loading simple WAV files (16-bit PCM or 32-bit float) - * into a normalized floating-point sample buffer. - * - * Example: - * auto wav = p2t::WavFile::load("sound.wav"); - * const auto &d = wav.data(); - * // d.sampleRate, d.numChannels, d.samples - */ - #ifndef PYTOTUNE_WAVREADER_H #define PYTOTUNE_WAVREADER_H #include @@ -19,83 +6,75 @@ namespace p2t { /** - * @struct WavData - * @brief Represents decoded WAV audio data. - * - * The samples vector contains floating point samples normalized to the range [-1.0, 1.0]. - * If numChannels > 1 the samples are interleaved (channel0, channel1, ...). TODO: might want to change that later. + * Represents decoded WAV audio data. */ struct WavData { - /** - * @brief Sample rate in Hertz (Hz). - * - * Zero indicates no data or uninitialized. - */ - uint32_t sampleRate = 0; // in Hz + /// Sample rate in Hz. + uint32_t sampleRate = 0; - /** - * @brief Number of channels (1 = mono, 2 = stereo, ...). - * For now, only mono is supported. - */ + /// Number of channels in the source audio. uint16_t numChannels = 0; - /** - * @brief Interleaved audio samples as floats in [-1.0, 1.0]. - * - * For multi-channel audio the layout is: - * sample0_chan0, sample0_chan1, ..., sample1_chan0, sample1_chan1, ... - */ - std::vector samples; // interleaved if stereo + /// Audio samples as normalized floats. + std::vector samples; }; /** - * @class WavFile - * @brief Simple helper to load WAV files into a WavData structure. - * - * Use WavFile::load(path) to create an instance. The loader currently supports - * 16-bit PCM and 32-bit float WAV formats. Loading failures throw std::runtime_error. + * Helper class for loading and storing WAV files. + * For now, only one track (mono) is supported. All further channels are discarded. */ class WavFile { public: + /** + * Construct from decoded WAV data. + * @param data WAV payload to store in this instance. + */ explicit WavFile(WavData data); /** - * @brief Get the loaded WAV data. - * @return const WavData& Reference to the internal WavData. + * Get the decoded WAV data. + * @return Reference to the stored `WavData`. */ const WavData &data() const { return wavData; } /** - * @brief Load a WAV file from disk. + * Load a WAV file from disk. * @param path Filesystem path to the WAV file. - * @return WavFile Instance holding the decoded data. - * @throws std::runtime_error on file I/O or format errors. - * - * For now, stereo files will only load first channel and ignore others. + * @return WAV file instance holding decoded audio data. + * @throws std::runtime_error On file I/O or unsupported/invalid format. */ static WavFile load(const std::string &path); + /** + * Store audio to disk using default audio format. + * @param path Destination WAV path. + */ void store(const std::string &path) const { store(path, 1); // default to PCM } + /** + * Store audio to disk using an explicit WAV audio format code. + * @param path Destination WAV path. + * @param audioFormat WAV audio format code (e.g. PCM = 1). + */ void store(const std::string &path, uint16_t audioFormat) const; private: /** - * @brief Default constructor is private; use the static load() factory. + * Default constructor is private; use the static `load` factory. */ WavFile() = default; /** - * @brief Read and parse the given WAV file into wavData_. - * @param path Path to the WAV file to read. - * @throws std::runtime_error on failure to open, parse or unsupported formats. + * Read and parse a WAV file into internal storage. + * @param path Path to the WAV file. + * @throws std::runtime_error On parse or format errors. */ void readFile(const std::string &path); /** - * @brief Storage for the decoded WAV content. + * Storage for decoded WAV content. */ WavData wavData; }; From 2669b5694b06cc99b8a6a697de2bb058ba0bbad7 Mon Sep 17 00:00:00 2001 From: Mioriarty Date: Wed, 11 Mar 2026 09:53:34 +0100 Subject: [PATCH 2/7] add readme --- README.md | 337 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 325 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index bdbdca6..b20834e 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,330 @@ # PytoTune -## Goal -### Input -There are 2 possible inputs -- Paths to a sound file and a midi file some options (eg what happens if midi has a pause or midi is polyphonic) -- Path to a sound file and a scale (array of relative pitches to the tuning pitch, eg "C major") and a tuning (the pitch of a4, eg 440 Hz). Might do: Support scales that don't repeat in a octave. +PytoTune is an open-source pitch-correction library with a C++ backend, Python bindings, and a command-line interface. It was built as an Algorithm Engineering project and focuses on making the full Auto-Tune-style pipeline transparent, reproducible, and fast. -### Output -The pitch corrected sound file. +The project supports two correction modes: -### Motivation -We want to learn how pitch correction works in detail and how it can be efficiently optimized. -But most importantly, we cannot sing and want to create outstanding cloud rap. +- **MIDI-guided correction**: match an input WAV file to the notes of a MIDI file +- **Scale-guided correction**: quantize detected pitches to a musical scale such as `C major`, `E minor`, or more exotic tunings -### Keywords -cloud rap, auto-tune, music, python library, deluxe, parallelized, open source, audio \ No newline at end of file +Under the hood, PytoTune combines: + +- **YIN-based pitch detection** +- **Scale or MIDI target pitch generation** +- **Fourier-based pitch shifting** +- **OpenMP parallelization** +- **SIMD acceleration via Google Highway** + +## Table of Contents + +- [Features](#features) +- [Project Status](#project-status) +- [How It Works](#how-it-works) +- [Repository Layout](#repository-layout) +- [Requirements](#requirements) +- [Setup and Build](#setup-and-build) +- [Python Usage](#python-usage) +- [CLI Usage](#cli-usage) +- [Testing](#testing) +- [Benchmarking](#benchmarking) +- [Limitations](#limitations) +- [Further Reading](#further-reading) +- [License](#license) + +## Features + +- C++20 core implementation for the heavy DSP work +- Python extension module built with `pybind11` +- Standalone CLI executable for file-based workflows +- Two pitch-correction modes: + - WAV + MIDI → corrected WAV + - WAV + musical scale → corrected WAV +- Built-in scale parsing, including common western modes and non-standard scales +- Configurable pitch-detection ranges, including presets for common vocal ranges +- Optimized YIN detector with: + - OpenMP parallelization + - SIMD vectorization with Highway + - signal decimation before detection +- Unit tests with GoogleTest +- Linux benchmarking target and helper scripts + +## Project Status + +PytoTune is currently a **research/project codebase** rather than a polished end-user package. The core library, CLI, tests, and Python bindings are present in this repository. However, the repository does **not** currently ship PyPI packaging metadata such as a `pyproject.toml`. + +That means local usage is currently centered around **building with CMake**. + +## How It Works + +The processing pipeline is: + +1. **Load input data** from a WAV file and, optionally, a MIDI file +2. **Split audio into overlapping windows** (default: window size `4096`, stride `1024`) +3. **Detect pitch per window** using the YIN algorithm +4. **Compute target pitches** either from: + - a MIDI note sequence, or + - the nearest note in a chosen musical scale +5. **Apply pitch shifting** with a Fourier-transform-based algorithm +6. **Overlap-add corrected windows** into the final output WAV file + +This architecture keeps the full signal-processing chain explicit and easy to inspect. + +## Repository Layout + +```text +. +├── include/ # Public headers +├── src/ # C++ implementation, CLI, Python bindings +├── tests/ # GoogleTest test suite and sample test assets +├── benchmarks/ # Benchmark executable and helper scripts +├── paper/ # Project paper and figures +├── highway/ # SIMD dependency vendored into the repo +├── CMakeLists.txt # Main build configuration +└── README.md +``` + +## Requirements + +### Core build requirements + +- **CMake 3.9+** +- **A C++20 compiler** + - Clang + - GCC + - or MSVC +- **OpenMP** +- **Git** + +### For Python bindings + +- **Python 3** +- Python development headers + +### Dependencies fetched automatically by CMake + +The following dependencies are downloaded automatically during configuration: + +- `pybind11` +- `googletest` + +The project also builds against the bundled `highway/` directory. + +### Platform notes + +- **Linux**: the smoothest setup path, and the benchmarking target is only enabled on Linux +- **macOS**: you may need to install an OpenMP runtime such as Homebrew `libomp` +- **Windows**: supported by the codebase and CMake configuration, but Linux/macOS were the main benchmarking platforms + +## Setup and Build + +### 1. Clone the repository + +```bash +git clone +cd PytoTune +``` + +### 2. Configure a release build + +```bash +cmake -S . -B build -DCMAKE_BUILD_TYPE=Release +``` + +### 3. Build everything + +```bash +cmake --build build -j +``` + +This builds: + +- the core static library +- the Python extension module `pytotune` +- the CLI executable `pytotune_cli` +- the GoogleTest binary `pytotune_tests` + +### Optional build flags + +The following CMake options are available: + +- `PYTOTUNE_USE_HWY=ON|OFF` — enable/disable Highway SIMD in pitch detection +- `PYTOTUNE_USE_PREDEFINED_TWIDDLES=ON|OFF` — enable/disable predefined twiddle optimization in the FFT +- `PYTOTUNE_REIMPLEMENTED_WINDOWING=ON|OFF` — enable/disable the alternative pitch-shifter windowing implementation + +Example: + +```bash +cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DPYTOTUNE_USE_HWY=OFF +cmake --build build -j +``` + +### macOS note + +If CMake cannot find OpenMP on macOS, install an OpenMP runtime first and then re-run configuration. With Homebrew, this is typically done by installing `libomp`. + +## Python Usage + +After building, the Python extension module is available in the build directory. + +### Make the module importable + +```bash +export PYTHONPATH="$PWD/build:$PYTHONPATH" +``` + +### Example: tune to a scale + +```python +import pytotune + +scale = pytotune.Scale.from_name("C major") +pytotune.tuneToScale("input.wav", scale, "output_scale.wav") +``` + +### Example: tune to MIDI + +```python +import pytotune + +pytotune.tuneToMidi("input.wav", "reference.mid", "output_midi.wav") +``` + +### Example: use an explicit pitch range + +```python +import pytotune + +pitch_range = pytotune.PitchRange(82.41, 1046.50) +scale = pytotune.Scale.from_name("E minor") +pytotune.tuneToScale("input.wav", scale, "output.wav", pitch_range) +``` + +### Example: use a preset singer range + +```python +import pytotune + +pitch_range = pytotune.singer_to_pitch_range("tenor") +pytotune.tuneToMidi("input.wav", "reference.mid", "output.wav", pitch_range) +``` + +### Available pitch-range presets + +- `hearable` +- `piano` +- `human` +- `man` +- `woman` +- `bass` +- `tenor` +- `bariton` +- `alto` +- `soprano` +- `cat` + +### Scale creation + +You can create scales from names such as: + +- `C major` +- `A minor` +- `E blues` +- `D dorian` +- `chromatic` +- `whole-tone` +- `quarter-tone` +- `edo19` +- `bohlen-pierce` + +By default, `Scale.from_name(...)` uses **A4 = 442 Hz** unless you pass a different tuning explicitly. + +Example: + +```python +import pytotune + +scale = pytotune.Scale.from_name("C major", 440) +``` + +## CLI Usage + +The repository builds an executable named `pytotune_cli`. + +### Basic syntax + +```bash +./build/pytotune_cli midi [ | ] +./build/pytotune_cli scale [ | ] +``` + +### Examples + +Tune a vocal track to a MIDI file using the default human range: + +```bash +./build/pytotune_cli midi input.wav reference.mid output.wav +``` + +Tune a file to `C major` using a preset vocal range: + +```bash +./build/pytotune_cli scale input.wav "C major" output.wav soprano +``` + +Tune a file to `E minor` using a custom pitch-detection range: + +```bash +./build/pytotune_cli scale input.wav "E minor" output.wav 80 900 +``` + +## Testing + +Build and run the C++ test suite with: + +```bash +cmake --build build --target pytotune_tests -j +ctest --test-dir build --output-on-failure +``` + +The test data used by the suite lives in `tests/data/`. + +## Benchmarking + +Benchmark support is enabled only on **Linux**. + +### Build a benchmarking configuration + +The helper scripts in `benchmarks/` expect a build directory named `cmake-build-relwithdebinfo`. + +```bash +cmake -S . -B cmake-build-relwithdebinfo -DCMAKE_BUILD_TYPE=RelWithDebInfo +cmake --build cmake-build-relwithdebinfo --target pytotune_benchmarks -j +``` + +### Run helper scripts + +Examples: + +```bash +bash benchmarks/benchmark_pitch_detection_scale.sh +bash benchmarks/benchmark_pipeline_scale.sh +``` + +Generated CSV outputs are written into the `benchmarks/` directory. + +## Limitations + +- The project is primarily designed for **offline processing**, not real-time use +- The current workflow is centered around **WAV input/output** and **MIDI reference files** +- The pitch detector is best suited to **monophonic or voice-like material** +- The repository currently provides **CMake-based local builds**, not a ready-to-publish PyPI package +- Audio quality was not the sole optimization target; the main focus of the project was algorithm engineering and performance analysis + +## Further Reading + +- The full project write-up is available in `paper/paper.tex` +- Test assets and naming conventions are documented in `tests/data/README.md` + +## License + +This project is licensed under the terms of the `LICENSE` file in the repository. \ No newline at end of file From a912db2f491eed52ab34612a641bb4558d52e87a Mon Sep 17 00:00:00 2001 From: Mioriarty Date: Wed, 11 Mar 2026 09:58:34 +0100 Subject: [PATCH 3/7] fix casing of python bindings --- README.md | 10 +++++----- src/python_bindings.cpp | 24 ++++++++++++------------ 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index b20834e..1f77969 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,7 @@ export PYTHONPATH="$PWD/build:$PYTHONPATH" ```python import pytotune -scale = pytotune.Scale.from_name("C major") +scale = pytotune.Scale.fromName("C major") pytotune.tuneToScale("input.wav", scale, "output_scale.wav") ``` @@ -195,7 +195,7 @@ pytotune.tuneToMidi("input.wav", "reference.mid", "output_midi.wav") import pytotune pitch_range = pytotune.PitchRange(82.41, 1046.50) -scale = pytotune.Scale.from_name("E minor") +scale = pytotune.Scale.fromName("E minor") pytotune.tuneToScale("input.wav", scale, "output.wav", pitch_range) ``` @@ -204,7 +204,7 @@ pytotune.tuneToScale("input.wav", scale, "output.wav", pitch_range) ```python import pytotune -pitch_range = pytotune.singer_to_pitch_range("tenor") +pitch_range = pytotune.singerToPitchRange("tenor") pytotune.tuneToMidi("input.wav", "reference.mid", "output.wav", pitch_range) ``` @@ -236,14 +236,14 @@ You can create scales from names such as: - `edo19` - `bohlen-pierce` -By default, `Scale.from_name(...)` uses **A4 = 442 Hz** unless you pass a different tuning explicitly. +By default, `Scale.fromName(...)` uses **A4 = 442 Hz** unless you pass a different tuning explicitly. Example: ```python import pytotune -scale = pytotune.Scale.from_name("C major", 440) +scale = pytotune.Scale.fromName("C major", 440) ``` ## CLI Usage diff --git a/src/python_bindings.cpp b/src/python_bindings.cpp index 50f64b0..e1af75a 100644 --- a/src/python_bindings.cpp +++ b/src/python_bindings.cpp @@ -50,7 +50,7 @@ PYBIND11_MODULE(pytotune, m) { }); // Expose helper to map singer names to PitchRange - m.def("singer_to_pitch_range", &singerToPitchRange, "Convert singer name to a PitchRange", py::arg("singer")); + m.def("singerToPitchRange", &singerToPitchRange, "Convert singer name to a PitchRange", py::arg("singer")); // Expose common voice ranges as module attributes for convenience m.attr("VoiceRange_HUMAN") = py::cast(p2t::VoiceRanges::HUMAN); @@ -67,36 +67,36 @@ PYBIND11_MODULE(pytotune, m) { py::class_(m, "Scale") .def(py::init >(), - py::arg("base_note"), - py::arg("repeat_factor"), + py::arg("baseNote"), + py::arg("repeatFactor"), py::arg("notes")) - .def_static("from_name", + .def_static("fromName", &p2t::Scale::fromName, py::arg("name"), py::arg("tuning") = DEFAULT_A4) - .def_static("from_mode_name", + .def_static("fromModeName", &p2t::Scale::fromModeName, - py::arg("mode_name"), - py::arg("base_note")) + py::arg("modeName"), + py::arg("baseNote")) - .def_static("from_mode", + .def_static("fromMode", &p2t::Scale::fromMode, py::arg("mode"), - py::arg("base_note")) + py::arg("baseNote")) - .def("closest_pitch", + .def("closestPitch", &p2t::Scale::getClosestPitchInScale, py::arg("pitch")) .def_property( - "base_note", + "baseNote", &p2t::Scale::getBaseNote, &p2t::Scale::setBaseNote) .def_property( - "repeat_factor", + "repeatFactor", &p2t::Scale::getRepeatFactor, &p2t::Scale::setRepeatFactor) From c18fc4b228ec7d2810cee3c517c978eabb1e5e51 Mon Sep 17 00:00:00 2001 From: Mioriarty Date: Wed, 11 Mar 2026 10:03:33 +0100 Subject: [PATCH 4/7] fix naming inconsistencies --- src/api.cpp | 8 ++++---- src/cli.cpp | 12 ++++++------ src/python_bindings.cpp | 6 +++--- test_bindings.py | 14 +++++++------- tests/test_api.cpp | 8 ++++---- 5 files changed, 24 insertions(+), 24 deletions(-) diff --git a/src/api.cpp b/src/api.cpp index 567202e..5049480 100644 --- a/src/api.cpp +++ b/src/api.cpp @@ -6,8 +6,8 @@ #include "pytotune/io/wav_file.h" namespace p2t { - void tuneToMidi(const std::string &wavPath, const std::string &midiPath, const std::string &outPath, - const PitchRange pitchRange) { + void matchMidi(const std::string &wavPath, const std::string &midiPath, const std::string &outPath, + const PitchRange pitchRange) { auto wav = WavFile::load(wavPath); auto midi = MidiFile::load(midiPath); @@ -20,8 +20,8 @@ namespace p2t { outWav.store(outPath); } - void tuneToScale(const std::string &wavPath, const Scale &scale, - const std::string &outPath, const PitchRange pitchRange) { + void roundToScale(const std::string &wavPath, const Scale &scale, + const std::string &outPath, const PitchRange pitchRange) { auto wav = WavFile::load(wavPath); int windowSize = 4096; diff --git a/src/cli.cpp b/src/cli.cpp index 02a5ea3..97cd928 100644 --- a/src/cli.cpp +++ b/src/cli.cpp @@ -68,14 +68,14 @@ int main(int argc, char *argv[]) { std::string outPath = argv[4]; if (argc == 5) { - p2t::tuneToMidi(wavPath, midiPath, outPath, p2t::VoiceRanges::HUMAN); + p2t::matchMidi(wavPath, midiPath, outPath, p2t::VoiceRanges::HUMAN); } else if (argc == 6) { - p2t::tuneToMidi(wavPath, midiPath, outPath, singerToPitchRange(argv[5])); + p2t::matchMidi(wavPath, midiPath, outPath, singerToPitchRange(argv[5])); } else { const p2t::PitchRange pr = { std::stof(argv[5]), std::stof(argv[6]) }; - p2t::tuneToMidi(wavPath, midiPath, outPath, pr); + p2t::matchMidi(wavPath, midiPath, outPath, pr); } } else if (mode == "scale") { if (argc < 5 || argc > 7) { @@ -89,14 +89,14 @@ int main(int argc, char *argv[]) { p2t::Scale scale = p2t::Scale::fromName(scaleName); if (argc == 5) { - p2t::tuneToScale(wavPath, scale, outPath, p2t::VoiceRanges::HUMAN); + p2t::roundToScale(wavPath, scale, outPath, p2t::VoiceRanges::HUMAN); } else if (argc == 6) { - p2t::tuneToScale(wavPath, scale, outPath, singerToPitchRange(argv[5])); + p2t::roundToScale(wavPath, scale, outPath, singerToPitchRange(argv[5])); } else { const p2t::PitchRange pr = { std::stof(argv[5]), std::stof(argv[6]) }; - p2t::tuneToScale(wavPath, scale, outPath, pr); + p2t::roundToScale(wavPath, scale, outPath, pr); } } else { std::cerr << "Error: Unknown mode '" << mode << "'." << std::endl; diff --git a/src/python_bindings.cpp b/src/python_bindings.cpp index e1af75a..9d7fad4 100644 --- a/src/python_bindings.cpp +++ b/src/python_bindings.cpp @@ -86,7 +86,7 @@ PYBIND11_MODULE(pytotune, m) { py::arg("mode"), py::arg("baseNote")) - .def("closestPitch", + .def("getClosestPitchInScale", &p2t::Scale::getClosestPitchInScale, py::arg("pitch")) @@ -108,11 +108,11 @@ PYBIND11_MODULE(pytotune, m) { &p2t::Scale::setNotes); // Make pitch_range optional by defaulting to HUMAN (PitchRange is registered above) - m.def("tuneToMidi", &p2t::tuneToMidi, "Tune a WAV file using a MIDI file as reference", + m.def("matchMidi", &p2t::matchMidi, "Tune a WAV file using a MIDI file as reference", py::arg("wav_path"), py::arg("midi_path"), py::arg("out_path"), py::arg("pitch_range") = p2t::VoiceRanges::HUMAN); - m.def("tuneToScale", &p2t::tuneToScale, "Tune a WAV file to a musical scale", + m.def("roundToScale", &p2t::roundToScale, "Tune a WAV file to a musical scale", py::arg("wav_path"), py::arg("scale"), py::arg("out_path"), py::arg("pitch_range") = p2t::VoiceRanges::HUMAN); } diff --git a/test_bindings.py b/test_bindings.py index 51c08ea..32ce322 100644 --- a/test_bindings.py +++ b/test_bindings.py @@ -13,18 +13,18 @@ def test_scale(): out_path = "tests/testoutput/out_scale.wav" try: - scale = pytotune.Scale.from_name("C major") + scale = pytotune.Scale.fromName("C major") # call without pitch_range (should use default HUMAN) - pytotune.tune_to_scale(wav_path, scale, out_path) + pytotune.roundToScale(wav_path, scale, out_path) print(f"Success! Output saved to {out_path}") except Exception as e: print(f"Error: {e}") - # Example: use singer_to_pitch_range helper + # Example: use singerToPitchRange helper try: - pr = pytotune.singer_to_pitch_range("man") + pr = pytotune.singerToPitchRange("man") out_path2 = "tests/testoutput/out_scale_man.wav" - pytotune.tune_to_scale(wav_path, scale, out_path2, pr) + pytotune.roundToScale(wav_path, scale, out_path2, pr) print(f"Success with singer range! Output saved to {out_path2}") except Exception as e: print(f"Error using singer range: {e}") @@ -38,7 +38,7 @@ def test_midi(): try: # call without pitch_range (should use default HUMAN) - pytotune.tune_to_midi(wav_path, midi_path, out_path) + pytotune.matchMidi(wav_path, midi_path, out_path) print(f"Success! Output saved to {out_path}") except Exception as e: print(f"Error: {e}") @@ -47,7 +47,7 @@ def test_midi(): try: pr = pytotune.PitchRange(200.0, 800.0) out_path2 = "tests/testoutput/out_midi_customrange.wav" - pytotune.tune_to_midi(wav_path, midi_path, out_path2, pr) + pytotune.matchMidi(wav_path, midi_path, out_path2, pr) print(f"Success with explicit PitchRange! Output saved to {out_path2}") except Exception as e: print(f"Error with explicit PitchRange: {e}") diff --git a/tests/test_api.cpp b/tests/test_api.cpp index ecba7b3..0405f4f 100644 --- a/tests/test_api.cpp +++ b/tests/test_api.cpp @@ -8,22 +8,22 @@ #include "pytotune/io/midi_file.h" #include "test_utils.h" -TEST(PythonBindingsTest, TuneToMidi) { +TEST(PythonBindingsTest, MatchMidi) { std::string midiFile = constants::TEST_DATA_DIR + "/test.mid"; std::string wavFile = constants::VOICE_F400_SR4100; std::string outputFile = constants::TEST_OUTPUT_DIR + "/test_tune_to_midi.wav"; EXPECT_NO_THROW({ - p2t::tuneToMidi(wavFile, midiFile, outputFile); + p2t::matchMidi(wavFile, midiFile, outputFile); }); } -TEST(PythonBindingsTest, TuneToScale) { +TEST(PythonBindingsTest, RoundToScale) { std::string wavFile = constants::VOICE_F400_SR4100; std::string outputFile = constants::TEST_OUTPUT_DIR + "/test_tune_to_scale.wav"; p2t::Scale scale = p2t::Scale::fromName("C major"); EXPECT_NO_THROW({ - p2t::tuneToScale(wavFile, scale, outputFile); + p2t::roundToScale(wavFile, scale, outputFile); }); } From fcabe49f01f5de66c58c57dab22314f3fc83e1d3 Mon Sep 17 00:00:00 2001 From: Mioriarty Date: Wed, 11 Mar 2026 10:03:51 +0100 Subject: [PATCH 5/7] fix naming inconsistencies (2) --- include/pytotune/api.h | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/include/pytotune/api.h b/include/pytotune/api.h index 6f44cf1..a60cf3c 100644 --- a/include/pytotune/api.h +++ b/include/pytotune/api.h @@ -15,8 +15,8 @@ namespace p2t { * @param pitchRange The maximum pitch detection range * */ - void tuneToMidi(const std::string &wavPath, const std::string &midiPath, const std::string &outPath, - PitchRange pitchRange = VoiceRanges::HUMAN); + void matchMidi(const std::string &wavPath, const std::string &midiPath, const std::string &outPath, + PitchRange pitchRange = VoiceRanges::HUMAN); /** * Tune a wav file to a musical scale. @@ -25,8 +25,8 @@ namespace p2t { * @param outPath Path to the output wav file * @param pitchRange The maximum pitch detection range */ - void tuneToScale(const std::string &wavPath, const Scale &scale, const std::string &outPath, - PitchRange pitchRange = VoiceRanges::HUMAN); + void roundToScale(const std::string &wavPath, const Scale &scale, const std::string &outPath, + PitchRange pitchRange = VoiceRanges::HUMAN); } // namespace p2t #endif // PYTOTUNE_API_H From 4c548bde7db18640935941571c1f2d0f8fe3578d Mon Sep 17 00:00:00 2001 From: Mioriarty Date: Wed, 11 Mar 2026 10:04:09 +0100 Subject: [PATCH 6/7] fix readme for naming inconsistencies --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 1f77969..c51cc81 100644 --- a/README.md +++ b/README.md @@ -178,7 +178,7 @@ export PYTHONPATH="$PWD/build:$PYTHONPATH" import pytotune scale = pytotune.Scale.fromName("C major") -pytotune.tuneToScale("input.wav", scale, "output_scale.wav") +pytotune.roundToScale("input.wav", scale, "output_scale.wav") ``` ### Example: tune to MIDI @@ -186,7 +186,7 @@ pytotune.tuneToScale("input.wav", scale, "output_scale.wav") ```python import pytotune -pytotune.tuneToMidi("input.wav", "reference.mid", "output_midi.wav") +pytotune.matchMidi("input.wav", "reference.mid", "output_midi.wav") ``` ### Example: use an explicit pitch range @@ -196,7 +196,7 @@ import pytotune pitch_range = pytotune.PitchRange(82.41, 1046.50) scale = pytotune.Scale.fromName("E minor") -pytotune.tuneToScale("input.wav", scale, "output.wav", pitch_range) +pytotune.roundToScale("input.wav", scale, "output.wav", pitch_range) ``` ### Example: use a preset singer range @@ -205,7 +205,7 @@ pytotune.tuneToScale("input.wav", scale, "output.wav", pitch_range) import pytotune pitch_range = pytotune.singerToPitchRange("tenor") -pytotune.tuneToMidi("input.wav", "reference.mid", "output.wav", pitch_range) +pytotune.matchMidi("input.wav", "reference.mid", "output.wav", pitch_range) ``` ### Available pitch-range presets From a8e3a026a5bafe86c69ff09246c1a47c72cd9bcd Mon Sep 17 00:00:00 2001 From: Mioriarty Date: Wed, 11 Mar 2026 10:05:07 +0100 Subject: [PATCH 7/7] add git clone link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c51cc81..f9a48e6 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ The project also builds against the bundled `highway/` directory. ### 1. Clone the repository ```bash -git clone +git clone https://github.com/brokkoli71/PytoTune.git cd PytoTune ```