Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@ All notable changes to ElectroBow are documented in this file.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.3.6] - 2026-10-04

### Changed

- Finalized the realtime MVP review with the existing per-voice pitch stability,
contextual harmonic handling, physical-pluck trigger separation, polyphonic
continuity, and continuous input-dynamics behavior preserved.
- Made embedded UI asset URLs relative and hardened JUCE resource-provider path
handling across WebView2/WebKit URL forms, preventing valid bundled assets
from being reported as missing during page startup.
- Confirmed the existing implementation with the automated pitch, trigger,
dynamics, analyzer, voice-manager, and processor tests.

## [1.3.5] - 2026-10-03

### Added
Expand Down
13 changes: 9 additions & 4 deletions Source/PluginEditor.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,15 @@ ElectroBowAudioProcessorEditor::~ElectroBowAudioProcessorEditor() {

std::optional<juce::WebBrowserComponent::Resource>
ElectroBowAudioProcessorEditor::getResource(const juce::String& url) const {
const auto path =
url == "/"
? juce::String("index.html")
: url.fromLastOccurrenceOf("/", false, false).upToFirstOccurrenceOf("?", false, false);
// Resource-provider URLs differ slightly between JUCE backends. Normalize
// the final path component so query strings/fragments and leading slashes
// cannot make an embedded asset look missing.
auto path = url.fromLastOccurrenceOf("/", false, false);

if (path.isEmpty())
path = "index.html";

path = path.upToFirstOccurrenceOf("?", false, false).upToFirstOccurrenceOf("#", false, false);

if (path == "index.html")
return makeResource("index_html", "text/html");
Expand Down
137 changes: 43 additions & 94 deletions Source/PolyPitchDetector.h
Original file line number Diff line number Diff line change
Expand Up @@ -8,27 +8,23 @@
#include <aubio.h>
#include <pitch/pitchyin.h>

class PitchDetector
{
public:
class PitchDetector {
public:
static constexpr int kWindowSize = 4096;
static constexpr int kHopSize = 1024;

PitchDetector()
{
PitchDetector() {
reset();
}

~PitchDetector()
{
~PitchDetector() {
destroyAubio();
}

PitchDetector(const PitchDetector&) = delete;
PitchDetector& operator=(const PitchDetector&) = delete;

void prepare(double newSampleRate)
{
void prepare(double newSampleRate) {
sampleRate = std::max(1.0, newSampleRate);

destroyAubio();
Expand All @@ -37,13 +33,11 @@ class PitchDetector
reset();
}

void push(const float* samples, int numSamples)
{
void push(const float* samples, int numSamples) {
if (samples == nullptr || numSamples <= 0)
return;

for (int i = 0; i < numSamples; ++i)
{
for (int i = 0; i < numSamples; ++i) {
inputBuffer[static_cast<std::size_t>(writePosition)] = samples[i];

writePosition = (writePosition + 1) % kWindowSize;
Expand All @@ -57,41 +51,34 @@ class PitchDetector

// Analyse immediately on the first complete window,
// then every kHopSize samples afterwards.
if (samplesSinceAnalysis >= kHopSize)
{
if (samplesSinceAnalysis >= kHopSize) {
samplesSinceAnalysis = 0;
analyseCurrentWindow();
}
}
}

float getFrequencyHz() const noexcept
{
float getFrequencyHz() const noexcept {
return frequencyHz;
}

float getConfidence() const noexcept
{
float getConfidence() const noexcept {
return confidence;
}

float getBestFrequencyHz() const noexcept
{
float getBestFrequencyHz() const noexcept {
return frequencyHz;
}

float getBestConfidence() const noexcept
{
float getBestConfidence() const noexcept {
return confidence;
}

int getMidiNote() const noexcept
{
int getMidiNote() const noexcept {
return midiNote;
}

void reset() noexcept
{
void reset() noexcept {
inputBuffer.fill(0.0f);
analysisBuffer.fill(0.0f);

Expand All @@ -104,11 +91,9 @@ class PitchDetector
midiNote = 0;
}

private:
void createAubio()
{
aubioPitchYin =
new_aubio_pitchyin(static_cast<uint_t>(kWindowSize));
private:
void createAubio() {
aubioPitchYin = new_aubio_pitchyin(static_cast<uint_t>(kWindowSize));

if (aubioPitchYin == nullptr)
return;
Expand All @@ -117,44 +102,34 @@ class PitchDetector
// 0.15 was already used successfully in the previous version.
aubio_pitchyin_set_tolerance(aubioPitchYin, 0.15f);

aubioInput =
new_fvec(static_cast<uint_t>(kWindowSize));
aubioInput = new_fvec(static_cast<uint_t>(kWindowSize));

aubioOutput = new_fvec(1);

if (aubioInput == nullptr || aubioOutput == nullptr)
{
if (aubioInput == nullptr || aubioOutput == nullptr) {
destroyAubio();
}
}

void destroyAubio() noexcept
{
if (aubioOutput != nullptr)
{
void destroyAubio() noexcept {
if (aubioOutput != nullptr) {
del_fvec(aubioOutput);
aubioOutput = nullptr;
}

if (aubioInput != nullptr)
{
if (aubioInput != nullptr) {
del_fvec(aubioInput);
aubioInput = nullptr;
}

if (aubioPitchYin != nullptr)
{
if (aubioPitchYin != nullptr) {
del_aubio_pitchyin(aubioPitchYin);
aubioPitchYin = nullptr;
}
}

void analyseCurrentWindow()
{
if (aubioPitchYin == nullptr ||
aubioInput == nullptr ||
aubioOutput == nullptr)
{
void analyseCurrentWindow() {
if (aubioPitchYin == nullptr || aubioInput == nullptr || aubioOutput == nullptr) {
clearDetection();
return;
}
Expand All @@ -170,10 +145,8 @@ class PitchDetector
at writePosition.
*/

for (int i = 0; i < kWindowSize; ++i)
{
const int sourceIndex =
(writePosition + i) % kWindowSize;
for (int i = 0; i < kWindowSize; ++i) {
const int sourceIndex = (writePosition + i) % kWindowSize;

analysisBuffer[static_cast<std::size_t>(i)] =
inputBuffer[static_cast<std::size_t>(sourceIndex)];
Expand All @@ -192,47 +165,31 @@ class PitchDetector
for (float sample : analysisBuffer)
energy += static_cast<double>(sample) * sample;

const double rms =
std::sqrt(energy / static_cast<double>(kWindowSize));
const double rms = std::sqrt(energy / static_cast<double>(kWindowSize));

if (rms < 0.000001)
{
if (rms < 0.000001) {
clearDetection();
return;
}

for (int i = 0; i < kWindowSize; ++i)
{
fvec_set_sample(
aubioInput,
analysisBuffer[static_cast<std::size_t>(i)],
static_cast<uint_t>(i));
for (int i = 0; i < kWindowSize; ++i) {
fvec_set_sample(aubioInput, analysisBuffer[static_cast<std::size_t>(i)],
static_cast<uint_t>(i));
}

aubio_pitchyin_do(
aubioPitchYin,
aubioInput,
aubioOutput);
aubio_pitchyin_do(aubioPitchYin, aubioInput, aubioOutput);

const float period =
fvec_get_sample(aubioOutput, 0);
const float period = fvec_get_sample(aubioOutput, 0);

const float detectedConfidence =
std::clamp(
aubio_pitchyin_get_confidence(aubioPitchYin),
0.0f,
1.0f);

if (period <= 0.0f ||
!std::isfinite(period) ||
detectedConfidence <= 0.0f)
{
std::clamp(aubio_pitchyin_get_confidence(aubioPitchYin), 0.0f, 1.0f);

if (period <= 0.0f || !std::isfinite(period) || detectedConfidence <= 0.0f) {
clearDetection();
return;
}

const float detectedFrequency =
static_cast<float>(sampleRate / period);
const float detectedFrequency = static_cast<float>(sampleRate / period);

/*
This is deliberately continuous Hz.
Expand All @@ -242,10 +199,8 @@ class PitchDetector
must survive.
*/

if (!std::isfinite(detectedFrequency) ||
detectedFrequency < 25.0f ||
detectedFrequency > 1200.0f)
{
if (!std::isfinite(detectedFrequency) || detectedFrequency < 25.0f ||
detectedFrequency > 1200.0f) {
clearDetection();
return;
}
Expand All @@ -259,17 +214,12 @@ class PitchDetector
It must never be used as the synthesis frequency.
*/

const float midi =
69.0f +
12.0f *
std::log2(detectedFrequency / 440.0f);
const float midi = 69.0f + 12.0f * std::log2(detectedFrequency / 440.0f);

midiNote =
static_cast<int>(std::lround(midi));
midiNote = static_cast<int>(std::lround(midi));
}

void clearDetection() noexcept
{
void clearDetection() noexcept {
frequencyHz = 0.0f;
confidence = 0.0f;
midiNote = 0;
Expand All @@ -292,4 +242,3 @@ class PitchDetector
fvec_t* aubioInput = nullptr;
fvec_t* aubioOutput = nullptr;
};

7 changes: 6 additions & 1 deletion UI/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";

export default defineConfig({
// The bundle is served from JUCE's in-memory resource provider rather than
// a real site root. Relative asset URLs work consistently across WebView2,
// WebKit, and hosts that rewrite the resource-provider URL.
base: "./",
plugins: [react(), tailwindcss()],
build: {
outDir: "dist",
Expand All @@ -12,7 +16,8 @@ export default defineConfig({
rollupOptions: {
output: {
entryFileNames: "app.js",
assetFileNames: (asset) => (asset.name?.endsWith(".css") ? "app.css" : "[name][extname]"),
assetFileNames: (asset) =>
asset.names?.some((name) => name.endsWith(".css")) ? "app.css" : "[name][extname]",
},
},
},
Expand Down
Loading