This commit is contained in:
2026-02-09 22:30:57 -08:00
parent 4e76d0db9a
commit d23a385861
11 changed files with 360 additions and 1451 deletions

View File

@@ -78,7 +78,7 @@ exec-once = uwsm app -sb -t service -- nm-applet
exec-once = uwsm app -sb -t service -- waybar -c ~/.config/waybar/catppuccin-macchiato/config.jsonc -s ~/.config/waybar/catppuccin-macchiato/style.css
exec-once = uwsm app -sb -t service -- hyprsunset
exec-once = uwsm app -sb -t service -- /usr/lib/polkit-kde-authentication-agent-1
exec-once = uwsm app -sb -t service -- variety
# exec-once = uwsm app -sb -t service -- variety
exec-once = ~/.local/bin/aria
# exec-once = dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP

View File

@@ -148,7 +148,7 @@ profile-restore=copy-equal
title=' '
keepaspect=no
[immersion]
[subminer]
cookies=yes
cookies-file=/truenas/sudacode/japanese/youtube-cookies.txt
ytdl-format=bestvideo+bestaudio/best
@@ -157,6 +157,9 @@ ytdl-raw-options-append=write-auto-subs=
ytdl-raw-options-append=sub-langs=ja.*|en|ja-en
ytdl-raw-options-append=cookies=/truenas/sudacode/japanese/youtube-cookies.txt
sub-auto=fuzzy
sid=auto
secondary-sid=auto
secondary-sub-visibility=no
alang=ja,jp,jpn,japanese,en,eng,english,English,enUS,en-US
slang=ja,jp,jpn,japanese,en,eng,english,English,enUS,en-US
vlang=ja,jpn

View File

