substrate 0.1.0 → 0.2.0
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 +4 -4
- data/lib/substrate/classify.rb +65 -0
- data/lib/substrate/cli/availability.rb +11 -0
- data/lib/substrate/cli/error.rb +7 -0
- data/lib/substrate/cli/runner.rb +31 -0
- data/lib/substrate/command.rb +24 -0
- data/lib/substrate/configuration.rb +21 -0
- data/lib/substrate/detection/path_resolver.rb +33 -0
- data/lib/substrate/distill.rb +31 -0
- data/lib/substrate/extract_pdf.rb +95 -0
- data/lib/substrate/handlers/article.rb +39 -0
- data/lib/substrate/handlers/pdf.rb +29 -0
- data/lib/substrate/handlers/raw_text.rb +22 -0
- data/lib/substrate/handlers/text_file.rb +26 -0
- data/lib/substrate/handlers/transcript.rb +48 -0
- data/lib/substrate/mediums/pdf/chapter.rb +9 -0
- data/lib/substrate/mediums/pdf/content.rb +13 -0
- data/lib/substrate/mediums/pdf/destination.rb +9 -0
- data/lib/substrate/mediums/pdf/destination_resolver.rb +131 -0
- data/lib/substrate/mediums/pdf/document.rb +46 -0
- data/lib/substrate/mediums/pdf/encrypted_pdf.rb +9 -0
- data/lib/substrate/mediums/pdf/entry.rb +9 -0
- data/lib/substrate/mediums/pdf/invalid_pdf.rb +9 -0
- data/lib/substrate/mediums/pdf/invalid_text.rb +9 -0
- data/lib/substrate/mediums/pdf/limit_exceeded.rb +17 -0
- data/lib/substrate/mediums/pdf/limits.rb +21 -0
- data/lib/substrate/mediums/pdf/named_destinations.rb +94 -0
- data/lib/substrate/mediums/pdf/no_selectable_text.rb +9 -0
- data/lib/substrate/mediums/pdf/outline.rb +9 -0
- data/lib/substrate/mediums/pdf/outline_facts.rb +115 -0
- data/lib/substrate/mediums/pdf/outline_parser.rb +67 -0
- data/lib/substrate/mediums/pdf/page.rb +9 -0
- data/lib/substrate/mediums/pdf/page_extractor.rb +28 -0
- data/lib/substrate/mediums/pdf/reader.rb +40 -0
- data/lib/substrate/mediums/pdf/text_extractor.rb +36 -0
- data/lib/substrate/primitives/article_content.rb +10 -0
- data/lib/substrate/primitives/extraction.rb +5 -0
- data/lib/substrate/primitives/metadata.rb +21 -0
- data/lib/substrate/primitives/page_collection.rb +5 -0
- data/lib/substrate/primitives/pdf_file.rb +5 -0
- data/lib/substrate/primitives/raw_text.rb +5 -0
- data/lib/substrate/primitives/source.rb +5 -0
- data/lib/substrate/primitives/text_content.rb +5 -0
- data/lib/substrate/primitives/text_file.rb +5 -0
- data/lib/substrate/primitives/transcripts/content.rb +11 -0
- data/lib/substrate/primitives/transcripts/metadata.rb +25 -0
- data/lib/substrate/primitives/transcripts/segment.rb +25 -0
- data/lib/substrate/primitives/transcripts/word.rb +23 -0
- data/lib/substrate/primitives/url_candidate.rb +13 -0
- data/lib/substrate/source_ref.rb +5 -0
- data/lib/substrate/sources/article.rb +34 -0
- data/lib/substrate/sources/audio.rb +57 -0
- data/lib/substrate/sources/pdf.rb +35 -0
- data/lib/substrate/sources/text.rb +23 -0
- data/lib/substrate/sources/url.rb +36 -0
- data/lib/substrate/transcribers/deepgram/client.rb +70 -0
- data/lib/substrate/transcribers/deepgram/configuration.rb +41 -0
- data/lib/substrate/transcribers/deepgram/response_parser.rb +102 -0
- data/lib/substrate/transcribers/deepgram.rb +45 -0
- data/lib/substrate/transcribers/whisper_cpp/response_parser.rb +66 -0
- data/lib/substrate/transcribers/whisper_cpp.rb +87 -0
- data/lib/substrate/transcripts/select_time_range.rb +59 -0
- data/lib/substrate/url_resolvers/generic.rb +71 -0
- data/lib/substrate/url_resolvers/resolver_for.rb +35 -0
- data/lib/substrate/version.rb +1 -1
- data/lib/substrate.rb +16 -3
- metadata +106 -1
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Substrate
|
|
4
|
+
module Transcripts
|
|
5
|
+
Word = Data.define(
|
|
6
|
+
:text,
|
|
7
|
+
:start,
|
|
8
|
+
:end,
|
|
9
|
+
:confidence,
|
|
10
|
+
:speaker
|
|
11
|
+
) do
|
|
12
|
+
def initialize(
|
|
13
|
+
text:,
|
|
14
|
+
start:,
|
|
15
|
+
end:,
|
|
16
|
+
confidence: nil,
|
|
17
|
+
speaker: nil
|
|
18
|
+
)
|
|
19
|
+
super
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Substrate
|
|
4
|
+
module Sources
|
|
5
|
+
class Article
|
|
6
|
+
def initialize(url:, text:, title:, metadata:)
|
|
7
|
+
@url = url
|
|
8
|
+
@text = text
|
|
9
|
+
@title = title
|
|
10
|
+
@metadata = metadata
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def source_ref
|
|
14
|
+
@source_ref ||= SourceRef.new(
|
|
15
|
+
kind: :article,
|
|
16
|
+
locator: metadata.final_url || url
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def body
|
|
21
|
+
ArticleContent.new(
|
|
22
|
+
source_ref: source_ref,
|
|
23
|
+
text: text,
|
|
24
|
+
title: title,
|
|
25
|
+
metadata: metadata
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
attr_reader :url, :text, :title, :metadata
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Substrate
|
|
4
|
+
module Sources
|
|
5
|
+
class Audio
|
|
6
|
+
class TranscriptionError < StandardError; end
|
|
7
|
+
|
|
8
|
+
DEFAULT_PROVIDER = :deepgram
|
|
9
|
+
|
|
10
|
+
def initialize(path, provider: DEFAULT_PROVIDER, **options)
|
|
11
|
+
@path = path
|
|
12
|
+
@provider = provider
|
|
13
|
+
@options = options
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def source_ref
|
|
17
|
+
@source_ref ||= SourceRef.new(kind: :audio, locator: path)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def body
|
|
21
|
+
@body ||= begin
|
|
22
|
+
transcribe
|
|
23
|
+
rescue TranscriptionError => e
|
|
24
|
+
raise TranscriptionError, e.message
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def select_time_range(range)
|
|
29
|
+
result = Transcripts::SelectTimeRange.call(content: body, range: range)
|
|
30
|
+
raise result.error if result.failure?
|
|
31
|
+
|
|
32
|
+
result.payload
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
attr_reader :path, :provider, :options
|
|
38
|
+
|
|
39
|
+
def transcribe
|
|
40
|
+
provider_class = resolve_provider(provider)
|
|
41
|
+
result = provider_class.call(path: path, **options)
|
|
42
|
+
raise TranscriptionError, result.error.message unless result.success?
|
|
43
|
+
|
|
44
|
+
result.payload
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def resolve_provider(name)
|
|
48
|
+
case name
|
|
49
|
+
when :deepgram then Transcribers::Deepgram
|
|
50
|
+
when :whisper_cpp then Transcribers::WhisperCpp
|
|
51
|
+
else
|
|
52
|
+
raise ArgumentError, "Unknown transcription provider: #{name}"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Substrate
|
|
4
|
+
module Sources
|
|
5
|
+
class Pdf
|
|
6
|
+
def initialize(path)
|
|
7
|
+
@path = path
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def source_ref
|
|
11
|
+
@source_ref ||= SourceRef.new(kind: :pdf, locator: path)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def chapters
|
|
15
|
+
document.chapters
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def page_count
|
|
19
|
+
document.page_count
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def select_pages(range)
|
|
23
|
+
PageCollection.new(source_ref: source_ref, pages: range)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
attr_reader :path
|
|
29
|
+
|
|
30
|
+
def document
|
|
31
|
+
@document ||= Mediums::Pdf::Document.new(path, source_ref: source_ref)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Substrate
|
|
4
|
+
module Sources
|
|
5
|
+
class Text
|
|
6
|
+
def initialize(path)
|
|
7
|
+
@path = path
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def source_ref
|
|
11
|
+
@source_ref ||= SourceRef.new(kind: :text, locator: path)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def body
|
|
15
|
+
TextContent.new(source_ref: source_ref, text: File.read(path))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
attr_reader :path
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Substrate
|
|
6
|
+
module Sources
|
|
7
|
+
class Url
|
|
8
|
+
class ResolutionError < StandardError; end
|
|
9
|
+
|
|
10
|
+
def initialize(url)
|
|
11
|
+
@url = url
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def source_ref
|
|
15
|
+
@source_ref ||= SourceRef.new(kind: :url, locator: url)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def candidates
|
|
19
|
+
@candidates ||= begin
|
|
20
|
+
result = UrlResolvers::ResolverFor.call(url: url)
|
|
21
|
+
raise ResolutionError, result.error.message unless result.success?
|
|
22
|
+
|
|
23
|
+
result.payload
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def primary_candidate
|
|
28
|
+
candidates.min_by(&:rank)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
attr_reader :url
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "faraday/retry"
|
|
5
|
+
|
|
6
|
+
module Substrate
|
|
7
|
+
module Transcribers
|
|
8
|
+
class Deepgram
|
|
9
|
+
class Client
|
|
10
|
+
class Error < StandardError; end
|
|
11
|
+
|
|
12
|
+
BASE_URL = "https://api.deepgram.com"
|
|
13
|
+
RETRYABLE_STATUSES = [429, 500, 502, 503, 504].freeze
|
|
14
|
+
MAX_RETRIES = 3
|
|
15
|
+
DEFAULT_TIMEOUT = 120
|
|
16
|
+
|
|
17
|
+
def initialize(api_key:, timeout: DEFAULT_TIMEOUT, retry_interval: 1)
|
|
18
|
+
@api_key = api_key
|
|
19
|
+
@connection = build_connection(timeout: timeout, retry_interval: retry_interval)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def transcribe(path:, params:)
|
|
23
|
+
response = connection.post("/v1/listen", File.binread(path)) do |req|
|
|
24
|
+
req.headers["Content-Type"] = content_type(path)
|
|
25
|
+
req.params = params
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
raise Error, "API error: #{response.status} #{response.body}" unless response.success?
|
|
29
|
+
|
|
30
|
+
response.body
|
|
31
|
+
rescue Faraday::Error => e
|
|
32
|
+
raise Error, "Request failed: #{e.message}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
attr_reader :connection, :api_key
|
|
38
|
+
|
|
39
|
+
def build_connection(timeout:, retry_interval:)
|
|
40
|
+
Faraday.new(url: BASE_URL) do |f|
|
|
41
|
+
f.response :raise_error
|
|
42
|
+
f.response :json, content_type: /\bjson$/
|
|
43
|
+
|
|
44
|
+
f.request :retry,
|
|
45
|
+
max: MAX_RETRIES,
|
|
46
|
+
interval: retry_interval,
|
|
47
|
+
backoff_factor: 2,
|
|
48
|
+
retry_statuses: RETRYABLE_STATUSES
|
|
49
|
+
|
|
50
|
+
f.headers["Authorization"] = "Token #{api_key}"
|
|
51
|
+
|
|
52
|
+
f.options.timeout = timeout
|
|
53
|
+
f.options.open_timeout = timeout / 2
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def content_type(path)
|
|
58
|
+
case File.extname(path).downcase
|
|
59
|
+
when ".mp3" then "audio/mp3"
|
|
60
|
+
when ".wav" then "audio/wav"
|
|
61
|
+
when ".m4a" then "audio/m4a"
|
|
62
|
+
when ".ogg" then "audio/ogg"
|
|
63
|
+
when ".flac" then "audio/flac"
|
|
64
|
+
else "audio/mp3"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Substrate
|
|
4
|
+
module Transcribers
|
|
5
|
+
class Deepgram
|
|
6
|
+
class Configuration
|
|
7
|
+
attr_reader :api_key, :language, :diarize, :punctuate, :model
|
|
8
|
+
|
|
9
|
+
DEFAULT_MODEL = "nova-2"
|
|
10
|
+
|
|
11
|
+
def initialize(
|
|
12
|
+
language: nil,
|
|
13
|
+
diarize: false,
|
|
14
|
+
punctuate: true,
|
|
15
|
+
model: DEFAULT_MODEL
|
|
16
|
+
)
|
|
17
|
+
@api_key = Substrate::Configuration.deepgram_api_key
|
|
18
|
+
@language = language
|
|
19
|
+
@diarize = !!diarize
|
|
20
|
+
@punctuate = !!punctuate
|
|
21
|
+
@model = model
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def valid?
|
|
25
|
+
!api_key.to_s.empty?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def to_params
|
|
29
|
+
{
|
|
30
|
+
model: model,
|
|
31
|
+
smart_format: true,
|
|
32
|
+
punctuate: punctuate,
|
|
33
|
+
utterances: true,
|
|
34
|
+
language: language,
|
|
35
|
+
diarize: diarize || nil
|
|
36
|
+
}.compact
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Substrate
|
|
4
|
+
module Transcribers
|
|
5
|
+
class Deepgram
|
|
6
|
+
class ResponseParser
|
|
7
|
+
class MalformedResponseError < StandardError; end
|
|
8
|
+
|
|
9
|
+
def initialize(response, path:, diarized: false)
|
|
10
|
+
@response = response
|
|
11
|
+
@path = path
|
|
12
|
+
@diarized = diarized
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def parse
|
|
16
|
+
Transcripts::Content.new(
|
|
17
|
+
source_ref: source_ref,
|
|
18
|
+
segments: segments,
|
|
19
|
+
metadata: metadata
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
attr_reader :response, :path, :diarized
|
|
26
|
+
|
|
27
|
+
def alternatives
|
|
28
|
+
response.dig("results", "channels", 0, "alternatives", 0)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def metadata
|
|
32
|
+
Transcripts::Metadata.new(
|
|
33
|
+
provider: :deepgram,
|
|
34
|
+
model: model_name,
|
|
35
|
+
language: response.dig("metadata", "language"),
|
|
36
|
+
duration_seconds: response.dig("metadata", "duration"),
|
|
37
|
+
diarized: diarized,
|
|
38
|
+
word_timestamps: words.any?
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def model_name
|
|
43
|
+
model_info.values.first&.fetch("arch", nil) || Configuration::DEFAULT_MODEL
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def segments
|
|
47
|
+
return utterances.map { |utterance| utterance_to_segment(utterance) } if utterances.any?
|
|
48
|
+
|
|
49
|
+
[words_to_segment(alternatives["transcript"], words)]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def words
|
|
53
|
+
alternatives["words"] || []
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def utterance_to_segment(utterance)
|
|
57
|
+
Transcripts::Segment.new(
|
|
58
|
+
text: utterance["transcript"],
|
|
59
|
+
start: utterance["start"],
|
|
60
|
+
end: utterance["end"],
|
|
61
|
+
confidence: utterance["confidence"],
|
|
62
|
+
speaker: utterance["speaker"],
|
|
63
|
+
words: utterance.fetch("words", []).map { |w| word_data_to_word(w) }
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def words_to_segment(transcript, word_list)
|
|
68
|
+
Transcripts::Segment.new(
|
|
69
|
+
text: transcript,
|
|
70
|
+
start: word_list.first["start"],
|
|
71
|
+
end: word_list.last["end"],
|
|
72
|
+
confidence: nil,
|
|
73
|
+
speaker: nil,
|
|
74
|
+
words: word_list.map { |w| word_data_to_word(w) }
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def word_data_to_word(word_data)
|
|
79
|
+
Transcripts::Word.new(
|
|
80
|
+
text: word_data["punctuated_word"] || word_data["word"],
|
|
81
|
+
start: word_data["start"],
|
|
82
|
+
end: word_data["end"],
|
|
83
|
+
confidence: word_data["confidence"],
|
|
84
|
+
speaker: word_data["speaker"]
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def utterances
|
|
89
|
+
response.dig("results", "utterances") || []
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def model_info
|
|
93
|
+
response.dig("metadata", "model_info") || {}
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def source_ref
|
|
97
|
+
@source_ref ||= SourceRef.new(kind: :audio, locator: path)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Substrate
|
|
4
|
+
module Transcribers
|
|
5
|
+
class Deepgram < Command
|
|
6
|
+
class TranscriptionError < StandardError; end
|
|
7
|
+
|
|
8
|
+
def initialize(path:, **options)
|
|
9
|
+
@path = path
|
|
10
|
+
@config = Configuration.new(**options)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call
|
|
14
|
+
return failure(missing_key_error) unless config.valid?
|
|
15
|
+
|
|
16
|
+
response = client.transcribe(path: path, params: config.to_params)
|
|
17
|
+
content = parser(response).parse
|
|
18
|
+
|
|
19
|
+
success(payload: content)
|
|
20
|
+
rescue Client::Error => e
|
|
21
|
+
failure(transcription_error(e.message))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
attr_reader :path, :config
|
|
27
|
+
|
|
28
|
+
def client
|
|
29
|
+
@client ||= Client.new(api_key: config.api_key)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def parser(response)
|
|
33
|
+
ResponseParser.new(response, path: path, diarized: config.diarize)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def missing_key_error
|
|
37
|
+
ArgumentError.new("Deepgram API key required. Configure with Substrate::Configuration.deepgram_api_key = 'your_key'")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def transcription_error(message)
|
|
41
|
+
TranscriptionError.new(message)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Substrate
|
|
4
|
+
module Transcribers
|
|
5
|
+
class WhisperCpp
|
|
6
|
+
class ResponseParser < Command
|
|
7
|
+
def initialize(output:, original_path:)
|
|
8
|
+
@output = output
|
|
9
|
+
@original_path = original_path
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call
|
|
13
|
+
success(payload: parse)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
attr_reader :output, :original_path
|
|
19
|
+
|
|
20
|
+
def parse
|
|
21
|
+
Transcripts::Content.new(
|
|
22
|
+
source_ref: source_ref,
|
|
23
|
+
segments: segments,
|
|
24
|
+
metadata: metadata
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def segments
|
|
29
|
+
transcription.map do |segment|
|
|
30
|
+
Transcripts::Segment.new(
|
|
31
|
+
text: segment.fetch("text"),
|
|
32
|
+
start: segment["offsets"]["from"] / 1000.0,
|
|
33
|
+
end: segment["offsets"]["to"] / 1000.0,
|
|
34
|
+
confidence: nil,
|
|
35
|
+
speaker: nil,
|
|
36
|
+
words: []
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def metadata
|
|
42
|
+
Transcripts::Metadata.new(
|
|
43
|
+
provider: :whisper_cpp,
|
|
44
|
+
model: params.fetch("model", "unknown"),
|
|
45
|
+
language: output.fetch("result", {})["language"] || params["language"],
|
|
46
|
+
duration_seconds: nil,
|
|
47
|
+
diarized: false,
|
|
48
|
+
word_timestamps: false
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def transcription
|
|
53
|
+
output.fetch("transcription")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def params
|
|
57
|
+
output.fetch("params", {})
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def source_ref
|
|
61
|
+
SourceRef.new(kind: :audio, locator: original_path)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tmpdir"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Substrate
|
|
7
|
+
module Transcribers
|
|
8
|
+
class WhisperCpp < Command
|
|
9
|
+
class TranscriptionError < StandardError; end
|
|
10
|
+
|
|
11
|
+
DEFAULT_MODEL = "base"
|
|
12
|
+
DEFAULT_THREADS = 4
|
|
13
|
+
|
|
14
|
+
def initialize(path:, model: DEFAULT_MODEL, threads: DEFAULT_THREADS, language: nil, model_path: "~/.local/share/whisper/models/ggml-#{model}.bin", executable: :auto)
|
|
15
|
+
@path = path
|
|
16
|
+
@threads = threads
|
|
17
|
+
@language = language
|
|
18
|
+
@model_path = File.expand_path(model_path || "~/.local/share/whisper/models/ggml-#{model}.bin")
|
|
19
|
+
@executable = executable || :auto
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call
|
|
23
|
+
select_transcription_executable
|
|
24
|
+
verify_transcription_requirements
|
|
25
|
+
transcribe_recording
|
|
26
|
+
rescue ArgumentError => error
|
|
27
|
+
failure(error)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
attr_reader :path, :threads, :language, :model_path, :executable
|
|
33
|
+
|
|
34
|
+
def select_transcription_executable
|
|
35
|
+
@executable = (Cli::Availability.call("whisper-cli") ? "whisper-cli" : "whisper") if executable == :auto
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def verify_transcription_requirements
|
|
39
|
+
unless Cli::Availability.call(executable)
|
|
40
|
+
raise ArgumentError, "Whisper CLI not found: #{executable}. Install whisper.cpp or pass executable:"
|
|
41
|
+
end
|
|
42
|
+
unless File.file?(model_path) && File.readable?(model_path)
|
|
43
|
+
raise ArgumentError, "Whisper model is not readable: #{model_path}"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def transcribe_recording
|
|
48
|
+
transcript = Dir.mktmpdir("substrate-whisper-") do |directory|
|
|
49
|
+
audio_path = File.join(directory, "audio.wav")
|
|
50
|
+
output_prefix = File.join(directory, "transcript")
|
|
51
|
+
run!(preparation_arguments(audio_path), stage: "Audio preparation")
|
|
52
|
+
run!(transcription_arguments(audio_path, output_prefix), stage: "Whisper transcription")
|
|
53
|
+
read_transcript("#{output_prefix}.json")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
success(payload: transcript)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def preparation_arguments(audio_path)
|
|
60
|
+
["ffmpeg", "-nostdin", "-i", path, "-ar", "16000", "-ac", "1", "-c:a", "pcm_s16le", "-y", audio_path]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def transcription_arguments(audio_path, output_prefix)
|
|
64
|
+
arguments = [executable, "-m", model_path, "-f", audio_path, "-t", threads.to_s, "--output-json", "-of", output_prefix]
|
|
65
|
+
arguments.concat(["-l", language]) if language
|
|
66
|
+
arguments
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def run!(arguments, stage:)
|
|
70
|
+
result = Cli::Runner.call(argv: arguments)
|
|
71
|
+
raise TranscriptionError, "#{stage} failed: #{result.error.message}" if result.failure?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def read_transcript(output_path)
|
|
75
|
+
output = JSON.parse(File.read(output_path))
|
|
76
|
+
result = ResponseParser.call(output: output, original_path: path)
|
|
77
|
+
raise TranscriptionError, "Invalid Whisper transcript: #{result.error.message}" if result.failure?
|
|
78
|
+
|
|
79
|
+
result.payload
|
|
80
|
+
rescue Errno::ENOENT
|
|
81
|
+
raise TranscriptionError, "Whisper did not produce a JSON transcript"
|
|
82
|
+
rescue JSON::ParserError => e
|
|
83
|
+
raise TranscriptionError, "Invalid Whisper JSON: #{e.message}"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|