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.
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.1
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.11
70
+ rubygems_version: 4.0.12
67
71
  specification_version: 4
68
72
  summary: Thor-inspired tiny CLI builder
69
73
  test_files: []