@@ -1,405 +0,0 @@
----------------------
-- #example ytdl_preload.conf
-- # make sure lines do not have trailing whitespace
-- # ytdl_opt has no sanity check and should be formatted exactly how it would appear in yt-dlp CLI, they are split into a key/value pair on whitespace
-- # at least on Windows, do not escape '\' in temp, just us a single one for each divider
-- #temp=R:\ytdltest
-- #ytdl_opt1=-r 50k
-- #ytdl_opt2=-N 5
-- #ytdl_opt#=etc
----------------------
local nextIndex
local caught = true
-- local pop = false
local ytdl = "yt-dlp"
local utils = require 'mp.utils'
local options = require 'mp.options'
local opts = {
temp = "/tmp/ytdl-preload",
ytdl_opt1 = "",
ytdl_opt2 = "",
ytdl_opt3 = "",
ytdl_opt4 = "",
ytdl_opt5 = "",
ytdl_opt6 = "",
ytdl_opt7 = "",
ytdl_opt8 = "",
ytdl_opt9 = "",
}
options.read_options(opts, "ytdl_preload")
local additionalOpts = {}
for k, v in pairs(opts) do
if k:find("ytdl_opt%d") and v ~= "" then
additionalOpts[k] = v
-- print("entry")
-- print(k .. v)
end
end
local cachePath = opts.temp
local chapter_list = {}
local json = ""
local filesToDelete = {}
local function exists(file)
local ok, err, code = os.rename(file, file)
if not ok then
if code == 13 then -- Permission denied, but it exists
return true
end
end
return ok, err
end
local function useNewLoadfile()
for _, c in pairs(mp.get_property_native("command-list")) do
if c["name"] == "loadfile" then
for _, a in pairs(c["args"]) do
if a["name"] == "index" then
return true
end
end
end
end
end
--from ytdl_hook
local function time_to_secs(time_string)
local ret
local a, b, c = time_string:match("(%d+):(%d%d?):(%d%d)")
if a ~= nil then
ret = (a * 3600 + b * 60 + c)
else
a, b = time_string:match("(%d%d?):(%d%d)")
if a ~= nil then
ret = (a * 60 + b)
end
end
return ret
end
local function extract_chapters(data, video_length)
local ret = {}
for line in data:gmatch("[^\r\n]+") do
local time = time_to_secs(line)
if time and (time < video_length) then
table.insert(ret, { time = time, title = line })
end
end
table.sort(ret, function(a, b) return a.time < b.time end)
return ret
end
local function chapters()
if json.chapters then
for i = 1, #json.chapters do
local chapter = json.chapters[i]
local title = chapter.title or ""
if title == "" then
title = string.format('Chapter %02d', i)
end
table.insert(chapter_list, { time = chapter.start_time, title = title })
end
elseif not (json.description == nil) and not (json.duration == nil) then
chapter_list = extract_chapters(json.description, json.duration)
end
end
--end ytdl_hook
local title = ""
local fVideo = ""
local fAudio = ""
local function load_files(dtitle, destination, audio, wait)
if wait then
if exists(destination .. ".mka") then
print("---wait success: found mka---")
audio = "audio-file=" .. destination .. '.mka,'
else
print("---could not find mka after wait, audio may be missing---")
end
end
-- if audio ~= "" then
-- table.insert(filesToDelete, destination .. ".mka")
-- end
-- table.insert(filesToDelete, destination .. ".mkv")
dtitle = dtitle:gsub("-" .. ("[%w_-]"):rep(11) .. "$", "")
dtitle = dtitle:gsub("^" .. ("%d"):rep(10) .. "%-", "")
if useNewLoadfile() then
mp.commandv("loadfile", destination .. ".mkv", "append", -1,
audio .. 'force-media-title="' .. dtitle .. '",demuxer-max-back-bytes=1MiB,demuxer-max-bytes=3MiB,ytdl=no')
else
mp.commandv("loadfile", destination .. ".mkv", "append",
audio .. 'force-media-title="' .. dtitle .. '",demuxer-max-back-bytes=1MiB,demuxer-max-bytes=3MiB,ytdl=no') --,sub-file="..destination..".en.vtt") --in case they are not set up to autoload
end
mp.commandv("playlist_move", mp.get_property("playlist-count") - 1, nextIndex)
mp.commandv("playlist_remove", nextIndex + 1)
caught = true
title = ""
-- pop = true
end
local listenID = ""
local function listener(event)
if not caught and event.prefix == mp.get_script_name() and string.find(event.text, listenID) then
local destination = string.match(event.text, "%[download%] Destination: (.+).mkv") or
string.match(event.text, "%[download%] (.+).mkv has already been downloaded")
-- if destination then print("---"..cachePath) end;
if destination and string.find(destination, string.gsub(cachePath, '~/', '')) then
-- print(listenID)
mp.unregister_event(listener)
_, title = utils.split_path(destination)
local audio = ""
if fAudio == "" then
load_files(title, destination, audio, false)
else
if exists(destination .. ".mka") then
audio = "audio-file=" .. destination .. '.mka,'
load_files(title, destination, audio, false)
else
print("---expected mka but could not find it, waiting for 2 seconds---")
mp.add_timeout(2, function()
load_files(title, destination, audio, true)
end)
end
end
end
end
end
--from ytdl_hook
mp.add_hook("on_preloaded", 10, function()
if string.find(mp.get_property("path"), cachePath) then
chapters()
if next(chapter_list) ~= nil then
mp.set_property_native("chapter-list", chapter_list)
chapter_list = {}
json = ""
end
end
end)
--end ytdl_hook
function dump(o)
if type(o) == 'table' then
local s = '{ '
for k, v in pairs(o) do
if type(k) ~= 'number' then k = '"' .. k .. '"' end
s = s .. '[' .. k .. '] = ' .. dump(v) .. ','
end
return s .. '} '
else
return tostring(o)
end
end
local function addOPTS(old)
for k, v in pairs(additionalOpts) do
-- print(k)
if string.find(v, "%s") then
for l, w in string.gmatch(v, "([-%w]+) (.+)") do
table.insert(old, l)
table.insert(old, w)
end
else
table.insert(old, v)
end
end
-- print(dump(old))
return old
end
local AudioDownloadHandle = {}
local VideoDownloadHandle = {}
local JsonDownloadHandle = {}
local function download_files(id, success, result, error)
if result.killed_by_us then
return
end
local jfile = cachePath .. "/" .. id .. ".json"
local jfileIO = io.open(jfile, "w")
jfileIO:write(result.stdout)
jfileIO:close()
json = utils.parse_json(result.stdout)
-- print(dump(json))
if json.requested_downloads[1].requested_formats ~= nil then
local args = { ytdl, "--no-continue", "-q", "-f", fAudio, "--restrict-filenames", "--no-playlist", "--no-part",
"-o", cachePath .. "/" .. id .. "-%(title)s-%(id)s.mka", "--load-info-json", jfile }
args = addOPTS(args)
AudioDownloadHandle = mp.command_native_async({
name = "subprocess",
args = args,
playback_only = false
}, function()
end)
else
fAudio = ""
fVideo = fVideo:gsub("bestvideo", "best")
fVideo = fVideo:gsub("bv", "best")
end
local args = { ytdl, "--no-continue", "-f", fVideo .. '/best', "--restrict-filenames", "--no-playlist",
"--no-part", "-o", cachePath .. "/" .. id .. "-%(title)s-%(id)s.mkv", "--load-info-json", jfile }
args = addOPTS(args)
VideoDownloadHandle = mp.command_native_async({
name = "subprocess",
args = args,
playback_only = false
}, function()
end)
end
local function DL()
local index = tonumber(mp.get_property("playlist-pos"))
if mp.get_property("playlist/" .. index .. "/filename"):find("/videos$") and mp.get_property("playlist/" .. index + 1 .. "/filename"):find("/shorts$") then
return
end
if tonumber(mp.get_property("playlist-pos-1")) > 0 and mp.get_property("playlist-pos-1") ~= mp.get_property("playlist-count") then
nextIndex = index + 1
local nextFile = mp.get_property("playlist/" .. nextIndex .. "/filename")
if nextFile and caught and nextFile:find("://", 0, false) then
caught = false
mp.enable_messages("info")
mp.register_event("log-message", listener)
local ytFormat = mp.get_property("ytdl-format")
fVideo = string.match(ytFormat, '(.+)%+.+//?') or 'bestvideo'
fAudio = string.match(ytFormat, '.+%+(.+)//?') or 'bestaudio'
-- print("start"..nextFile)
listenID = tostring(os.time())
local args = { ytdl, "--dump-single-json", "--no-simulate", "--skip-download",
"--restrict-filenames",
"--no-playlist", "--sub-lang", "en", "--write-sub", "--no-part", "-o",
cachePath .. "/" .. listenID .. "-%(title)s-%(id)s.%(ext)s", nextFile }
args = addOPTS(args)
-- print(dump(args))
table.insert(filesToDelete, listenID)
JsonDownloadHandle = mp.command_native_async({
name = "subprocess",
args = args,
capture_stdout = true,
capture_stderr = true,
playback_only = false
}, function(...)
download_files(listenID, ...)
end)
end
end
end
local function clearCache()
-- print(pop)
--if pop == true then
mp.abort_async_command(AudioDownloadHandle)
mp.abort_async_command(VideoDownloadHandle)
mp.abort_async_command(JsonDownloadHandle)
-- for k, v in pairs(filesToDelete) do
-- print("remove: " .. v)
-- os.remove(v)
-- end
local ftd = io.open(cachePath .. "/temp.files", "a")
for k, v in pairs(filesToDelete) do
ftd:write(v .. "\n")
if package.config:sub(1, 1) ~= '/' then
os.execute('del /Q /F "' .. cachePath .. "\\" .. v .. '*"')
else
os.execute('rm -f ' .. cachePath .. "/" .. v .. "*")
end
end
ftd:close()
print('clear')
mp.command("quit")
--end
end
mp.add_hook("on_unload", 50, function()
-- mp.abort_async_command(AudioDownloadHandle)
-- mp.abort_async_command(VideoDownloadHandle)
mp.abort_async_command(JsonDownloadHandle)
mp.unregister_event(listener)
caught = true
listenID = "resetYtdlPreloadListener"
-- print(listenID)
end)
local skipInitial
mp.observe_property("playlist-count", "number", function()
if skipInitial then
DL()
else
skipInitial = true
end
end)
--from ytdl_hook
local platform_is_windows = (package.config:sub(1, 1) == "\\")
local o = {
exclude = "",
try_ytdl_first = false,
use_manifests = false,
all_formats = false,
force_all_formats = true,
ytdl_path = "",
}
local paths_to_search = { "yt-dlp", "yt-dlp_x86", "youtube-dl" }
--local options = require 'mp.options'
options.read_options(o, "ytdl_hook")
local separator = platform_is_windows and ";" or ":"
if o.ytdl_path:match("[^" .. separator .. "]") then
paths_to_search = {}
for path in o.ytdl_path:gmatch("[^" .. separator .. "]+") do
table.insert(paths_to_search, path)
end
end
local function exec(args)
local ret = mp.command_native({
name = "subprocess",
args = args,
capture_stdout = true,
capture_stderr = true
})
return ret.status, ret.stdout, ret, ret.killed_by_us
end
local msg = require 'mp.msg'
local command = {}
for _, path in pairs(paths_to_search) do
-- search for youtube-dl in mpv's config dir
local exesuf = platform_is_windows and ".exe" or ""
local ytdl_cmd = mp.find_config_file(path .. exesuf)
if ytdl_cmd then
msg.verbose("Found youtube-dl at: " .. ytdl_cmd)
ytdl = ytdl_cmd
break
else
msg.verbose("No youtube-dl found with path " .. path .. exesuf .. " in config directories")
--search in PATH
command[1] = path
es, json, result, aborted = exec(command)
if result.error_string == "init" then
msg.verbose("youtube-dl with path " .. path .. exesuf .. " not found in PATH or not enough permissions")
else
msg.verbose("Found youtube-dl with path " .. path .. exesuf .. " in PATH")
ytdl = path
break
end
end
end
--end ytdl_hook
mp.register_event("start-file", DL)
mp.register_event("shutdown", clearCache)
local ftd = io.open(cachePath .. "/temp.files", "r")
while ftd ~= nil do
local line = ftd:read()
if line == nil or line == "" then
ftd:close()
io.open(cachePath .. "/temp.files", "w"):close()
break
end
-- print("DEL::"..line)
if package.config:sub(1, 1) ~= '/' then
os.execute('del /Q /F "' .. cachePath .. "\\" .. line .. '*" >nul 2>nul')
else
os.execute('rm -f ' .. cachePath .. "/" .. line .. "* &> /dev/null")
end
end

View File

@@ -0,0 +1 @@
ytdl-preload.lua##os.Linux

View File

