diff --git a/bindings/ruby/.gitignore b/bindings/ruby/.gitignore new file mode 100644 index 0000000..596263f --- /dev/null +++ b/bindings/ruby/.gitignore @@ -0,0 +1,19 @@ +/.bundle/ +/.yardoc +/_yardoc/ +/coverage/ +/doc/ +/pkg/ +/html/ +/spec/reports/ +/tmp/ +*.bundle +*.so +*.o +*.a +mkmf.log +target/ +Gemfile.lock +/vendor/bundle/ +ext/ebur128_stream/.rustc_info.json +ext/ebur128_stream/release/ diff --git a/bindings/ruby/CHANGELOG.md b/bindings/ruby/CHANGELOG.md new file mode 100644 index 0000000..bb47cf0 --- /dev/null +++ b/bindings/ruby/CHANGELOG.md @@ -0,0 +1,5 @@ +## [Unreleased] + +## [0.1.0] - 2026-09-04 + +- Initial release diff --git a/bindings/ruby/Gemfile b/bindings/ruby/Gemfile new file mode 100644 index 0000000..f12f6f0 --- /dev/null +++ b/bindings/ruby/Gemfile @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +# Specify your gem's dependencies in ebur128_stream.gemspec +gemspec diff --git a/bindings/ruby/README.md b/bindings/ruby/README.md new file mode 100644 index 0000000..778b21a --- /dev/null +++ b/bindings/ruby/README.md @@ -0,0 +1,256 @@ +# EBUR128Stream + +A Ruby binding for [ebur128-stream][rust-impl], a streaming, zero-allocation EBU R128 loudness measurement in pure Rust. + +## Installation + +Install the gem and add to the application's Gemfile by executing: + +```bash +bundle add ebur128_stream +``` + +If bundler is not being used to manage dependencies, install the gem by executing: + +```bash +gem install ebur128_stream +``` + +## Usage + +```ruby +require "ebur128_stream" + +include EBUR128Stream + +analyzer = Analyzer.new( + channels: [:left, :right], + sample_rate: 48_000, + modes: [:integrated, :true_peak] +) +analyzer.push_interleaved samples + +report = analyzer.finalize +report.integrated_lufs # => -8.030723453035735 +report.true_peak_dbtp # => 20.01377283514713 +``` + +EBUR128Stream provides two main classes: + +* [Analyzer](#analyzer) analyzes audio data and reports loudness. +* [Normalizer](#normalizer) analyzes audio data and applies the calibrated gain. + + +### Analyzer + +EBUR128Stream::Analyzer is a streaming EBU R128 loudness analyzer. It reads audio data and reports statistics such as integrated LUFS, dBTP. + +It provides a streaming API, which means you can push chunks of audio data incrementally and get the current status (EBUR128Stream::Snapshot) and the final report (EBUR128Stream::Report). + +### Initialization + +First, initialize EBUR128Stream::Analyzer: + +```ruby +analyzer = EBUR128Stream::Analyzer.new( + # Required. Supports :left, :right, :center, :left_surround, :right_surround, :lfe, :other. + channels: [:left, :right], + + # Optional. Must be one of 22_050, 32_000, 44_100, 48_000, 88_200, 96_000, 192_000. Defaults to 48_000. + sample_rate: 48_000, + + # Optional. Supports :integrated, :momentary, :short_term, :true_peak, :lra, :all. Defaults to [:all]. + modes: [:all], + + # Optional. Hint at audio length in seconds so that buffer is allocated first. + expected_duration: 60 +) +``` + +See [Rust documentation](https://docs.rs/ebur128-stream/latest/ebur128_stream/struct.AnalyzerBuilder.html) for details on arguments. + +### Pushing samples + +Then, push audio samples to EBUR128Stream::Analyzer#push_interleaved or EBUR128Stream::Analyzer#push_planar as you get them: + +```ruby +while samples = get_samples + analyzer.push_interleaved(samples) + # Or, analyzer.push_planar samples +end +``` + +Interleaved samples looks like this: + +```ruby +[L1, R1, L2, R2, L3, R3, ...] +``` + +(*Note* that it's not a 2-D array (`[[L1, R1], [L2, R2], ...]`) but a flat array.) + +Planar samples looks like this: + +```ruby +[[L1, L2, L3, ...], [R1, R2, R3, ...]] +``` + +In addition to 1-D or 2-D `Array`s, EBUR128Stream::Analyzer#push_interleaved and EBUR128Stream::Analyzer#push_planar accept MemoryView producers such as: + +* `Gst::Sample` from [GStreamer][] gem +* `Torch::Tensor` from [TorchAudio][] or [TorchCodec][] gem (w/ [NDAV::TorchTensor][]) +* `Numo::NArray` from [Numo::NArray][] or [Numo::NArray Alternative][] gem when generating and processing audio data with it (w/ [NDAV::Numo::NArray][]) + +TorchAudio example here: + +```ruby +samples, sample_rate = TorchAudio.load("path/to/audio") +analyzer.push_planar(samples) +``` + +### Snapshots + +At any point during streaming, you can get the current statistics (EBUR128Stream::Snapshot) by calling EBUR128Stream::Analyzer#snapshot: + +```ruby +snapshot = analyzer.snapshot + +snapshot.programme_duration_seconds #=> Current duration +snapshot.momentary_lufs #=> Loudness sliding 400ms window in LUFS +snapshot.short_term_lufs #=> Loudness sliding 3s window in LUFS +snapshot.integrated_lufs #=> Loudness so far in LUFS +snapshot.true_peak_dbtp #=> True peak so far in dBTP +snapshot.loudness_range_lu #=> Loudness range in LU +``` + +The attributes may be `nil` when there are not enough samples or when the corresponding mode was not specified at initialization. + +EBUR128Stream::Snapshot implements `#to_h`: + +```ruby +snapshot.to_h #=> {momentary_lufs: ..., short_term_lufs: ..., ...} +``` + +Also, `#deconstruct_keys` is implemented: + +```ruby +snapshot.deconstruct_keys(nil) #=> {momentary_lufs: ..., short_term_lufs: ..., ...} + +case analyzer.snapshot +in EBUR128Stream::Snapshot[true_peak_dbtp: 1.0..] => snapshot + $stderr.puts "High true peak found at #{snapshot.programme_duration_seconds}s: #{snapshot.true_peak_dbtp} dBTP" +else + # noop +end +``` + +### Finalization + +Finally, call EBUR128Stream::Analyzer#finalize to complete the analysis and get the report: + +```ruby +report = analyzer.finalize + +report.programme_duration_seconds #=> Total duration in seconds +report.integrated_lufs +report.loudness_range_lu +report.true_peak_dbtp +report.momentary_max_lufs #=> Maximum momentary loudness in LUFS +report.short_term_max_lufs #=> Maximum short term loudness in LUFS +``` + +EBUR128Stream::Report also implements `#to_h` and `#deconstruct_keys` like EBUR128Stream::Snapshot. + + +### Normalizer + +EBUR128Stream::Normalizer analyzes audio data and normalizes it to the target loudness in place. + +#### Initialization + +Initialize EBUR128Stream::Normalizer with target loudness. + +```ruby +normalizer = EBUR128Stream::Normalizer.new( + # Required. + sample_rate: 48_000, + + # Required. + channels: [:left, :right], + + # Optional. Target loudness in LUFS. Defaults to -23.0. + target_lufs: -14.0, + + # Optional. Cap the post-normalisation true peak at this dBTP value. + true_peak_ceiling_dbtp: -1.0, +) +``` + +#### Normalization + +Pass interleaved samples to EBUR128Stream::Normalizer#normalize_in_place: + +```ruby +report = normalizer.normalize_in_place(interleaved_samples) +``` + +*Note* that it modifies the passed `Array` in place as the method name implies. + +This returns EBUR128Stream::NormalizeReport: + +```ruby +report.measured_integrated_lufs #=> Measured integrated loudness before normalization. +report.measured_true_peak_dbtp # Measured true peak before normalization, in dBTP. +report.target_lufs #=> Target loudness, in LUFS. +report.true_peak_ceiling_dbtp #=> True peak ceiling in dBTP. +report.applied_gain_db #=> Gain that was actually applied in dB. +report.limited_by_true_peak #=> true if the gain was attenuated to honour the true peak ceiling. +``` + +EBUR128Stream::NormalizeReport also implements `#to_h` and `deconstruct_keys`. + +Additionally, it accepts MemoryView as a `samples` argument. The MemoryView must be writable. + +## Examples + +Complete examples are available in the sample directory. + +* sample/analyze-wavefile.rb - A basic example of analyzing audio from wave file using pure-Ruby [WaveFile][] gem. +* sample/analyze-microphone.rb - An example that displays microphone loudness in real time. +* sample/analyze-planar-data.rb - An example of analyzing planar audio data instead of interleaved data. +* sample/normalize.rb - An example of normalizing audio data to desired loudness and saving it to a file. + +## Development + +After checking out the repo, run `bundle install` to install dependencies. Then, run `bundle exec rake test` to run the tests. You can also run `bundle exec rake console` for an interactive prompt that will allow you to experiment. + +To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). + +## Contributing + +Bug reports and pull requests are welcome on GitHub at https://github.com/vanjamodrinjak21/ebur128_stream. Mention @KitaitiMakoto for issues and pull requests related to the Ruby binding. + +## License + +Licensed under either of + +- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0) +- MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT) + +at your option. + +## See also + +* [Rust crate][rust-impl] +* [BS.1770][] + +[rust-impl]: https://github.com/vanjamodrinjak21/ebur128-stream +[Analyzer]: rdoc-ref:EBUR128Stream::Analyzer +[BS.1770]: https://www.itu.int/rec/R-REC-BS.1770 +[GStreamer]: https://github.com/ruby-gnome/ruby-gnome/tree/main/gstreamer +[TorchAudio]: https://github.com/ankane/torchaudio-ruby +[TorchCodec]: https://github.com/ankane/torchcodec-ruby +[Numo::NArray]: https://ruby-numo.github.io/numo-narray/ +[Numo::NArray Alternative]: https://github.com/yoshoku/numo-narray-alt +[WaveFile]: https://wavefilegem.com/ +[NDAV::TorchTensor]: https://gitlab.com/KitaitiMakoto/ndav-torch-tensor +[NDAV::Numo::NArray]: https://gitlab.com/KitaitiMakoto/ndav-numo-narray diff --git a/bindings/ruby/Rakefile b/bindings/ruby/Rakefile new file mode 100644 index 0000000..57084c4 --- /dev/null +++ b/bindings/ruby/Rakefile @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require "rubygems/tasks" +require "rake/testtask" +require "kar/dsl" +require "rdoc/task" + +GEMSPEC = Gem::Specification.load("ebur128_stream.gemspec") + +LICENSE_FILES = FileList["LICENSE-APACHE", "LICENSE-MIT"] +LICENSE_FILES.each do |license| + file license => "../../#{license}" do |t| + copy t.source, t.name + end +end + +Gem::Tasks.new + +cargo "ebur128_stream" + +Rake::TestTask.new(test: :cargo) +RDoc::Task.new + +task default: :test + +task sync_version: ["lib/ebur128_stream/version.rb", "ext/ebur128_stream/Cargo.toml"] do |t| + require_relative t.sources[0] + sh "cargo", "set-version", "--manifest-path", t.sources[1], "--offline", EBUR128Stream::VERSION +end + +task build: [:sync_version, :cargo] + LICENSE_FILES diff --git a/bindings/ruby/ebur128_stream.gemspec b/bindings/ruby/ebur128_stream.gemspec new file mode 100644 index 0000000..5609e93 --- /dev/null +++ b/bindings/ruby/ebur128_stream.gemspec @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require_relative "lib/ebur128_stream/version" + +Gem::Specification.new do |spec| + spec.name = "ebur128_stream" + spec.version = EBUR128Stream::VERSION + spec.authors = ["Kitaiti Makoto"] + spec.email = ["KitaitiMakoto@gmail.com"] + spec.licenses = ["Apache-2.0", "MIT"] + + spec.summary = "Streaming, zero-allocation EBU R128 loudness measurement." + spec.description = "Ruby binding for Streaming, zero-allocation EBU R128 loudness measurement in pure Rust." + spec.homepage = "https://github.com/KitaitiMakoto/ebur128-stream/tree/ruby/bindings/ruby" + spec.required_ruby_version = ">= 3.2.0" + spec.metadata["allowed_push_host"] = "https://rubygems.org" + spec.metadata["homepage_uri"] = spec.homepage + spec.metadata["source_code_uri"] = "https://github.com/KitaitiMakoto/ebur128-stream/tree/ruby/bindings/ruby" + spec.metadata["changelog_uri"] = "https://github.com/KitaitiMakoto/ebur128-stream/tree/ruby/bindings/ruby/CHANGELOG.md" + + # Uncomment the line below to require MFA for gem pushes. + # This helps protect your gem from supply chain attacks by ensuring + # no one can publish a new version without multi-factor authentication. + # See: https://guides.rubygems.org/mfa-requirement-opt-in/ + # spec.metadata["rubygems_mfa_required"] = "true" + + # Specify which files should be added to the gem when it is released. + # The `git ls-files -z` loads the files in the RubyGem that have been added into git. + gemspec = File.basename(__FILE__) + spec.files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls| + ls.readlines("\x0", chomp: true) + end + ["LICENSE-APACHE", "LICENSE-MIT"] + spec.executables = spec.files.grep(%r{\Abin/}) { |f| File.basename(f) } + spec.require_paths = ["lib"] + spec.extensions = ["ext/ebur128_stream/Cargo.toml"] + + # Uncomment to register a new dependency of your gem + # spec.add_dependency "example-gem", "~> 1.0" + + spec.add_development_dependency "irb" + spec.add_development_dependency "rake" + spec.add_development_dependency "test-unit" + spec.add_development_dependency "rubygems-tasks" + spec.add_development_dependency "kar" + spec.add_development_dependency "numo-narray-alt" + spec.add_development_dependency "ndav-numo-narray" + + # For more information and examples about making a new gem, check out our + # guide at: https://guides.rubygems.org/make-your-own-gem/ +end diff --git a/bindings/ruby/ext/ebur128_stream/Cargo.lock b/bindings/ruby/ext/ebur128_stream/Cargo.lock new file mode 100644 index 0000000..5ec8a47 --- /dev/null +++ b/bindings/ruby/ext/ebur128_stream/Cargo.lock @@ -0,0 +1,359 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn", +] + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "ebur128-stream" +version = "0.1.0" +dependencies = [ + "ebur128-stream 0.2.0", + "grey-knights", + "magnus", + "rb-sys", + "rb-sys-env", + "rb-sys-test-helpers", +] + +[[package]] +name = "ebur128-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e026ac32fee8397cdbaafee0030443af608d6c4832e590905b16c9450b7536" +dependencies = [ + "bitflags", + "libm", + "wide", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "grey-knights" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddc9622bcf1fe8243d34e708e00e4b41ca627a385e1d0684df7ddbe79f1c663" +dependencies = [ + "magnus", + "rb-sys", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "magnus" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b36a5b126bbe97eb0d02d07acfeb327036c6319fd816139a49824a83b7f9012" +dependencies = [ + "magnus-macros", + "rb-sys", + "rb-sys-env", + "seq-macro", +] + +[[package]] +name = "magnus-macros" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47607461fd8e1513cb4f2076c197d8092d921a1ea75bd08af97398f593751892" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rb-sys" +version = "0.9.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02faf625bb10ba893e3ae620f19c9fb1b5f8fcae0fe4eb86bb3f2230fad75edb" +dependencies = [ + "rb-sys-build", +] + +[[package]] +name = "rb-sys-build" +version = "0.9.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05be6f9c86fe5482808162826f6c047d093b3670582296c770de1d94f8066694" +dependencies = [ + "bindgen", + "lazy_static", + "proc-macro2", + "quote", + "regex", + "shell-words", + "syn", +] + +[[package]] +name = "rb-sys-env" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cca7ad6a7e21e72151d56fe2495a259b5670e204c3adac41ee7ef676ea08117a" + +[[package]] +name = "rb-sys-test-helpers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccc637c53cad6a6b49ad51c5857c64e36bd70c955ffafe9dc9cc5bc9dd0b3b18" +dependencies = [ + "rb-sys", + "rb-sys-env", + "rb-sys-test-helpers-macros", +] + +[[package]] +name = "rb-sys-test-helpers-macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c1fce35d9ac77a2745e539232fb82a75856675a5daf4ef8b1ca994d89f8b3f" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "safe_arch" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "seq-macro" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wide" +version = "0.7.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" +dependencies = [ + "bytemuck", + "safe_arch", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" diff --git a/bindings/ruby/ext/ebur128_stream/Cargo.toml b/bindings/ruby/ext/ebur128_stream/Cargo.toml new file mode 100644 index 0000000..013a313 --- /dev/null +++ b/bindings/ruby/ext/ebur128_stream/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "ebur128-stream" +version = "0.1.0" +edition = "2024" +authors = ["Kitaiti Makoto "] +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +magnus = { version = "0.8.2", features = ["rb-sys"] } +rb-sys = { version = "0.9", features = ["stable-api-compiled-fallback"] } +ebur128-stream-rs = { package = "ebur128-stream", version = "0.2.0", features= ["normalize", "simd"] } +grey-knights = { version = "0.1.0" } + +[build-dependencies] +rb-sys-env = "0.2.2" + +[dev-dependencies] +rb-sys-test-helpers = { version = "0.3.0" } diff --git a/bindings/ruby/ext/ebur128_stream/build.rs b/bindings/ruby/ext/ebur128_stream/build.rs new file mode 100644 index 0000000..80a7842 --- /dev/null +++ b/bindings/ruby/ext/ebur128_stream/build.rs @@ -0,0 +1,5 @@ +pub fn main() -> Result<(), Box> { + let _ = rb_sys_env::activate()?; + + Ok(()) +} diff --git a/bindings/ruby/ext/ebur128_stream/src/analyzer.rs b/bindings/ruby/ext/ebur128_stream/src/analyzer.rs new file mode 100644 index 0000000..67bac25 --- /dev/null +++ b/bindings/ruby/ext/ebur128_stream/src/analyzer.rs @@ -0,0 +1,159 @@ +use crate::{Channels, Error, InterleavedSamples, PlanarSamples, Report, Snapshot}; +use ebur128_stream_rs as engine; +use magnus::{ + Integer, Module, RArray, RModule, Ruby, Symbol, TryConvert, Value, function, method, + prelude::*, + scan_args::{get_kwargs, scan_args}, +}; +use std::{ + cell::{Ref, RefCell, RefMut}, + time::Duration, +}; + +#[magnus::wrap(class = "EBUR128Stream::Analyzer")] +struct Analyzer { + analyzer: RefCell>, +} + +impl Analyzer { + fn new(args: &[Value]) -> Result { + let args = scan_args::<(), (), (), (), _, ()>(args)?; + let kws = + get_kwargs::<_, (Channels,), (Option, Option, Option), ()>( + args.keywords, + &["channels"], + &["sample_rate", "modes", "expected_duration"], + )?; + let (channels,) = kws.required; + let (sample_rate, modes, expected_duration) = kws.optional; + + let mut builder = engine::AnalyzerBuilder::new().channels(&channels); + + if let Some(sample_rate) = sample_rate { + builder = builder.sample_rate(sample_rate.to_u32()?); + } + + if let Some(values) = modes { + use engine::Mode; + + let mut modes = Mode::empty(); + for value in values.into_iter() { + let mode = Symbol::try_convert(value)?; + modes |= match mode.name()?.as_ref() { + "integrated" => Mode::Integrated, + "momentary" => Mode::Momentary, + "short_term" => Mode::ShortTerm, + "true_peak" => Mode::TruePeak, + "lra" => Mode::Lra, + "all" => Mode::All, + _ => { + return Err(Error::argument(format!("unknown mode: {mode}")))?; + } + } + } + builder = builder.modes(modes); + } + if let Some(expected_duration) = expected_duration { + builder = builder.expected_duration(Duration::from_secs(expected_duration.to_u64()?)); + } + + let analyzer = builder.build().map_err(Error::runtime)?; + + Ok(Self { + analyzer: RefCell::new(Some(analyzer)), + }) + } + + fn sample_rate(&self) -> Result { + Ok(self.analyzer()?.sample_rate()) + } + + fn channels(ruby: &Ruby, rb_self: &Self) -> Result { + Ok(Channels::from(rb_self.analyzer()?.channels()).try_into_rarray(ruby)?) + } + + fn modes(ruby: &Ruby, rb_self: &Self) -> Result { + let syms = rb_self + .analyzer()? + .modes() + .iter_names() + .map(|mode| ruby.to_symbol(mode.0.to_lowercase())) + .collect::>(); + Ok(ruby.ary_new_from_values(&syms)) + } + + fn samples_per_block(&self) -> Result { + Ok(self.analyzer()?.samples_per_block()) + } + + fn push_interleaved(&self, samples: InterleavedSamples) -> Result<(), Error> { + Ok(self.analyzer_mut()?.push_interleaved(samples.as_slice())?) + } + + fn push_planar(&self, samples: PlanarSamples) -> Result<(), Error> { + Ok(self + .analyzer_mut()? + .push_planar(&samples.channel_slices())?) + } + + fn snapshot(&self) -> Result { + let snapshot = self.analyzer_mut()?.snapshot(); + + Ok(Snapshot { snapshot }) + } + + fn reset(&self) -> Result<(), Error> { + self.analyzer_mut()?.reset(); + + Ok(()) + } + + fn finalize(&self) -> Result { + let mut analyzer = self + .analyzer + .try_borrow_mut() + .map_err(|_| Error::runtime("analyzer already in use"))?; + let analyzer = analyzer + .take() + .ok_or_else(|| Error::runtime("analyzer already finalized"))?; + let report = analyzer.finalize(); + + Ok(Report { report }) + } + + fn analyzer<'a>(&'a self) -> Result, Error> { + let analyzer = self + .analyzer + .try_borrow() + .map_err(|_| Error::runtime("analyzer already in use"))?; + + Ref::filter_map(analyzer, Option::as_ref) + .map_err(|_| Error::runtime("analyzer not initialized")) + } + + fn analyzer_mut<'a>(&'a self) -> Result, Error> { + let analyzer = self + .analyzer + .try_borrow_mut() + .map_err(|_| Error::runtime("analyzer already in use"))?; + + RefMut::filter_map(analyzer, Option::as_mut) + .map_err(|_| Error::runtime("analyzer not initialized")) + } +} + +pub(crate) fn init(ruby: &Ruby, module: &RModule) -> Result<(), Error> { + let analyzer = module.define_class("Analyzer", ruby.class_object())?; + analyzer.define_singleton_method("new", function!(Analyzer::new, -1))?; + analyzer.define_method("sample_rate", method!(Analyzer::sample_rate, 0))?; + analyzer.define_method("channels", method!(Analyzer::channels, 0))?; + analyzer.define_method("modes", method!(Analyzer::modes, 0))?; + analyzer.define_method("samples_per_block", method!(Analyzer::samples_per_block, 0))?; + analyzer.define_method("push_interleaved", method!(Analyzer::push_interleaved, 1))?; + analyzer.define_method("push_planar", method!(Analyzer::push_planar, 1))?; + analyzer.define_method("snapshot", method!(Analyzer::snapshot, 0))?; + analyzer.define_method("finalize", method!(Analyzer::finalize, 0))?; + analyzer.define_method("reset", method!(Analyzer::reset, 0))?; + + Ok(()) +} diff --git a/bindings/ruby/ext/ebur128_stream/src/error.rs b/bindings/ruby/ext/ebur128_stream/src/error.rs new file mode 100644 index 0000000..4211a19 --- /dev/null +++ b/bindings/ruby/ext/ebur128_stream/src/error.rs @@ -0,0 +1,57 @@ +use ebur128_stream_rs as engine; +use magnus::{Ruby, error::IntoError}; + +pub(crate) enum Error { + Magnus(magnus::Error), + Engine(engine::Error), + Runtime(String), + Argument(String), +} + +impl From for Error { + fn from(err: magnus::Error) -> Self { + Self::Magnus(err) + } +} + +impl From for Error { + fn from(err: engine::Error) -> Self { + Self::Engine(err) + } +} + +impl IntoError for Error { + fn into_error(self, ruby: &Ruby) -> magnus::Error { + match self { + Self::Magnus(err) => err, + Self::Engine(err) => { + let err_class = match err { + engine::Error::InterleavedLengthNotMultiple { + samples: _, + channels: _, + } + | engine::Error::ChannelMismatch { + expected: _, + got: _, + } + | engine::Error::PlanarLengthMismatch { first: _, got: _ } + | engine::Error::NonFiniteSample => ruby.exception_arg_error(), + _ => ruby.exception_runtime_error(), + }; + magnus::Error::new(err_class, err.to_string()) + } + Self::Runtime(msg) => magnus::Error::new(ruby.exception_runtime_error(), msg), + Self::Argument(msg) => magnus::Error::new(ruby.exception_arg_error(), msg), + } + } +} + +impl Error { + pub(crate) fn runtime(err: T) -> Self { + Self::Runtime(err.to_string()) + } + + pub(crate) fn argument(err: T) -> Self { + Self::Argument(err.to_string()) + } +} diff --git a/bindings/ruby/ext/ebur128_stream/src/lib.rs b/bindings/ruby/ext/ebur128_stream/src/lib.rs new file mode 100644 index 0000000..c4300ec --- /dev/null +++ b/bindings/ruby/ext/ebur128_stream/src/lib.rs @@ -0,0 +1,123 @@ +mod analyzer; +mod error; +mod normalize; +mod report; +mod samples; +mod snapshot; + +use crate::{ + error::Error, + report::Report, + samples::{InterleavedSamples, PlanarSamples}, + snapshot::Snapshot, +}; +use ebur128_stream_rs as engine; +use magnus::{RArray, Ruby, Symbol, TryConvert, Value}; +use std::ops::Deref; + +struct Channel(engine::Channel); + +impl From for engine::Channel { + fn from(value: Channel) -> Self { + value.0 + } +} + +impl TryConvert for Channel { + fn try_convert(val: Value) -> Result { + let channel = Symbol::try_convert(val)?; + Ok(match channel.name()?.as_ref() { + "left" => Self(engine::Channel::Left), + "right" => Self(engine::Channel::Right), + "center" => Self(engine::Channel::Center), + "left_surround" => Self(engine::Channel::LeftSurround), + "right_surround" => Self(engine::Channel::RightSurround), + "lfe" => Self(engine::Channel::Lfe), + "other" => Self(engine::Channel::Other), + _ => { + let ruby = Ruby::get_with(val); + return Err(magnus::Error::new( + ruby.exception_arg_error(), + format!("unknown channel: {val}"), + )); + } + }) + } +} + +pub(crate) struct Channels { + inner: Vec, +} + +impl Deref for Channels { + type Target = Vec; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl<'a> From<&'a [engine::Channel]> for Channels { + fn from(value: &'a [engine::Channel]) -> Self { + Self { + inner: value.to_vec(), + } + } +} + +impl TryConvert for Channels { + fn try_convert(val: Value) -> Result { + Ok(Self { + inner: RArray::try_convert(val)? + .into_iter() + .map(|value| Ok(Channel::try_convert(value)?.into())) + .collect::, magnus::Error>>()?, + }) + } +} + +impl Channels { + fn try_into_rarray(&self, ruby: &Ruby) -> Result { + let syms = self + .inner + .iter() + .map(|channel| { + use engine::Channel::*; + + let str = match channel { + Left => "left", + Right => "right", + Center => "center", + LeftSurround => "left_surround", + RightSurround => "right_surround", + Lfe => "lfe", + Other => "other", + _ => { + return Err(magnus::Error::new( + ruby.exception_runtime_error(), + "couldn't convert to Symbol: {channel}", + )); + } + }; + Ok(ruby.to_symbol(str)) + }) + .collect::, magnus::Error>>()?; + Ok(ruby.ary_new_from_values(&syms)) + } + + fn into_boxed_slice(self) -> Box<[engine::Channel]> { + self.inner.into_boxed_slice() + } +} + +#[magnus::init] +fn init(ruby: &Ruby) -> Result<(), Error> { + let ebur128_stream = ruby.define_module("EBUR128Stream")?; + + analyzer::init(ruby, &ebur128_stream)?; + snapshot::init(ruby, &ebur128_stream)?; + report::init(ruby, &ebur128_stream)?; + normalize::init(ruby, &ebur128_stream)?; + + Ok(()) +} diff --git a/bindings/ruby/ext/ebur128_stream/src/normalize.rs b/bindings/ruby/ext/ebur128_stream/src/normalize.rs new file mode 100644 index 0000000..1812093 --- /dev/null +++ b/bindings/ruby/ext/ebur128_stream/src/normalize.rs @@ -0,0 +1,121 @@ +use crate::{Channels, error::Error, samples::WritableInterleavedSamples}; +use ebur128_stream_rs as engine; +use magnus::{ + RModule, Ruby, Value, function, method, + prelude::*, + scan_args::{get_kwargs, scan_args}, +}; + +// Members are the same to engine::normalize::Normalizer +#[magnus::wrap(class = "EBUR128Stream::Normalizer")] +struct Normalizer { + sample_rate: u32, + channels: Box<[engine::Channel]>, + target_lufs: Option, + true_peak_ceiling_dbtp: Option, +} + +impl Normalizer { + fn new(args: &[Value]) -> Result { + let args = scan_args::<(), (), (), (), _, ()>(args)?; + let kws = get_kwargs::<_, (u32, Channels), (Option, Option), ()>( + args.keywords, + &["sample_rate", "channels"], + &["target_lufs", "true_peak_ceiling_dbtp"], + )?; + let (sample_rate, channels) = kws.required; + let (target_lufs, true_peak_ceiling_dbtp) = kws.optional; + + Ok(Self { + sample_rate, + channels: channels.into_boxed_slice(), + target_lufs, + true_peak_ceiling_dbtp, + }) + } + + fn normalize_in_place( + &self, + mut samples: WritableInterleavedSamples, + ) -> Result { + let mut normalizer = engine::normalize::Normalizer::new(self.sample_rate, &self.channels); + if let Some(target_lufs) = self.target_lufs { + normalizer = normalizer.target_lufs(target_lufs); + } + if let Some(true_peak_ceiling_dbtp) = self.true_peak_ceiling_dbtp { + normalizer = normalizer.true_peak_ceiling_dbtp(true_peak_ceiling_dbtp); + } + + let report = normalizer + .normalize_in_place(samples.as_mut_slice()) + .map_err(Error::runtime)?; + samples.write_back_in_place()?; + + Ok(NormalizeReport { report }) + } +} + +#[magnus::wrap(class = "EBUR128Stream::NormalizeReport")] +struct NormalizeReport { + report: engine::normalize::NormalizeReport, +} + +impl NormalizeReport { + fn measured_integrated_lufs(&self) -> Option { + self.report.measured_integrated_lufs + } + + fn measured_true_peak_dbtp(&self) -> Option { + self.report.measured_true_peak_dbtp + } + + fn target_lufs(&self) -> f64 { + self.report.target_lufs + } + + fn true_peak_ceiling_dbtp(&self) -> Option { + self.report.true_peak_ceiling_dbtp + } + + fn applied_gain_db(&self) -> f64 { + self.report.applied_gain_db + } + + fn limited_by_true_peak(&self) -> bool { + self.report.limited_by_true_peak + } +} + +pub(crate) fn init(ruby: &Ruby, module: &RModule) -> Result<(), Error> { + let normalizer = module.define_class("Normalizer", ruby.class_object())?; + normalizer.define_singleton_method("new", function!(Normalizer::new, -1))?; + normalizer.define_method( + "normalize_in_place", + method!(Normalizer::normalize_in_place, 1), + )?; + + let normalize_report = module.define_class("NormalizeReport", ruby.class_object())?; + normalize_report.define_method( + "measured_integrated_lufs", + method!(NormalizeReport::measured_integrated_lufs, 0), + )?; + normalize_report.define_method( + "measured_true_peak_dbtp", + method!(NormalizeReport::measured_true_peak_dbtp, 0), + )?; + normalize_report.define_method("target_lufs", method!(NormalizeReport::target_lufs, 0))?; + normalize_report.define_method( + "true_peak_ceiling_dbtp", + method!(NormalizeReport::true_peak_ceiling_dbtp, 0), + )?; + normalize_report.define_method( + "applied_gain_db", + method!(NormalizeReport::applied_gain_db, 0), + )?; + normalize_report.define_method( + "limited_by_true_peak", + method!(NormalizeReport::limited_by_true_peak, 0), + )?; + + Ok(()) +} diff --git a/bindings/ruby/ext/ebur128_stream/src/report.rs b/bindings/ruby/ext/ebur128_stream/src/report.rs new file mode 100644 index 0000000..8827a0a --- /dev/null +++ b/bindings/ruby/ext/ebur128_stream/src/report.rs @@ -0,0 +1,52 @@ +use crate::error::Error; +use ebur128_stream_rs as engine; +use magnus::{RModule, Ruby, method, prelude::*}; + +#[magnus::wrap(class = "EBUR128Stream::Report")] +pub(crate) struct Report { + pub(crate) report: engine::Report, +} + +impl Report { + fn integrated_lufs(&self) -> Option { + self.report.integrated_lufs() + } + + fn loudness_range_lu(&self) -> Option { + self.report.loudness_range_lu() + } + + fn true_peak_dbtp(&self) -> Option { + self.report.true_peak_dbtp() + } + + fn momentary_max_lufs(&self) -> Option { + self.report.momentary_max_lufs() + } + + fn short_term_max_lufs(&self) -> Option { + self.report.short_term_max_lufs() + } + + fn programme_duration_seconds(&self) -> f64 { + self.report.programme_duration_seconds() + } +} + +pub(crate) fn init(ruby: &Ruby, module: &RModule) -> Result<(), Error> { + let report = module.define_class("Report", ruby.class_object())?; + report.define_method("integrated_lufs", method!(Report::integrated_lufs, 0))?; + report.define_method("loudness_range_lu", method!(Report::loudness_range_lu, 0))?; + report.define_method("true_peak_dbtp", method!(Report::true_peak_dbtp, 0))?; + report.define_method("momentary_max_lufs", method!(Report::momentary_max_lufs, 0))?; + report.define_method( + "short_term_max_lufs", + method!(Report::short_term_max_lufs, 0), + )?; + report.define_method( + "programme_duration_seconds", + method!(Report::programme_duration_seconds, 0), + )?; + + Ok(()) +} diff --git a/bindings/ruby/ext/ebur128_stream/src/samples.rs b/bindings/ruby/ext/ebur128_stream/src/samples.rs new file mode 100644 index 0000000..6f60204 --- /dev/null +++ b/bindings/ruby/ext/ebur128_stream/src/samples.rs @@ -0,0 +1,209 @@ +use crate::error::Error; +use grey_knights::memory_view::{Flags, FlagsChainable, ItemComponent, ValidatedMemoryView}; +use magnus::{RArray, Ruby, TryConvert, Value, error::IntoError}; + +fn is_acceptable_component(component: ItemComponent) -> bool { + component.offset == 0 && component.repeat == 1 && is_acceptable_format(component.format) +} + +fn is_acceptable_format(format: char) -> bool { + match format { + 'f' => true, + + #[cfg(target_endian = "little")] + 'e' => true, + + #[cfg(target_endian = "big")] + 'g' => true, + + _ => false, + } +} + +pub(crate) enum InterleavedSamples { + Array { samples: Vec }, + MemoryView { view: ValidatedMemoryView }, +} + +impl TryConvert for InterleavedSamples { + fn try_convert(val: Value) -> Result { + if let Some(view) = Self::consume_memory_view(val) { + Ok(Self::MemoryView { view }) + } else if let Some(obj) = RArray::from_value(val) { + Ok(Self::Array { + samples: obj.to_vec()?, + }) + } else { + Err(Error::argument(format!("unsupported samples type: {val}")) + .into_error(&Ruby::get_with(val))) + } + } +} + +impl InterleavedSamples { + pub(crate) fn as_slice(&self) -> &[f32] { + match self { + Self::Array { samples } => samples, + Self::MemoryView { view } => view.data(), + } + } + + fn consume_memory_view(val: Value) -> Option> { + let view = ValidatedMemoryView::::new(val, Flags::any_contiguous()); + if let Ok(mut view) = view { + if Self::is_acceptable(&mut view).unwrap_or(false) { + return Some(view); + } + } + let view = ValidatedMemoryView::::new(val, Flags::simple()); + if let Ok(mut view) = view { + if Self::is_acceptable(&mut view).unwrap_or(false) { + return Some(view); + } + } + None + } + + // TODO: Check format more strictly(size, other expression) + fn is_acceptable(view: &mut ValidatedMemoryView) -> Result { + let item_desc = view.item_desc()?; + Ok(view.ndim() == 1 + && item_desc.len() == 1 + && item_desc + .into_iter() + .next() + .is_some_and(is_acceptable_component)) + } +} + +pub(crate) enum WritableInterleavedSamples { + Array { obj: RArray, samples: Vec }, + MemoryView { view: ValidatedMemoryView }, +} + +impl TryConvert for WritableInterleavedSamples { + fn try_convert(val: Value) -> Result { + if let Some(view) = Self::consume_memory_view(val) { + Ok(Self::MemoryView { view }) + } else if let Some(obj) = RArray::from_value(val) { + Ok(Self::Array { + obj, + samples: obj.to_vec()?, + }) + } else { + Err(Error::argument(format!("unsupported samples type: {val}")) + .into_error(&Ruby::get_with(val))) + } + } +} + +impl WritableInterleavedSamples { + pub(crate) fn as_mut_slice(&mut self) -> &mut [f32] { + match self { + Self::Array { obj: _, samples } => samples, + Self::MemoryView { view } => view.data_as_mut(), + } + } + + pub(crate) fn write_back_in_place(self) -> Result<(), Error> { + match self { + Self::Array { obj, samples } => { + let ruby = Ruby::get_with(obj); + obj.replace(ruby.ary_from_vec(samples))?; + } + Self::MemoryView { view: _ } => {} + } + + Ok(()) + } + + fn consume_memory_view(val: Value) -> Option> { + let view = ValidatedMemoryView::::new(val, Flags::writable().any_contiguous()); + if let Ok(mut view) = view { + if Self::is_acceptable(&mut view).unwrap_or(false) { + return Some(view); + } + } + let view = ValidatedMemoryView::::new(val, Flags::simple()); + if let Ok(mut view) = view { + if !view.is_readonly() && Self::is_acceptable(&mut view).unwrap_or(false) { + return Some(view); + } + } + None + } + + fn is_acceptable(view: &mut ValidatedMemoryView) -> Result { + let item_desc = view.item_desc()?; + Ok(view.ndim() == 1 + && item_desc.len() == 1 + && item_desc + .into_iter() + .next() + .is_some_and(is_acceptable_component)) + } +} + +pub(crate) enum PlanarSamples { + Array { samples: Vec> }, + MemoryView { view: ValidatedMemoryView }, +} + +impl TryConvert for PlanarSamples { + fn try_convert(val: Value) -> Result { + if let Some(view) = Self::consume_memory_view(val) { + Ok(Self::MemoryView { view }) + } else if let Some(obj) = RArray::from_value(val) { + Ok(Self::Array { + samples: obj.to_vec()?, + }) + } else { + Err(Error::argument(format!("unsupported samples type: {val}")) + .into_error(&Ruby::get_with(val))) + } + } +} + +impl PlanarSamples { + pub fn channel_slices(&self) -> Vec<&[f32]> { + match self { + Self::Array { samples } => samples.iter().map(Vec::as_slice).collect(), + Self::MemoryView { view } => { + let shape = view.shape().expect("ndim > 1 is checked when calling "); + let n_channels = shape[0]; + let channel_len = shape[1]; + if channel_len == 0 { + (0..n_channels).map(|_| &view.data()[..0]).collect() + } else { + view.data().chunks_exact(channel_len).collect() + } + } + } + } + + fn consume_memory_view(val: Value) -> Option> { + let view = ValidatedMemoryView::::new(val, Flags::row_major()); + if let Ok(mut view) = view { + if Self::is_acceptable(&mut view).unwrap_or(false) { + return Some(view); + } + } + let view = ValidatedMemoryView::::new(val, Flags::simple()); + if let Ok(mut view) = view { + if Self::is_acceptable(&mut view).unwrap_or(false) { + return Some(view); + } + } + None + } + + fn is_acceptable(view: &mut ValidatedMemoryView) -> Result { + let item_desc = view.item_desc()?; + Ok(view.ndim() == 2 + && item_desc.len() == 1 + && item_desc + .into_iter() + .next() + .is_some_and(is_acceptable_component)) + } +} diff --git a/bindings/ruby/ext/ebur128_stream/src/snapshot.rs b/bindings/ruby/ext/ebur128_stream/src/snapshot.rs new file mode 100644 index 0000000..757bd7d --- /dev/null +++ b/bindings/ruby/ext/ebur128_stream/src/snapshot.rs @@ -0,0 +1,49 @@ +use crate::Error; +use ebur128_stream_rs as engine; +use magnus::{RModule, Ruby, method, prelude::*}; + +#[magnus::wrap(class = "EBUR128Stream::Snapshot")] +pub(crate) struct Snapshot { + pub(crate) snapshot: engine::Snapshot, +} + +impl Snapshot { + fn momentary_lufs(&self) -> Option { + self.snapshot.momentary_lufs() + } + + fn short_term_lufs(&self) -> Option { + self.snapshot.short_term_lufs() + } + + fn integrated_lufs(&self) -> Option { + self.snapshot.integrated_lufs() + } + + fn true_peak_dbtp(&self) -> Option { + self.snapshot.true_peak_dbtp() + } + + fn loudness_range_lu(&self) -> Option { + self.snapshot.loudness_range_lu() + } + + fn programme_duration_seconds(&self) -> f64 { + self.snapshot.programme_duration_seconds() + } +} + +pub(crate) fn init(ruby: &Ruby, module: &RModule) -> Result<(), Error> { + let snapshot = module.define_class("Snapshot", ruby.class_object())?; + snapshot.define_method("momentary_lufs", method!(Snapshot::momentary_lufs, 0))?; + snapshot.define_method("short_term_lufs", method!(Snapshot::short_term_lufs, 0))?; + snapshot.define_method("integrated_lufs", method!(Snapshot::integrated_lufs, 0))?; + snapshot.define_method("loudness_range_lu", method!(Snapshot::loudness_range_lu, 0))?; + snapshot.define_method("true_peak_dbtp", method!(Snapshot::true_peak_dbtp, 0))?; + snapshot.define_method( + "programme_duration_seconds", + method!(Snapshot::programme_duration_seconds, 0), + )?; + + Ok(()) +} diff --git a/bindings/ruby/lib/ebur128_stream.rb b/bindings/ruby/lib/ebur128_stream.rb new file mode 100644 index 0000000..d5ce661 --- /dev/null +++ b/bindings/ruby/lib/ebur128_stream.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require_relative "ebur128_stream/version" +require "ebur128_stream/ebur128_stream" + +module EBUR128Stream + class Error < StandardError; end + + module Reportable + def deconstruct_keys(keys = nil) + keys = self.class::ATTRS if keys.nil? + (keys & self.class::ATTRS).inject({}) {|deconstructed, attr| + deconstructed[attr] = send(attr) + deconstructed + } + end + + def inspect + "#<%{class} %{attrs}>" % { + class: self.class, + attrs: self.class::ATTRS.collect {|attr| "#{attr}=#{send(attr).inspect}"}.join(" ") + } + end + + def ==(other) + deconstruct_keys(nil) == other.deconstruct_keys(nil) + end + end + + class Snapshot + include Reportable + + ATTRS = [ + :momentary_lufs, + :short_term_lufs, + :integrated_lufs, + :loudness_range_lu, + :true_peak_dbtp, + :programme_duration_seconds + ] + end + + class Report + include Reportable + + ATTRS = [ + :integrated_lufs, + :loudness_range_lu, + :true_peak_dbtp, + :momentary_max_lufs, + :short_term_max_lufs, + :programme_duration_seconds + ] + end + + class NormalizeReport + include Reportable + + ATTRS = [ + :measured_integrated_lufs, + :measured_true_peak_dbtp, + :target_lufs, + :true_peak_ceiling_dbtp, + :applied_gain_db, + :limited_by_true_peak, + ] + end +end diff --git a/bindings/ruby/lib/ebur128_stream/version.rb b/bindings/ruby/lib/ebur128_stream/version.rb new file mode 100644 index 0000000..3cd463b --- /dev/null +++ b/bindings/ruby/lib/ebur128_stream/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module EBUR128Stream + VERSION = "0.1.0" +end diff --git a/bindings/ruby/sample/analyze-microphone.rb b/bindings/ruby/sample/analyze-microphone.rb new file mode 100644 index 0000000..40eb3c3 --- /dev/null +++ b/bindings/ruby/sample/analyze-microphone.rb @@ -0,0 +1,110 @@ +require "ebur128_stream" +require "gstreamer" +require "numo/narray/alt" +require "ndav/numo/narray" +require "io/console" + +CHANNELS = [:left, :right] +RATE = 48_000 +HEIGHT = $stdout.winsize[0] +WIDTH = $stdout.winsize[1] +PADDING_BLOCK_START = HEIGHT / 2 - 2 +PADDING_INLINE = WIDTH / 5 +AREA_WIDTH = WIDTH - PADDING_INLINE * 2 +LIMIT = -70 + +include NDAV::Converter + +def main(argv) + analyser = setup_ebur128_stream + + print "\e[2J" + + setup_gstreamer do |sample| + # GStreamer's Gst::Sample is 2-D but EBUR128Stream requires 1-D. + # Reshapes it using Numo::NArray + samples = NumoNArray(sample) + samples.reshape!(*samples.shape.reduce(:*)) + + analyser.push_interleaved samples + analyser.snapshot => {momentary_lufs:} + next unless momentary_lufs + + render_loudness momentary_lufs + end +end + +def setup_ebur128_stream + EBUR128Stream::Analyzer.new(channels: CHANNELS, sample_rate: RATE, modes: [:momentary]) +end + +def setup_gstreamer + pipeline = Gst::Pipeline.new("ebur128-stream") + src = Gst::ElementFactory.make("autoaudiosrc", nil) + convert = Gst::ElementFactory.make("audioconvert", nil) + resample = Gst::ElementFactory.make("audioresample", nil) + sink = Gst::ElementFactory.make("appsink", nil) + + caps = Gst::Caps.new("audio/x-raw") + caps["format"] = "F32LE" # F32 doesn't work for macOS + caps["rate", :int] = RATE + caps["channels", :int] = CHANNELS.length + caps["layout"] = "interleaved" + sink.caps = caps + + sink.emit_signals = true + sink.signal_connect :new_sample do |_| + begin + yield sink.pull_sample + rescue => err + $stderr.puts err + end + Gst::FlowReturn::OK + end + + pipeline << src << convert << resample << sink + src >> convert >> resample >> sink + + loop = GLib::MainLoop.new + + bus = pipeline.bus + bus.add_watch do |bus, message| + case message.type + when Gst::MessageType::EOS + loop.quit + when Gst::MessageType::ERROR + error, debug = message.parse_error + $stderr.puts error + $stderr.puts debug + loop.quit + end + true + end + + pipeline.play + begin + loop.run + rescue Interrupt + pp :Interrupt + rescue err + $stderr.puts err + ensure + pipeline.stop + GC.start + end +end + +def render_loudness(loudness) + len = (-LIMIT + loudness) * (-AREA_WIDTH / LIMIT) + volume = "|" * len + ws = " " * (AREA_WIDTH - len) + print "\e[#{PADDING_BLOCK_START};#{PADDING_INLINE}H" + print "\e[#{PADDING_INLINE}G" + puts "#{volume}#{ws}" + puts + digits = "%.3f" % loudness + print "\e[#{WIDTH - PADDING_INLINE - digits.to_s.length}G" + print digits +end + +main ARGV diff --git a/bindings/ruby/sample/analyze-planar-data.rb b/bindings/ruby/sample/analyze-planar-data.rb new file mode 100644 index 0000000..eb723ca --- /dev/null +++ b/bindings/ruby/sample/analyze-planar-data.rb @@ -0,0 +1,38 @@ +require "ebur128_stream" +require "torchaudio" +require "ndav/torch/tensor" + +def main(argv) + waveform, sample_rate = TorchAudio.load(argv.shift) + + # TorchAudio.load returns planar waveform: + # [[L1, L2, L3, ...], [R1, R2, R3, ...]] + # which is suitable for push_panar + analyzer = EBUR128Stream::Analyzer.new(sample_rate:, channels: [:left, :right]) + analyzer.push_planar waveform + report = analyzer.finalize + + puts format_report(report) +end + +def format_report(report) + template = <<~EOS + === Report === + duration: %.2f seconds + integrated: %.2f LUFS + LRA: %.2f LU + true peak: %.2f dBTP + momentary max: %.2f LUFS + short term max: %.2f LUFS + EOS + template % { + dur: report.programme_duration_seconds, + int: report.integrated_lufs, + lra: report.loudness_range_lu, + tp: report.true_peak_dbtp, + mom: report.momentary_max_lufs, + st: report.short_term_max_lufs, + } +end + +main ARGV diff --git a/bindings/ruby/sample/analyze-wavefile.rb b/bindings/ruby/sample/analyze-wavefile.rb new file mode 100644 index 0000000..1644807 --- /dev/null +++ b/bindings/ruby/sample/analyze-wavefile.rb @@ -0,0 +1,77 @@ +require "ebur128_stream" +require "wavefile" +require "pathname" +require "tempfile" + +SAMPLE_RATE = 48_000 +FORMAT = WaveFile::Format.new(:stereo, :float, SAMPLE_RATE) + +def main(argv) + audio_path = argv.shift || make_fixture_audio + + analyzer = EBUR128Stream::Analyzer.new(channels: [:left, :right], sample_rate: SAMPLE_RATE) + WaveFile::Reader.new(audio_path).each_buffer do |buffer| + # WaveFile::Buffer#samples returns 2-D array: + # [[L1, R2], [L2, R2], [L3, R3], ...] + # EBUR128Stream::Analyzer#push_interleaved requires flat array: + # [L1, R1, L2, R2, L3, R3, ...] + analyzer.push_interleaved buffer.samples.flatten + snapshot = analyzer.snapshot + + puts format_snapshot(snapshot) + end + report = analyzer.finalize + + puts + puts format_report(report) +end + +def format_snapshot(snapshot) + template = "[%f] momentary: %{mom}, short term: %{st}, integrated: %{int}, true peak: %{tp}" + template % { + dur: snapshot.programme_duration_seconds, + mom: (snapshot.momentary_lufs&.to_s || "N/A")[..3], + st: (snapshot.short_term_lufs&.to_s || "N/A")[..3], + int: (snapshot.integrated_lufs&.to_s || "N/A")[..3], + tp: (snapshot.true_peak_dbtp&.to_s || "N/A")[..3], + } +end + +def format_report(report) + template = <<~EOS + === Report === + duration: %.2f seconds + integrated: %.2f LUFS + LRA: %.2f LU + true peak: %.2f dBTP + momentary max: %.2f LUFS + short term max: %.2f LUFS + EOS + template % { + dur: report.programme_duration_seconds, + int: report.integrated_lufs, + lra: report.loudness_range_lu, + tp: report.true_peak_dbtp, + mom: report.momentary_max_lufs, + st: report.short_term_max_lufs, + } +end + +def make_fixture_audio + file = Tempfile.new(["", ".wav"]) + + freq = 440 + length = 5 # seconds + WaveFile::Writer.new file.to_path, FORMAT do |writer| + (SAMPLE_RATE * length).times do |n| + phase = 2 * Math::PI * n / (SAMPLE_RATE / freq) + value = Math.sin(phase) + buffer = WaveFile::Buffer.new([[value, value]], FORMAT) # left, right + writer.write buffer + end + end + + file.to_path +end + +main ARGV diff --git a/bindings/ruby/sample/normalize.rb b/bindings/ruby/sample/normalize.rb new file mode 100644 index 0000000..0200a73 --- /dev/null +++ b/bindings/ruby/sample/normalize.rb @@ -0,0 +1,35 @@ +require "ebur128_stream" +require "torchaudio" +require "ndav/torch/tensor" + +def main(argv) + input = argv.shift + output = argv.shift + unless output + abort "Usage: ruby #{$PROGRAM_NAME} INPUT OUTPUT" + end + + waveform, sample_rate = TorchAudio.load(input) + + # TorchAudio returns a planar samples + # but, EBUR128Stream requires an interleaved samples for normalization. + # We need to reshape the waveform. + samples = waveform.transpose(1, 0) + shape = samples.shape + + # Currently 2-D array. Reshapes to 1-D. + samples = samples.reshape(shape.reduce(&:*)) + + normalizer = EBUR128Stream::Normalizer.new( + sample_rate:, + channels: [:left, :right], + target_lufs: -14.0 + ) + normalize_report = normalizer.normalize_in_place(samples) + + # Restores the samples to planar layout + out_samples = samples.reshape(shape).transpose(1, 0) + TorchAudio.save(output, samples, sample_rate) +end + +main ARGV diff --git a/bindings/ruby/sig/ebur128_stream.rbs b/bindings/ruby/sig/ebur128_stream.rbs new file mode 100644 index 0000000..959f18b --- /dev/null +++ b/bindings/ruby/sig/ebur128_stream.rbs @@ -0,0 +1,69 @@ +module EBUR128Stream + VERSION: String + # See the writing guide of rbs: https://github.com/ruby/rbs#guides + + class Analyzer + def self.new: ( + channels: Array[Symbol], + ?sample_rate: Integer, + ?modes: Array[Symbol], + ?expected_duration: Integer + ) -> instance + + def channels: -> Array[Symbol] + def sample_rate: -> Integer + def modes: -> Array[Symbol] + def expected_duration: -> Integer + + def push_interleaved: (Array[Integer]) -> void + def push_planar: (Array[Array[Integer]]) -> void + + def snapshot: -> Snapshot + def reset: -> void + def finalize: -> Report + end + + class Normalizer + def new: ( + sample_rate: Integer, + channels: Array[Symbol], + ?target_lufs: Integer, + ?true_peak_ceiling_dbtp: Integer + ) -> instance + + def normalize_in_place: (Array[Integer]) -> NormalizeReport + end + + class Snapshot + include Reportable + + def momentary_lufs: -> (Integer | nil) + def short_term_lufs: -> (Integer | nil) + def integrated_lufs: -> (Integer | nil) + def loudness_range_lu: -> (Integer | nil) + def true_peak_dbtp: -> (Integer | nil) + def programme_duration_seconds: -> Integer + end + + class Report + include Reportable + + def integrated_lufs: -> (Integer | nil) + def loudness_range_lu: -> (Integer | nil) + def true_peak_dbtp: -> (Integer | nil) + def momentary_max_lufs: -> (Integer | nil) + def short_term_max_lufs: -> (Integer | nil) + def programme_duration_seconds: -> Integer + end + + class NormalizeReport + include Reportable + + def measured_integrated_lufs: -> (Integer | nil) + def measured_true_peak_dbtp: -> (Integer | nil) + def target_lufs: -> Integer + def true_peak_ceiling_dbtp: -> (Integer | nil) + def applied_gain_db: -> (Integer | nil) + def limited_by_true_peak: -> bool + end +end diff --git a/bindings/ruby/test/helper.rb b/bindings/ruby/test/helper.rb new file mode 100644 index 0000000..59a6354 --- /dev/null +++ b/bindings/ruby/test/helper.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +$LOAD_PATH.unshift File.expand_path("../lib", __dir__) +require "ebur128_stream" + +require "test-unit" +require "numo/narray/alt" +require "ndav/numo/narray" + +class Test::Unit::TestCase + def generate_samples + sample_rate = 48_000 + frame = 2 * Math::PI / (sample_rate / 4) + sample_rate.times.flat_map do |n| + Math.cos(frame * n) * 10 + end + end +end diff --git a/bindings/ruby/test/test_analizer.rb b/bindings/ruby/test/test_analizer.rb new file mode 100644 index 0000000..e2cf67b --- /dev/null +++ b/bindings/ruby/test/test_analizer.rb @@ -0,0 +1,114 @@ +require_relative "helper" + +class TestAnalyzer < Test::Unit::TestCase + include EBUR128Stream + + test "new" do + assert_raise ArgumentError do + Analyzer.new + end + assert_instance_of Analyzer, Analyzer.new(channels: [:left]) + assert_raise ArgumentError do + Analyzer.new(channels: [:left, :nothing]) + end + assert_instance_of Analyzer, Analyzer.new(channels: [:center], sample_rate: 48_000) + assert_raise RuntimeError do + Analyzer.new(channels: [:center], modes: []) + end + assert_instance_of Analyzer, Analyzer.new(channels: [:center], modes: [:integrated, :true_peak]) + assert_raise ArgumentError do + Analyzer.new(channels: [:center], modes: [:unknown]) + end + end + + test "push_interleaved" do + analyzer = Analyzer.new(channels: [:left, :right], modes: [:all]) + assert_nothing_raised do + analyzer.push_interleaved [1.0, 1.0, 2.0, 2.0, 3.0, 3.0] + end + assert_raise ArgumentError do + analyzer.push_interleaved [1.0, 1.0, 2.0] + end + end + + test "push_interleaved MemoryView" do + analyzer = Analyzer.new(channels: [:left, :right], modes: [:all]) + + valid_data = Numo::SFloat[1.0, 1.0, 2.0, 2.0, 3.0, 3.0] + assert_nothing_raised do + analyzer.push_interleaved valid_data + end + + assert_nothing_raised do + analyzer.push_interleaved Numo::SFloat[1.0, 1.0, 2.0, 2.0, 3.0, 3.0] + end + assert_raise ArgumentError do + analyzer.push_interleaved Numo::SFloat[1.0, 1.0, 2.0] + end + end + + test "push_planar" do + analyzer = Analyzer.new(channels: [:left, :right], modes: [:all]) + assert_nothing_raised do + analyzer.push_planar [[1.0, 2.0, 3.0], [1.0, 2.0, 3.0]] + analyzer.push_planar [[1.0, 2.0, 3.0], [1.0, 2.0, 3.0]] + end + assert_raise ArgumentError do + analyzer.push_planar [[1.0, 2.0, 3.0], [1.0, 2.0, 3.0], [1.0, 2.0, 3.0]] + end + end + + test "push_planar MemoryView" do + analyzer = Analyzer.new(channels: [:left, :right], modes: [:all]) + assert_nothing_raised do + analyzer.push_planar Numo::SFloat[[1.0, 2.0, 3.0], [1.0, 2.0, 3.0]] + analyzer.push_planar Numo::SFloat[[1.0, 2.0, 3.0], [1.0, 2.0, 3.0]] + end + assert_raise ArgumentError do + analyzer.push_planar Numo::SFloat[[1.0, 2.0, 3.0], [1.0, 2.0, 3.0], [1.0, 2.0, 3.0]] + end + end + + test "finalize" do + analyzer = Analyzer.new(channels: [:left, :right], modes: [:all]) + analyzer.push_interleaved [1.0, 1.0, 2.0, 2.0, 3.0, 3.0] + report = nil + assert_nothing_raised do + report = analyzer.finalize + end + assert_instance_of Report, report + assert_raise_with_message RuntimeError, /finalized/ do + analyzer.finalize + end + end + + test "reset" do + analyzer = Analyzer.new(channels: [:left, :right], modes: [:all]) + analyzer.push_interleaved [1.0] * 48_000 * 2 + + assert_equal 1.0, analyzer.snapshot.programme_duration_seconds + analyzer.reset + assert_equal 0.0, analyzer.snapshot.programme_duration_seconds + end + + test "modes" do + analyzer = Analyzer.new(channels: [:left, :right], modes: [:momentary, :integrated]) + + assert_equal [:integrated, :momentary], analyzer.modes + end + + test "push_interleaved Array and MemoryView" do + samples = generate_samples + + analyzer_ary = Analyzer.new(channels: [:left, :right], modes: [:all]) + analyzer_ary.push_interleaved samples + report_ary = analyzer_ary.finalize + + samples_mv = Numo::SFloat.cast(samples) + analyzer_mv = Analyzer.new(channels: [:left, :right], modes: [:all]) + analyzer_mv.push_interleaved samples_mv + report_mv = analyzer_mv.finalize + + assert_equal report_ary, report_mv + end +end diff --git a/bindings/ruby/test/test_ebur128_stream.rb b/bindings/ruby/test/test_ebur128_stream.rb new file mode 100644 index 0000000..9df8bee --- /dev/null +++ b/bindings/ruby/test/test_ebur128_stream.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require_relative "helper" + +class EBUR128StreamTest < Test::Unit::TestCase + include EBUR128Stream + + test "VERSION" do + assert do + ::EBUR128Stream.const_defined?(:VERSION) + end + end +end diff --git a/bindings/ruby/test/test_normalize_report.rb b/bindings/ruby/test/test_normalize_report.rb new file mode 100644 index 0000000..d4f8945 --- /dev/null +++ b/bindings/ruby/test/test_normalize_report.rb @@ -0,0 +1,35 @@ +require_relative "helper" + +class TestNormalizeReport < Test::Unit::TestCase + include EBUR128Stream + + def setup + sample_rate = 48_000 + normalizer = Normalizer.new(channels: [:left, :right], sample_rate:) + frame = 2 * Math::PI / (sample_rate / 4) + samples = sample_rate.times.flat_map do |n| + Math.cos(frame * n) * 10 + end + @report = normalizer.normalize_in_place(samples) + end + + def test_attributes + assert_instance_of Float, @report.measured_integrated_lufs + assert_instance_of Float, @report.measured_true_peak_dbtp + assert_instance_of Float, @report.target_lufs + assert_nil @report.true_peak_ceiling_dbtp + assert_instance_of Float, @report.applied_gain_db + assert_false @report.limited_by_true_peak + end + + def test_deconstruct_keys + deconstructed = @report.deconstruct_keys(nil) + + assert_instance_of Float, deconstructed[:measured_integrated_lufs] + assert_instance_of Float, deconstructed[:measured_true_peak_dbtp] + assert_instance_of Float, deconstructed[:target_lufs] + assert_nil deconstructed[:true_peak_ceiling_dbtp] + assert_instance_of Float, deconstructed[:applied_gain_db] + assert_false deconstructed[:limited_by_true_peak] + end +end diff --git a/bindings/ruby/test/test_normalizer.rb b/bindings/ruby/test/test_normalizer.rb new file mode 100644 index 0000000..68fa90f --- /dev/null +++ b/bindings/ruby/test/test_normalizer.rb @@ -0,0 +1,49 @@ +require_relative "helper" + +class TestNormalizer < Test::Unit::TestCase + include EBUR128Stream + + def test_new + assert_instance_of Normalizer, Normalizer.new(sample_rate: 48_000, channels: [:left, :right]) + end + + def test_normalize_in_place + sample_rate = 48_000 + normalizer = Normalizer.new(channels: [:left, :right], sample_rate:) + frame = 2 * Math::PI / (sample_rate / 4) + samples = sample_rate.times.flat_map do |n| + Math.cos(frame * n) * 10 + end + before = samples.dup + normalizer.normalize_in_place(samples) + + assert do + samples != before + end + end + + def test_normalize_in_place_memory_view + sample_rate = 48_000 + normalizer = Normalizer.new(channels: [:left, :right], sample_rate:) + frame = 2 * Math::PI / (sample_rate / 4) + samples = sample_rate.times.flat_map do |n| + Math.cos(frame * n) * 10 + end + samples_mv = Numo::SFloat.cast(samples) + normalizer.normalize_in_place(samples_mv) + + assert do + samples_mv.to_a != samples + end + + samples2 = sample_rate.times.flat_map do |n| + Math.cos(frame * n) * 10 + end + normalizer2 = Normalizer.new(channels: [:left, :right], sample_rate:) + normalizer2.normalize_in_place(samples2) + assert_equal samples2.length, samples_mv.length + samples_mv.each_with_index do |sample, i| + assert_in_delta samples2[i], sample, 0.001, "at sample #{i}" + end + end +end diff --git a/bindings/ruby/test/test_report.rb b/bindings/ruby/test/test_report.rb new file mode 100644 index 0000000..595d065 --- /dev/null +++ b/bindings/ruby/test/test_report.rb @@ -0,0 +1,34 @@ +require_relative "helper" + +class TestReport < Test::Unit::TestCase + def setup + sample_rate = 48_000 + analyzer = EBUR128Stream::Analyzer.new(channels: [:left, :right], sample_rate:) + frame = 2 * Math::PI / (sample_rate / 4) + sample_rate.times do |n| + value = Math.cos(frame * n) * 10 + analyzer.push_interleaved [value, value] + end + @report = analyzer.finalize + end + + def test_attributes + assert_instance_of Float, @report.integrated_lufs + assert_nil @report.loudness_range_lu + assert_instance_of Float, @report.true_peak_dbtp + assert_instance_of Float, @report.momentary_max_lufs + assert_nil @report.short_term_max_lufs + assert_equal 1.0, @report.programme_duration_seconds + end + + def test_deconstruct_keys + deconstructed = @report.deconstruct_keys(nil) + + assert_instance_of Float, deconstructed[:integrated_lufs] + assert_nil deconstructed[:loudness_range_lu] + assert_instance_of Float, deconstructed[:true_peak_dbtp] + assert_instance_of Float, deconstructed[:momentary_max_lufs] + assert_nil deconstructed[:short_term_max_lufs] + assert_equal 1.0, deconstructed[:programme_duration_seconds] + end +end diff --git a/bindings/ruby/test/test_snapshot.rb b/bindings/ruby/test/test_snapshot.rb new file mode 100644 index 0000000..1351534 --- /dev/null +++ b/bindings/ruby/test/test_snapshot.rb @@ -0,0 +1,36 @@ +require_relative "helper" + +class TestSnapshot < Test::Unit::TestCase + include EBUR128Stream + + def setup + sample_rate = 48_000 + analyzer = Analyzer.new(channels: [:left, :right], sample_rate:) + frame = 2 * Math::PI / (sample_rate / 4) + sample_rate.times do |n| + value = Math.cos(frame * n) * 10 + analyzer.push_interleaved [value, value] + end + @snapshot = analyzer.snapshot + end + + def test_attributes + assert_instance_of Float, @snapshot.momentary_lufs + assert_nil @snapshot.short_term_lufs + assert_instance_of Float, @snapshot.integrated_lufs + assert_nil @snapshot.loudness_range_lu + assert_instance_of Float, @snapshot.true_peak_dbtp + assert_equal 1.0, @snapshot.programme_duration_seconds + end + + def test_deconstruct_keys + deconstructed = @snapshot.deconstruct_keys(nil) + + assert_instance_of Float, deconstructed[:momentary_lufs] + assert_nil deconstructed[:short_term_lufs] + assert_instance_of Float, deconstructed[:integrated_lufs] + assert_nil deconstructed[:loudness_range_lu] + assert_instance_of Float, deconstructed[:true_peak_dbtp] + assert_equal 1.0, deconstructed[:programme_duration_seconds] + end +end \ No newline at end of file