Skip to content

Commit c81b2da

Browse files
ruby : transcribe without GVL, accept more MemoryViews, Windows support, fix memory size report, improve document (ggml-org#3775)
* Change MemoryView example using NDAV * Add note on audio attributes for #full and #full_parallel * Support more variants of MemoryView * Use IO.popen instead of Kernel.` for Windows compatibility * Use cmake's -C option instead of multiple -D options * Fix memsize calculation * Remove unused argument * Add is_interrupted field to abort callback container * Fix RBS syntax * Address document comment for RDoc * Add .document for RDoc * Add .rdoc_options * Run #full without GVL * Initialize callbacks with nil * Specify implicity Whisper::Params to distinguish from Whisper::Context::Params * Run callbacks without GVL * Call log callback with GVL * Run full_parallel without GVL * Run transcribe without GVL * Fix ruby_whisper_lock_gvl and ruby_whisper_unlock_gvl * Fix return value of encoder_begin_callback * Report GVL unlocking from transcribe * Remove unused interface * Restore overload of full_parallel * Close process * Fix struct name * Make is_without_gvl thread local * Use rb_thread_call_with_gvl instead of global variable * Retrieve instance variable in GVL * Narrow acceptable MemoryView format * Fix option cache path * Reduce files in package * Use append_cflags * Add ext/*.rb to task dependencies * Use copy instead of cp * Make TestPackage more portable * Patch for lower version Ruby * Make build scripts more portable * Add Windows support * Don't raise exceptions
1 parent 4bf7336 commit c81b2da

17 files changed

Lines changed: 647 additions & 160 deletions

bindings/ruby/.document

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
README.md
2+
LICENSE
3+
sig

bindings/ruby/.rdoc_options

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
title: whispercpp
2+
main_page: README.md

bindings/ruby/README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,7 @@ Whisper::Context.new("base")
360360
361361
### Low-level API to transcribe ###
362362
363-
You can also call `Whisper::Context#full` and `#full_parallel` with a Ruby array as samples. Although `#transcribe` with audio file path is recommended because it extracts PCM samples in C++ and is fast, `#full` and `#full_parallel` give you flexibility.
363+
You can also call `Whisper::Context#full` and `#full_parallel` with a Ruby array as samples. Although `#transcribe` with audio file path is recommended because it extracts PCM samples in C++ and is fast, `#full` and `#full_parallel` give you flexibility. Unlike `#transcribe`, these methods requires 16,000 Hz, 32-bit float audio.
364364
365365
```ruby
366366
require "whisper"
@@ -383,16 +383,16 @@ If you can prepare audio data as C array and export it as a MemoryView, whisperc
383383
384384
```ruby
385385
require "torchaudio"
386-
require "arrow-numo-narray"
386+
require "ndav/torch/tensor"
387387
require "whisper"
388388
389389
waveform, sample_rate = TorchAudio.load("test/fixtures/jfk.wav")
390-
# Convert Torch::Tensor to Arrow::Array via Numo::NArray
391-
samples = waveform.squeeze.numo.to_arrow.to_arrow_array
390+
# Convert Torch::Tensor to NDAV
391+
samples = waveform.squeeze.to_ndav
392392
393393
whisper = Whisper::Context.new("base")
394394
whisper
395-
# Arrow::Array exports MemoryView
395+
# NDAV exports MemoryView
396396
.full(Whisper::Params.new, samples)
397397
```
398398

bindings/ruby/Rakefile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ EXTSOURCES.each do |src|
1616
file src
1717
directory dir
1818
file dest => [src, dir] do |t|
19-
cp t.source, t.name
19+
copy t.source, t.name
2020
end
2121
SOURCES.include dest
2222
end
@@ -34,7 +34,7 @@ LIB_NAME = "whisper".ext(RbConfig::CONFIG["DLEXT"])
3434
SO_FILE = File.join("ext", LIB_NAME)
3535
LIB_FILE = File.join("lib", LIB_NAME)
3636

37-
file "ext/Makefile" => SRC + ["ext/extconf.rb"] + SOURCES do |t|
37+
file "ext/Makefile" => SRC + SOURCES + FileList["ext/*.rb"] do |t|
3838
chdir "ext" do
3939
ruby "extconf.rb"
4040
end

bindings/ruby/ext/dependencies.rb

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,23 +22,25 @@ def libs
2222
else
2323
nil
2424
end
25-
}.reverse.collect {|lib| "lib#{lib}.a"}
25+
}.reverse.collect {|lib| "#{prefix(lib)}#{lib}.#{RbConfig::CONFIG['LIBEXT']}"}
2626
end
2727

2828
def to_s
2929
libs.join(" ")
3030
end
3131

32+
def local_libs
33+
to_s
34+
end
35+
3236
private
3337

3438
def dot_path
3539
File.join(__dir__, "build", "whisper.cpp.dot")
3640
end
3741

3842
def generate_dot
39-
args = ["-S", "sources", "-B", "build", "--graphviz", dot_path, "-D", "BUILD_SHARED_LIBS=OFF"]
40-
args << @options.to_s unless @options.to_s.empty?
41-
system @cmake, *args, exception: true
43+
system @cmake, "-S", "sources", "-B", "build", *@options.graphviz_cmake_args, "--graphviz", dot_path, *@options, exception: true
4244
end
4345

4446
def parse_dot
@@ -59,6 +61,10 @@ def parse_dot
5961
end
6062
end
6163

64+
def prefix(lib)
65+
"lib"
66+
end
67+
6268
def tsort_each_node
6369
@nodes.each_key do |node|
6470
yield node
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
require_relative "dependencies"
2+
3+
class DependenciesForWindows < Dependencies
4+
def local_libs
5+
libs.collect {|lib| %|"#{lib_path(lib)}"|}.join(" ")
6+
end
7+
8+
private
9+
10+
def prefix(lib)
11+
lib.start_with?("ggml") ? "" : "lib"
12+
end
13+
14+
def lib_path(lib)
15+
File.join(__dir__, lib).tr("\\", "/")
16+
end
17+
end

bindings/ruby/ext/extconf.rb

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,35 @@
11
require "mkmf"
2-
require_relative "options"
3-
require_relative "dependencies"
2+
3+
if RUBY_PLATFORM.match? /mswin|mingw|ucrt/
4+
require_relative "options_for_windows"
5+
require_relative "dependencies_for_windows"
6+
7+
Opts = OptionsForWindows
8+
Deps = DependenciesForWindows
9+
else
10+
require_relative "options"
11+
require_relative "dependencies"
12+
13+
Opts = Options
14+
Deps = Dependencies
15+
end
416

517
cmake = find_executable("cmake") || abort
6-
options = Options.new(cmake).to_s
18+
options = Opts.new(cmake)
719
have_library("gomp") rescue nil
8-
libs = Dependencies.new(cmake, options).to_s
20+
libs = Deps.new(cmake, options)
921

10-
$CFLAGS << " -O3 -march=native"
22+
append_cflags ["-O3", "-march=native"]
1123
$INCFLAGS << " -Isources/include -Isources/ggml/include -Isources/examples"
12-
$LOCAL_LIBS << " #{libs}"
24+
$LOCAL_LIBS << " #{libs.local_libs}"
1325
$cleanfiles << " build #{libs}"
1426

1527
create_makefile "whisper" do |conf|
1628
conf << <<~EOF
1729
$(TARGET_SO): #{libs}
1830
#{libs}: cmake-targets
1931
cmake-targets:
20-
#{"\t"}#{cmake} -S sources -B build -D BUILD_SHARED_LIBS=OFF -D CMAKE_ARCHIVE_OUTPUT_DIRECTORY=#{__dir__} -D CMAKE_POSITION_INDEPENDENT_CODE=ON #{options}
21-
#{"\t"}#{cmake} --build build --config Release --target common whisper
32+
#{"\t"}"#{cmake}" -S sources -B build #{options}
33+
#{"\t"}"#{cmake}" --build build --config Release --target common whisper
2234
EOF
2335
end

bindings/ruby/ext/options.rb

Lines changed: 54 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,36 @@
1+
require "fileutils"
2+
13
class Options
24
def initialize(cmake="cmake")
35
@cmake = cmake
46
@options = {}
57

68
configure
9+
write_cache_file
10+
end
11+
12+
def to_a
13+
[
14+
"-D", "BUILD_SHARED_LIBS=OFF",
15+
"-D", "WHISPER_BUILD_TESTS=OFF",
16+
"-D", "CMAKE_ARCHIVE_OUTPUT_DIRECTORY=#{__dir__}",
17+
"-D", "CMAKE_POSITION_INDEPENDENT_CODE=ON",
18+
"-C", cache_path
19+
]
720
end
821

922
def to_s
10-
@options
11-
.reject {|name, (type, value)| value.nil?}
12-
.collect {|name, (type, value)| "-D #{name}=#{value == true ? "ON" : value == false ? "OFF" : value.shellescape}"}
13-
.join(" ")
23+
command_line(*to_a)
1424
end
1525

16-
def cmake_options
17-
return @cmake_options if @cmake_options
26+
def graphviz_cmake_args
27+
[]
28+
end
1829

19-
output = nil
20-
Dir.chdir __dir__ do
21-
output = `#{@cmake.shellescape} -S sources -B build -L`
22-
end
23-
@cmake_options = output.lines.drop_while {|line| line.chomp != "-- Cache values"}.drop(1)
30+
private
31+
32+
def cmake_options
33+
@cmake_options ||= cmake_options_output.lines.drop_while {|line| line.chomp != "-- Cache values"}.drop(1)
2434
.filter_map {|line|
2535
option, value = line.chomp.split("=", 2)
2636
name, type = option.split(":", 2)
@@ -34,7 +44,11 @@ def cmake_options
3444
}.to_h
3545
end
3646

37-
private
47+
def cmake_options_output
48+
Dir.chdir(__dir__) do
49+
IO.popen([@cmake, "-S", "sources", "-B", "build", "-L"]) {|io| io.read}
50+
end
51+
end
3852

3953
def configure
4054
cmake_options.each_pair do |name, (type, default_value)|
@@ -74,12 +88,38 @@ def option_name(name)
7488

7589
def enabled?(option)
7690
op = @options[option]
77-
raise "Option not exist: #{option}" unless op
78-
raise "Option not boolean: #{option}(#{op[0]})" unless op[0] == "BOOL"
91+
return false unless op
92+
return false unless op[0] == "BOOL"
7993
if op[1].nil?
8094
cmake_options[option][1]
8195
else
8296
op[1]
8397
end
8498
end
99+
100+
def cache_path
101+
File.join(__dir__, "sources", "Options.cmake")
102+
end
103+
104+
def write_cache_file
105+
FileUtils.mkpath File.dirname(cache_path)
106+
File.open cache_path, "w" do |file|
107+
@options.reject {|name, (type, value)| value.nil?}.each do |name, (type, value)|
108+
line = "set(CACHE{%<name>s} TYPE %<type>s FORCE VALUE %<value>s)" % {
109+
name:,
110+
type:,
111+
value: value == true ? "ON" : value == false ? "OFF" : escape_cmake(value)
112+
}
113+
file.puts line
114+
end
115+
end
116+
end
117+
118+
def escape_cmake(str)
119+
str.gsub(/[\\"]/, '\\\\\&')
120+
end
121+
122+
def command_line(*args)
123+
args.collect {|arg| %|"#{arg.to_s.gsub(/[\\"]/, '\\\\\&')}"|}.join(" ")
124+
end
85125
end
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
require_relative "options"
2+
3+
class OptionsForWindows < Options
4+
def to_s
5+
command_line(*generator_args, *to_a)
6+
end
7+
8+
def graphviz_cmake_args
9+
generator_args
10+
end
11+
12+
private
13+
14+
def arm?
15+
RbConfig::CONFIG["host_cpu"].to_s.downcase.match?(/\A(?:arm64|aarch64)\z/)
16+
end
17+
18+
def cmake_options_output
19+
Dir.chdir(__dir__) do
20+
IO.popen([@cmake, "-S", "sources", "-B", "build", *generator_args, "-L"]) {|io| io.read}
21+
end
22+
end
23+
24+
def generator_args
25+
generator = cmake_generator
26+
["-G", generator] if generator && !generator.empty?
27+
end
28+
29+
def cmake_generator
30+
return @cmake_generator if defined?(@cmake_generator)
31+
32+
generator = ENV["CMAKE_GENERATOR"]
33+
abort "CMAKE_GENERATOR=#{generator} is unsupported for mingw/ucrt Ruby" if visual_studio_generator_name?(generator)
34+
return @cmake_generator = generator unless generator.nil? || generator.empty?
35+
36+
ninja = find_executable("ninja")
37+
return @cmake_generator = "Ninja" if ninja
38+
39+
make = find_executable("make")
40+
return @cmake_generator = "MSYS Makefiles" if make
41+
42+
mingw32_make = find_executable("mingw32-make")
43+
return @cmake_generator = "MinGW Makefiles" if mingw32_make
44+
45+
@cmake_generator = nil
46+
end
47+
48+
def visual_studio_generator_name?(generator)
49+
generator && generator.start_with?("Visual Studio")
50+
end
51+
end

bindings/ruby/ext/ruby_whisper.c

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ ID id_cache;
2929
ID id_n_processors;
3030

3131
static bool is_log_callback_finalized = false;
32+
static bool is_ruby_log_callback_present = false;
3233

3334
// High level API
3435
extern VALUE ruby_whisper_segment_allocate(VALUE klass);
@@ -106,18 +107,43 @@ static VALUE ruby_whisper_s_finalize_log_callback(VALUE self, VALUE id) {
106107
return Qnil;
107108
}
108109

110+
typedef struct {
111+
int level;
112+
const char * buffer;
113+
} call_log_callbacks_args;
114+
115+
static void*
116+
call_log_callbacks(void *v_args) {
117+
VALUE log_callback = rb_iv_get(mWhisper, "log_callback");
118+
if (NIL_P(log_callback)) {
119+
return NULL;
120+
}
121+
122+
call_log_callbacks_args *args = (call_log_callbacks_args *)v_args;
123+
VALUE user_data = rb_iv_get(mWhisper, "user_data");
124+
rb_funcall(log_callback, id_call, 3, INT2NUM(args->level), rb_str_new2(args->buffer), user_data);
125+
126+
return NULL;
127+
}
128+
109129
static void
110130
ruby_whisper_log_callback(enum ggml_log_level level, const char * buffer, void * user_data) {
111131
if (is_log_callback_finalized) {
112132
return;
113133
}
114-
VALUE log_callback = rb_iv_get(mWhisper, "log_callback");
115-
if (NIL_P(log_callback)) {
134+
if (!is_ruby_log_callback_present) {
116135
return;
117136
}
118137

119-
VALUE udata = rb_iv_get(mWhisper, "user_data");
120-
rb_funcall(log_callback, id_call, 3, INT2NUM(level), rb_str_new2(buffer), udata);
138+
call_log_callbacks_args args = {
139+
level,
140+
buffer,
141+
};
142+
if (ruby_thread_has_gvl_p()) {
143+
call_log_callbacks((void *)&args);
144+
} else {
145+
rb_thread_call_with_gvl(call_log_callbacks, (void *)&args);
146+
}
121147
}
122148

123149
/*
@@ -140,8 +166,10 @@ static VALUE ruby_whisper_s_log_set(VALUE self, VALUE log_callback, VALUE user_d
140166

141167
if (NIL_P(log_callback)) {
142168
whisper_log_set(NULL, NULL);
169+
is_ruby_log_callback_present = false;
143170
} else {
144171
whisper_log_set(ruby_whisper_log_callback, NULL);
172+
is_ruby_log_callback_present = true;
145173
}
146174

147175
return Qnil;

0 commit comments

Comments
 (0)