lux-hammer 0.3.1 → 0.3.3
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/.version +1 -1
- data/AGENTS.md +28 -4
- data/README.md +97 -0
- data/lib/hammer/builder.rb +44 -0
- data/lib/hammer/builtins.rb +181 -0
- data/lib/hammer/recipe.rb +92 -0
- data/lib/lux-hammer.rb +157 -9
- data/recipes/git-helper.rb +624 -0
- data/recipes/srt.rb +270 -0
- metadata +6 -2
data/recipes/srt.rb
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
# desc: Local SRT extraction via whisper.cpp
|
|
2
|
+
|
|
3
|
+
desc <<~TXT
|
|
4
|
+
Local subtitle extraction with whisper.cpp.
|
|
5
|
+
|
|
6
|
+
Quickstart:
|
|
7
|
+
srt install # brew install whisper-cpp + pick a model
|
|
8
|
+
srt model:select # pick a model (downloads it, removes others)
|
|
9
|
+
srt extract video.mp4 # video -> video.srt
|
|
10
|
+
|
|
11
|
+
Pieces (composed by `extract`, also runnable on their own):
|
|
12
|
+
srt audio video.mp4 # video -> video.wav (16 kHz mono)
|
|
13
|
+
srt transcribe clip.wav # wav -> clip.srt
|
|
14
|
+
srt doctor
|
|
15
|
+
TXT
|
|
16
|
+
|
|
17
|
+
MODELS_DIR ||= File.expand_path('~/.cache/whisper-models')
|
|
18
|
+
MODEL_URL ||= 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main'
|
|
19
|
+
# Silero VAD model lives in a sibling dir so the catalog scan in
|
|
20
|
+
# `current_model` doesn't see it as a transcription model.
|
|
21
|
+
VAD_DIR ||= File.join(MODELS_DIR, 'vad')
|
|
22
|
+
VAD_FILE ||= 'ggml-silero-v5.1.2.bin'
|
|
23
|
+
VAD_URL ||= "https://huggingface.co/ggml-org/whisper-vad/resolve/main/#{VAD_FILE}"
|
|
24
|
+
|
|
25
|
+
# Curated subset of the whisper.cpp ggml catalog. Name matches the suffix
|
|
26
|
+
# in `ggml-<name>.bin`; sizes are approximate.
|
|
27
|
+
MODEL_CATALOG ||= [
|
|
28
|
+
['tiny', '39 MB', 'fastest, multilingual'],
|
|
29
|
+
['tiny.en', '39 MB', 'fastest, English only'],
|
|
30
|
+
['base', '74 MB', 'small + balanced, multilingual'],
|
|
31
|
+
['base.en', '74 MB', 'small + balanced, English only'],
|
|
32
|
+
['small', '244 MB', 'good quality, multilingual'],
|
|
33
|
+
['small.en', '244 MB', 'good quality, English only'],
|
|
34
|
+
['medium', '769 MB', 'better quality, slower'],
|
|
35
|
+
['medium.en', '769 MB', 'better quality, English only'],
|
|
36
|
+
['large-v3', '1.5 GB', 'best quality, slowest'],
|
|
37
|
+
['large-v3-turbo', '809 MB', 'near-large quality, much faster']
|
|
38
|
+
].freeze
|
|
39
|
+
|
|
40
|
+
# Recipe bodies eval inside Hammer::Builder#instance_exec, so top-level
|
|
41
|
+
# `def` lands on the builder singleton and is invisible to handler procs.
|
|
42
|
+
# Constants and module methods resolve lexically, so helpers go here.
|
|
43
|
+
module SRT
|
|
44
|
+
module_function
|
|
45
|
+
|
|
46
|
+
def have?(bin)
|
|
47
|
+
system("command -v #{bin} >/dev/null 2>&1")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def require_tool!(bin, hint)
|
|
51
|
+
return if have?(bin)
|
|
52
|
+
Hammer::Shell.error "#{bin} not found on PATH - #{hint}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Name of the currently-selected model (nil if nothing downloaded yet).
|
|
56
|
+
def current_model
|
|
57
|
+
return nil unless File.directory?(MODELS_DIR)
|
|
58
|
+
file = Dir.children(MODELS_DIR).find { |f| f =~ /\Aggml-(.+)\.bin\z/ }
|
|
59
|
+
file && file[/\Aggml-(.+)\.bin\z/, 1]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def current_model_path
|
|
63
|
+
name = current_model or return nil
|
|
64
|
+
File.join(MODELS_DIR, "ggml-#{name}.bin")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
VIDEO_EXTS ||= %w[.mp4 .mkv .mov .avi .webm .m4v].freeze
|
|
68
|
+
|
|
69
|
+
# Largest video file in `dir` (by bytes), or nil if none. Used by
|
|
70
|
+
# `extract` when called without an explicit video.
|
|
71
|
+
def largest_video(dir)
|
|
72
|
+
return nil unless dir && File.directory?(dir)
|
|
73
|
+
Dir.children(dir)
|
|
74
|
+
.select { |f| VIDEO_EXTS.include?(File.extname(f).downcase) }
|
|
75
|
+
.map { |f| File.join(dir, f) }
|
|
76
|
+
.select { |p| File.file?(p) }
|
|
77
|
+
.max_by { |p| File.size(p) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Ensure the Silero VAD model is on disk and return its path. Downloaded
|
|
81
|
+
# on first use; whisper-cli's --vad flag is what keeps it from
|
|
82
|
+
# hallucinating over music and silence.
|
|
83
|
+
def ensure_vad!
|
|
84
|
+
require 'fileutils'
|
|
85
|
+
FileUtils.mkdir_p(VAD_DIR)
|
|
86
|
+
path = File.join(VAD_DIR, VAD_FILE)
|
|
87
|
+
return path if File.file?(path)
|
|
88
|
+
tmp = "#{path}.partial"
|
|
89
|
+
Hammer::Shell.say.gray "downloading Silero VAD model..."
|
|
90
|
+
ok = system(%(curl -L --fail -o "#{tmp}" "#{VAD_URL}"))
|
|
91
|
+
Hammer::Shell.error 'failed to download Silero VAD model' unless ok
|
|
92
|
+
File.rename(tmp, path)
|
|
93
|
+
path
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
task :install do
|
|
98
|
+
desc 'Install whisper-cpp via brew and pick a model'
|
|
99
|
+
|
|
100
|
+
proc do
|
|
101
|
+
if SRT.have?('whisper-cli')
|
|
102
|
+
say.gray 'whisper-cli present, skipping brew install'
|
|
103
|
+
else
|
|
104
|
+
sh 'brew install whisper-cpp'
|
|
105
|
+
end
|
|
106
|
+
hammer 'model:select' unless SRT.current_model
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
namespace :model do
|
|
111
|
+
task :select do
|
|
112
|
+
desc 'Pick a whisper model: downloads it and removes any others'
|
|
113
|
+
|
|
114
|
+
proc do
|
|
115
|
+
current = SRT.current_model
|
|
116
|
+
items = MODEL_CATALOG.map do |name, size, info|
|
|
117
|
+
mark = current == name ? '*' : ' '
|
|
118
|
+
"#{mark} #{name.ljust(16)} #{size.ljust(8)} #{info}"
|
|
119
|
+
end
|
|
120
|
+
idx = choose 'pick a model (* = current)', items
|
|
121
|
+
next say.gray('cancelled') unless idx
|
|
122
|
+
|
|
123
|
+
name = MODEL_CATALOG[idx][0]
|
|
124
|
+
require 'fileutils'
|
|
125
|
+
FileUtils.mkdir_p(MODELS_DIR)
|
|
126
|
+
dest = File.join(MODELS_DIR, "ggml-#{name}.bin")
|
|
127
|
+
unless File.file?(dest)
|
|
128
|
+
sh %(curl -L --fail -o "#{dest}" "#{MODEL_URL}/ggml-#{name}.bin")
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Drop every other ggml-*.bin so only the selection remains.
|
|
132
|
+
Dir.children(MODELS_DIR).each do |f|
|
|
133
|
+
next unless f =~ /\Aggml-.+\.bin\z/
|
|
134
|
+
next if f == "ggml-#{name}.bin"
|
|
135
|
+
File.delete(File.join(MODELS_DIR, f))
|
|
136
|
+
say.gray "removed #{f}"
|
|
137
|
+
end
|
|
138
|
+
say.green "selected #{name}"
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
task :audio do
|
|
144
|
+
desc "Extract 16 kHz mono WAV (whisper.cpp's input format) from a video"
|
|
145
|
+
example 'audio movie.mp4'
|
|
146
|
+
example 'audio movie.mp4 -o tmp/movie.wav'
|
|
147
|
+
# `:video` is declared first so positional ARGV fills it; the parser
|
|
148
|
+
# fills non-boolean opts in declaration order.
|
|
149
|
+
opt :video, req: true, placeholder: 'VIDEO'
|
|
150
|
+
opt :output, alias: :o, desc: 'output wav path (default: <input>.wav)'
|
|
151
|
+
|
|
152
|
+
proc do |opts|
|
|
153
|
+
src = opts[:video]
|
|
154
|
+
error "no such file: #{src}" unless File.file?(src)
|
|
155
|
+
SRT.require_tool! 'ffmpeg', 'install with `brew install ffmpeg`'
|
|
156
|
+
wav = opts[:output] || src.sub(/\.[^.]+\z/, '') + '.wav'
|
|
157
|
+
sh %(ffmpeg -y -loglevel error -i "#{src}" -ar 16000 -ac 1 -c:a pcm_s16le "#{wav}")
|
|
158
|
+
say.green wav
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
task :transcribe do
|
|
163
|
+
desc 'Transcribe a 16 kHz mono WAV into an SRT next to it'
|
|
164
|
+
example 'transcribe clip.wav'
|
|
165
|
+
example 'transcribe clip.wav --lang auto'
|
|
166
|
+
opt :wav, req: true, placeholder: 'WAV'
|
|
167
|
+
opt :lang, default: 'en', desc: 'language code or "auto"'
|
|
168
|
+
|
|
169
|
+
proc do |opts|
|
|
170
|
+
wav = opts[:wav]
|
|
171
|
+
error "no such file: #{wav}" unless File.file?(wav)
|
|
172
|
+
SRT.require_tool! 'whisper-cli', 'run `srt install`'
|
|
173
|
+
mdl = SRT.current_model_path or error 'no model selected - run `srt model:select`'
|
|
174
|
+
vad = SRT.ensure_vad!
|
|
175
|
+
# Tag the output with the lang so multiple tracks can coexist:
|
|
176
|
+
# clip.wav -> clip.en.srt
|
|
177
|
+
base = wav.sub(/\.wav\z/, '') + ".#{opts[:lang]}"
|
|
178
|
+
# -mc 0 : don't carry prior-segment text into the next window
|
|
179
|
+
# (kills the "same line repeated 100x" loop on long audio)
|
|
180
|
+
# --suppress-nst : suppress non-speech tokens during music / silence
|
|
181
|
+
# --vad -vm ... : Silero VAD pre-pass so silent sections aren't transcribed
|
|
182
|
+
sh %(whisper-cli -m "#{mdl}" -l #{opts[:lang]} -mc 0 --suppress-nst ) +
|
|
183
|
+
%(--vad -vm "#{vad}" -osrt -of "#{base}" "#{wav}")
|
|
184
|
+
say.green "#{base}.srt"
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
task :extract do
|
|
189
|
+
desc 'Video -> SRT (audio extraction + whisper transcription)'
|
|
190
|
+
example 'extract # picks largest video in cwd'
|
|
191
|
+
example 'extract movie.mp4 --lang en --keep-wav'
|
|
192
|
+
opt :video, placeholder: 'VIDEO'
|
|
193
|
+
opt :lang, default: 'en', desc: 'language code or "auto"'
|
|
194
|
+
opt :keep_wav, type: :boolean, desc: 'keep the intermediate .wav'
|
|
195
|
+
|
|
196
|
+
proc do |opts|
|
|
197
|
+
# `LLM_LOCAL_CWD` is set by the `llm-local` wrapper before it chdirs
|
|
198
|
+
# into the project; falls back to Dir.pwd for direct `srt` runs.
|
|
199
|
+
src = opts[:video] || SRT.largest_video(ENV['LLM_LOCAL_CWD'] || Dir.pwd)
|
|
200
|
+
error 'no video given and none found in current folder' unless src
|
|
201
|
+
error "no such file: #{src}" unless File.file?(src)
|
|
202
|
+
base = src.sub(/\.[^.]+\z/, '')
|
|
203
|
+
wav = "#{base}.wav"
|
|
204
|
+
hammer :audio, src, output: wav
|
|
205
|
+
hammer :transcribe, wav, lang: opts[:lang]
|
|
206
|
+
File.delete(wav) unless opts[:keep_wav]
|
|
207
|
+
say.green "#{base}.#{opts[:lang]}.srt"
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
task :fix do
|
|
212
|
+
desc 'Translate non-target-language cues in an SRT via claude/codex CLI'
|
|
213
|
+
example 'fix movie.en.srt # target inferred from filename'
|
|
214
|
+
example 'fix movie.srt --lang en # target from --lang fallback'
|
|
215
|
+
opt :srt, req: true, placeholder: 'SRT'
|
|
216
|
+
opt :lang, default: 'en', desc: 'target language code (default: en)'
|
|
217
|
+
|
|
218
|
+
proc do |opts|
|
|
219
|
+
src = opts[:srt]
|
|
220
|
+
error "no such file: #{src}" unless File.file?(src)
|
|
221
|
+
|
|
222
|
+
# Prefer the language tag baked into the filename (e.g. movie.en.srt -> en);
|
|
223
|
+
# fall back to --lang when the filename has no tag.
|
|
224
|
+
target = File.basename(src)[/\.([a-z]{2,3})\.srt\z/i, 1]&.downcase || opts[:lang]
|
|
225
|
+
|
|
226
|
+
cli =
|
|
227
|
+
if SRT.have?('claude') then 'claude'
|
|
228
|
+
elsif SRT.have?('codex') then 'codex'
|
|
229
|
+
else error 'neither `claude` nor `codex` CLI found on PATH'
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
prompt = <<~TXT
|
|
233
|
+
Edit the SRT file at "#{src}" in place.
|
|
234
|
+
Target language: #{target}.
|
|
235
|
+
For every subtitle cue whose text is not in #{target}, translate the text to #{target}.
|
|
236
|
+
Leave cues already in #{target} unchanged.
|
|
237
|
+
Preserve cue numbers, timestamps, and blank lines exactly as in the original.
|
|
238
|
+
Do not add commentary - just write the corrected SRT back to the same file.
|
|
239
|
+
TXT
|
|
240
|
+
|
|
241
|
+
require 'shellwords'
|
|
242
|
+
cmd =
|
|
243
|
+
case cli
|
|
244
|
+
when 'claude' then %(claude -p --permission-mode acceptEdits #{Shellwords.escape(prompt)})
|
|
245
|
+
when 'codex' then %(codex exec -s workspace-write #{Shellwords.escape(prompt)})
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
say.gray cmd
|
|
249
|
+
sh cmd
|
|
250
|
+
say.green src
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
task :doctor do
|
|
255
|
+
desc 'Check ffmpeg / whisper-cli / selected model are present'
|
|
256
|
+
|
|
257
|
+
proc do
|
|
258
|
+
{
|
|
259
|
+
'ffmpeg' => 'ffmpeg',
|
|
260
|
+
'whisper-cli' => 'whisper-cli'
|
|
261
|
+
}.each do |label, bin|
|
|
262
|
+
SRT.have?(bin) ? say.green("ok #{label}") : say.red("-- #{label} (missing)")
|
|
263
|
+
end
|
|
264
|
+
if (m = SRT.current_model)
|
|
265
|
+
say.green "ok model #{m}"
|
|
266
|
+
else
|
|
267
|
+
say.red '-- no model selected (run `srt model:select`)'
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: lux-hammer
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.3.
|
|
4
|
+
version: 0.3.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dino Reic
|
|
@@ -36,14 +36,18 @@ files:
|
|
|
36
36
|
- "./README.md"
|
|
37
37
|
- "./bin/hammer"
|
|
38
38
|
- "./lib/hammer/builder.rb"
|
|
39
|
+
- "./lib/hammer/builtins.rb"
|
|
39
40
|
- "./lib/hammer/command.rb"
|
|
40
41
|
- "./lib/hammer/command_builder.rb"
|
|
41
42
|
- "./lib/hammer/dotenv.rb"
|
|
42
43
|
- "./lib/hammer/loader.rb"
|
|
43
44
|
- "./lib/hammer/option.rb"
|
|
44
45
|
- "./lib/hammer/parser.rb"
|
|
46
|
+
- "./lib/hammer/recipe.rb"
|
|
45
47
|
- "./lib/hammer/shell.rb"
|
|
46
48
|
- "./lib/lux-hammer.rb"
|
|
49
|
+
- "./recipes/git-helper.rb"
|
|
50
|
+
- "./recipes/srt.rb"
|
|
47
51
|
- bin/hammer
|
|
48
52
|
homepage: https://github.com/dux/hammer
|
|
49
53
|
licenses:
|
|
@@ -63,7 +67,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
63
67
|
- !ruby/object:Gem::Version
|
|
64
68
|
version: '0'
|
|
65
69
|
requirements: []
|
|
66
|
-
rubygems_version: 4.0.
|
|
70
|
+
rubygems_version: 4.0.12
|
|
67
71
|
specification_version: 4
|
|
68
72
|
summary: Thor-inspired tiny CLI builder
|
|
69
73
|
test_files: []
|