@@ -1,405 +0,0 @@
----------------------
-- #example ytdl_preload.conf
-- # make sure lines do not have trailing whitespace
-- # ytdl_opt has no sanity check and should be formatted exactly how it would appear in yt-dlp CLI, they are split into a key/value pair on whitespace
-- # at least on Windows, do not escape '\' in temp, just us a single one for each divider
-- #temp=R:\ytdltest
-- #ytdl_opt1=-r 50k
-- #ytdl_opt2=-N 5
-- #ytdl_opt#=etc
----------------------
local nextIndex
local caught = true
-- local pop = false
local ytdl = "yt-dlp"
local utils = require 'mp.utils'
local options = require 'mp.options'
local opts = {
temp = "/tmp/ytdl-preload",
ytdl_opt1 = "",
ytdl_opt2 = "",
ytdl_opt3 = "",
ytdl_opt4 = "",
ytdl_opt5 = "",
ytdl_opt6 = "",
ytdl_opt7 = "",
ytdl_opt8 = "",
ytdl_opt9 = "",
}
options.read_options(opts, "ytdl_preload")
local additionalOpts = {}
for k, v in pairs(opts) do
if k:find("ytdl_opt%d") and v ~= "" then
additionalOpts[k] = v
-- print("entry")
-- print(k .. v)
end
end
local cachePath = opts.temp
local chapter_list = {}
local json = ""
local filesToDelete = {}
local function exists(file)
local ok, err, code = os.rename(file, file)
if not ok then
if code == 13 then -- Permission denied, but it exists
return true
end
end
return ok, err
end
local function useNewLoadfile()
for _, c in pairs(mp.get_property_native("command-list")) do
if c["name"] == "loadfile" then
for _, a in pairs(c["args"]) do
if a["name"] == "index" then
return true
end
end
end
end
end
--from ytdl_hook
local function time_to_secs(time_string)
local ret
local a, b, c = time_string:match("(%d+):(%d%d?):(%d%d)")
if a ~= nil then
ret = (a * 3600 + b * 60 + c)
else
a, b = time_string:match("(%d%d?):(%d%d)")
if a ~= nil then
ret = (a * 60 + b)
end
end
return ret
end
local function extract_chapters(data, video_length)
local ret = {}
for line in data:gmatch("[^\r\n]+") do
local time = time_to_secs(line)
if time and (time < video_length) then
table.insert(ret, { time = time, title = line })
end
end
table.sort(ret, function(a, b) return a.time < b.time end)
return ret
end
local function chapters()
if json.chapters then
for i = 1, #json.chapters do
local chapter = json.chapters[i]
local title = chapter.title or ""
if title == "" then
title = string.format('Chapter %02d', i)
end
table.insert(chapter_list, { time = chapter.start_time, title = title })
end
elseif not (json.description == nil) and not (json.duration == nil) then
chapter_list = extract_chapters(json.description, json.duration)
end
end
--end ytdl_hook
local title = ""
local fVideo = ""
local fAudio = ""
local function load_files(dtitle, destination, audio, wait)
if wait then
if exists(destination .. ".mka") then
print("---wait success: found mka---")
audio = "audio-file=" .. destination .. '.mka,'
else
print("---could not find mka after wait, audio may be missing---")
end
end
-- if audio ~= "" then
-- table.insert(filesToDelete, destination .. ".mka")
-- end
-- table.insert(filesToDelete, destination .. ".mkv")
dtitle = dtitle:gsub("-" .. ("[%w_-]"):rep(11) .. "$", "")
dtitle = dtitle:gsub("^" .. ("%d"):rep(10) .. "%-", "")
if useNewLoadfile() then
mp.commandv("loadfile", destination .. ".mkv", "append", -1,
audio .. 'force-media-title="' .. dtitle .. '",demuxer-max-back-bytes=1MiB,demuxer-max-bytes=3MiB,ytdl=no')
else
mp.commandv("loadfile", destination .. ".mkv", "append",
audio .. 'force-media-title="' .. dtitle .. '",demuxer-max-back-bytes=1MiB,demuxer-max-bytes=3MiB,ytdl=no') --,sub-file="..destination..".en.vtt") --in case they are not set up to autoload
end
mp.commandv("playlist_move", mp.get_property("playlist-count") - 1, nextIndex)
mp.commandv("playlist_remove", nextIndex + 1)
caught = true
title = ""
-- pop = true
end
local listenID = ""
local function listener(event)
if not caught and event.prefix == mp.get_script_name() and string.find(event.text, listenID) then
local destination = string.match(event.text, "%[download%] Destination: (.+).mkv") or
string.match(event.text, "%[download%] (.+).mkv has already been downloaded")
-- if destination then print("---"..cachePath) end;
if destination and string.find(destination, string.gsub(cachePath, '~/', '')) then
-- print(listenID)
mp.unregister_event(listener)
_, title = utils.split_path(destination)
local audio = ""
if fAudio == "" then
load_files(title, destination, audio, false)
else
if exists(destination .. ".mka") then
audio = "audio-file=" .. destination .. '.mka,'
load_files(title, destination, audio, false)
else
print("---expected mka but could not find it, waiting for 2 seconds---")
mp.add_timeout(2, function()
load_files(title, destination, audio, true)
end)
end
end
end
end
end
--from ytdl_hook
mp.add_hook("on_preloaded", 10, function()
if string.find(mp.get_property("path"), cachePath) then
chapters()
if next(chapter_list) ~= nil then
mp.set_property_native("chapter-list", chapter_list)
chapter_list = {}
json = ""
end
end
end)
--end ytdl_hook
function dump(o)
if type(o) == 'table' then
local s = '{ '
for k, v in pairs(o) do
if type(k) ~= 'number' then k = '"' .. k .. '"' end
s = s .. '[' .. k .. '] = ' .. dump(v) .. ','
end
return s .. '} '
else
return tostring(o)
end
end
local function addOPTS(old)
for k, v in pairs(additionalOpts) do
-- print(k)
if string.find(v, "%s") then
for l, w in string.gmatch(v, "([-%w]+) (.+)") do
table.insert(old, l)
table.insert(old, w)
end
else
table.insert(old, v)
end
end
-- print(dump(old))
return old
end
local AudioDownloadHandle = {}
local VideoDownloadHandle = {}
local JsonDownloadHandle = {}
local function download_files(id, success, result, error)
if result.killed_by_us then
return
end
local jfile = cachePath .. "/" .. id .. ".json"
local jfileIO = io.open(jfile, "w")
jfileIO:write(result.stdout)
jfileIO:close()
json = utils.parse_json(result.stdout)
-- print(dump(json))
if json.requested_downloads[1].requested_formats ~= nil then
local args = { ytdl, "--no-continue", "-q", "-f", fAudio, "--restrict-filenames", "--no-playlist", "--no-part",
"-o", cachePath .. "/" .. id .. "-%(title)s-%(id)s.mka", "--load-info-json", jfile }
args = addOPTS(args)
AudioDownloadHandle = mp.command_native_async({
name = "subprocess",
args = args,
playback_only = false
}, function()
end)
else
fAudio = ""
fVideo = fVideo:gsub("bestvideo", "best")
fVideo = fVideo:gsub("bv", "best")
end
local args = { ytdl, "--no-continue", "-f", fVideo .. '/best', "--restrict-filenames", "--no-playlist",
"--no-part", "-o", cachePath .. "/" .. id .. "-%(title)s-%(id)s.mkv", "--load-info-json", jfile }
args = addOPTS(args)
VideoDownloadHandle = mp.command_native_async({
name = "subprocess",
args = args,
playback_only = false
}, function()
end)
end
local function DL()
local index = tonumber(mp.get_property("playlist-pos"))
if mp.get_property("playlist/" .. index .. "/filename"):find("/videos$") and mp.get_property("playlist/" .. index + 1 .. "/filename"):find("/shorts$") then
return
end
if tonumber(mp.get_property("playlist-pos-1")) > 0 and mp.get_property("playlist-pos-1") ~= mp.get_property("playlist-count") then
nextIndex = index + 1
local nextFile = mp.get_property("playlist/" .. nextIndex .. "/filename")
if nextFile and caught and nextFile:find("://", 0, false) then
caught = false
mp.enable_messages("info")
mp.register_event("log-message", listener)
local ytFormat = mp.get_property("ytdl-format")
fVideo = string.match(ytFormat, '(.+)%+.+//?') or 'bestvideo'
fAudio = string.match(ytFormat, '.+%+(.+)//?') or 'bestaudio'
-- print("start"..nextFile)
listenID = tostring(os.time())
local args = { ytdl, "--dump-single-json", "--no-simulate", "--skip-download",
"--restrict-filenames",
"--no-playlist", "--sub-lang", "en", "--write-sub", "--no-part", "-o",
cachePath .. "/" .. listenID .. "-%(title)s-%(id)s.%(ext)s", nextFile }
args = addOPTS(args)
-- print(dump(args))
table.insert(filesToDelete, listenID)
JsonDownloadHandle = mp.command_native_async({
name = "subprocess",
args = args,
capture_stdout = true,
capture_stderr = true,
playback_only = false
}, function(...)
download_files(listenID, ...)
end)
end
end
end
local function clearCache()
-- print(pop)
--if pop == true then
mp.abort_async_command(AudioDownloadHandle)
mp.abort_async_command(VideoDownloadHandle)
mp.abort_async_command(JsonDownloadHandle)
-- for k, v in pairs(filesToDelete) do
-- print("remove: " .. v)
-- os.remove(v)
-- end
local ftd = io.open(cachePath .. "/temp.files", "a")
for k, v in pairs(filesToDelete) do
ftd:write(v .. "\n")
if package.config:sub(1, 1) ~= '/' then
os.execute('del /Q /F "' .. cachePath .. "\\" .. v .. '*"')
else
os.execute('rm -f ' .. cachePath .. "/" .. v .. "*")
end
end
ftd:close()
print('clear')
mp.command("quit")
--end
end
mp.add_hook("on_unload", 50, function()
-- mp.abort_async_command(AudioDownloadHandle)
-- mp.abort_async_command(VideoDownloadHandle)
mp.abort_async_command(JsonDownloadHandle)
mp.unregister_event(listener)
caught = true
listenID = "resetYtdlPreloadListener"
-- print(listenID)
end)
local skipInitial
mp.observe_property("playlist-count", "number", function()
if skipInitial then
DL()
else
skipInitial = true
end
end)
--from ytdl_hook
local platform_is_windows = (package.config:sub(1, 1) == "\\")
local o = {
exclude = "",
try_ytdl_first = false,
use_manifests = false,
all_formats = false,
force_all_formats = true,
ytdl_path = "",
}
local paths_to_search = { "yt-dlp", "yt-dlp_x86", "youtube-dl" }
--local options = require 'mp.options'
options.read_options(o, "ytdl_hook")
local separator = platform_is_windows and ";" or ":"
if o.ytdl_path:match("[^" .. separator .. "]") then
paths_to_search = {}
for path in o.ytdl_path:gmatch("[^" .. separator .. "]+") do
table.insert(paths_to_search, path)
end
end
local function exec(args)
local ret = mp.command_native({
name = "subprocess",
args = args,
capture_stdout = true,
capture_stderr = true
})
return ret.status, ret.stdout, ret, ret.killed_by_us
end
local msg = require 'mp.msg'
local command = {}
for _, path in pairs(paths_to_search) do
-- search for youtube-dl in mpv's config dir
local exesuf = platform_is_windows and ".exe" or ""
local ytdl_cmd = mp.find_config_file(path .. exesuf)
if ytdl_cmd then
msg.verbose("Found youtube-dl at: " .. ytdl_cmd)
ytdl = ytdl_cmd
break
else
msg.verbose("No youtube-dl found with path " .. path .. exesuf .. " in config directories")
--search in PATH
command[1] = path
es, json, result, aborted = exec(command)
if result.error_string == "init" then
msg.verbose("youtube-dl with path " .. path .. exesuf .. " not found in PATH or not enough permissions")
else
msg.verbose("Found youtube-dl with path " .. path .. exesuf .. " in PATH")
ytdl = path
break
end
end
end
--end ytdl_hook
mp.register_event("start-file", DL)
mp.register_event("shutdown", clearCache)
local ftd = io.open(cachePath .. "/temp.files", "r")
while ftd ~= nil do
local line = ftd:read()
if line == nil or line == "" then
ftd:close()
io.open(cachePath .. "/temp.files", "w"):close()
break
end
-- print("DEL::"..line)
if package.config:sub(1, 1) ~= '/' then
os.execute('del /Q /F "' .. cachePath .. "\\" .. line .. '*" >nul 2>nul')
else
os.execute('rm -f ' .. cachePath .. "/" .. line .. "* &> /dev/null")
end
end

