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 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
@@ -0,0 +1,4 @@
1
+ --markup markdown
2
+ --output-dir doc
3
+ lib/**/*.rb
4
+ README.md
data/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ ## [0.1.0] - 2026-03-07
6
+
7
+ - Initial release.
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
+ ![Image](https://github.com/user-attachments/assets/1e88395b-c715-4c9a-b458-d1cb9fceb848)
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}"