muze 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.
- checksums.yaml +7 -0
- data/.yardopts +4 -0
- data/CHANGELOG.md +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +93 -0
- data/Rakefile +30 -0
- data/benchmarks/baseline.json +24 -0
- data/benchmarks/native_vs_ruby.rb +23 -0
- data/benchmarks/quality_metrics.rb +265 -0
- data/benchmarks/quality_thresholds.md +28 -0
- data/benchmarks/support/fixture_library.rb +107 -0
- data/examples/beat_tracking.rb +26 -0
- data/examples/chroma_svg.rb +33 -0
- data/examples/feature_report.rb +37 -0
- data/examples/hpss_demo.rb +46 -0
- data/examples/load_and_specshow.rb +30 -0
- data/ext/muze/extconf.rb +6 -0
- data/ext/muze/muze_ext.c +75 -0
- data/lib/muze/beat/beat_track.rb +107 -0
- data/lib/muze/core/dct.rb +63 -0
- data/lib/muze/core/resample.rb +122 -0
- data/lib/muze/core/stft.rb +231 -0
- data/lib/muze/core/windows.rb +69 -0
- data/lib/muze/display/specshow.rb +100 -0
- data/lib/muze/effects/harmonic_percussive.rb +62 -0
- data/lib/muze/effects/time_stretch.rb +171 -0
- data/lib/muze/errors.rb +18 -0
- data/lib/muze/feature/chroma.rb +68 -0
- data/lib/muze/feature/mfcc.rb +120 -0
- data/lib/muze/feature/spectral.rb +266 -0
- data/lib/muze/filters/chroma_filter.rb +54 -0
- data/lib/muze/filters/mel.rb +91 -0
- data/lib/muze/io/audio_loader/ffmpeg_backend.rb +127 -0
- data/lib/muze/io/audio_loader/wavify_backend.rb +52 -0
- data/lib/muze/io/audio_loader.rb +117 -0
- data/lib/muze/native.rb +45 -0
- data/lib/muze/onset/onset_detect.rb +97 -0
- data/lib/muze/version.rb +5 -0
- data/lib/muze.rb +251 -0
- metadata +132 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 852274e098d5ea8d512680e8dbcf9962b8e380ce778c51912d5efa77cf5d4427
|
|
4
|
+
data.tar.gz: 4e49c534ea90ca6d01c1a3e37ca88e975db1a404a59b37288f8397642b71a7a5
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 874a8d2467e25523dd91361a41b299b7c8e9dfec8bd22def1f382795026ce28b7aa086f19eb8ffc7616c25f90e60471c909c2a777c3f4bc8fced6ba1fbf8c65a
|
|
7
|
+
data.tar.gz: c4d63ca9bd4a35ff74e34eec358a03e3c3c5dd15dd03589debbb00c97c02d1719849ca883149afd984f76065bed8377678af88e2297832eaddab43d8dad3b970
|
data/.yardopts
ADDED
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yudai Takada
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Muze
|
|
2
|
+
|
|
3
|
+
Muze is a Ruby audio feature extraction library that provides a full pipeline from audio loading to spectral analysis, feature extraction, rhythm analysis, effects, and lightweight visualization.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
Add this line to your application's Gemfile:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
gem "muze"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Then execute:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
bundle install
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Or install directly:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
gem install muze
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Quick Start
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
require "muze"
|
|
31
|
+
|
|
32
|
+
y, sr = RAF.load("sample.wav", sr: 22_050)
|
|
33
|
+
mel = RAF.melspectrogram(y:, sr:)
|
|
34
|
+
mfcc = RAF.mfcc(y:, sr:, n_mfcc: 13)
|
|
35
|
+
tempo, beats = RAF.beat_track(y:, sr:)
|
|
36
|
+
RAF.specshow(Muze.power_to_db(mel), output: "mel.svg")
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Main Features
|
|
40
|
+
|
|
41
|
+
| Area | APIs |
|
|
42
|
+
| --- | --- |
|
|
43
|
+
| Audio I/O | `RAF.load` (`wav`, `flac`, `mp3`, `ogg`) |
|
|
44
|
+
| STFT stack | `RAF.stft`, `RAF.istft`, `RAF.magphase` |
|
|
45
|
+
| Scale helpers | `RAF.power_to_db`, `RAF.amplitude_to_db` |
|
|
46
|
+
| Filters | `RAF.mel`, `RAF.chroma` |
|
|
47
|
+
| Features | `RAF.melspectrogram`, `RAF.mfcc`, `RAF.delta` |
|
|
48
|
+
| Spectral descriptors | centroid, bandwidth, rolloff, flatness, contrast, zcr, rms |
|
|
49
|
+
| Rhythm | `RAF.onset_strength`, `RAF.onset_detect`, `RAF.beat_track`, `RAF.tempogram` |
|
|
50
|
+
| Effects | `RAF.hpss`, `RAF.time_stretch`, `RAF.pitch_shift`, `RAF.trim` |
|
|
51
|
+
| Visualization | `RAF.specshow`, `RAF.waveshow` |
|
|
52
|
+
|
|
53
|
+
`wav` is loaded with `wavify`, and `flac/mp3/ogg` requires `ffmpeg` + `ffprobe`
|
|
54
|
+
available on `PATH`.
|
|
55
|
+
|
|
56
|
+
## Development
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
bundle install
|
|
60
|
+
bundle exec rspec
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Benchmark and regression check:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
bundle exec rake bench
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
This writes a JSON report to `benchmarks/reports/latest.json` and compares metrics
|
|
70
|
+
against `benchmarks/baseline.json`. To refresh the baseline:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
MUZE_BENCH_UPDATE_BASELINE=1 bundle exec rake bench
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Quality regression thresholds for effects are documented in
|
|
77
|
+
`benchmarks/quality_thresholds.md`.
|
|
78
|
+
|
|
79
|
+
Optional native extension:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
bundle exec rake compile
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Generate API docs:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
bundle exec yard doc
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## License
|
|
92
|
+
|
|
93
|
+
Muze is available under the [MIT License](./LICENSE.txt).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "rspec/core/rake_task"
|
|
5
|
+
require "rake/clean"
|
|
6
|
+
require_relative "benchmarks/quality_metrics"
|
|
7
|
+
|
|
8
|
+
RSpec::Core::RakeTask.new(:spec)
|
|
9
|
+
|
|
10
|
+
directory "ext/muze"
|
|
11
|
+
|
|
12
|
+
desc "Compile optional C extension"
|
|
13
|
+
task :compile do
|
|
14
|
+
Dir.chdir("ext/muze") do
|
|
15
|
+
ruby "extconf.rb"
|
|
16
|
+
sh "make"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
desc "Run quality/performance benchmarks and compare with baseline"
|
|
21
|
+
task :bench do
|
|
22
|
+
Muze::Benchmarks::QualityMetrics.run(
|
|
23
|
+
output_path: ENV.fetch("MUZE_BENCH_OUTPUT", Muze::Benchmarks::QualityMetrics::DEFAULT_OUTPUT_PATH),
|
|
24
|
+
baseline_path: ENV.fetch("MUZE_BENCH_BASELINE", Muze::Benchmarks::QualityMetrics::DEFAULT_BASELINE_PATH),
|
|
25
|
+
fail_on_regression: ENV.fetch("MUZE_BENCH_FAIL_ON_REGRESSION", "1") != "0",
|
|
26
|
+
update_baseline: ENV.fetch("MUZE_BENCH_UPDATE_BASELINE", "0") == "1"
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
task default: :spec
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"generated_at": "2026-03-05T08:25:13Z",
|
|
3
|
+
"ruby_version": "4.0.0",
|
|
4
|
+
"metrics": {
|
|
5
|
+
"istft_reconstruction_error": {
|
|
6
|
+
"value": 0.014255197813810787,
|
|
7
|
+
"unit": "rmse",
|
|
8
|
+
"direction": "lower",
|
|
9
|
+
"max_regression_ratio": 1.2
|
|
10
|
+
},
|
|
11
|
+
"time_stretch_processing_seconds": {
|
|
12
|
+
"value": 0.0018263333218379153,
|
|
13
|
+
"unit": "seconds_per_fixture",
|
|
14
|
+
"direction": "lower",
|
|
15
|
+
"max_regression_ratio": 4.0
|
|
16
|
+
},
|
|
17
|
+
"pitch_shift_processing_seconds": {
|
|
18
|
+
"value": 0.0046148888973726165,
|
|
19
|
+
"unit": "seconds_per_fixture",
|
|
20
|
+
"direction": "lower",
|
|
21
|
+
"max_regression_ratio": 4.0
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "benchmark"
|
|
4
|
+
require_relative "../lib/muze"
|
|
5
|
+
|
|
6
|
+
signal = Array.new(200_000) { rand(-1.0..1.0) }
|
|
7
|
+
values = Array.new(255) { rand }
|
|
8
|
+
|
|
9
|
+
puts "Native extension loaded: #{Muze::Native.extension_loaded?}"
|
|
10
|
+
|
|
11
|
+
Benchmark.bm(24) do |x|
|
|
12
|
+
x.report("frame_slices") do
|
|
13
|
+
20.times { Muze::Native.frame_slices(signal, 2048, 512) }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
x.report("median1d") do
|
|
17
|
+
2000.times { Muze::Native.median1d(values) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
x.report("sinc_resample") do
|
|
21
|
+
10.times { Muze.resample(signal, orig_sr: 44_100, target_sr: 22_050, res_type: :sinc) }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "optparse"
|
|
6
|
+
require "time"
|
|
7
|
+
|
|
8
|
+
require_relative "../lib/muze"
|
|
9
|
+
require_relative "support/fixture_library"
|
|
10
|
+
|
|
11
|
+
module Muze
|
|
12
|
+
module Benchmarks
|
|
13
|
+
# Quality and performance benchmark runner with baseline comparison.
|
|
14
|
+
module QualityMetrics
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
DEFAULT_OUTPUT_PATH = "benchmarks/reports/latest.json"
|
|
18
|
+
DEFAULT_BASELINE_PATH = "benchmarks/baseline.json"
|
|
19
|
+
DEFAULT_ITERATIONS = 3
|
|
20
|
+
|
|
21
|
+
METRIC_DEFINITIONS = {
|
|
22
|
+
"istft_reconstruction_error" => {
|
|
23
|
+
unit: "rmse",
|
|
24
|
+
direction: "lower",
|
|
25
|
+
max_regression_ratio: 1.20
|
|
26
|
+
},
|
|
27
|
+
"time_stretch_processing_seconds" => {
|
|
28
|
+
unit: "seconds_per_fixture",
|
|
29
|
+
direction: "lower",
|
|
30
|
+
max_regression_ratio: 4.00
|
|
31
|
+
},
|
|
32
|
+
"pitch_shift_processing_seconds" => {
|
|
33
|
+
unit: "seconds_per_fixture",
|
|
34
|
+
direction: "lower",
|
|
35
|
+
max_regression_ratio: 4.00
|
|
36
|
+
}
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
39
|
+
# @param output_path [String]
|
|
40
|
+
# @param baseline_path [String]
|
|
41
|
+
# @param fail_on_regression [Boolean]
|
|
42
|
+
# @param update_baseline [Boolean]
|
|
43
|
+
# @return [Hash]
|
|
44
|
+
def run(output_path:, baseline_path:, fail_on_regression:, update_baseline:)
|
|
45
|
+
fixtures = Muze::Benchmarks::FixtureLibrary.build
|
|
46
|
+
metrics = collect_metrics(fixtures)
|
|
47
|
+
baseline_metrics = load_baseline_metrics(baseline_path)
|
|
48
|
+
regressions = detect_regressions(metrics, baseline_metrics)
|
|
49
|
+
|
|
50
|
+
report = {
|
|
51
|
+
generated_at: Time.now.utc.iso8601,
|
|
52
|
+
ruby_version: RUBY_VERSION,
|
|
53
|
+
ruby_platform: RUBY_PLATFORM,
|
|
54
|
+
fixture_names: fixtures.keys,
|
|
55
|
+
baseline_path: baseline_path,
|
|
56
|
+
metrics: metrics,
|
|
57
|
+
regressions: regressions
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
write_json(output_path, report)
|
|
61
|
+
update_baseline_file(baseline_path, metrics) if update_baseline
|
|
62
|
+
|
|
63
|
+
if fail_on_regression && !regressions.empty?
|
|
64
|
+
raise Muze::Error, format_regression_error(regressions)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
report
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @param fixtures [Hash{String => Numo::SFloat}]
|
|
71
|
+
# @return [Hash]
|
|
72
|
+
def collect_metrics(fixtures)
|
|
73
|
+
{
|
|
74
|
+
"istft_reconstruction_error" => metric_entry(
|
|
75
|
+
value: average_istft_error(fixtures),
|
|
76
|
+
definition: METRIC_DEFINITIONS.fetch("istft_reconstruction_error")
|
|
77
|
+
),
|
|
78
|
+
"time_stretch_processing_seconds" => metric_entry(
|
|
79
|
+
value: average_runtime_per_fixture(fixtures) { |signal| Muze.time_stretch(signal, rate: 1.25) },
|
|
80
|
+
definition: METRIC_DEFINITIONS.fetch("time_stretch_processing_seconds")
|
|
81
|
+
),
|
|
82
|
+
"pitch_shift_processing_seconds" => metric_entry(
|
|
83
|
+
value: average_runtime_per_fixture(fixtures) { |signal| Muze.pitch_shift(signal, sr: 22_050, n_steps: 4.0) },
|
|
84
|
+
definition: METRIC_DEFINITIONS.fetch("pitch_shift_processing_seconds")
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
private_class_method :collect_metrics
|
|
89
|
+
|
|
90
|
+
# @param value [Float]
|
|
91
|
+
# @param definition [Hash]
|
|
92
|
+
# @return [Hash]
|
|
93
|
+
def metric_entry(value:, definition:)
|
|
94
|
+
{
|
|
95
|
+
value: value,
|
|
96
|
+
unit: definition.fetch(:unit),
|
|
97
|
+
direction: definition.fetch(:direction),
|
|
98
|
+
max_regression_ratio: definition.fetch(:max_regression_ratio)
|
|
99
|
+
}
|
|
100
|
+
end
|
|
101
|
+
private_class_method :metric_entry
|
|
102
|
+
|
|
103
|
+
# @param fixtures [Hash{String => Numo::SFloat}]
|
|
104
|
+
# @return [Float]
|
|
105
|
+
def average_istft_error(fixtures)
|
|
106
|
+
errors = fixtures.values.map do |signal|
|
|
107
|
+
stft_matrix = Muze.stft(signal, n_fft: 1024, hop_length: 256)
|
|
108
|
+
reconstructed = Muze.istft(stft_matrix, hop_length: 256, length: signal.size)
|
|
109
|
+
delta = signal - reconstructed
|
|
110
|
+
Math.sqrt((delta * delta).mean.to_f)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
errors.sum / errors.size.to_f
|
|
114
|
+
end
|
|
115
|
+
private_class_method :average_istft_error
|
|
116
|
+
|
|
117
|
+
# @param fixtures [Hash{String => Numo::SFloat}]
|
|
118
|
+
# @yieldparam signal [Numo::SFloat]
|
|
119
|
+
# @return [Float]
|
|
120
|
+
def average_runtime_per_fixture(fixtures)
|
|
121
|
+
fixtures.each_value { |signal| yield(signal) }
|
|
122
|
+
elapsed = measure_elapsed do
|
|
123
|
+
DEFAULT_ITERATIONS.times do
|
|
124
|
+
fixtures.each_value { |signal| yield(signal) }
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
elapsed / (DEFAULT_ITERATIONS.to_f * fixtures.size)
|
|
129
|
+
end
|
|
130
|
+
private_class_method :average_runtime_per_fixture
|
|
131
|
+
|
|
132
|
+
# @yieldreturn [void]
|
|
133
|
+
# @return [Float]
|
|
134
|
+
def measure_elapsed
|
|
135
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
136
|
+
yield
|
|
137
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
138
|
+
end
|
|
139
|
+
private_class_method :measure_elapsed
|
|
140
|
+
|
|
141
|
+
# @param baseline_path [String]
|
|
142
|
+
# @return [Hash]
|
|
143
|
+
def load_baseline_metrics(baseline_path)
|
|
144
|
+
return {} unless File.exist?(baseline_path)
|
|
145
|
+
|
|
146
|
+
baseline_json = JSON.parse(File.read(baseline_path))
|
|
147
|
+
baseline_json.fetch("metrics", {})
|
|
148
|
+
rescue JSON::ParserError
|
|
149
|
+
{}
|
|
150
|
+
end
|
|
151
|
+
private_class_method :load_baseline_metrics
|
|
152
|
+
|
|
153
|
+
# @param current_metrics [Hash]
|
|
154
|
+
# @param baseline_metrics [Hash]
|
|
155
|
+
# @return [Array<Hash>]
|
|
156
|
+
def detect_regressions(current_metrics, baseline_metrics)
|
|
157
|
+
current_metrics.filter_map do |metric_name, current|
|
|
158
|
+
baseline = baseline_metrics[metric_name]
|
|
159
|
+
next unless baseline
|
|
160
|
+
|
|
161
|
+
baseline_value = baseline.fetch("value").to_f
|
|
162
|
+
ratio = current.fetch(:max_regression_ratio)
|
|
163
|
+
max_allowed_value = baseline_value * ratio
|
|
164
|
+
current_value = current.fetch(:value)
|
|
165
|
+
next unless current_value > max_allowed_value
|
|
166
|
+
|
|
167
|
+
{
|
|
168
|
+
metric: metric_name,
|
|
169
|
+
baseline_value: baseline_value,
|
|
170
|
+
current_value: current_value,
|
|
171
|
+
max_allowed_value: max_allowed_value,
|
|
172
|
+
max_regression_ratio: ratio
|
|
173
|
+
}
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
private_class_method :detect_regressions
|
|
177
|
+
|
|
178
|
+
# @param path [String]
|
|
179
|
+
# @param report [Hash]
|
|
180
|
+
# @return [void]
|
|
181
|
+
def write_json(path, report)
|
|
182
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
183
|
+
File.write(path, JSON.pretty_generate(report))
|
|
184
|
+
end
|
|
185
|
+
private_class_method :write_json
|
|
186
|
+
|
|
187
|
+
# @param baseline_path [String]
|
|
188
|
+
# @param metrics [Hash]
|
|
189
|
+
# @return [void]
|
|
190
|
+
def update_baseline_file(baseline_path, metrics)
|
|
191
|
+
FileUtils.mkdir_p(File.dirname(baseline_path))
|
|
192
|
+
payload = {
|
|
193
|
+
generated_at: Time.now.utc.iso8601,
|
|
194
|
+
ruby_version: RUBY_VERSION,
|
|
195
|
+
metrics: metrics.transform_values do |entry|
|
|
196
|
+
{
|
|
197
|
+
value: entry.fetch(:value),
|
|
198
|
+
unit: entry.fetch(:unit),
|
|
199
|
+
direction: entry.fetch(:direction),
|
|
200
|
+
max_regression_ratio: entry.fetch(:max_regression_ratio)
|
|
201
|
+
}
|
|
202
|
+
end
|
|
203
|
+
}
|
|
204
|
+
File.write(baseline_path, JSON.pretty_generate(payload))
|
|
205
|
+
end
|
|
206
|
+
private_class_method :update_baseline_file
|
|
207
|
+
|
|
208
|
+
# @param regressions [Array<Hash>]
|
|
209
|
+
# @return [String]
|
|
210
|
+
def format_regression_error(regressions)
|
|
211
|
+
lines = regressions.map do |regression|
|
|
212
|
+
format(
|
|
213
|
+
"%<metric>s regressed: current=%<current>.8f baseline=%<baseline>.8f limit=%<limit>.8f (x%<ratio>.2f)",
|
|
214
|
+
metric: regression.fetch(:metric),
|
|
215
|
+
current: regression.fetch(:current_value),
|
|
216
|
+
baseline: regression.fetch(:baseline_value),
|
|
217
|
+
limit: regression.fetch(:max_allowed_value),
|
|
218
|
+
ratio: regression.fetch(:max_regression_ratio)
|
|
219
|
+
)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
"Benchmark regression detected:\n#{lines.join("\n")}"
|
|
223
|
+
end
|
|
224
|
+
private_class_method :format_regression_error
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
if $PROGRAM_NAME == __FILE__
|
|
230
|
+
options = {
|
|
231
|
+
output_path: Muze::Benchmarks::QualityMetrics::DEFAULT_OUTPUT_PATH,
|
|
232
|
+
baseline_path: Muze::Benchmarks::QualityMetrics::DEFAULT_BASELINE_PATH,
|
|
233
|
+
fail_on_regression: false,
|
|
234
|
+
update_baseline: false
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
OptionParser.new do |opts|
|
|
238
|
+
opts.banner = "Usage: ruby benchmarks/quality_metrics.rb [options]"
|
|
239
|
+
|
|
240
|
+
opts.on("--output PATH", "Output JSON path (default: #{options[:output_path]})") do |path|
|
|
241
|
+
options[:output_path] = path
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
opts.on("--baseline PATH", "Baseline JSON path (default: #{options[:baseline_path]})") do |path|
|
|
245
|
+
options[:baseline_path] = path
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
opts.on("--fail-on-regression", "Exit with non-zero code on baseline regression") do
|
|
249
|
+
options[:fail_on_regression] = true
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
opts.on("--update-baseline", "Write current metrics into baseline JSON") do
|
|
253
|
+
options[:update_baseline] = true
|
|
254
|
+
end
|
|
255
|
+
end.parse!
|
|
256
|
+
|
|
257
|
+
begin
|
|
258
|
+
report = Muze::Benchmarks::QualityMetrics.run(**options)
|
|
259
|
+
puts "Benchmark report written to #{options[:output_path]}"
|
|
260
|
+
puts "No regressions detected" if report.fetch(:regressions).empty?
|
|
261
|
+
rescue Muze::Error => e
|
|
262
|
+
warn e.message
|
|
263
|
+
exit(1)
|
|
264
|
+
end
|
|
265
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Effects Quality Regression Thresholds
|
|
2
|
+
|
|
3
|
+
This document defines thresholds for `time_stretch` and `pitch_shift` regression tests.
|
|
4
|
+
|
|
5
|
+
## Signals
|
|
6
|
+
|
|
7
|
+
- Harmonic: 440Hz + 660Hz mixed sine wave (`2.0s`, `22_050Hz`)
|
|
8
|
+
- Transient: click impulses at fixed timestamps (`2.0s`, `22_050Hz`)
|
|
9
|
+
|
|
10
|
+
## Thresholds
|
|
11
|
+
|
|
12
|
+
- `time_stretch` dominant frequency drift:
|
|
13
|
+
- Condition: `rate=2.0` and `rate=0.5`
|
|
14
|
+
- Threshold: within `20Hz` from original dominant frequency
|
|
15
|
+
- Rationale: keeps clear musical pitch identity while allowing FFT-bin granularity error.
|
|
16
|
+
|
|
17
|
+
- `time_stretch` transient alignment:
|
|
18
|
+
- Condition: `rate=2.0`
|
|
19
|
+
- Threshold: each detected click within `256 samples` from expected scaled position
|
|
20
|
+
- Rationale: catches obvious transient collapse while tolerating local phase vocoder artifacts.
|
|
21
|
+
|
|
22
|
+
- `pitch_shift` octave peak movement:
|
|
23
|
+
- Condition: `n_steps=+12`
|
|
24
|
+
- Threshold: contains peaks near `880Hz` (`±25Hz`) and `1320Hz` (`±35Hz`)
|
|
25
|
+
- Rationale: verifies octave translation for fundamental + harmonic pair.
|
|
26
|
+
|
|
27
|
+
These thresholds are intentionally conservative to avoid flaky CI failures while still
|
|
28
|
+
catching meaningful quality regressions.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Muze
|
|
4
|
+
module Benchmarks
|
|
5
|
+
# Fixture signal builder for quality/performance benchmark runs.
|
|
6
|
+
module FixtureLibrary
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# @param sample_rate [Integer]
|
|
10
|
+
# @param duration [Float]
|
|
11
|
+
# @return [Hash{String => Numo::SFloat}]
|
|
12
|
+
def build(sample_rate: 22_050, duration: 1.0)
|
|
13
|
+
sample_count = [(sample_rate * duration).round, 1].max
|
|
14
|
+
|
|
15
|
+
sine = sine_wave(
|
|
16
|
+
sample_rate: sample_rate,
|
|
17
|
+
sample_count: sample_count,
|
|
18
|
+
frequency: 440.0,
|
|
19
|
+
amplitude: 0.8
|
|
20
|
+
)
|
|
21
|
+
click = click_track(
|
|
22
|
+
sample_rate: sample_rate,
|
|
23
|
+
sample_count: sample_count,
|
|
24
|
+
interval_seconds: 0.1
|
|
25
|
+
)
|
|
26
|
+
simple_mix = mixed_signal(sample_rate:, sample_count:)
|
|
27
|
+
|
|
28
|
+
{
|
|
29
|
+
"sine" => Numo::SFloat.cast(sine),
|
|
30
|
+
"click" => Numo::SFloat.cast(click),
|
|
31
|
+
"simple_mix" => Numo::SFloat.cast(simple_mix)
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @param sample_rate [Integer]
|
|
36
|
+
# @param sample_count [Integer]
|
|
37
|
+
# @param frequency [Float]
|
|
38
|
+
# @param amplitude [Float]
|
|
39
|
+
# @return [Array<Float>]
|
|
40
|
+
def sine_wave(sample_rate:, sample_count:, frequency:, amplitude:)
|
|
41
|
+
Array.new(sample_count) do |index|
|
|
42
|
+
angle = (2.0 * Math::PI * frequency * index) / sample_rate
|
|
43
|
+
amplitude * Math.sin(angle)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
private_class_method :sine_wave
|
|
47
|
+
|
|
48
|
+
# @param sample_rate [Integer]
|
|
49
|
+
# @param sample_count [Integer]
|
|
50
|
+
# @param interval_seconds [Float]
|
|
51
|
+
# @return [Array<Float>]
|
|
52
|
+
def click_track(sample_rate:, sample_count:, interval_seconds:)
|
|
53
|
+
click_interval = [(sample_rate * interval_seconds).round, 1].max
|
|
54
|
+
signal = Array.new(sample_count, 0.0)
|
|
55
|
+
|
|
56
|
+
index = 0
|
|
57
|
+
while index < sample_count
|
|
58
|
+
signal[index] = 0.95
|
|
59
|
+
index += click_interval
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
signal
|
|
63
|
+
end
|
|
64
|
+
private_class_method :click_track
|
|
65
|
+
|
|
66
|
+
# @param sample_rate [Integer]
|
|
67
|
+
# @param sample_count [Integer]
|
|
68
|
+
# @return [Array<Float>]
|
|
69
|
+
def mixed_signal(sample_rate:, sample_count:)
|
|
70
|
+
low = sine_wave(
|
|
71
|
+
sample_rate: sample_rate,
|
|
72
|
+
sample_count: sample_count,
|
|
73
|
+
frequency: 220.0,
|
|
74
|
+
amplitude: 0.5
|
|
75
|
+
)
|
|
76
|
+
high = sine_wave(
|
|
77
|
+
sample_rate: sample_rate,
|
|
78
|
+
sample_count: sample_count,
|
|
79
|
+
frequency: 880.0,
|
|
80
|
+
amplitude: 0.35
|
|
81
|
+
)
|
|
82
|
+
clicks = click_track(
|
|
83
|
+
sample_rate: sample_rate,
|
|
84
|
+
sample_count: sample_count,
|
|
85
|
+
interval_seconds: 0.2
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
normalize_peak(
|
|
89
|
+
low.each_with_index.map { |sample, index| sample + high[index] + (0.25 * clicks[index]) }
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
private_class_method :mixed_signal
|
|
93
|
+
|
|
94
|
+
# @param signal [Array<Float>]
|
|
95
|
+
# @param target_peak [Float]
|
|
96
|
+
# @return [Array<Float>]
|
|
97
|
+
def normalize_peak(signal, target_peak: 0.9)
|
|
98
|
+
peak = signal.map(&:abs).max.to_f
|
|
99
|
+
return signal if peak <= 0.0
|
|
100
|
+
|
|
101
|
+
scale = target_peak / peak
|
|
102
|
+
signal.map { |sample| sample * scale }
|
|
103
|
+
end
|
|
104
|
+
private_class_method :normalize_peak
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Usage: bundle exec ruby examples/beat_tracking.rb path/to/audio.wav
|
|
5
|
+
|
|
6
|
+
require_relative "../lib/muze"
|
|
7
|
+
|
|
8
|
+
input_path = ARGV[0]
|
|
9
|
+
abort("Usage: bundle exec ruby examples/beat_tracking.rb path/to/audio.wav") unless input_path
|
|
10
|
+
|
|
11
|
+
target_sr = 22_050
|
|
12
|
+
n_fft = 1024
|
|
13
|
+
hop_length = 256
|
|
14
|
+
|
|
15
|
+
y, sr = Muze.load(input_path, sr: target_sr, mono: true)
|
|
16
|
+
onset_envelope = Muze.onset_strength(y:, sr:, hop_length:, n_fft:)
|
|
17
|
+
onset_times = Muze.onset_detect(onset_envelope:, sr:, hop_length:, backtrack: true, units: :time)
|
|
18
|
+
tempo, beat_frames = Muze.beat_track(onset_envelope:, sr:, hop_length:)
|
|
19
|
+
beat_times = beat_frames.map { |frame| (frame * hop_length.to_f) / sr }
|
|
20
|
+
|
|
21
|
+
puts "Input: #{input_path}"
|
|
22
|
+
puts format("Estimated tempo: %.2f BPM", tempo)
|
|
23
|
+
puts "Detected onsets: #{onset_times.length}"
|
|
24
|
+
puts "Detected beats: #{beat_times.length}"
|
|
25
|
+
puts "First onsets (s): #{onset_times.first(8).map { |time| format('%.3f', time) }.join(', ')}"
|
|
26
|
+
puts "First beats (s): #{beat_times.first(8).map { |time| format('%.3f', time) }.join(', ')}"
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Usage: bundle exec ruby examples/chroma_svg.rb path/to/audio.wav [output.svg]
|
|
5
|
+
|
|
6
|
+
require "fileutils"
|
|
7
|
+
require_relative "../lib/muze"
|
|
8
|
+
|
|
9
|
+
note_names = %w[C C# D D# E F F# G G# A A# B].freeze
|
|
10
|
+
input_path = ARGV[0]
|
|
11
|
+
abort("Usage: bundle exec ruby examples/chroma_svg.rb path/to/audio.wav [output.svg]") unless input_path
|
|
12
|
+
|
|
13
|
+
sample_name = File.basename(input_path, ".*")
|
|
14
|
+
output_path = ARGV[1] || File.expand_path("output/#{sample_name}_chroma.svg", __dir__)
|
|
15
|
+
FileUtils.mkdir_p(File.dirname(output_path))
|
|
16
|
+
|
|
17
|
+
target_sr = 22_050
|
|
18
|
+
n_fft = 2048
|
|
19
|
+
hop_length = 512
|
|
20
|
+
|
|
21
|
+
y, sr = Muze.load(input_path, sr: target_sr, mono: true)
|
|
22
|
+
chroma = Muze.chroma_stft(y:, sr:, n_chroma: 12, n_fft:, hop_length:)
|
|
23
|
+
Muze.specshow(chroma, sr:, hop_length:, y_axis: :linear, output: output_path)
|
|
24
|
+
|
|
25
|
+
pitch_strengths = chroma.to_a.map do |row|
|
|
26
|
+
row.sum(0.0) / [row.length, 1].max
|
|
27
|
+
end
|
|
28
|
+
dominant_pitch = note_names.fetch(pitch_strengths.each_with_index.max_by(&:first).last)
|
|
29
|
+
|
|
30
|
+
puts "Input: #{input_path}"
|
|
31
|
+
puts "Chroma shape: #{chroma.shape.join(' x ')}"
|
|
32
|
+
puts "Dominant pitch class: #{dominant_pitch}"
|
|
33
|
+
puts "Wrote: #{output_path}"
|