View File

@@ -1,405 +0,0 @@
----------------------
-- #example ytdl_preload.conf
-- # make sure lines do not have trailing whitespace
-- # ytdl_opt has no sanity check and should be formatted exactly how it would appear in yt-dlp CLI, they are split into a key/value pair on whitespace
-- # at least on Windows, do not escape '\' in temp, just us a single one for each divider
-- #temp=R:\ytdltest
-- #ytdl_opt1=-r 50k
-- #ytdl_opt2=-N 5
-- #ytdl_opt#=etc
----------------------
local nextIndex
local caught = true
-- local pop = false
local ytdl = "yt-dlp"
local utils = require 'mp.utils'
local options = require 'mp.options'
local opts = {
temp = "/tmp/ytdl-preload",
ytdl_opt1 = "",
ytdl_opt2 = "",
ytdl_opt3 = "",
ytdl_opt4 = "",
ytdl_opt5 = "",
ytdl_opt6 = "",
ytdl_opt7 = "",
ytdl_opt8 = "",
ytdl_opt9 = "",
}
options.read_options(opts, "ytdl_preload")
local additionalOpts = {}
for k, v in pairs(opts) do
if k:find("ytdl_opt%d") and v ~= "" then
additionalOpts[k] = v
-- print("entry")
-- print(k .. v)
end
end
local cachePath = opts.temp
local chapter_list = {}
local json = ""
local filesToDelete = {}
local function exists(file)
local ok, err, code = os.rename(file, file)
if not ok then
if code == 13 then -- Permission denied, but it exists
return true
end
end
return ok, err
end
local function useNewLoadfile()
for _, c in pairs(mp.get_property_native("command-list")) do
if c["name"] == "loadfile" then
for _, a in pairs(c["args"]) do
if a["name"] == "index" then
return true
end
end
end
end
end
--from ytdl_hook
local function time_to_secs(time_string)
local ret
local a, b, c = time_string:match("(%d+):(%d%d?):(%d%d)")
if a ~= nil then
ret = (a * 3600 + b * 60 + c)
else
a, b = time_string:match("(%d%d?):(%d%d)")
if a ~= nil then
ret = (a * 60 + b)
end
end
return ret
end
local function extract_chapters(data, video_length)
local ret = {}
for line in data:gmatch("[^\r\n]+") do
local time = time_to_secs(line)
if time and (time < video_length) then
table.insert(ret, { time = time, title = line })
end
end
table.sort(ret, function(a, b) return a.time < b.time end)
return ret
end
local function chapters()
if json.chapters then
for i = 1, #json.chapters do
local chapter = json.chapters[i]
local title = chapter.title or ""
if title == "" then
title = string.format('Chapter %02d', i)
end
table.insert(chapter_list, { time = chapter.start_time, title = title })
end
elseif not (json.description == nil) and not (json.duration == nil) then
chapter_list = extract_chapters(json.description, json.duration)
end
end
--end ytdl_hook
local title = ""
local fVideo = ""
local fAudio = ""
local function load_files(dtitle, destination, audio, wait)
if wait then
if exists(destination .. ".mka") then
print("---wait success: found mka---")
audio = "audio-file=" .. destination .. '.mka,'
else
print("---could not find mka after wait, audio may be missing---")
end
end
-- if audio ~= "" then
-- table.insert(filesToDelete, destination .. ".mka")
-- end
-- table.insert(filesToDelete, destination .. ".mkv")
dtitle = dtitle:gsub("-" .. ("[%w_-]"):rep(11) .. "$", "")
dtitle = dtitle:gsub("^" .. ("%d"):rep(10) .. "%-", "")
if useNewLoadfile() then
mp.commandv("loadfile", destination .. ".mkv", "append", -1,
audio .. 'force-media-title="' .. dtitle .. '",demuxer-max-back-bytes=1MiB,demuxer-max-bytes=3MiB,ytdl=no')
else
mp.commandv("loadfile", destination .. ".mkv", "append",
audio .. 'force-media-title="' .. dtitle .. '",demuxer-max-back-bytes=1MiB,demuxer-max-bytes=3MiB,ytdl=no') --,sub-file="..destination..".en.vtt") --in case they are not set up to autoload
end
mp.commandv("playlist_move", mp.get_property("playlist-count") - 1, nextIndex)
mp.commandv("playlist_remove", nextIndex + 1)
caught = true
title = ""
-- pop = true
end
local listenID = ""
local function listener(event)
if not caught and event.prefix == mp.get_script_name() and string.find(event.text, listenID) then
local destination = string.match(event.text, "%[download%] Destination: (.+).mkv") or
string.match(event.text, "%[download%] (.+).mkv has already been downloaded")
-- if destination then print("---"..cachePath) end;
if destination and string.find(destination, string.gsub(cachePath, '~/', '')) then
-- print(listenID)
mp.unregister_event(listener)
_, title = utils.split_path(destination)
local audio = ""
if fAudio == "" then
load_files(title, destination, audio, false)
else
if exists(destination .. ".mka") then
audio = "audio-file=" .. destination .. '.mka,'
load_files(title, destination, audio, false)
else
print("---expected mka but could not find it, waiting for 2 seconds---")
mp.add_timeout(2, function()
load_files(title, destination, audio, true)
end)
end
end
end
end
end
--from ytdl_hook
mp.add_hook("on_preloaded", 10, function()
if string.find(mp.get_property("path"), cachePath) then
chapters()
if next(chapter_list) ~= nil then
mp.set_property_native("chapter-list", chapter_list)
chapter_list = {}
json = ""
end
end
end)
--end ytdl_hook
function dump(o)
if type(o) == 'table' then
local s = '{ '
for k, v in pairs(o) do
if type(k) ~= 'number' then k = '"' .. k .. '"' end
s = s .. '[' .. k .. '] = ' .. dump(v) .. ','
end
return s .. '} '
else
return tostring(o)
end
end
local function addOPTS(old)
for k, v in pairs(additionalOpts) do
-- print(k)
if string.find(v, "%s") then
for l, w in string.gmatch(v, "([-%w]+) (.+)") do
table.insert(old, l)
table.insert(old, w)
end
else
table.insert(old, v)
end
end
-- print(dump(old))
return old
end
local AudioDownloadHandle = {}
local VideoDownloadHandle = {}
local JsonDownloadHandle = {}
local function download_files(id, success, result, error)
if result.killed_by_us then
return
end
local jfile = cachePath .. "/" .. id .. ".json"
local jfileIO = io.open(jfile, "w")
jfileIO:write(result.stdout)
jfileIO:close()
json = utils.parse_json(result.stdout)
-- print(dump(json))
if json.requested_downloads[1].requested_formats ~= nil then
local args = { ytdl, "--no-continue", "-q", "-f", fAudio, "--restrict-filenames", "--no-playlist", "--no-part",
"-o", cachePath .. "/" .. id .. "-%(title)s-%(id)s.mka", "--load-info-json", jfile }
args = addOPTS(args)
AudioDownloadHandle = mp.command_native_async({
name = "subprocess",
args = args,
playback_only = false
}, function()
end)
else
fAudio = ""
fVideo = fVideo:gsub("bestvideo", "best")
fVideo = fVideo:gsub("bv", "best")
end
local args = { ytdl, "--no-continue", "-f", fVideo .. '/best', "--restrict-filenames", "--no-playlist",
"--no-part", "-o", cachePath .. "/" .. id .. "-%(title)s-%(id)s.mkv", "--load-info-json", jfile }
args = addOPTS(args)
VideoDownloadHandle = mp.command_native_async({
name = "subprocess",
args = args,
playback_only = false
}, function()
end)
end
local function DL()
local index = tonumber(mp.get_property("playlist-pos"))
if mp.get_property("playlist/" .. index .. "/filename"):find("/videos$") and mp.get_property("playlist/" .. index + 1 .. "/filename"):find("/shorts$") then
return
end
if tonumber(mp.get_property("playlist-pos-1")) > 0 and mp.get_property("playlist-pos-1") ~= mp.get_property("playlist-count") then
nextIndex = index + 1
local nextFile = mp.get_property("playlist/" .. nextIndex .. "/filename")
if nextFile and caught and nextFile:find("://", 0, false) then
caught = false
mp.enable_messages("info")
mp.register_event("log-message", listener)
local ytFormat = mp.get_property("ytdl-format")
fVideo = string.match(ytFormat, '(.+)%+.+//?') or 'bestvideo'
fAudio = string.match(ytFormat, '.+%+(.+)//?') or 'bestaudio'
-- print("start"..nextFile)
listenID = tostring(os.time())
local args = { ytdl, "--dump-single-json", "--no-simulate", "--skip-download",
"--restrict-filenames",
"--no-playlist", "--sub-lang", "en", "--write-sub", "--no-part", "-o",
cachePath .. "/" .. listenID .. "-%(title)s-%(id)s.%(ext)s", nextFile }
args = addOPTS(args)
-- print(dump(args))
table.insert(filesToDelete, listenID)
JsonDownloadHandle = mp.command_native_async({
name = "subprocess",
args = args,
capture_stdout = true,
capture_stderr = true,
playback_only = false
}, function(...)
download_files(listenID, ...)
end)
end
end
end
local function clearCache()
-- print(pop)
--if pop == true then
mp.abort_async_command(AudioDownloadHandle)
mp.abort_async_command(VideoDownloadHandle)
mp.abort_async_command(JsonDownloadHandle)
-- for k, v in pairs(filesToDelete) do
-- print("remove: " .. v)
-- os.remove(v)
-- end
local ftd = io.open(cachePath .. "/temp.files", "a")
for k, v in pairs(filesToDelete) do
ftd:write(v .. "\n")
if package.config:sub(1, 1) ~= '/' then
os.execute('del /Q /F "' .. cachePath .. "\\" .. v .. '*"')
else
os.execute('rm -f ' .. cachePath .. "/" .. v .. "*")
end
end
ftd:close()
print('clear')
mp.command("quit")
--end
end
mp.add_hook("on_unload", 50, function()
-- mp.abort_async_command(AudioDownloadHandle)
-- mp.abort_async_command(VideoDownloadHandle)
mp.abort_async_command(JsonDownloadHandle)
mp.unregister_event(listener)
caught = true
listenID = "resetYtdlPreloadListener"
-- print(listenID)
end)
local skipInitial
mp.observe_property("playlist-count", "number", function()
if skipInitial then
DL()
else
skipInitial = true
end
end)
--from ytdl_hook
local platform_is_windows = (package.config:sub(1, 1) == "\\")
local o = {
exclude = "",
try_ytdl_first = false,
use_manifests = false,
all_formats = false,
force_all_formats = true,
ytdl_path = "",
}
local paths_to_search = { "yt-dlp", "yt-dlp_x86", "youtube-dl" }
--local options = require 'mp.options'
options.read_options(o, "ytdl_hook")
local separator = platform_is_windows and ";" or ":"
if o.ytdl_path:match("[^" .. separator .. "]") then
paths_to_search = {}
for path in o.ytdl_path:gmatch("[^" .. separator .. "]+") do
table.insert(paths_to_search, path)
end
end
local function exec(args)
local ret = mp.command_native({
name = "subprocess",
args = args,
capture_stdout = true,
capture_stderr = true
})
return ret.status, ret.stdout, ret, ret.killed_by_us
end
local msg = require 'mp.msg'
local command = {}
for _, path in pairs(paths_to_search) do
-- search for youtube-dl in mpv's config dir
local exesuf = platform_is_windows and ".exe" or ""
local ytdl_cmd = mp.find_config_file(path .. exesuf)
if ytdl_cmd then
msg.verbose("Found youtube-dl at: " .. ytdl_cmd)
ytdl = ytdl_cmd
break
else
msg.verbose("No youtube-dl found with path " .. path .. exesuf .. " in config directories")
--search in PATH
command[1] = path
es, json, result, aborted = exec(command)
if result.error_string == "init" then
msg.verbose("youtube-dl with path " .. path .. exesuf .. " not found in PATH or not enough permissions")
else
msg.verbose("Found youtube-dl with path " .. path .. exesuf .. " in PATH")
ytdl = path
break
end
end
end
--end ytdl_hook
mp.register_event("start-file", DL)
mp.register_event("shutdown", clearCache)
local ftd = io.open(cachePath .. "/temp.files", "r")
while ftd ~= nil do
local line = ftd:read()
if line == nil or line == "" then
ftd:close()
io.open(cachePath .. "/temp.files", "w"):close()
break
end
-- print("DEL::"..line)
if package.config:sub(1, 1) ~= '/' then
os.execute('del /Q /F "' .. cachePath .. "\\" .. line .. '*" >nul 2>nul')
else
os.execute('rm -f ' .. cachePath .. "/" .. line .. "* &> /dev/null")
end
end

