chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:45:58 +08:00
commit 2dd9ea9aee
261 changed files with 32719 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
/.bundle/
/.yardoc
/_yardoc/
/coverage/
/doc/
/pkg/
/spec/reports/
/tmp/
# rspec failure tracking
.rspec_status
.idea
+3
View File
@@ -0,0 +1,3 @@
--format documentation
--color
--require spec_helper
+53
View File
@@ -0,0 +1,53 @@
plugins:
- rubocop-rake
- rubocop-rspec
AllCops:
TargetRubyVersion: 2.5
NewCops: enable
Style/StringLiterals:
EnforcedStyle: double_quotes
Style/StringLiteralsInInterpolation:
EnforcedStyle: double_quotes
Style/TrailingCommaInArguments:
EnforcedStyleForMultiline: consistent_comma
Style/TrailingCommaInArrayLiteral:
EnforcedStyleForMultiline: consistent_comma
Style/TrailingCommaInHashLiteral:
EnforcedStyleForMultiline: consistent_comma
Style/NumericPredicate:
EnforcedStyle: comparison
Layout/CaseIndentation:
EnforcedStyle: end
Metrics/AbcSize:
Max: 27
Metrics/CyclomaticComplexity:
Max: 10
Metrics/PerceivedComplexity:
Max: 10
Metrics/MethodLength:
Max: 20
Style/SymbolArray:
Exclude:
- 'lib/vosk/ffi.rb'
RSpec/MultipleMemoizedHelpers:
Max: 10
RSpec/ExampleLength:
Max: 10
RSpec/MultipleExpectations:
Max: 3
+18
View File
@@ -0,0 +1,18 @@
# frozen_string_literal: true
source "https://rubygems.org"
# Specify your gem's dependencies in vosk.gemspec
gemspec
gem "rake", "~> 13.0"
gem "rspec", "~> 3.0"
gem "rubocop", "~> 1.21"
gem "rubocop-rake", "~> 0.7.1"
gem "rubocop-rspec", "~> 3.9"
gem "wavefile", "~> 1.1"
gem "webmock", "~> 3.26"
+109
View File
@@ -0,0 +1,109 @@
PATH
remote: .
specs:
vosk (0.3.45)
bytesize (~> 0.1)
ffi (~> 1.6)
fileutils (~> 1.7)
httparty (~> 0.21)
progressbar (~> 1.13)
rubyzip (~> 2.4)
srt (~> 0.1.5)
GEM
remote: https://rubygems.org/
specs:
addressable (2.8.8)
public_suffix (>= 2.0.2, < 8.0)
ast (2.4.2)
bigdecimal (4.0.1)
bytesize (0.1.2)
crack (1.0.1)
bigdecimal
rexml
csv (3.2.6)
diff-lcs (1.5.1)
ffi (1.17.0-x86_64-linux-gnu)
fileutils (1.7.3)
hashdiff (1.2.1)
httparty (0.22.0)
csv
mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
json (2.9.0)
language_server-protocol (3.17.0.3)
lint_roller (1.1.0)
mini_mime (1.1.5)
multi_xml (0.6.0)
parallel (1.26.3)
parser (3.3.10.2)
ast (~> 2.4.1)
racc
prism (1.9.0)
progressbar (1.13.0)
public_suffix (7.0.2)
racc (1.8.1)
rainbow (3.1.1)
rake (13.2.1)
regexp_parser (2.9.3)
rexml (3.4.4)
rspec (3.13.0)
rspec-core (~> 3.13.0)
rspec-expectations (~> 3.13.0)
rspec-mocks (~> 3.13.0)
rspec-core (3.13.2)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.3)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-mocks (3.13.2)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-support (3.13.2)
rubocop (1.84.2)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
parallel (~> 1.10)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.49.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.49.0)
parser (>= 3.3.7.2)
prism (~> 1.7)
rubocop-rake (0.7.1)
lint_roller (~> 1.1)
rubocop (>= 1.72.1)
rubocop-rspec (3.9.0)
lint_roller (~> 1.1)
rubocop (~> 1.81)
ruby-progressbar (1.13.0)
rubyzip (2.4.1)
srt (0.1.5)
unicode-display_width (3.1.2)
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
wavefile (1.1.2)
webmock (3.26.1)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
PLATFORMS
x86_64-linux
DEPENDENCIES
rake (~> 13.0)
rspec (~> 3.0)
rubocop (~> 1.21)
rubocop-rake (~> 0.7.1)
rubocop-rspec (~> 3.9)
vosk!
wavefile (~> 1.1)
webmock (~> 3.26)
BUNDLED WITH
2.4.22
+138
View File
@@ -0,0 +1,138 @@
# Vosk Ruby
Ruby bindings for [Vosk](https://alphacephei.com/vosk/) — an offline speech recognition toolkit supporting 20+ languages.
## Installation
Add to your Gemfile:
```ruby
gem "vosk"
```
Or install directly:
```bash
gem install vosk
```
The gem ships with a precompiled `libvosk` for supported platforms. On other platforms it will attempt to load a system-installed `libvosk`.
## Usage
### Basic transcription
```ruby
require "vosk"
require "wavefile"
# Load a model by language (downloaded automatically if not cached)
model = Vosk::Model.new(lang: "en-us")
# Or by name, or by local path:
# model = Vosk::Model.new(model_name: "vosk-model-small-en-us-0.4")
# model = Vosk::Model.new(model_path: "/path/to/model")
WaveFile::Reader.new("audio.wav") do |reader|
rec = Vosk::KaldiRecognizer.new(model, reader.format.sample_rate)
reader.each_buffer(4000) do |buffer|
data = buffer.samples.pack(WaveFile::PACK_CODES.dig(:pcm, 16))
if rec.accept_waveform(data).nonzero?
puts rec.result # JSON: {"text": "..."}
else
puts rec.partial_result # JSON: {"partial": "..."}
end
end
puts rec.final_result
end
```
Audio must be mono, 16-bit PCM WAV. Use [wavefile](https://github.com/jstrait/wavefile) to read it.
### Grammar / keyword recognition
Pass a JSON array of phrases as the third argument:
```ruby
rec = Vosk::KaldiRecognizer.new(model, sample_rate, '["one two three", "[unk]"]')
```
### Recognizer options
```ruby
rec.words = true # include per-word timing in results
rec.partial_words = true # include per-word timing in partial results
rec.max_alternatives = 5 # return n-best list instead of single result
rec.nlsml = true # return NLSML instead of JSON
```
### SRT subtitle generation
`srt_result` reads raw PCM from any IO stream and returns an SRT-formatted string. Use ffmpeg to decode any audio format on the fly:
```ruby
require "vosk"
SAMPLE_RATE = 16_000
model = Vosk::Model.new(lang: "en-us")
rec = Vosk::KaldiRecognizer.new(model, SAMPLE_RATE)
rec.words = true # required for word-level timestamps
IO.popen(["ffmpeg", "-loglevel", "quiet", "-i", "audio.mp4",
"-ar", SAMPLE_RATE.to_s, "-ac", "1", "-f", "s16le", "-",]) do |stream|
puts rec.srt_result(stream)
end
```
The `words_per_line:` keyword controls how many words appear per subtitle line (default: 7):
```ruby
rec.srt_result(stream, words_per_line: 5)
```
### Speaker identification
```ruby
spk_model = Vosk::SpkModel.new("/path/to/spk-model")
rec = Vosk::KaldiRecognizer.new(model, sample_rate)
rec.spk_model = spk_model
```
### Listing available models
```ruby
puts Vosk.models # all model names
puts Vosk.languages # all supported language codes
```
### Logging
```ruby
Vosk.log_level = -1 # suppress all output
Vosk.log_level = 0 # default
```
### Transcriber CLI
The gem includes a `vosk-transcriber` executable:
```bash
vosk-transcriber audio.wav
```
## Model storage
Models are cached in `~/.cache/vosk/` by default, or in the directory set by `$VOSK_MODEL_PATH`.
## Development
```bash
bundle install
bundle exec rake spec
```
## License
Apache-2.0
+50
View File
@@ -0,0 +1,50 @@
# frozen_string_literal: true
require "bundler/gem_tasks"
require "rspec/core/rake_task"
require "rubygems/package_task"
RSpec::Core::RakeTask.new(:spec)
require "rubocop/rake_task"
require "fileutils"
RuboCop::RakeTask.new
# TODO: modify 'build' task somehow to include pre-compiled binary
# (or add this logic into gemspec)
spec = Gem::Specification.load("vosk.gemspec")
# Tweak spec when building pre-compiled gem:
# (see example in https://github.com/oxidize-rb/actions/blob/main/cross-gem/action.yml and https://github.com/oxidize-rb/rb-sys/blob/main/gem/lib/rb_sys/extensiontask.rb)
# See also https://github.com/rake-compiler/rake-compiler/blob/master/lib/rake/extensiontask.rb and https://github.com/rake-compiler/rake-compiler-dock
# def cross_compile?
# # TODO: impl
# false
# end
# TODO: maybe it should be a separate task that extends Gem::PackageTask
# if cross_compile?
# # Can't use 'file' task bc we don't know file names exactly
# task :libs do
# # TODO
# # vosk_source = os.getenv("VOSK_SOURCE", os.path.abspath(os.path.join(os.path.dirname(__file__),
# # "..")))
# FileUtils.cp Dir[File.join(vosk_source, "src/lib*.*")], "lib/vosk"
# # TODO: check it works properly (spec is modified after `gem` task is already created)
# # Matches package_data = {'vosk': ['*.so', '*.dll', '*.dyld']},
# spec.files += Dir["lib/vosk/*.{so,dll,dyld}"]
# end
# task gem: :libs
# # TODO: figure out platform
# # system = os.environ.get('VOSK_SYSTEM', platform.system())
# # architecture = os.environ.get('VOSK_ARCHITECTURE', platform.architecture()[0])
# # machine = os.environ.get('VOSK_MACHINE', platform.machine())
# spec.platform = Gem::Platform.new(platf)
# end
# add your default gem packing task
# Run with `rake build`
Gem::PackageTask.new(spec)
task default: %i[spec rubocop]
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
require "bundler/setup"
require "vosk"
# You can add fixtures and/or initialization code here to make experimenting
# with your gem easier. You can also use a different console, if you like.
require "irb"
IRB.start(__FILE__)
Executable
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
set -vx
bundle install
# Do any other automated setup that you need to do here
+1
View File
@@ -0,0 +1 @@
../../python/example/test.wav
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
require "vosk"
require "wavefile"
# You can set log level to -1 to disable debug messages
Vosk.log_level = 0
WaveFile::Reader.new(ARGV[0]) do |reader|
reader_format = reader.format
unless reader_format.channels == 1 && reader_format.bits_per_sample == 16 && reader_format.sample_format == :pcm
puts("Audio file must be WAV format mono PCM.")
exit(1)
end
model = Vosk::Model.new(lang: "en-us")
# You can also init model by name or with a folder path
# model = Model(model_name: "vosk-model-en-us-0.21")
# model = Model(model_path: "models/en")
rec = Vosk::KaldiRecognizer.new(model, reader_format.sample_rate)
rec.words = true
rec.partial_words = true
reader.each_buffer(4000) do |buffer|
data = buffer.samples.pack(WaveFile::PACK_CODES.dig(:pcm, 16))
if rec.accept_waveform(data).nonzero?
puts rec.result
else
puts rec.partial_result
end
end
puts rec.final_result
end
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
require "vosk"
SAMPLE_RATE = 16_000
Vosk.log_level = -1
model = Vosk::Model.new(lang: "en-us")
rec = Vosk::KaldiRecognizer.new(model, SAMPLE_RATE)
rec.words = true
IO.popen(["ffmpeg", "-loglevel", "quiet", "-i", "audio.mp4",
"-ar", SAMPLE_RATE.to_s, "-ac", "1", "-f", "s16le", "-",]) do |stream|
puts rec.srt_result(stream)
end
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
require "vosk"
require "wavefile"
WaveFile::Reader.new(ARGV[0]) do |reader|
reader_format = reader.format
unless reader_format.channels == 1 && reader_format.bits_per_sample == 16 && reader_format.sample_format == :pcm
puts("Audio file must be WAV format mono PCM.")
exit(1)
end
model = Vosk::Model.new(lang: "en-us")
# You can also specify the possible word or phrase list as JSON list,
# the order doesn't have to be strict
rec = Vosk::KaldiRecognizer.new(
model, reader_format.sample_rate,
'["oh one two three", "four five six", "seven eight nine zero", "[unk]"]',
)
reader.each_buffer(4000) do |buffer|
data = buffer.samples.pack(WaveFile::PACK_CODES.dig(:pcm, 16))
if rec.accept_waveform(data).nonzero?
puts rec.result
rec.grammar = '["one zero one two three oh", "four five six", "seven eight nine zero", "[unk]"]'
else
puts rec.partial_result
end
end
puts rec.final_result
end
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
require "vosk"
# TODO
+345
View File
@@ -0,0 +1,345 @@
# frozen_string_literal: true
require_relative "vosk/version"
require "httparty"
require_relative "vosk/progressbar"
require_relative "vosk/ffi"
require "zip"
require "fileutils"
require "json"
require "srt"
# Vosk speech recognition system
module Vosk
class Error < StandardError; end
# Remote location of the models and local folders
MODEL_PRE_URL = "https://alphacephei.com/vosk/models/"
MODEL_LIST_URL = "#{MODEL_PRE_URL}model-list.json"
# TODO: Test on Windows
MODEL_DIRS = [
ENV.fetch("VOSK_MODEL_PATH", nil), "/usr/share/vosk",
File.join(Dir.home, "AppData/Local/vosk"), File.join(Dir.home, ".cache/vosk"),
].compact.freeze
# Different from Python: no need to print inside the method, simply use +puts Vosk.models+
def self.models
response = HTTParty.get(MODEL_LIST_URL, timeout: 10)
response.map { |model| model["name"] }
end
# Different from Python: no need to print inside the method, simply use +puts Vosk.languages+
def self.languages
response = HTTParty.get(MODEL_LIST_URL, timeout: 10)
response.map { |model| model["lang"] }.uniq
end
# Model stores all the data required for recognition
# it contains static data and can be shared across processing
# threads.
class Model
attr_reader :handle
def initialize(model_path: nil, model_name: nil, lang: nil)
model_path ||= get_model_path(model_name, lang)
@handle = C.vosk_model_new(model_path)
end
def vosk_model_find_word(word)
C.vosk_model_find_word(@handle, word)
end
private
def get_model_path(model_name, lang)
if model_name
get_model_by_name(model_name)
else
get_model_by_lang(lang)
end
end
def get_model_by_name(model_name)
MODEL_DIRS.each do |directory|
next unless Dir.exist?(directory)
entry = Dir.entries(directory).find { |f| f == model_name }
return File.join(directory, entry) if entry
end
response = HTTParty.get(MODEL_LIST_URL, timeout: 10)
result_model = response.map { |m| m["name"] }.find { |n| n == model_name }
unless result_model
# It's not common for Ruby gems to exit the whole process, but I decided to match Python behavior
puts "model name #{model_name} does not exist"
exit(1)
end
# It always selects the last dir for downloads, ignoring env and windows-specific dir
dest = File.join(MODEL_DIRS.last, result_model)
download_model(dest)
dest
end
def get_model_by_lang(lang)
MODEL_DIRS.each do |directory|
next unless Dir.exist?(directory)
entry = Dir.entries(directory).find { |f| f.match?(/\Avosk-model(-small)?-#{Regexp.escape(lang)}/) }
return File.join(directory, entry) if entry
end
response = HTTParty.get(MODEL_LIST_URL, timeout: 10)
result_model = response.find do |m|
m["lang"] == lang && m["type"] == "small" && m["obsolete"] == "false"
end&.dig("name")
unless result_model
# It's not common for Ruby gems to exit the whole process, but I decided to match Python behavior
puts "lang #{lang} does not exist"
exit(1)
end
# It always selects the last dir for downloads, ignoring env and windows-specific dir
dest = File.join(MODEL_DIRS.last, result_model)
download_model(dest)
dest
end
# Python param "model_name" is, in fact, a full path
# rubocop:disable Metrics/MethodLength
def download_model(model_path)
dir = File.dirname(model_path)
# Python version won't try to create the directory if a file with the same name exists
FileUtils.makedirs(dir)
model_name = File.basename(model_path)
zip_path = "#{model_path}.zip"
url = "#{MODEL_PRE_URL}#{model_name}.zip"
progressbar = ProgressBar.create(
# Why add MODEL_PRE_URL and then split it away?
title: "#{model_name}.zip",
total: nil,
progress_mark: "",
format: "%t: %j%%|%B| %s/%z [%d<%o, %r/s]",
rate_scale: ->(rate) { ByteSize.new(rate.to_i).to_s },
)
begin
download_file(url, zip_path) do |bsize, tsize|
progressbar.total = tsize if tsize && tsize >= progressbar.progress
progressbar.progress += bsize
end
progressbar.finish
ensure
progressbar&.stop
end
Zip::File.open(zip_path) do |zip_file|
zip_file.each do |entry|
entry_path = File.join(dir, entry.name)
FileUtils.makedirs(File.dirname(entry_path))
entry.extract(entry_path) { true }
end
end
File.unlink(zip_path)
end
# rubocop:enable Metrics/MethodLength
def download_file(url, dest, &callback)
File.open(dest, File::CREAT | File::WRONLY | File::TRUNC | File::BINARY) do |file|
response = HTTParty.get(url, stream_body: true) do |fragment|
next unless fragment.http_response.is_a?(Net::HTTPSuccess)
file.write(fragment)
callback&.call(fragment.bytesize, fragment.http_response["Content-Length"]&.to_i)
end
raise HTTParty::ResponseError.new(response), "Code #{response.code}" unless response.success?
end
end
end
# Speaker model is the same as model but contains the data
# for speaker identification.
class SpkModel
attr_reader :handle
def initialize(model_path)
@handle = C.vosk_spk_model_new(model_path)
end
end
# Endpointer scaling factor
class EndpointerMode
C::VoskEndpointerMode.symbol_map.each do |name, value|
const_set(name.upcase, value)
end
end
# Recognizer object is the main object which processes data.
# Each recognizer usually runs in own thread and takes audio as input.
# Once audio is processed recognizer returns JSON object as a string
# which represent decoded information - words, confidences, times, n-best lists,
# speaker information and so on
class KaldiRecognizer
# Python version accepts *args, so in case of a wrong number of arguments it'll raise TypeError,
# while Ruby raises ArgumentError
def initialize(model, sample_rate, grammar_or_spk_model = nil)
@handle = case grammar_or_spk_model
when nil
C.vosk_recognizer_new(model.handle, sample_rate.to_f)
when SpkModel
C.vosk_recognizer_new_spk(model.handle, sample_rate.to_f, grammar_or_spk_model.handle)
when String
C.vosk_recognizer_new_grm(model.handle, sample_rate.to_f, grammar_or_spk_model)
else
raise TypeError, "Unknown arguments"
end
end
def max_alternatives=(max_alternatives)
C.vosk_recognizer_set_max_alternatives(@handle, max_alternatives)
end
def words=(enable_words)
C.vosk_recognizer_set_words(@handle, enable_words ? 1 : 0)
end
def partial_words=(enable_partial_words)
C.vosk_recognizer_set_partial_words(@handle, enable_partial_words ? 1 : 0)
end
def nlsml=(enable_nlsml)
C.vosk_recognizer_set_nlsml(@handle, enable_nlsml ? 1 : 0)
end
def endpointer_mode=(mode)
C.vosk_recognizer_set_endpointer_mode(@handle, mode.to_i)
end
def set_endpointer_delays(t_start_max, t_end, t_max)
C.vosk_recognizer_set_endpointer_delays(@handle, t_start_max.to_f, t_end.to_f, t_max.to_f)
end
def spk_model=(spk_model)
C.vosk_recognizer_set_spk_model(@handle, spk_model.handle)
end
def grammar=(grammar)
C.vosk_recognizer_set_grm(@handle, grammar)
end
def accept_waveform(data)
res = C.vosk_recognizer_accept_waveform(@handle, data, data.bytesize)
raise Error, "Failed to process waveform" if res < 0
res
end
def result
C.vosk_recognizer_result(@handle)
end
def partial_result
C.vosk_recognizer_partial_result(@handle)
end
def final_result
C.vosk_recognizer_final_result(@handle)
end
def reset
C.vosk_recognizer_reset(@handle)
end
def srt_result(stream, words_per_line: 7)
results = []
while (data = stream.read(4000))
results.push(result) if accept_waveform(data).nonzero?
end
results.push(final_result)
create_srt(results, words_per_line)
end
private
def create_srt(results, words_per_line)
srt = SRT::File.new
results.each do |res|
jres = JSON.parse(res)
next unless jres.key?("result")
jres["result"].each_slice(words_per_line) do |line|
sub = SRT::Line.new
sub.sequence = srt.lines.length + 1
sub.start_time = line.first["start"]
sub.end_time = line.last["end"]
sub.text = line.map { |w| w["word"] }.join(" ")
srt.lines.push(sub)
end
end
srt.to_s
end
end
# Batch model object
class BatchModel
attr_reader :handle
# Python version accepts additional ignored args for some reason
def initialize(model_path)
@handle = C.vosk_batch_model_new(model_path)
end
def wait
C.vosk_batch_model_wait(@handle)
end
end
# Batch recognizer object
class BatchRecognizer
# Python version accepts *args, but I don't just use regular arguments
def initialize(batch_model, sample_rate)
@handle = C.vosk_batch_recognizer_new(batch_model.handle, sample_rate.to_f)
end
def accept_waveform(data)
C.vosk_batch_recognizer_accept_waveform(@handle, data, data.bytesize)
end
def result
res = C.vosk_batch_recognizer_front_result(@handle)
C.vosk_batch_recognizer_pop(@handle)
res
end
def finish_stream
C.vosk_batch_recognizer_finish_stream(@handle)
end
def pending_chunks
C.vosk_batch_recognizer_get_pending_chunks(@handle)
end
end
# Inverse text normalization
class Processor
# Python version accepts *args, but I don't just use regular arguments
def initialize(lang, type)
@handle = C.vosk_text_processor_new(lang, type)
end
def process(text)
C.vosk_text_processor_itn(@handle, text)
end
end
def self.log_level=(level)
C.vosk_set_log_level(level)
end
def self.gpu_init
C.vosk_gpu_init
end
def self.gpu_thread_init
C.vosk_gpu_thread_init
end
end
+180
View File
@@ -0,0 +1,180 @@
# frozen_string_literal: true
require "ffi"
module Vosk
module C # :nodoc: all
extend FFI::Library
# FIXME: Load same way as in Python, test on Windows
# This second option, 'vosk', allows system-wide installed library to be loaded.
# I see you search /usr/share/vosk, so I guess it's supported somehow.
# It'll allow the gem to be used on systems not supported in pre-compiled releases.
# (in fact, we only need the first in a pre-compiled release and the second otherwise,
# but not worth the effort to put more configuration in the build stage - not possible without hacks)
# But when we load a lib not shipped with the gem itself, we (might) need to ensure it's a compatible version
# Note: options in the array are alternatives, only the first found is loaded
# Note: probably needs RubyInstaller::Runtime.add_dll_directory on Windows - if FFI::Platform.windows?
ffi_lib [File.join(__dir__, FFI.map_library_name("vosk")), "vosk"]
class VoskModel < FFI::AutoPointer
def self.from_native(ptr, _ctx)
raise Error, "Failed to create a model" if ptr.null?
super
end
def self.release(ptr)
C.vosk_model_free(ptr)
end
end
class VoskSpkModel < FFI::AutoPointer
def self.from_native(ptr, _ctx)
raise Error, "Failed to create a speaker model" if ptr.null?
super
end
def self.release(ptr)
C.vosk_spk_model_free(ptr)
end
end
VoskEndpointerMode = enum(
:VoskEndpointerMode,
[
:default, 0,
:short, 1,
:long, 2,
:very_long, 3,
],
)
class VoskRecognizer < FFI::AutoPointer
def self.from_native(ptr, _ctx)
raise Error, "Failed to create a recognizer" if ptr.null?
super
end
def self.release(ptr)
C.vosk_recognizer_free(ptr)
end
end
class VoskBatchModel < FFI::AutoPointer
def self.from_native(ptr, _ctx)
raise Error, "Failed to create a model" if ptr.null?
super
end
def self.release(ptr)
C.vosk_batch_model_free(ptr)
end
end
class VoskBatchRecognizer < FFI::AutoPointer
def self.from_native(ptr, _ctx)
raise Error, "Failed to create a recognizer" if ptr.null?
super
end
def self.release(ptr)
C.vosk_batch_recognizer_free(ptr)
end
end
class VoskTextProcessor < FFI::AutoPointer
def self.from_native(ptr, _ctx)
raise Error, "Failed to create processor" if ptr.null?
super
end
def self.release(ptr)
C.vosk_text_processor_free(ptr)
end
end
attach_function :vosk_model_new, [:string], VoskModel
attach_function :vosk_model_free, [VoskModel], :void
attach_function :vosk_model_find_word, [VoskModel, :string], :int
attach_function :vosk_spk_model_new, [:string], VoskSpkModel
attach_function :vosk_spk_model_free, [VoskSpkModel], :void
attach_function :vosk_recognizer_new, [VoskModel, :float], VoskRecognizer
attach_function :vosk_recognizer_new_spk, [VoskModel, :float, VoskSpkModel], VoskRecognizer
attach_function :vosk_recognizer_new_grm, [VoskModel, :float, :string], VoskRecognizer
attach_function :vosk_recognizer_set_spk_model, [VoskRecognizer, VoskSpkModel], :void
attach_function :vosk_recognizer_set_grm, [VoskRecognizer, :string], :void
attach_function :vosk_recognizer_set_max_alternatives, [VoskRecognizer, :int], :void
attach_function :vosk_recognizer_set_words, [VoskRecognizer, :int], :void
attach_function :vosk_recognizer_set_partial_words, [VoskRecognizer, :int], :void
attach_function :vosk_recognizer_set_nlsml, [VoskRecognizer, :int], :void
# TODO: remove this, it was needed only because I used libvosk from Python package
if Gem::Version.new(VERSION) >= Gem::Version.new("0.3.46")
attach_function :vosk_recognizer_set_endpointer_mode, [VoskRecognizer, VoskEndpointerMode], :void
attach_function :vosk_recognizer_set_endpointer_delays, [VoskRecognizer, :float, :float, :float], :void
end
attach_function :vosk_recognizer_accept_waveform, [VoskRecognizer, :buffer_in, :int], :int
# vosk_recognizer_accept_waveform_s; vosk_recognizer_accept_waveform_f - skipped
attach_function :vosk_recognizer_result, [VoskRecognizer], :string
attach_function :vosk_recognizer_partial_result, [VoskRecognizer], :string
attach_function :vosk_recognizer_final_result, [VoskRecognizer], :string
attach_function :vosk_recognizer_reset, [VoskRecognizer], :void
attach_function :vosk_recognizer_free, [VoskRecognizer], :void
attach_function :vosk_set_log_level, [:int], :void
attach_function :vosk_gpu_init, [], :void
attach_function :vosk_gpu_thread_init, [], :void
attach_function :vosk_batch_model_new, [:string], VoskBatchModel
attach_function :vosk_batch_model_free, [VoskBatchModel], :void
attach_function :vosk_batch_model_wait, [VoskBatchModel], :void
attach_function :vosk_batch_recognizer_new, [VoskBatchModel, :float], VoskBatchRecognizer
attach_function :vosk_batch_recognizer_free, [VoskBatchRecognizer], :void
attach_function :vosk_batch_recognizer_accept_waveform, [VoskBatchRecognizer, :buffer_in, :int], :void
# vosk_batch_recognizer_set_nlsml - skipped
attach_function :vosk_batch_recognizer_finish_stream, [VoskBatchRecognizer], :void
attach_function :vosk_batch_recognizer_front_result, [VoskBatchRecognizer], :string
attach_function :vosk_batch_recognizer_pop, [VoskBatchRecognizer], :void
attach_function :vosk_batch_recognizer_get_pending_chunks, [VoskBatchRecognizer], :int
# https://github.com/ffi/ffi/issues/467
class OwnedString
extend FFI::DataConverter
native_type :strptr
def self.to_native(_value, _context)
raise TypeError, "owned_string can't be used for input"
end
def self.from_native((str, ptr), _context)
C.free(ptr)
str
end
end
typedef OwnedString, :owned_string
attach_function :free, [:pointer], :void
# TODO: remove this, it was needed only because I used libvosk from Python package
if Gem::Version.new(VERSION) >= Gem::Version.new("0.3.48")
attach_function :vosk_text_processor_new, [:string, :string], VoskTextProcessor
attach_function :vosk_text_processor_free, [VoskTextProcessor], :void
# NOTE: you have a memory leak here in your python version, needs to be
# ptr = _c.vosk_text_processor_itn(self._handle, text.encode('utf-8'))
# str = _ffi.string(ptr).decode('utf-8')
# _ffi.gc(ptr, _c.free) # or call libc free directly
attach_function :vosk_text_processor_itn, [VoskTextProcessor, :string], :owned_string
end
end
private_constant :C
end
+58
View File
@@ -0,0 +1,58 @@
# frozen_string_literal: true
require "progressbar"
require "bytesize"
# Extends progressbar with tqdm-like output:
# %s / %z → human-readable byte sizes: current size / total siZe
# %d / %o → compact time: elapsed Duration / Outstanding remaining
# %r → rate_scale lambda may return a ByteSize string
# Override rate_of_change so rate_scale can return a string.
# The default format '%i' coerces the result to integer, breaking string values.
ProgressBar::Components::Rate.prepend(Module.new do
def rate_of_change(format_string = "%s")
return "0" if elapsed_seconds <= 0
format_string % scaled_rate
end
end)
class ProgressBar # :nodoc: all
# Add ByteSize-formatted progress methods to the Progress component.
class Progress
def progress_with_precision
ByteSize.new(progress).to_s
end
def total_with_unknown_indicator_with_precision
total ? ByteSize.new(total).to_s : total_with_unknown_indicator
end
end
module Components
# Add label-free time methods to the Time component.
class Time
def elapsed_no_label
val = elapsed
val.start_with?("00:") ? val[3..-1] : val
end
def estimated_no_label
val = estimated_with_friendly_oob.split(": ", 2).last
val.start_with?("00:") ? val[3..-1] : val
end
end
end
end
# Extend MOLECULES with new non-overlapping keys — originals are untouched.
# remove_const avoids the "already initialized constant" warning.
molecules = ProgressBar::Format::Molecule::MOLECULES
ProgressBar::Format::Molecule.send(:remove_const, :MOLECULES)
ProgressBar::Format::Molecule::MOLECULES = molecules.merge(
s: %i[progressable progress_with_precision],
z: %i[progressable total_with_unknown_indicator_with_precision],
d: %i[time_component elapsed_no_label],
o: %i[time_component estimated_no_label],
).freeze
+5
View File
@@ -0,0 +1,5 @@
# frozen_string_literal: true
module Vosk
VERSION = "0.3.45"
end
+4
View File
@@ -0,0 +1,4 @@
module Vosk
VERSION: String
# See the writing guide of rbs: https://github.com/ruby/rbs#guides
end
+18
View File
@@ -0,0 +1,18 @@
# frozen_string_literal: true
require "vosk"
require "webmock/rspec"
RSpec.configure do |config|
# Enable flags like --only-failures and --next-failure
config.example_status_persistence_file_path = ".rspec_status"
# Disable RSpec exposing methods globally on `Module` and `main`
config.disable_monkey_patching!
config.expect_with :rspec do |c|
c.syntax = :expect
end
end
Vosk.log_level = -1
+173
View File
@@ -0,0 +1,173 @@
# frozen_string_literal: true
RSpec.describe Vosk::Model do
let(:tmpdir) { Dir.mktmpdir }
let(:en_model_path) { File.join(Dir.home, ".cache/vosk/vosk-model-small-en-us-0.4") }
let(:model_name) { "vosk-model-small-en-us-0.4" }
let(:model_list) do
[{ "name" => model_name, "lang" => "en-us", "type" => "small", "obsolete" => "false" }]
end
# Allocate an instance without calling initialize — no C library needed.
let(:stub_model) { described_class.allocate }
after { FileUtils.rm_rf(tmpdir) }
it "raises Vosk::Error on a bad path" do
expect do
described_class.new(model_path: "/nonexistent/path")
end.to raise_error(Vosk::Error, "Failed to create a model")
end
context "when loaded from a path" do
subject(:model) { described_class.new(model_path: en_model_path) }
it "constructs successfully" do
expect(model).to be_a(described_class)
end
it "finds a known word" do
expect(model.vosk_model_find_word("one")).to be >= 0
end
it "returns -1 for an unknown word" do
expect(model.vosk_model_find_word("xyzzy")).to eq(-1)
end
end
describe "#get_model_by_name" do
context "when the model directory already exists in MODEL_DIRS" do
before do
FileUtils.makedirs(File.join(tmpdir, model_name))
stub_const("Vosk::MODEL_DIRS", [tmpdir])
end
it "returns its path without hitting the network" do
expect(stub_model.send(:get_model_by_name, model_name)).to eq(File.join(tmpdir, model_name))
end
end
context "when the model is not in any MODEL_DIR" do
before do
stub_const("Vosk::MODEL_DIRS", [tmpdir])
stub_request(:get, Vosk::MODEL_LIST_URL)
.to_return(status: 200, body: model_list.to_json, headers: { "Content-Type" => "application/json" })
allow(stub_model).to receive(:download_model)
end
it "downloads the model to the last MODEL_DIR" do
stub_model.send(:get_model_by_name, model_name)
expect(stub_model).to have_received(:download_model).with(File.join(tmpdir, model_name))
end
it "returns the expected path after downloading" do
expect(stub_model.send(:get_model_by_name, model_name)).to eq(File.join(tmpdir, model_name))
end
it "exits when the model name is not in the remote list" do
expect { stub_model.send(:get_model_by_name, "vosk-model-unknown") }.to raise_error(SystemExit)
end
end
end
describe "#get_model_by_lang" do
context "when a matching model directory already exists" do
before do
FileUtils.makedirs(File.join(tmpdir, model_name))
stub_const("Vosk::MODEL_DIRS", [tmpdir])
end
it "returns its path without hitting the network" do
expect(stub_model.send(:get_model_by_lang, "en-us")).to eq(File.join(tmpdir, model_name))
end
it "also matches vosk-model-<lang> (without -small-)" do
other = "vosk-model-en-us-0.22"
FileUtils.makedirs(File.join(tmpdir, other))
stub_const("Vosk::MODEL_DIRS", [tmpdir])
expect(stub_model.send(:get_model_by_lang, "en-us")).not_to be_nil
end
end
context "when no local model exists for the language" do
before do
stub_const("Vosk::MODEL_DIRS", [tmpdir])
stub_request(:get, Vosk::MODEL_LIST_URL)
.to_return(status: 200, body: model_list.to_json, headers: { "Content-Type" => "application/json" })
allow(stub_model).to receive(:download_model)
end
it "downloads a small model for the language" do
stub_model.send(:get_model_by_lang, "en-us")
expect(stub_model).to have_received(:download_model).with(File.join(tmpdir, model_name))
end
it "exits when no model is available for the language" do
expect { stub_model.send(:get_model_by_lang, "xx-unknown") }.to raise_error(SystemExit)
end
end
end
describe "#download_model" do
let(:model_path) { File.join(tmpdir, model_name) }
let(:zip_content) do
Zip::OutputStream.write_buffer do |zip|
zip.put_next_entry("#{model_name}/conf/model.conf")
zip.write("# model config\n" * 1_000)
zip.put_next_entry("#{model_name}/README")
zip.write("test model\n")
end.string
end
let(:progress_bar_content) { StringIO.new }
let(:progress_bar_stream) do
dbl = double
allow(dbl).to receive(:tty?).with(no_args).and_return(true)
allow(dbl).to receive(:print) do |*args|
progress_bar_content.print(*args)
end
allow(dbl).to receive(:flush).with(no_args).and_return(dbl)
allow(dbl).to receive(:winsize).with(no_args).and_return([40, 180])
dbl
end
before do
stub_const("ProgressBar::Output::DEFAULT_OUTPUT_STREAM", progress_bar_stream)
stub_request(:get, "#{Vosk::MODEL_PRE_URL}#{model_name}.zip")
.to_return(status: 200, body: zip_content, headers: { "Content-Length" => zip_content.bytesize.to_s })
end
it "extracts the archive into the parent directory" do
stub_model.send(:download_model, model_path)
expect(File.exist?(File.join(model_path, "conf/model.conf"))).to be(true)
end
it "deletes the zip file after extraction" do
stub_model.send(:download_model, model_path)
expect(File.exist?("#{model_path}.zip")).to be(false)
end
it "creates the parent directory when it does not yet exist" do
nested_path = File.join(tmpdir, "new_subdir", model_name)
stub_model.send(:download_model, nested_path)
expect(Dir.exist?(File.dirname(nested_path))).to be(true)
end
it "uses the correct download URL" do
stub_model.send(:download_model, model_path)
expect(WebMock).to have_requested(:get, "#{Vosk::MODEL_PRE_URL}#{model_name}.zip")
end
it "displays a progress bar during download" do
stub_model.send(:download_model, model_path)
# TODO: test callback when multiple fragments are received
expect(progress_bar_content.string).to eq(
(" " * 180).concat(
"\r" \
"vosk-model-small-en-us-0.4.zip: 0%|=---=---=---=---=---=---=---=---=---=---=---=---=---=---=---=---=--" \
"-=---=---=---=---=---=---=---=---=---=---=| 0 bytes/?? [00:00<??:??:??, 0/s]\r" \
"vosk-model-small-en-us-0.4.zip: 100%|███████████████████████████████████████████████████████████████████" \
"████████████████████████████████████| 402 bytes/402 bytes [00:00<00:00, 0/s]\n",
),
)
end
end
end
+182
View File
@@ -0,0 +1,182 @@
# frozen_string_literal: true
require "json"
require "srt"
require "wavefile"
RSpec.describe Vosk do
let(:en_model_path) { File.join(Dir.home, ".cache/vosk/vosk-model-small-en-us-0.4") }
let(:test_wav_path) { File.expand_path("../example/test.wav", __dir__) }
let(:wave_reader) { WaveFile::Reader.new(test_wav_path) }
let(:wave_chunks) do
chunks = []
wave_reader.each_buffer(4000) { |buffer| chunks.push(buffer.samples.pack(WaveFile::PACK_CODES.dig(:pcm, 16))) }
chunks
end
after { wave_reader.close }
it "has a version number" do
expect(Vosk::VERSION).not_to be_nil
end
describe "EndpointerMode" do
it "defines the correct integer constants" do
expect([
Vosk::EndpointerMode::DEFAULT,
Vosk::EndpointerMode::SHORT,
Vosk::EndpointerMode::LONG,
Vosk::EndpointerMode::VERY_LONG,
]).to eq([0, 1, 2, 3])
end
end
describe Vosk::SpkModel do
it "raises Vosk::Error on a bad path" do
expect do
described_class.new("/nonexistent/path")
end.to raise_error(Vosk::Error, "Failed to create a speaker model")
end
end
describe Vosk::KaldiRecognizer do
let(:model) { Vosk::Model.new(model_path: en_model_path) }
let(:wave_sample_rate) { wave_reader.format.sample_rate }
describe "initialization" do
it "accepts (model, sample_rate)" do
expect(described_class.new(model, wave_sample_rate)).to be_a(described_class)
end
it "accepts (model, sample_rate, grammar_string)" do
rec = described_class.new(model, wave_sample_rate, '["one two three", "[unk]"]')
expect(rec).to be_a(described_class)
end
it "raises TypeError for an unknown third argument type" do
expect { described_class.new(model, wave_sample_rate, 42) }.to raise_error(TypeError)
end
end
context "when processing audio" do
subject(:rec) { described_class.new(model, wave_sample_rate) }
let(:wave_stream) do
stream = wave_chunks.each_with_object(StringIO.new) { |chunk, stream| stream.write(chunk) }
stream.rewind
stream
end
let(:expected_srt) do
<<~SRT
1
00:00:00,870 --> 00:00:02,610
what zero zero zero one
2
00:00:03,930 --> 00:00:04,950
no no to uno
3
00:00:06,240 --> 00:00:08,010
cyril one eight zero three
SRT
end
it "accept_waveform returns 0 or 1" do
results = wave_chunks.map { |chunk| rec.accept_waveform(chunk) }.uniq
expect(results).to contain_exactly(0, 1)
end
it "result returns valid JSON" do
rec.accept_waveform(wave_chunks.first)
expect { JSON.parse(rec.result) }.not_to raise_error
end
it "partial_result returns valid JSON" do
rec.accept_waveform(wave_chunks.first)
expect { JSON.parse(rec.partial_result) }.not_to raise_error
end
it "final_result returns valid JSON" do
expect { JSON.parse(rec.final_result) }.not_to raise_error
end
it "transcribes the test file to non-empty text" do
wave_chunks.each { |chunk| rec.accept_waveform(chunk) }
text = JSON.parse(rec.final_result)["text"]
expect(text).not_to be_empty
end
it "reset clears the partial result" do
rec.accept_waveform(wave_chunks.first)
rec.reset
expect(JSON.parse(rec.partial_result)["partial"]).to be_nil.or(eq(""))
end
it "set_words does not raise" do
expect { rec.words = true }.not_to raise_error
end
it "set_words includes per-word timing in results" do
rec.words = true
wave_chunks.each { |chunk| rec.accept_waveform(chunk) }
result = JSON.parse(rec.final_result)
expect(result).to have_key("text")
expect(result["result"]).to be_an(Array)
end
it "set_partial_words does not raise" do
expect { rec.partial_words = true }.not_to raise_error
end
it "set_max_alternatives produces an alternatives array" do
rec.max_alternatives = 5
wave_chunks.each { |chunk| rec.accept_waveform(chunk) }
result = JSON.parse(rec.final_result)
expect(result).to have_key("alternatives")
expect(result["alternatives"]).to be_an(Array)
end
it "set_endpointer_mode accepts EndpointerMode constants",
skip: Gem::Version.new(Vosk::VERSION) < Gem::Version.new("0.3.46") && "requires libvosk >= 0.3.46" do
expect { rec.endpointer_mode = :short }.not_to raise_error
end
it "set_endpointer_delays accepts float values",
skip: Gem::Version.new(Vosk::VERSION) < Gem::Version.new("0.3.46") && "requires libvosk >= 0.3.46" do
expect { rec.set_endpointer_delays(0.5, 1.0, 30.0) }.not_to raise_error
end
it "set_grammar changes the active grammar" do
expect { rec.grammar = '["one two three", "[unk]"]' }.not_to raise_error
end
it "srt_result produces a valid SRT" do
rec.words = true
expect(rec.srt_result(wave_stream)).to eq(expected_srt)
end
end
context "with a grammar recognizer" do
subject(:rec) do
described_class.new(
model, wave_sample_rate,
'["one two three four five six seven eight nine zero", "[unk]"]',
)
end
it "produces a result constrained to the grammar vocabulary" do
wave_chunks.each { |chunk| rec.accept_waveform(chunk) }
expect(JSON.parse(rec.final_result)).to have_key("text")
end
end
end
describe Vosk::Processor,
skip: Gem::Version.new(Vosk::VERSION) < Gem::Version.new("0.3.48") && "requires libvosk >= 0.3.48" do
it "raises Vosk::Error on a bad lang/type" do
expect { described_class.new("xx_invalid", "itn") }.to raise_error(Vosk::Error, "Failed to create processor")
end
end
end
+41
View File
@@ -0,0 +1,41 @@
# frozen_string_literal: true
require_relative "lib/vosk/version"
Gem::Specification.new do |spec|
spec.name = "vosk"
spec.version = Vosk::VERSION
spec.authors = ["Alpha Cephei Inc", "Vladimir Ulianitsky"]
spec.email = ["contact@alphacphei.com", "uvlad7@gmail.com"]
spec.summary = "Offline speech recognition API"
spec.description =
"Vosk is an offline open source speech recognition toolkit. " \
"It enables speech recognition for 20+ languages and dialects - " \
"English, Indian English, German, French, Spanish, Portuguese, Chinese, Russian, Turkish, Vietnamese, " \
"Italian, Dutch, Catalan, Arabic, Greek, Farsi, Filipino, Ukrainian, Kazakh, Swedish, Japanese, " \
"Esperanto, Hindi, Czech, Polish. More to come."
spec.homepage = "https://alphacephei.com/vosk"
spec.license = "Apache-2.0"
spec.required_ruby_version = ">= 2.5"
spec.metadata["homepage_uri"] = spec.homepage
spec.metadata["source_code_uri"] = "https://github.com/alphacep/vosk-api"
spec.files = [
*Dir["lib/**/*.rb"], *Dir["exe/**/*"], *Dir["sig/**/*"],
]
spec.bindir = "exe"
spec.executables = ["vosk-transcriber"]
spec.require_paths = ["lib"]
spec.add_dependency "bytesize", "~> 0.1"
spec.add_dependency "ffi", "~> 1.6"
spec.add_dependency "fileutils", "~> 1.7"
spec.add_dependency "httparty", "~> 0.21"
spec.add_dependency "progressbar", "~> 1.13"
spec.add_dependency "rubyzip", "~> 2.4"
spec.add_dependency "srt", "~> 0.1.5"
# For more information and examples about making a new gem, check out our
# guide at: https://bundler.io/guides/creating_gem.html
spec.metadata["rubygems_mfa_required"] = "true"
end