chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user