View File

@@ -1,126 +0,0 @@
{
"keybindings": [],
"auto_start_overlay": false,
"texthooker": {
"openBrowser": false,
},
"websocket": {
"enabled": "auto",
"port": 6677,
},
"ankiConnect": {
"enabled": true,
"url": "http://127.0.0.1:8765",
"pollingRate": 500,
"fields": {
"audio": "ExpressionAudio",
"image": "Picture",
"sentence": "Sentence",
"miscInfo": "MiscInfo",
"translation": "SelectionText",
},
"openRouter": {
"enabled": true,
"alwaysUseAiTranslation": true,
"apiKey": "",
"model": "openai/gpt-oss-120b:free",
"baseUrl": "https://openrouter.ai/api/v1",
"sourceLanguage": "Japanese",
"systemPrompt": "You are a translation engine for translating Japanese into natural-sounding, context-aware English. Return only the translated text with no extra explanations or commentary. The translation must preserve the original tone and intent of the source. If the input is not in the target language, translate it to the target language. If the input is already in the target language, return it as is.",
},
"media": {
"generateAudio": true,
"generateImage": true,
"imageType": "avif",
"imageFormat": "webp",
"animatedFps": 24,
"animatedMaxWidth": 640,
"animatedMaxHeight": null,
"animatedCrf": 35,
"audioPadding": 0.5,
"fallbackDuration": 3,
},
"behavior": {
"overwriteAudio": false,
"overwriteImage": true,
"mediaInsertMode": "append",
"highlightWord": true,
"notificationType": "system",
"showNotificationOnUpdate": true,
"autoUpdateNewCards": false,
},
"metadata": {
"pattern": "[SubMiner] %f (%t)",
},
"isLapis": {
"enabled": true,
"sentenceCardModel": "Lapis Morph",
"sentenceCardSentenceField": "Sentence",
"sentenceCardAudioField": "SentenceAudio",
},
"isKiku": {
"enabled": true,
"fieldGrouping": "manual",
"deleteDuplicateInAuto": true,
},
},
"subtitles": {
"primarySubLanguages": ["ja", "jpn"],
"secondarySubLanguages": ["en", "eng"],
"autoLoadSecondarySub": true,
"defaultMode": "hover",
"style": {
"fontFamily": "Noto Sans CJK JP Regular, Noto Sans CJK JP, Arial Unicode MS, Arial, sans-serif",
"fontSize": 35,
"fontColor": "#cad3f5",
"fontWeight": "normal",
"fontStyle": "normal",
"backgroundColor": "rgba(54, 58, 79, 0.69)",
"secondary": {
"fontSize": 24,
"fontColor": "#ffffff",
"backgroundColor": "transparent",
},
},
},
"subsync": {
"defaultMode": "manual",
"alass_path": "/Users/sudacode/.local/bin/alass-cli",
"ffsubsync_path": "/Users/sudacode/.local/bin/ffsubsync",
"ffmpeg_path": "/opt/homebrew/bin/ffmpeg",
},
"subtitleStyle": {
"fontFamily": "Noto Sans CJK JP Regular, Noto Sans CJK JP, Arial Unicode MS, Arial, sans-serif",
"fontSize": 24,
"fontColor": "#cad3f5",
"fontWeight": "normal",
"fontStyle": "normal",
"backgroundColor": "rgb(30, 32, 48, 0.88)",
"secondary": {
"fontSize": 24,
"fontColor": "#cad3f5",
"backgroundColor": "transparent",
},
},
"jimaku": {
// "apiKey": "YOUR_API_KEY",
// or use a command that outputs the key:
"apiKeyCommand": "cat ~/.jimaku-api-key",
"apiBaseUrl": "https://jimaku.cc",
"languagePreference": "ja",
"maxEntryResults": 10,
},
"shortcuts": {
"copySubtitle": "CommandOrControl+C",
"copySubtitleMultiple": "CommandOrControl+Shift+C",
"updateLastCardFromClipboard": "CommandOrControl+V",
"triggerFieldGrouping": "CommandOrControl+G",
"triggerSubsync": "CommandOrControl+Alt+S",
"mineSentence": "CommandOrControl+S",
"mineSentenceMultiple": "CommandOrControl+Shift+S",
"multiCopyTimeoutMs": 3000,
"toggleSecondarySub": "CommandOrControl+Shift+V",
"markAudioCard": "CommandOrControl+Shift+A",
"openRuntimeOptions": "CommandOrControl+Shift+O",
},
}

