rockbox_ffi 0.1.0-aarch64-linux

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: db0fe3ab3938a0dc2cef85f18bde81531d47bc2e04ac535a76bc4fcc4608ee53
4
+ data.tar.gz: 7f05d321ee12fd7e9bf2c7ab34ed1832f97cc8e366088c637022e42dab36d976
5
+ SHA512:
6
+ metadata.gz: 0cba3671cde9aa409253fe56a96d8f0039f66ff63c0929c3b96394d398fb730d9a2944ed98b536354b3ca0bf694f11fd84aae62d86d0cf0cc792e444af74937c
7
+ data.tar.gz: f895a5402d42a564137d1e2393ace1249b5e9bf9fa2a87de8ac8ebb75db18c07e7c233558ce2e5e39122b9fc96bf6b1d238559a85e12b580d7f10501e7cb733b
data/README.md ADDED
@@ -0,0 +1,115 @@
1
+ # rockbox_ffi (Ruby)
2
+
3
+ [![Gem](https://img.shields.io/gem/v/rockbox_ffi?logo=rubygems&logoColor=white)](https://rubygems.org/gems/rockbox_ffi)
4
+ ![Ruby](https://img.shields.io/badge/Ruby-2.6%2B-CC342D?logo=ruby&logoColor=white)
5
+ ![FFI](https://img.shields.io/badge/FFI-fiddle-CC342D)
6
+ ![License](https://img.shields.io/badge/license-GPL--2.0--or--later-blue)
7
+
8
+ Ruby bindings for the Rockbox **DSP**, **metadata**, and **playback** engine,
9
+ via [`fiddle`](https://docs.ruby-lang.org/en/master/Fiddle.html) (Ruby stdlib)
10
+ over the prebuilt `librockbox_ffi` shared library. No native extension is
11
+ compiled — the gem `dlopen`s the shared library at load time.
12
+
13
+ ## Setup
14
+
15
+ Build the shared library once (from the repo root):
16
+
17
+ ```sh
18
+ cargo build --release -p rockbox-ffi
19
+ ```
20
+
21
+ Then, from this directory:
22
+
23
+ ```sh
24
+ cd bindings/ruby
25
+ bundle install # installs rake + minitest (dev only)
26
+ ruby -Ilib examples/smoke.rb # end-to-end check
27
+ ```
28
+
29
+ The library is located automatically by walking up to
30
+ `target/release/librockbox_ffi.{dylib,so}`. Override with the
31
+ `ROCKBOX_FFI_LIB` environment variable.
32
+
33
+ ## Usage
34
+
35
+ ```ruby
36
+ require "rockbox_ffi"
37
+
38
+ # --- metadata ---------------------------------------------------------
39
+ meta = RockboxFFI::Metadata.read("song.flac")
40
+ puts "#{meta[:artist]} — #{meta[:title]} (#{meta[:duration_ms]} ms)"
41
+ RockboxFFI::Metadata.probe("track.opus") # => "Opus"
42
+
43
+ # --- DSP (interleaved stereo int16) -----------------------------------
44
+ RockboxFFI::Dsp.open(44_100) do |dsp|
45
+ dsp.eq_enable(true)
46
+ dsp.set_eq_band(0, 60, 0.7, 3.0)
47
+ dsp.set_replaygain(RockboxFFI::DspReplayGainMode::TRACK, true, 0.0)
48
+ dsp.set_replaygain_gains(track_gain_db: -6.02) # halves amplitude
49
+ processed = dsp.process(samples) # Array<Integer>
50
+ end
51
+
52
+ # --- playback (needs an output device) --------------------------------
53
+ RockboxFFI::Player.open(volume: 0.8) do |player|
54
+ player.set_replaygain(RockboxFFI::ReplayGainMode::TRACK, 0.0, true)
55
+ player.set_crossfade(RockboxFFI::CrossfadeMode::ALWAYS)
56
+ player.set_queue(["a.flac", "b.mp3", "c.opus"])
57
+ player.play
58
+ player.status # => {state: "playing", index: 0, ...}
59
+ end
60
+ ```
61
+
62
+ `Dsp` and `Player` own native resources. Use the block form (`.open`) to close
63
+ them automatically, or call `#close` yourself; a GC finalizer frees the handle
64
+ as a backstop.
65
+
66
+ ## API
67
+
68
+ | Namespace | Contents |
69
+ | ------------------------ | ----------------------------------------------------------------- |
70
+ | `RockboxFFI::Metadata` | `read(path) => Hash`, `probe(filename) => String \| nil` |
71
+ | `RockboxFFI::Dsp` | EQ / tone / surround / compressor / ReplayGain, `process(samples)` |
72
+ | `RockboxFFI::Player` | queue + transport + crossfade + ReplayGain, `status => Hash` |
73
+ | `RockboxFFI::*Mode` | `DspReplayGainMode`, `ReplayGainMode`, `CrossfadeMode`, `MixMode` |
74
+
75
+ Rich values (metadata, player status) cross the FFI boundary as JSON and come
76
+ back as Hashes with **symbol keys**. Sample buffers are plain `Array<Integer>`
77
+ of interleaved-stereo signed 16-bit samples.
78
+
79
+ ### Two ReplayGain encodings
80
+
81
+ The DSP and player use *different* mode integers (a quirk of the C ABI):
82
+
83
+ - `Dsp#set_replaygain` → `DspReplayGainMode` (`TRACK=0, ALBUM=1, SHUFFLE=2, OFF=3`)
84
+ - `Player#set_replaygain` → `ReplayGainMode` (`OFF=0, TRACK=1, ALBUM=2`)
85
+
86
+ Use the named constants and you won't have to remember which is which.
87
+
88
+ ## Examples
89
+
90
+ ```sh
91
+ ruby -Ilib examples/smoke.rb # metadata + DSP + player checks
92
+ ruby -Ilib examples/play.rb [path] # play a file through the output device
93
+ ```
94
+
95
+ ## Interactive console
96
+
97
+ ```sh
98
+ ./bin/console # or: rake console
99
+ ```
100
+
101
+ Drops into IRB with `RockboxFFI` loaded and `FIXTURE` pointing at a sample
102
+ track:
103
+
104
+ ```ruby
105
+ RockboxFFI::Metadata.read(FIXTURE)[:title] # => "Speak"
106
+ p = RockboxFFI::Player.new(volume: 0.6)
107
+ p.set_queue([FIXTURE]); p.play
108
+ p.status[:state] # => "playing"
109
+ ```
110
+
111
+ ## Test
112
+
113
+ ```sh
114
+ bundle exec rake test
115
+ ```
data/examples/play.rb ADDED
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Play an audio file through the real output device.
4
+ #
5
+ # Run: ruby -Ilib examples/play.rb [path-to-audio]
6
+
7
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
8
+ require "rockbox_ffi"
9
+
10
+ REPO = File.expand_path("../../..", __dir__)
11
+ FIXTURE = File.join(REPO, "crates", "rocksky", "fixtures", "08 - Internet Money - Speak(Explicit).m4a")
12
+
13
+ file = ARGV[0] || FIXTURE
14
+
15
+ player = RockboxFFI::Player.new(volume: 0.8)
16
+ player.set_queue([file])
17
+ player.play
18
+ puts "▶ playing #{file}"
19
+
20
+ # Reinstall a SIGINT handler AFTER the player boots: the native audio engine
21
+ # installs its own signal handler while starting the output device, which
22
+ # otherwise swallows Ctrl-C. We exit! straight away instead of calling
23
+ # player.stop/close — those are blocking native calls that can deadlock
24
+ # against the engine thread. The OS reclaims the output device on exit.
25
+ trap("INT") do
26
+ puts "\nstopped"
27
+ exit!(130)
28
+ end
29
+
30
+ # Poll status until playback finishes (state returns to "stopped").
31
+ loop do
32
+ st = player.status
33
+ pos = st[:position_ms] / 1000.0
34
+ dur = st[:duration_ms] / 1000.0
35
+ printf("\r[%s] %.1fs / %.1fs ", st[:state], pos, dur)
36
+ $stdout.flush
37
+
38
+ if st[:state] == "stopped" && st[:position_ms].positive?
39
+ puts "\n✔ done"
40
+ break
41
+ end
42
+ sleep 0.5
43
+ end
44
+
45
+ exit!(0)
data/examples/smoke.rb ADDED
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ # End-to-end smoke test for the Ruby bindings.
4
+ #
5
+ # Run: ruby -Ilib examples/smoke.rb
6
+
7
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
8
+ require "rockbox_ffi"
9
+
10
+ REPO = File.expand_path("../../..", __dir__)
11
+ FIXTURE = File.join(REPO, "crates", "rocksky", "fixtures", "08 - Internet Money - Speak(Explicit).m4a")
12
+
13
+ puts "ABI version: #{RockboxFFI.abi_version}"
14
+
15
+ # -- metadata -----------------------------------------------------------
16
+ meta = RockboxFFI::Metadata.read(FIXTURE)
17
+ puts "codec=#{meta[:codec].inspect} title=#{meta[:title].inspect} " \
18
+ "artist=#{meta[:artist].inspect} duration_ms=#{meta[:duration_ms]} " \
19
+ "sample_rate=#{meta[:sample_rate]}"
20
+ raise "codec label should not be empty" if meta[:codec].to_s.empty?
21
+
22
+ probe = RockboxFFI::Metadata.probe("song.flac")
23
+ puts "probe('song.flac') = #{probe.inspect}"
24
+ raise "expected FLAC, got #{probe.inspect}" unless probe == "FLAC"
25
+
26
+ # -- DSP: -6.0206 dB track gain should HALVE amplitude ------------------
27
+ rate = 44_100
28
+ RockboxFFI::Dsp.open(rate) do |dsp|
29
+ dsp.set_replaygain(RockboxFFI::DspReplayGainMode::TRACK, false, 0.0)
30
+ dsp.set_replaygain_gains(track_gain_db: -6.0206) # x0.5
31
+
32
+ sine = RockboxFFI.sine_stereo(1000, 1.0, rate, 16_000)
33
+ out = dsp.process(sine)
34
+ peak = out.map(&:abs).max
35
+ puts "DSP peak after -6.02 dB track gain: #{peak} (expected ~8000)"
36
+ raise "peak #{peak} out of range" unless (7_600..8_400).cover?(peak)
37
+ end
38
+
39
+ # -- Player (construct only, no audible playback) -----------------------
40
+ RockboxFFI::Player.open(volume: 0.0) do |player|
41
+ sr = player.sample_rate
42
+ puts "Player sample_rate=#{sr}"
43
+ raise "sample_rate should be > 0" unless sr.positive?
44
+
45
+ player.set_volume(0.0)
46
+ player.set_queue([FIXTURE])
47
+ sleep 0.1 # the queue command is applied asynchronously by the engine thread
48
+
49
+ st = player.status
50
+ puts "Player status: state=#{st[:state].inspect} queue_len=#{st[:queue_len]}"
51
+ raise "expected stopped, got #{st[:state].inspect}" unless st[:state] == "stopped"
52
+ raise "expected queue_len 1, got #{st[:queue_len]}" unless st[:queue_len] == 1
53
+ end
54
+
55
+ puts "\nALL CHECKS PASSED ✔"
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RockboxFFI
4
+ # The DSP pipeline: EQ, tone, surround, compressor, ReplayGain, resampler.
5
+ #
6
+ # The underlying dsp_config is a process-wide singleton, so only one Dsp may
7
+ # exist at a time and it must be used from one thread. Call #close when done,
8
+ # or use the block form Dsp.open(rate) { |dsp| ... }.
9
+ class Dsp
10
+ # Open a Dsp; if a block is given, close it automatically afterwards.
11
+ def self.open(sample_rate)
12
+ dsp = new(sample_rate)
13
+ return dsp unless block_given?
14
+
15
+ begin
16
+ yield dsp
17
+ ensure
18
+ dsp.close
19
+ end
20
+ end
21
+
22
+ def initialize(sample_rate)
23
+ @ptr = Lib.rb_dsp_new(Integer(sample_rate))
24
+ raise "rb_dsp_new returned NULL" if @ptr.null?
25
+
26
+ @finalizer = self.class.send(:finalizer, @ptr)
27
+ ObjectSpace.define_finalizer(self, @finalizer)
28
+ end
29
+
30
+ # -- lifecycle --------------------------------------------------------
31
+ def close
32
+ return if @ptr.nil?
33
+
34
+ ObjectSpace.undefine_finalizer(self)
35
+ Lib.rb_dsp_free(@ptr)
36
+ @ptr = nil
37
+ end
38
+
39
+ def closed?
40
+ @ptr.nil?
41
+ end
42
+
43
+ def self.finalizer(ptr)
44
+ proc { Lib.rb_dsp_free(ptr) }
45
+ end
46
+ private_class_method :finalizer
47
+
48
+ # -- configuration ----------------------------------------------------
49
+ def set_input_frequency(hz)
50
+ Lib.rb_dsp_set_input_frequency(@ptr, Integer(hz))
51
+ end
52
+
53
+ def flush
54
+ Lib.rb_dsp_flush(@ptr)
55
+ end
56
+
57
+ def eq_enable(enable)
58
+ Lib.rb_dsp_eq_enable(@ptr, RockboxFFI.b(enable))
59
+ end
60
+
61
+ # Configure one EQ band (0..=9). Band 0 low shelf, 9 high shelf.
62
+ def set_eq_band(band, cutoff_hz, q, gain_db)
63
+ Lib.rb_dsp_set_eq_band(@ptr, Integer(band), Integer(cutoff_hz), Float(q), Float(gain_db))
64
+ end
65
+
66
+ def set_eq_precut(db)
67
+ Lib.rb_dsp_set_eq_precut(@ptr, Float(db))
68
+ end
69
+
70
+ def set_tone(bass_db, treble_db)
71
+ Lib.rb_dsp_set_tone(@ptr, Integer(bass_db), Integer(treble_db))
72
+ end
73
+
74
+ def set_tone_cutoffs(bass_hz, treble_hz)
75
+ Lib.rb_dsp_set_tone_cutoffs(@ptr, Integer(bass_hz), Integer(treble_hz))
76
+ end
77
+
78
+ def set_surround(delay_ms, balance, fx1, fx2)
79
+ Lib.rb_dsp_set_surround(@ptr, Integer(delay_ms), Integer(balance), Integer(fx1), Integer(fx2))
80
+ end
81
+
82
+ def set_channel_config(mode)
83
+ Lib.rb_dsp_set_channel_config(@ptr, Integer(mode))
84
+ end
85
+
86
+ def set_stereo_width(percent)
87
+ Lib.rb_dsp_set_stereo_width(@ptr, Integer(percent))
88
+ end
89
+
90
+ def set_compressor(threshold, makeup_gain, ratio, knee, release_time, attack_time)
91
+ Lib.rb_dsp_set_compressor(
92
+ @ptr, Integer(threshold), Integer(makeup_gain), Integer(ratio),
93
+ Integer(knee), Integer(release_time), Integer(attack_time)
94
+ )
95
+ end
96
+
97
+ # mode: see DspReplayGainMode (TRACK=0, ALBUM=1, SHUFFLE=2, OFF=3).
98
+ def set_replaygain(mode, noclip, preamp_db)
99
+ Lib.rb_dsp_set_replaygain(@ptr, Integer(mode), RockboxFFI.b(noclip), Float(preamp_db))
100
+ end
101
+
102
+ # Per-track gains in plain dB / peaks as linear amplitude (1.0 = full
103
+ # scale). nil for any absent tag (mapped to the ABI's NaN sentinel).
104
+ def set_replaygain_gains(track_gain_db: nil, album_gain_db: nil, track_peak: nil, album_peak: nil)
105
+ Lib.rb_dsp_set_replaygain_gains(
106
+ @ptr, opt(track_gain_db), opt(album_gain_db), opt(track_peak), opt(album_peak)
107
+ )
108
+ end
109
+
110
+ # Native Q7.24 linear factors (the raw_* fields from Metadata.read),
111
+ # 0 = not tagged.
112
+ def set_replaygain_gains_raw(track_gain, album_gain, track_peak, album_peak)
113
+ Lib.rb_dsp_set_replaygain_gains_raw(
114
+ @ptr, Integer(track_gain), Integer(album_gain), Integer(track_peak), Integer(album_peak)
115
+ )
116
+ end
117
+
118
+ # -- processing -------------------------------------------------------
119
+ # Run interleaved stereo S16 +samples+ (an Array of Integers) through the
120
+ # pipeline. Returns a new Array of processed samples (length may differ
121
+ # from the input — the resampler buffers).
122
+ def process(samples)
123
+ raise ArgumentError, "input must be interleaved stereo (even length)" if samples.length.odd?
124
+
125
+ input = samples.pack("s<*") # int16 little-endian
126
+ out_len = "\0" * Fiddle::SIZEOF_VOIDP # size_t* out-param (written in place)
127
+ out_ptr = Lib.rb_dsp_process(@ptr, input, samples.length, out_len)
128
+
129
+ produced = out_len.unpack1(Fiddle::SIZEOF_VOIDP == 8 ? "Q" : "L")
130
+ return [] if out_ptr.null? || produced.zero?
131
+
132
+ begin
133
+ out_ptr.size = produced * 2
134
+ out_ptr[0, produced * 2].unpack("s<*")
135
+ ensure
136
+ Lib.rb_buffer_free(out_ptr, produced)
137
+ end
138
+ end
139
+
140
+ private
141
+
142
+ NAN = Float::NAN
143
+ private_constant :NAN
144
+
145
+ # nil -> NaN, the ABI's "tag absent" sentinel.
146
+ def opt(value)
147
+ value.nil? ? NAN : Float(value)
148
+ end
149
+ end
150
+
151
+ # Generate +seconds+ of a sine as interleaved stereo int16 (an Array).
152
+ def self.sine_stereo(freq_hz, seconds, rate, amplitude = 16_000)
153
+ n = (seconds * rate).to_i
154
+ buf = Array.new(n * 2)
155
+ (0...n).each do |i|
156
+ s = (Math.sin(i * 2.0 * Math::PI * freq_hz / rate) * amplitude).to_i
157
+ s = -32_768 if s < -32_768
158
+ s = 32_767 if s > 32_767
159
+ buf[i * 2] = s
160
+ buf[(i * 2) + 1] = s
161
+ end
162
+ buf
163
+ end
164
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RockboxFFI
4
+ # Note the two *different* ReplayGain encodings in the C ABI.
5
+
6
+ # Values for Dsp#set_replaygain (native Rockbox).
7
+ module DspReplayGainMode
8
+ TRACK = 0
9
+ ALBUM = 1
10
+ SHUFFLE = 2
11
+ OFF = 3
12
+ end
13
+
14
+ # Values for Player#set_replaygain.
15
+ module ReplayGainMode
16
+ OFF = 0
17
+ TRACK = 1
18
+ ALBUM = 2
19
+ end
20
+
21
+ module CrossfadeMode
22
+ OFF = 0
23
+ AUTO_SKIP = 1
24
+ MANUAL_SKIP = 2
25
+ SHUFFLE = 3
26
+ SHUFFLE_OR_MANUAL = 4
27
+ ALWAYS = 5
28
+ end
29
+
30
+ module MixMode
31
+ CROSSFADE = 0
32
+ MIX = 1
33
+ end
34
+
35
+ module ChannelConfig
36
+ STEREO = 0
37
+ MONO = 1
38
+ CUSTOM = 2
39
+ MONO_LEFT = 3
40
+ MONO_RIGHT = 4
41
+ KARAOKE = 5
42
+ SWAP = 6
43
+ end
44
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Fiddle (Ruby stdlib) loader for librockbox_ffi.
4
+ #
5
+ # Declares exactly the functions we call and dlopens the prebuilt shared
6
+ # library — no C is compiled here. The library is located via the
7
+ # ROCKBOX_FFI_LIB env var, then by walking up to the repo's target/release
8
+ # directory. Mirrors include/rockbox_ffi.h — keep in sync with the C ABI.
9
+
10
+ require "fiddle"
11
+ require "fiddle/import"
12
+
13
+ module RockboxFFI
14
+ # Locate the prebuilt shared library. Precedence:
15
+ # 1. ROCKBOX_FFI_LIB env var (explicit override)
16
+ # 2. the platform binary bundled in the gem's vendor/ dir (published gems)
17
+ # 3. target/release, by walking up from here (repo checkout / dev)
18
+ def self.library_path
19
+ names = %w[librockbox_ffi.dylib librockbox_ffi.so rockbox_ffi.dll]
20
+ tried = []
21
+
22
+ if (env = ENV["ROCKBOX_FFI_LIB"])
23
+ tried << env
24
+ return env if File.exist?(env)
25
+ end
26
+
27
+ # Bundled binary (platform gems ship one under <gem_root>/vendor/).
28
+ vendor = File.expand_path("../../vendor", __dir__)
29
+ names.each do |name|
30
+ cand = File.join(vendor, name)
31
+ tried << cand
32
+ return cand if File.exist?(cand)
33
+ end
34
+
35
+ # Walk up from this file looking for target/release (repo checkout).
36
+ dir = File.expand_path(__dir__)
37
+ loop do
38
+ rel = File.join(dir, "target", "release")
39
+ if File.directory?(rel)
40
+ names.each do |name|
41
+ cand = File.join(rel, name)
42
+ tried << cand
43
+ return cand if File.exist?(cand)
44
+ end
45
+ end
46
+ parent = File.dirname(dir)
47
+ break if parent == dir
48
+
49
+ dir = parent
50
+ end
51
+
52
+ raise LoadError, "could not locate librockbox_ffi shared library. Set " \
53
+ "ROCKBOX_FFI_LIB or run `cargo build --release -p " \
54
+ "rockbox-ffi`. Tried:\n #{tried.join("\n ")}"
55
+ end
56
+
57
+ # Thin Fiddle wrappers over the C ABI. Every function is a module method
58
+ # (e.g. `Lib.rb_dsp_new`). Opaque handles and char*/int16* returns come back
59
+ # as Fiddle::Pointer; scalars come back as Integer/Float.
60
+ module Lib
61
+ extend Fiddle::Importer
62
+ dlload RockboxFFI.library_path
63
+
64
+ # Fixed-width C types → Fiddle's native aliases (64-bit LP64 assumed).
65
+ typealias "uint32_t", "unsigned int"
66
+ typealias "int32_t", "int"
67
+ typealias "int16_t", "short"
68
+ typealias "int64_t", "long long"
69
+ typealias "uint64_t", "unsigned long long"
70
+ typealias "size_t", "unsigned long"
71
+ typealias "bool", "int" # passed as 0/1 in a full register on SysV/AAPCS
72
+
73
+ extern "uint32_t rb_ffi_abi_version()"
74
+
75
+ extern "void rb_string_free(void*)"
76
+ extern "void rb_buffer_free(void*, size_t)"
77
+
78
+ # ---- DSP ----------------------------------------------------------
79
+ extern "void* rb_dsp_new(uint32_t)"
80
+ extern "void rb_dsp_free(void*)"
81
+ extern "void rb_dsp_set_input_frequency(void*, uint32_t)"
82
+ extern "void rb_dsp_flush(void*)"
83
+ extern "void rb_dsp_eq_enable(void*, bool)"
84
+ extern "void rb_dsp_set_tone(void*, int32_t, int32_t)"
85
+ extern "void rb_dsp_set_tone_cutoffs(void*, int32_t, int32_t)"
86
+ extern "void rb_dsp_set_surround(void*, int32_t, int32_t, int32_t, int32_t)"
87
+ extern "void rb_dsp_set_channel_config(void*, int32_t)"
88
+ extern "void rb_dsp_set_stereo_width(void*, int32_t)"
89
+ extern "void rb_dsp_set_compressor(void*, int32_t, int32_t, int32_t, int32_t, int32_t, int32_t)"
90
+ extern "void rb_dsp_set_replaygain(void*, int32_t, bool, float)"
91
+ extern "void rb_dsp_set_replaygain_gains(void*, float, float, float, float)"
92
+ extern "void rb_dsp_set_replaygain_gains_raw(void*, int64_t, int64_t, int64_t, int64_t)"
93
+ extern "void rb_dsp_set_eq_band(void*, size_t, int32_t, float, float)"
94
+ extern "void rb_dsp_set_eq_precut(void*, float)"
95
+ extern "void* rb_dsp_process(void*, void*, size_t, void*)"
96
+
97
+ # ---- metadata -----------------------------------------------------
98
+ extern "void* rb_meta_read_json(void*)"
99
+ extern "void* rb_meta_probe(void*)"
100
+
101
+ # ---- player -------------------------------------------------------
102
+ extern "void* rb_player_new()"
103
+ extern "void* rb_player_new_with_config(uint32_t, float, float, int32_t, float, bool, int32_t, uint32_t, uint32_t, uint32_t, uint32_t, int32_t)"
104
+ extern "void rb_player_free(void*)"
105
+ extern "void rb_player_set_queue_json(void*, void*)"
106
+ extern "void rb_player_enqueue(void*, void*)"
107
+ extern "void rb_player_play(void*)"
108
+ extern "void rb_player_pause(void*)"
109
+ extern "void rb_player_toggle(void*)"
110
+ extern "void rb_player_stop(void*)"
111
+ extern "void rb_player_next(void*)"
112
+ extern "void rb_player_previous(void*)"
113
+ extern "void rb_player_skip_to(void*, size_t)"
114
+ extern "void rb_player_seek_ms(void*, uint64_t)"
115
+ extern "void rb_player_set_volume(void*, float)"
116
+ extern "void rb_player_set_crossfade(void*, int32_t, uint32_t, uint32_t, uint32_t, uint32_t, int32_t)"
117
+ extern "void rb_player_set_replaygain(void*, int32_t, float, bool)"
118
+ extern "float rb_player_volume(void*)"
119
+ extern "uint32_t rb_player_sample_rate(void*)"
120
+ extern "void* rb_player_status_json(void*)"
121
+ end
122
+
123
+ # true/false → 1/0 for the ABI's `bool` (declared as int above).
124
+ def self.b(flag)
125
+ flag ? 1 : 0
126
+ end
127
+
128
+ # Copy a heap C string returned by the ABI into a String, then free it.
129
+ # Returns nil for a NULL pointer (the ABI's error/absent signal).
130
+ def self.take_string(ptr)
131
+ return nil if ptr.null?
132
+
133
+ begin
134
+ ptr.to_s.force_encoding("UTF-8")
135
+ ensure
136
+ Lib.rb_string_free(ptr)
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module RockboxFFI
6
+ # Audio file metadata parsing.
7
+ module Metadata
8
+ module_function
9
+
10
+ # Parse the metadata of the audio file at +path+.
11
+ #
12
+ # Returns a Hash (symbol keys) with tag strings, +:duration_ms+,
13
+ # +:bitrate+, +:sample_rate+, a nested +:replaygain+ Hash (including the
14
+ # raw Q7.24 +raw_*+ fields), and optional +:album_art+ / +:cuesheet+
15
+ # locations. Raises on failure.
16
+ def read(path)
17
+ s = RockboxFFI.take_string(Lib.rb_meta_read_json(path.to_s))
18
+ raise "could not read metadata from #{path.inspect}" if s.nil?
19
+
20
+ JSON.parse(s, symbolize_names: true)
21
+ end
22
+
23
+ # Guess the codec label (e.g. "FLAC") from a filename's extension without
24
+ # opening the file. Returns nil for an unknown extension.
25
+ def probe(filename)
26
+ RockboxFFI.take_string(Lib.rb_meta_probe(filename.to_s))
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module RockboxFFI
6
+ # Queue-based player with native ReplayGain and Rockbox crossfade.
7
+ #
8
+ # A Player owns a live audio output device and a background engine thread —
9
+ # construct it only where an output device exists. Call #close when done,
10
+ # or use the block form Player.open(...) { |p| ... }.
11
+ #
12
+ # ReplayGain +mode+ here uses the *player* values: 0 off, 1 track, 2 album
13
+ # (see ReplayGainMode) — distinct from the DSP encoding.
14
+ class Player
15
+ DEFAULT_CONFIG = {
16
+ sample_rate: 0, # 0 => device default
17
+ buffer_seconds: 4.0,
18
+ volume: 1.0,
19
+ replaygain_mode: ReplayGainMode::OFF,
20
+ replaygain_preamp_db: 0.0,
21
+ replaygain_prevent_clipping: true,
22
+ crossfade_mode: CrossfadeMode::OFF,
23
+ fade_out_delay_ms: 0,
24
+ fade_out_duration_ms: 2000,
25
+ fade_in_delay_ms: 0,
26
+ fade_in_duration_ms: 2000,
27
+ mix_mode: MixMode::CROSSFADE
28
+ }.freeze
29
+
30
+ # Open a Player; if a block is given, close it automatically afterwards.
31
+ def self.open(**opts)
32
+ player = new(**opts)
33
+ return player unless block_given?
34
+
35
+ begin
36
+ yield player
37
+ ensure
38
+ player.close
39
+ end
40
+ end
41
+
42
+ # Player on the default device with Rockbox default settings.
43
+ def self.default
44
+ player = allocate
45
+ player.send(:init_ptr, Lib.rb_player_new)
46
+ player
47
+ end
48
+
49
+ # Create a player with configuration overrides (see DEFAULT_CONFIG keys).
50
+ # sample_rate: 0 means the device default.
51
+ def initialize(**opts)
52
+ c = DEFAULT_CONFIG.merge(opts)
53
+ ptr = Lib.rb_player_new_with_config(
54
+ Integer(c[:sample_rate]), Float(c[:buffer_seconds]), Float(c[:volume]),
55
+ Integer(c[:replaygain_mode]), Float(c[:replaygain_preamp_db]),
56
+ RockboxFFI.b(c[:replaygain_prevent_clipping]), Integer(c[:crossfade_mode]),
57
+ Integer(c[:fade_out_delay_ms]), Integer(c[:fade_out_duration_ms]),
58
+ Integer(c[:fade_in_delay_ms]), Integer(c[:fade_in_duration_ms]),
59
+ Integer(c[:mix_mode])
60
+ )
61
+ init_ptr(ptr)
62
+ end
63
+
64
+ # -- lifecycle --------------------------------------------------------
65
+ def close
66
+ return if @ptr.nil?
67
+
68
+ ObjectSpace.undefine_finalizer(self)
69
+ Lib.rb_player_free(@ptr)
70
+ @ptr = nil
71
+ end
72
+
73
+ def closed?
74
+ @ptr.nil?
75
+ end
76
+
77
+ def self.finalizer(ptr)
78
+ proc { Lib.rb_player_free(ptr) }
79
+ end
80
+ private_class_method :finalizer
81
+
82
+ # -- queue ------------------------------------------------------------
83
+ def set_queue(paths)
84
+ Lib.rb_player_set_queue_json(@ptr, JSON.generate(Array(paths).map(&:to_s)))
85
+ end
86
+
87
+ def enqueue(path)
88
+ Lib.rb_player_enqueue(@ptr, path.to_s)
89
+ end
90
+
91
+ # -- transport --------------------------------------------------------
92
+ def play
93
+ Lib.rb_player_play(@ptr)
94
+ end
95
+
96
+ def pause
97
+ Lib.rb_player_pause(@ptr)
98
+ end
99
+
100
+ def toggle
101
+ Lib.rb_player_toggle(@ptr)
102
+ end
103
+
104
+ def stop
105
+ Lib.rb_player_stop(@ptr)
106
+ end
107
+
108
+ def next
109
+ Lib.rb_player_next(@ptr)
110
+ end
111
+
112
+ def previous
113
+ Lib.rb_player_previous(@ptr)
114
+ end
115
+
116
+ def skip_to(index)
117
+ Lib.rb_player_skip_to(@ptr, Integer(index))
118
+ end
119
+
120
+ def seek_ms(ms)
121
+ Lib.rb_player_seek_ms(@ptr, Integer(ms))
122
+ end
123
+
124
+ # -- settings ---------------------------------------------------------
125
+ def set_volume(vol)
126
+ Lib.rb_player_set_volume(@ptr, Float(vol))
127
+ end
128
+
129
+ def volume
130
+ Lib.rb_player_volume(@ptr)
131
+ end
132
+
133
+ def sample_rate
134
+ Lib.rb_player_sample_rate(@ptr)
135
+ end
136
+
137
+ def set_crossfade(mode, fade_out_delay_ms: 0, fade_out_duration_ms: 2000,
138
+ fade_in_delay_ms: 0, fade_in_duration_ms: 2000,
139
+ mix_mode: MixMode::CROSSFADE)
140
+ Lib.rb_player_set_crossfade(
141
+ @ptr, Integer(mode), Integer(fade_out_delay_ms), Integer(fade_out_duration_ms),
142
+ Integer(fade_in_delay_ms), Integer(fade_in_duration_ms), Integer(mix_mode)
143
+ )
144
+ end
145
+
146
+ # mode: see ReplayGainMode (OFF=0, TRACK=1, ALBUM=2).
147
+ def set_replaygain(mode, preamp_db, prevent_clipping)
148
+ Lib.rb_player_set_replaygain(@ptr, Integer(mode), Float(preamp_db), RockboxFFI.b(prevent_clipping))
149
+ end
150
+
151
+ # -- status -----------------------------------------------------------
152
+ # A snapshot of the player's status as a Hash with symbol keys.
153
+ def status
154
+ s = RockboxFFI.take_string(Lib.rb_player_status_json(@ptr))
155
+ raise "rb_player_status_json returned NULL" if s.nil?
156
+
157
+ JSON.parse(s, symbolize_names: true)
158
+ end
159
+
160
+ private
161
+
162
+ def init_ptr(ptr)
163
+ raise "failed to create Player (no output device?)" if ptr.nil? || ptr.null?
164
+
165
+ @ptr = ptr
166
+ ObjectSpace.define_finalizer(self, self.class.send(:finalizer, ptr))
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RockboxFFI
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Ruby bindings for the Rockbox DSP / metadata / playback engine.
4
+ #
5
+ # Thin Fiddle wrappers over the librockbox_ffi C ABI. See RockboxFFI::Metadata,
6
+ # RockboxFFI::Dsp, and RockboxFFI::Player.
7
+
8
+ require "rockbox_ffi/version"
9
+ require "rockbox_ffi/ffi"
10
+ require "rockbox_ffi/enums"
11
+ require "rockbox_ffi/metadata"
12
+ require "rockbox_ffi/dsp"
13
+ require "rockbox_ffi/player"
14
+
15
+ module RockboxFFI
16
+ # ABI major version of the loaded library (bumped on breaking changes).
17
+ def self.abi_version
18
+ Lib.rb_ffi_abi_version
19
+ end
20
+ end
Binary file
metadata ADDED
@@ -0,0 +1,72 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rockbox_ffi
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: aarch64-linux
6
+ authors:
7
+ - Tsiry Sandratraina
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-07-09 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: fiddle
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '1.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '1.0'
27
+ description: 'Fiddle-based bindings over the librockbox_ffi C ABI: audio metadata
28
+ parsing, the Rockbox DSP pipeline (EQ / tone / surround / compressor / ReplayGain),
29
+ and a queue-based player.'
30
+ email:
31
+ - tsiry.sndr@rocksky.app
32
+ executables: []
33
+ extensions: []
34
+ extra_rdoc_files: []
35
+ files:
36
+ - README.md
37
+ - examples/play.rb
38
+ - examples/smoke.rb
39
+ - lib/rockbox_ffi.rb
40
+ - lib/rockbox_ffi/dsp.rb
41
+ - lib/rockbox_ffi/enums.rb
42
+ - lib/rockbox_ffi/ffi.rb
43
+ - lib/rockbox_ffi/metadata.rb
44
+ - lib/rockbox_ffi/player.rb
45
+ - lib/rockbox_ffi/version.rb
46
+ - vendor/librockbox_ffi.so
47
+ homepage: https://github.com/tsirysndr/rockboxd
48
+ licenses:
49
+ - GPL-2.0-or-later
50
+ metadata:
51
+ homepage_uri: https://github.com/tsirysndr/rockboxd
52
+ source_code_uri: https://github.com/tsirysndr/rockboxd/tree/master/bindings/ruby
53
+ post_install_message:
54
+ rdoc_options: []
55
+ require_paths:
56
+ - lib
57
+ required_ruby_version: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '2.6'
62
+ required_rubygems_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: '0'
67
+ requirements: []
68
+ rubygems_version: 3.5.22
69
+ signing_key:
70
+ specification_version: 4
71
+ summary: Ruby bindings for the Rockbox DSP / metadata / playback engine
72
+ test_files: []