View File

@@ -17,6 +17,7 @@ export XDG_CONFIG_HOME=$HOME/.config
export COMPOSE_BAKE=true
export ANKI_WAYLAND=1
export SUDO_PROMPT=$'\a[sudo] password for %u: '
export ELECTRON_OZONE_PLATFORM_HINT=x11
# nvidia
export NVD_BACKEND=direct

View File

@@ -14,7 +14,7 @@
"custom/mpd-scroll",
"custom/mpv-scroll",
"custom/firefox-scroll",
"hyprland/submap"
"hyprland/submap",
],
// "modules-center": ["hyprland/window"],
"modules-center": ["custom/notification"],
@@ -32,7 +32,7 @@
"network",
"pulseaudio",
"clock",
"custom/weather",
"custom/weather"
],
"hyprland/workspaces": {

View File

@@ -10,6 +10,31 @@ source ~/.environment
source <(fzf --zsh)
fzf-file-widget-smart-root() {
setopt localoptions pipefail no_aliases 2>/dev/null
local -a words
local last raw root sel
words=(${(z)LBUFFER})
last=${words[-1]}
raw=${(Q)last} # unquote shell word
root=${~raw} # expand ~
if [[ -n "$raw" && "$raw" == */ && -d "$root" ]]; then
sel="$(cd -- "$root" && __fzf_select)" || return
LBUFFER+="$sel"
else
LBUFFER+="$(__fzf_select)"
fi
zle reset-prompt
}
zle -N fzf-file-widget-smart-root
bindkey -M emacs '^T' fzf-file-widget-smart-root
bindkey -M vicmd '^T' fzf-file-widget-smart-root
bindkey -M viins '^T' fzf-file-widget-smart-root
eval $(thefuck --alias)
HISTFILE=~/.zsh_history

View File

@@ -1,15 +1,15 @@
#!/usr/bin/env bash
THEME="${THEME:-/opt/mpv-yomitan/catppuccin-macchiato.rasi}"
THEME="${THEME:-$HOME/.local/share/SubMiner/themes/subminer.rasi}"
FONTCONFIG_FILE=$HOME/.config/mpv/mpv-fonts.conf
COMMAND=mpv
VIDEO_EXTENSIONS="mkv|mp4|avi|webm|mov|flv|wmv|m4v|ts|m2ts"
# Parse command-line options first
while getopts ":it:" opt; do
while getopts ":st:" opt; do
case $opt in
i)
COMMAND="$COMMAND --profile=immersion"
s)
COMMAND="$COMMAND --profile=subminer"
;;
t)
THEME="$OPTARG"

View File

@@ -1,28 +1,37 @@
#!/usr/bin/env python3
"""Record microphone audio and transcribe it with whisper.cpp."""
"""Record microphone audio and transcribe it with whisper.cpp or faster-whisper."""
from __future__ import annotations
import argparse
import os
import re
import shutil
import signal
import subprocess
import sys
import tempfile
import threading
import time
import traceback
import wave
from pathlib import Path
import numpy as np
import sounddevice as sd
DEFAULT_MODEL = "~/models/whisper.cpp/ggml-small.bin"
DEFAULT_MODEL = "small"
DEFAULT_DURATION = 8.0
DEFAULT_STATE_DIR = Path.home() / ".cache" / "whisper-record-toggle"
DEFAULT_WHISPERCPP_MODEL_DIR = Path.home() / "models" / "whisper.cpp"
APP_NAME = "Whisper Record"
DEFAULT_TOGGLE_DEBOUNCE = 0.0
def _append_log(state_dir: Path, message: str) -> None:
state_dir.mkdir(parents=True, exist_ok=True)
log_file = state_dir / "worker.log"
timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
with log_file.open("a", encoding="utf-8") as fh:
fh.write(f"[{timestamp}] {message}\n")
class Notifier:
@@ -120,20 +129,6 @@ def _format_seconds(value: float) -> str:
return f"{minutes:02d}:{seconds:02d}"
def find_whisper_binary(explicit: str | None) -> str:
if explicit:
return explicit
for candidate in ("whisper-cli", "main", "whisper"):
path = shutil.which(candidate)
if path:
return path
raise RuntimeError(
"Could not find whisper.cpp binary. Pass --whisper-bin /path/to/whisper-cli"
)
def write_wav(path: Path, audio: np.ndarray, samplerate: int, channels: int) -> None:
with wave.open(str(path), "wb") as wav_file:
wav_file.setnchannels(channels)
@@ -142,72 +137,164 @@ def write_wav(path: Path, audio: np.ndarray, samplerate: int, channels: int) ->
wav_file.writeframes(audio.tobytes())
def transcribe(whisper_bin: str, model: str, wav_path: Path, notifier: Notifier) -> str:
with tempfile.TemporaryDirectory(prefix="whisper-out-") as out_dir:
out_base = Path(out_dir) / "transcript"
def transcribe(
backend: str,
model_name_or_path: str,
wav_path: Path,
notifier: Notifier,
device: str,
compute_type: str,
beam_size: int,
) -> str:
if backend == "whispercpp":
return transcribe_whispercpp(
model_name_or_path=model_name_or_path,
wav_path=wav_path,
notifier=notifier,
device=device,
beam_size=beam_size,
)
if backend == "ctranslate2":
return transcribe_ctranslate2(
model_name_or_path=model_name_or_path,
wav_path=wav_path,
notifier=notifier,
device=device,
compute_type=compute_type,
beam_size=beam_size,
)
raise RuntimeError(f"Unsupported backend: {backend}")
def _resolve_whispercpp_model(model_name_or_path: str) -> Path:
candidate = Path(model_name_or_path).expanduser()
if candidate.exists():
return candidate
name = model_name_or_path.strip()
search_paths = [
DEFAULT_WHISPERCPP_MODEL_DIR / name,
DEFAULT_WHISPERCPP_MODEL_DIR / f"ggml-{name}.bin",
DEFAULT_WHISPERCPP_MODEL_DIR / f"ggml-{name}.en.bin",
]
for path in search_paths:
if path.exists():
return path
raise RuntimeError(
"whisper.cpp model not found. Pass --model as a .bin path or place model at "
f"{DEFAULT_WHISPERCPP_MODEL_DIR}/ggml-<name>.bin (for example ggml-small.bin)."
)
def transcribe_whispercpp(
model_name_or_path: str,
wav_path: Path,
notifier: Notifier,
device: str,
beam_size: int,
) -> str:
whisper_cli = shutil.which("whisper-cli")
if not whisper_cli:
raise RuntimeError("whisper-cli not found in PATH. Install whisper.cpp.")
model_path = _resolve_whispercpp_model(model_name_or_path)
output_prefix = wav_path.parent / wav_path.stem
output_txt = Path(f"{output_prefix}.txt")
if output_txt.exists():
output_txt.unlink()
notifier.send("Transcribing", "Running whisper.cpp...", timeout_ms=1500)
cmd = [
whisper_bin,
"-m",
model,
whisper_cli,
"-f",
str(wav_path),
"-m",
str(model_path),
"-otxt",
"-of",
str(out_base),
"-nt",
str(output_prefix),
"-bs",
str(beam_size),
"-np",
]
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
if device == "cpu":
cmd.append("-ng")
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
details = (result.stderr or result.stdout or "").strip()
raise RuntimeError(details or "whisper.cpp failed.")
if not output_txt.exists():
details = (result.stderr or result.stdout or "").strip()
raise RuntimeError(
"whisper.cpp completed but no transcript file was produced. "
f"Expected: {output_txt}. {details}"
)
output_lines: list[str] = []
progress: dict[str, int | None] = {"pct": None}
return output_txt.read_text(encoding="utf-8").strip()
def _reader() -> None:
assert process.stdout is not None
for line in process.stdout:
output_lines.append(line)
match = re.search(r"(?<!\d)(\d{1,3})%", line)
if match:
progress["pct"] = min(100, int(match.group(1)))
reader = threading.Thread(target=_reader, daemon=True)
reader.start()
spinner = "|/-\\"
frame = 0
while process.poll() is None:
pct = progress["pct"]
status = (
f"Transcribing... {pct}%"
if pct is not None
else f"Transcribing... {spinner[frame % len(spinner)]}"
def transcribe_ctranslate2(
model_name_or_path: str,
wav_path: Path,
notifier: Notifier,
device: str,
compute_type: str,
beam_size: int,
) -> str:
whisper_cli = shutil.which("whisper-ctranslate2")
if not whisper_cli:
raise RuntimeError(
"whisper-ctranslate2 not found in PATH. Install with: pip install faster-whisper"
)
notifier.send("Transcribing", status, timeout_ms=1200)
print(f"\r{status}", end="", file=sys.stderr, flush=True)
frame += 1
time.sleep(0.35)
reader.join(timeout=1.0)
print("\r" + (" " * 48) + "\r", end="", file=sys.stderr, flush=True)
result_stdout = "".join(output_lines).strip()
if model_name_or_path.endswith(".bin"):
raise RuntimeError(
"faster-whisper/ctranslate2 does not use ggml .bin models. "
"Use a model name like 'small' or a CTranslate2 model directory."
)
if process.returncode != 0:
stderr = result_stdout
raise RuntimeError(f"whisper.cpp failed: {stderr}")
notifier.send("Transcribing", "Running whisper-ctranslate2...", timeout_ms=1500)
output_dir = wav_path.parent
output_txt = output_dir / f"{wav_path.stem}.txt"
if output_txt.exists():
output_txt.unlink()
txt_file = out_base.with_suffix(".txt")
if txt_file.exists():
return txt_file.read_text(encoding="utf-8").strip()
cmd = [
whisper_cli,
str(wav_path),
"--output_dir",
str(output_dir),
"--output_format",
"txt",
"--device",
device,
"--compute_type",
compute_type,
"--beam_size",
str(beam_size),
"--verbose",
"False",
]
model_dir_candidate = Path(model_name_or_path).expanduser()
if model_dir_candidate.exists() and model_dir_candidate.is_dir():
cmd.extend(["--model_directory", str(model_dir_candidate)])
elif "/" in model_name_or_path or model_name_or_path.startswith("."):
cmd.extend(["--model_directory", model_name_or_path])
else:
cmd.extend(["--model", model_name_or_path])
fallback = result_stdout
if fallback:
return fallback
raise RuntimeError("Transcription finished but no output text was produced.")
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
details = (result.stderr or result.stdout or "").strip()
raise RuntimeError(details or "whisper-ctranslate2 failed.")
if not output_txt.exists():
details = (result.stderr or result.stdout or "").strip()
raise RuntimeError(
"whisper-ctranslate2 completed but no transcript file was produced. "
f"Expected: {output_txt}. {details}"
)
return output_txt.read_text(encoding="utf-8").strip()
def _type_with_tool(text: str) -> None:
@@ -261,13 +348,26 @@ def _is_alive(pid: int | None) -> bool:
return True
def _read_and_clear_error(error_file: Path) -> str | None:
if not error_file.exists():
return None
message = error_file.read_text(encoding="utf-8").strip()
error_file.unlink()
return message or "Worker failed."
def _run_transcription_job(args: argparse.Namespace, duration: float | None) -> str:
notifier = Notifier()
model_path = Path(args.model).expanduser()
model_name_or_path = args.model
if (
"/" in model_name_or_path
or model_name_or_path.startswith(".")
or model_name_or_path.startswith("~")
):
model_path = Path(model_name_or_path).expanduser()
if not model_path.exists():
raise RuntimeError(f"Model file not found: {model_path}")
whisper_bin = find_whisper_binary(args.whisper_bin)
raise RuntimeError(f"Model path not found: {model_path}")
model_name_or_path = str(model_path)
notifier.send("Recording", "Starting...", timeout_ms=1200)
recorder = Recorder(samplerate=args.samplerate, channels=args.channels)
@@ -289,8 +389,20 @@ def _run_transcription_job(args: argparse.Namespace, duration: float | None) ->
with tempfile.TemporaryDirectory(prefix="whisper-audio-") as tmp_dir:
wav_path = Path(tmp_dir) / "input.wav"
write_wav(wav_path, audio, args.samplerate, args.channels)
notifier.send("Transcribing", "Running whisper.cpp...", timeout_ms=1500)
text = transcribe(whisper_bin, str(model_path), wav_path, notifier)
notifier.send(
"Transcribing",
f"Running backend: {args.backend}",
timeout_ms=1500,
)
text = transcribe(
backend=args.backend,
model_name_or_path=model_name_or_path,
wav_path=wav_path,
notifier=notifier,
device=args.device,
compute_type=args.compute_type,
beam_size=args.beam_size,
)
return text.strip()
@@ -324,12 +436,21 @@ def run_worker(args: argparse.Namespace) -> int:
transcript_file.unlink()
if error_file.exists():
error_file.unlink()
_append_log(
state_dir,
f"worker start model={args.model} device={args.device} compute_type={args.compute_type}",
)
try:
text = _run_transcription_job(args, duration=None)
transcript_file.write_text(text, encoding="utf-8")
_append_log(state_dir, f"worker complete transcript_chars={len(text)}")
except Exception as exc:
details = "".join(
traceback.format_exception(type(exc), exc, exc.__traceback__)
).strip()
error_file.write_text(str(exc), encoding="utf-8")
_append_log(state_dir, f"worker error: {details}")
return 1
finally:
if pid_file.exists():
@@ -354,6 +475,8 @@ def start_background(args: argparse.Namespace) -> int:
"--mode",
"once",
"--worker",
"--backend",
args.backend,
"--model",
args.model,
"--samplerate",
@@ -364,16 +487,32 @@ def start_background(args: argparse.Namespace) -> int:
str(args.notify_interval),
"--state-dir",
str(state_dir),
"--device",
args.device,
"--compute-type",
args.compute_type,
"--beam-size",
str(args.beam_size),
]
if args.whisper_bin:
cmd.extend(["--whisper-bin", args.whisper_bin])
log_path = state_dir / "worker.log"
with log_path.open("a", encoding="utf-8") as log_fh:
subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdout=log_fh,
stderr=log_fh,
start_new_session=True,
)
_append_log(state_dir, "start requested")
# If worker fails immediately (common with model/device config issues),
# surface that early instead of only showing "No active recording" later.
time.sleep(0.15)
worker_error = _read_and_clear_error(state_dir / "error.txt")
if worker_error:
print(worker_error, file=sys.stderr)
Notifier().send("Transcription error", worker_error, timeout_ms=3000)
return 1
Notifier().send(
"Recording", "Started (press keybind again to stop)", timeout_ms=1200
)
@@ -389,9 +528,14 @@ def stop_background(args: argparse.Namespace) -> int:
pid = _read_pid(pid_file)
if not _is_alive(pid):
worker_error = _read_and_clear_error(error_file)
if worker_error:
print(worker_error, file=sys.stderr)
Notifier().send("Transcription error", worker_error, timeout_ms=3000)
return 1
if pid_file.exists():
pid_file.unlink()
print("No active recording.")
print(f"No active recording. Check log: {state_dir / 'worker.log'}")
return 1
assert pid is not None
@@ -404,12 +548,13 @@ def stop_background(args: argparse.Namespace) -> int:
if _is_alive(pid):
print("Timed out waiting for transcription to finish.", file=sys.stderr)
_append_log(state_dir, "stop timeout waiting for worker exit")
return 1
if error_file.exists():
message = error_file.read_text(encoding="utf-8").strip()
error_file.unlink()
print(message or "Worker failed.", file=sys.stderr)
worker_error = _read_and_clear_error(error_file)
if worker_error:
print(worker_error, file=sys.stderr)
Notifier().send("Transcription error", worker_error, timeout_ms=3000)
return 1
text = ""
@@ -427,7 +572,7 @@ def stop_background(args: argparse.Namespace) -> int:
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Record from microphone and transcribe with whisper.cpp"
description="Record from microphone and transcribe with whisper.cpp or faster-whisper"
)
parser.add_argument(
"--mode",
@@ -435,18 +580,24 @@ def parse_args() -> argparse.Namespace:
default="once",
help="once: record/transcribe immediately, start/stop: background toggle pieces, toggle: start if idle else stop",
)
parser.add_argument("--start", action="store_true", help=argparse.SUPPRESS)
parser.add_argument("--stop", action="store_true", help=argparse.SUPPRESS)
parser.add_argument("--toggle", action="store_true", help=argparse.SUPPRESS)
parser.add_argument(
"--worker",
action="store_true",
help=argparse.SUPPRESS,
)
parser.add_argument(
"--model", default=DEFAULT_MODEL, help="Path to whisper.cpp model"
"--backend",
choices=("whispercpp", "ctranslate2"),
default="whispercpp",
help="Transcription backend (default: whispercpp)",
)
parser.add_argument(
"--whisper-bin",
default=None,
help="Path to whisper.cpp binary (default: auto-detect whisper-cli/main)",
"--model",
default=DEFAULT_MODEL,
help="Model name or path. For whispercpp: ggml .bin path/name. For ctranslate2: model name or model directory.",
)
parser.add_argument(
"--duration",
@@ -475,6 +626,22 @@ def parse_args() -> argparse.Namespace:
default="print",
help="How to emit transcript text: print to terminal or type into active window",
)
parser.add_argument(
"--device",
default="auto",
help="Inference device for faster-whisper (auto, cpu, cuda)",
)
parser.add_argument(
"--compute-type",
default="auto",
help="faster-whisper compute type (auto, default, float16, int8, int8_float16, ...)",
)
parser.add_argument(
"--beam-size",
type=int,
default=5,
help="Beam size for decoding (default: 5)",
)
parser.add_argument(
"--state-dir",
default=str(DEFAULT_STATE_DIR),
@@ -486,7 +653,27 @@ def parse_args() -> argparse.Namespace:
default=90.0,
help="Max seconds to wait for background transcription to finish on stop",
)
return parser.parse_args()
parser.add_argument(
"--toggle-debounce",
type=float,
default=DEFAULT_TOGGLE_DEBOUNCE,
help="Ignore repeated toggle triggers within this many seconds (default: 0.0, disabled)",
)
args = parser.parse_args()
legacy_modes = [
mode
for flag, mode in (
(args.start, "start"),
(args.stop, "stop"),
(args.toggle, "toggle"),
)
if flag
]
if len(legacy_modes) > 1:
parser.error("Use only one of --start, --stop, or --toggle.")
if legacy_modes:
args.mode = legacy_modes[0]
return args
def main() -> int:
@@ -505,6 +692,22 @@ def main() -> int:
return stop_background(args)
state_dir = Path(args.state_dir)
state_dir.mkdir(parents=True, exist_ok=True)
debounce_file = state_dir / "last-toggle.txt"
now = time.monotonic()
if args.toggle_debounce > 0 and debounce_file.exists():
try:
last = float(debounce_file.read_text(encoding="utf-8").strip())
except ValueError:
last = 0.0
if now - last < args.toggle_debounce:
_append_log(
state_dir,
f"toggle ignored by debounce: delta={now - last:.3f}s < {args.toggle_debounce:.3f}s",
)
return 0
debounce_file.write_text(f"{now:.6f}", encoding="utf-8")
pid = _read_pid(state_dir / "recording.pid")
if _is_alive(pid):
return stop_background(args)
@@ -512,4 +715,21 @@ def main() -> int:
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as exc:
state_dir = DEFAULT_STATE_DIR
try:
argv = sys.argv[1:]
if "--state-dir" in argv:
idx = argv.index("--state-dir")
if idx + 1 < len(argv):
state_dir = Path(argv[idx + 1]).expanduser()
except Exception:
pass
_append_log(
state_dir,
"fatal exception: "
+ "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)).strip(),
)
raise