Compare commits

...
Author SHA1 Message Date
Yusuf İpek 4d0c0e29f1 Merge pull request #70 from nomoreshow/feat/managed-vulkan-whisper
Ship a managed Vulkan whisper-server for Linux x64
2026-09-05 10:27:20 +03:00
yusufipek 4ae44720c8 Merge master, and let one function pick the archive
master grew _pick_asset() while this branch grew a second answer to the
same question inside install_program(). Two functions deciding which
archive this machine wants is one too many, so the managed build is
asked for at the top of the picker: Dikte's own Vulkan whisper-server
first where the machine is one it is built for, then upstream's newest,
then the nightly pointer, then the newest release that carries a build.

install_program() is back to master's three lines and reads which of the
two landed off the asset name.
2026-09-05 10:06:38 +03:00
Yusuf İpek 74c37c0112 Merge pull request #73 from yusufipk/llama-nightly-and-model-box
Find the llama.cpp nightly builds, and keep the model box honest
2026-09-05 09:51:24 +03:00
yusufipek 156d8bf8e8 Write the release order down where it will be read
Enabling immutable releases, configuring the dependency-release
environment, which order the two dispatch runs go in, and which five
constants have to move together were all in the pull request
description, which is not a place anybody looks a year later.

The same file says what this costs: the pinned digest means a backend
update is a Dikte release, and the build is deterministic between two
runs of one builder rather than across time, because the apt metadata
behind the pinned packages is not pinned and LunarG drops superseded
ones. Both are worth knowing before the next version bump, not during
it.

The README keeps a clause instead of the paragraph it had grown.
2026-09-05 09:46:08 +03:00
yusufipek 5f6e4ad782 Make a version bump possible without editing the workflow first
The digest was a literal in the workflow while the version was an input,
so a dispatch for anything but 1.9.3 built the archive and then failed
its own gate. The digest of a version nobody has reviewed cannot be
known before it is built, which made the inputs unusable for the one job
they exist for.

An empty expected_sha256 now reports the digest of what was built and
refuses to publish; a digest handed in is checked the way the literal
was. The input shapes are also checked before the checkout that uses
them rather than after it.
2026-09-05 09:45:48 +03:00
yusufipek 10ef4e62a9 Smoke-test the bundle the way Dikte starts it
The CPU run passed -ng, which tells whisper.cpp not to look for a
backend at all, so the claim the bundle rests on, that it starts and
transcribes where Vulkan cannot, was never tested. Dikte passes -ng only
when its GPU setting is off; every other run goes through the backend
registry.

Both runs lose the flag, and a third one is added between them: the
loader installed, nothing behind it. That is a real machine, libvulkan
pulled in by something else with no driver to go with it, and it is the
one whose answer nobody knew.
2026-09-05 09:45:19 +03:00
yusufipek 00a5283adb Let a downloaded program be downloaded again
The button disappeared the moment anything landed, and nothing else on
the window asks for that download. So a machine that got the processor
build before its graphics driver was installed can never be given the
Vulkan one, and nobody can pick up a newer whisper.cpp either: both of
those are the same missing control.

It reads "Download again" once a copy is here, and stays hidden while a
system one is on the PATH, because that is the copy that would run.
2026-09-05 09:45:00 +03:00
yusufipek c3bf328eef Build the bundle only for what the bundle is built from
The trigger listed dikte/ggml.py, both test files and the two READMEs,
which are among the files that change most often. A typo fix in the
README started a run that builds a container image, compiles every
Vulkan shader and starts two more containers, with a 45 minute timeout
on it because that is roughly what it costs.

Nothing is lost. What ties ggml.py to the release, the tag, version,
commit and reviewed digest, is asserted in tests/test_packaging.py, and
that file already runs on every pull request in milliseconds.
2026-09-05 09:37:08 +03:00
Yusuf İpek 3ad41d84f9 Merge pull request #69 from yusufipk/openrouter-subtitle-model
Let OpenRouter audio files use a chosen model instead of whisper-1
2026-09-05 09:25:57 +03:00
yusufipek 59180b8eff Say when the processor build landed instead of the Vulkan one
The install falls back to upstream's CPU archive whenever Dikte's own
release, the file in it, or its reviewed digest is not there, and until
the package is published by hand that is every download. It happened
without a word: the window said "Downloaded, version v1.9.3." either
way, and a graphics card sitting idle looks exactly like one being used.

The install record now carries which of the two builds landed, written
only where both were on offer, and the settings window says so on the
line that already reports the version.
2026-09-05 09:16:59 +03:00
yusufipek 956c3eaf3c Leave the tar extraction fallback where it was
Refusing the install on a Python without the extraction filters is a
change to how every archive on every platform is unpacked, and it has
nothing to do with shipping a Vulkan whisper-server. On those Pythons
the download stops working altogether, which is a worse answer than the
one that was there.

Worth doing on its own terms, in its own change, where the versions it
turns away can be argued about without a backend release riding on it.
2026-09-05 09:16:49 +03:00
nomoreshow 1f57455a34 Fix cross-platform test assumptions 2026-09-04 01:04:36 +03:00
nomoreshow e0eae4d8fe Ship a managed Vulkan whisper-server for Linux x64 2026-09-04 00:34:55 +03:00
yusufipek eda1398a2b Call the OpenRouter subtitle model the audio file model
Timestamps are a file transcription option, so the model box is named
after the file rather than the format it ends up in.
2026-09-02 12:43:07 +03:00
yusufipek 6e307bd8d0 Let OpenRouter subtitles use a chosen model instead of whisper-1
A timestamped run on OpenRouter always asked openai/whisper-1 for the
segments, whatever model was picked for plain transcription. Not every
model there returns segment times, so the one to use is now its own
setting, openrouter_subtitle_model, shown in the speech-to-text box only
when OpenRouter is the provider. Empty keeps the old whisper-1 fallback.

Target carries the choice as subtitle_model and timestamp_model() reads
it; the other providers are unchanged.
2026-09-02 12:41:43 +03:00
25 changed files with 1456 additions and 17 deletions
+222
View File
@@ -0,0 +1,222 @@
name: whisper.cpp Vulkan bundle
# Only what the bundle is built from. Compiling the Vulkan shaders takes
# tens of minutes, and a README typo is not worth one: what ties ggml.py to
# this release is a handful of assertions in tests/test_packaging.py, and
# those run on every pull request in milliseconds.
on:
pull_request:
paths:
- packaging/whisper-vulkan/**
- .github/workflows/whisper-vulkan.yml
workflow_dispatch:
inputs:
whisper_version:
description: Upstream whisper.cpp version (without v)
required: true
default: "1.9.3"
type: string
whisper_commit:
description: Peeled commit SHA for that reviewed upstream tag
required: true
default: "371b5a7561823ab2bb32142d2751e35e7534727b"
type: string
expected_sha256:
description: >-
Reviewed archive SHA-256. Leave empty for a version this file has
not reviewed: the digest of what was built is reported instead of
being checked, and publishing is refused.
required: false
default: ""
type: string
publish:
description: Publish a Dikte dependency release
required: true
default: false
type: boolean
permissions:
contents: read
concurrency:
group: whisper-vulkan-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
WHISPER_VERSION: ${{ inputs.whisper_version || '1.9.3' }}
WHISPER_COMMIT: ${{ inputs.whisper_commit || '371b5a7561823ab2bb32142d2751e35e7534727b' }}
REVIEWED_WHISPER_VERSION: "1.9.3"
REVIEWED_WHISPER_SHA256: c25ca76504144da488eb74441390a7b9aa7ce547e5f2f391cbd831253c9b54d8
jobs:
build:
runs-on: ubuntu-22.04
timeout-minutes: 45
steps:
- name: Check out Dikte
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- name: Validate source coordinates
shell: bash
run: |
set -euo pipefail
[[ "$WHISPER_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
[[ "$WHISPER_COMMIT" =~ ^[0-9a-f]{40}$ ]]
- name: Check out pinned whisper.cpp source
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: ggml-org/whisper.cpp
ref: ${{ env.WHISPER_COMMIT }}
path: vendor/whisper.cpp
fetch-depth: 0
persist-credentials: false
- name: Verify source version and commit
shell: bash
run: |
set -euo pipefail
test "$(git -C vendor/whisper.cpp rev-parse HEAD)" = "$WHISPER_COMMIT"
git -C vendor/whisper.cpp fetch --depth=1 origin \
"refs/tags/v$WHISPER_VERSION:refs/tags/v$WHISPER_VERSION"
test "$(git -C vendor/whisper.cpp rev-list -n1 "v$WHISPER_VERSION")" = "$WHISPER_COMMIT"
echo "SOURCE_DATE_EPOCH=$(git -C vendor/whisper.cpp show -s --format=%ct HEAD)" >> "$GITHUB_ENV"
- name: Build pinned build environment
run: docker build --pull=false -f packaging/whisper-vulkan/Dockerfile.build -t dikte-whisper-builder packaging/whisper-vulkan
- name: Build deterministic archive
run: |
docker run --rm \
-e WHISPER_VERSION -e WHISPER_COMMIT -e SOURCE_DATE_EPOCH \
-v "$PWD/vendor/whisper.cpp:/src:ro" \
-v "$PWD/packaging/whisper-vulkan:/packaging:ro" \
-v "$PWD/work:/work" \
dikte-whisper-builder \
bash /packaging/build-package.sh
mkdir -p dist
cp work/out/whisper-bin-ubuntu-vulkan-x64.* dist/
- name: Verify reviewed archive digest
shell: bash
env:
EXPECTED_SHA256: ${{ inputs.expected_sha256 }}
PUBLISH: ${{ inputs.publish }}
run: |
set -euo pipefail
read -r actual _ < dist/whisper-bin-ubuntu-vulkan-x64.tar.gz.sha256
echo "built archive sha256: $actual"
expected="$EXPECTED_SHA256"
if [ -z "$expected" ] \
&& [ "$WHISPER_VERSION" = "$REVIEWED_WHISPER_VERSION" ]; then
expected="$REVIEWED_WHISPER_SHA256"
fi
if [ -z "$expected" ]; then
# The digest of a version nobody has reviewed yet cannot be known
# before it is built. Reporting it is the whole point of the run;
# a release out of it is not.
if [ "${PUBLISH:-false}" = true ]; then
echo "refusing to publish an archive whose digest has not been reviewed" >&2
exit 1
fi
echo "::notice::no reviewed digest for $WHISPER_VERSION." \
"Review the one above, then dispatch again with expected_sha256."
exit 0
fi
test "$actual" = "$expected"
- name: Validate archive and ELF contract
run: OUT_DIR=dist packaging/whisper-vulkan/validate-package.sh
- name: Schema-validate CycloneDX 1.6 SBOM
run: |
docker run --rm \
-v "$PWD/dist/whisper-bin-ubuntu-vulkan-x64.cdx.json:/sbom.json:ro" \
cyclonedx/cyclonedx-cli@sha256:252c2e26f468c25fea1e63ecde1bc3198ad6e9dbb57f5ed3236bddcb2281b3a7 \
validate --input-file /sbom.json --input-format json \
--input-version v1_6 --fail-on-errors
- name: CPU fallback smoke test (no Vulkan loader)
run: OUT_DIR=dist packaging/whisper-vulkan/smoke-runtime.sh cpu
- name: Vulkan loader present, no device smoke test
run: OUT_DIR=dist packaging/whisper-vulkan/smoke-runtime.sh noicd
- name: Vulkan plugin-load smoke test (Mesa llvmpipe)
run: OUT_DIR=dist packaging/whisper-vulkan/smoke-runtime.sh vulkan
- name: Upload reviewed outputs
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: whisper-bin-ubuntu-vulkan-x64
path: dist/*
if-no-files-found: error
retention-days: 14
publish:
if: >-
github.event_name == 'workflow_dispatch' && inputs.publish &&
github.ref == 'refs/heads/master'
needs: build
runs-on: ubuntu-22.04
environment: dependency-release
permissions:
contents: write
id-token: write
attestations: write
artifact-metadata: write
steps:
- name: Download the exact tested outputs
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: whisper-bin-ubuntu-vulkan-x64
path: dist
- name: Verify digest sidecar
run: (cd dist && sha256sum --check whisper-bin-ubuntu-vulkan-x64.tar.gz.sha256)
- name: Attest build provenance
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4
with:
subject-path: dist/whisper-bin-ubuntu-vulkan-x64.tar.gz
- name: Attest SBOM to archive
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4
with:
subject-path: dist/whisper-bin-ubuntu-vulkan-x64.tar.gz
sbom-path: dist/whisper-bin-ubuntu-vulkan-x64.cdx.json
- name: Publish dependency release
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: whisper.cpp-v${{ inputs.whisper_version }}
RELEASE_TITLE: whisper.cpp v${{ inputs.whisper_version }} Vulkan bundle
RELEASE_NOTES: >-
Pinned source: ggml-org/whisper.cpp@${{ inputs.whisper_commit }}.
Verify with: gh attestation verify
whisper-bin-ubuntu-vulkan-x64.tar.gz
--repo ${{ github.repository }}
run: |
set -euo pipefail
if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "refusing to replace existing release $RELEASE_TAG" >&2
exit 1
fi
if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG" >/dev/null 2>&1; then
echo "refusing to replace existing tag $RELEASE_TAG" >&2
exit 1
fi
gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \
-f ref="refs/tags/$RELEASE_TAG" \
-f sha="$GITHUB_SHA" >/dev/null
test "$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG" \
--jq .object.sha)" = "$GITHUB_SHA"
gh release create "$RELEASE_TAG" dist/* \
--repo "$GITHUB_REPOSITORY" \
--verify-tag \
--prerelease \
--latest=false \
--title "$RELEASE_TITLE" \
--notes "$RELEASE_NOTES"
+3 -1
View File
@@ -163,7 +163,9 @@ running.
the program and the model, verifies the sha256 and refuses a download published
without one, then keeps a server alive while you dictate. The graphics card is
reached through CUDA, ROCm or Vulkan where the build allows. No key, no
account, nothing leaving the machine.
account, nothing leaving the machine. On x86_64 Linux the same button fetches
a Vulkan build of whisper-server that Dikte publishes itself, because
upstream's Linux archive is processor-only.
- **Silence never reaches the API.** Handed near-silence, a transcription model
invents a sentence instead of returning nothing ("Thanks for watching", or in
Turkish "Altyazı M.K."). A recording is dropped when nothing rose 10 dB above
+2
View File
@@ -160,6 +160,8 @@ olmasını ister.
checksum'suz yayınlanmış bir indirmeyi reddeder, sen dikte ettikçe sunucuyu
ayakta tutar. Derleme destekliyorsa ekran kartına CUDA, ROCm ya da Vulkan
üzerinden ulaşılır. Anahtar yok, hesap yok, makineden çıkan bir şey yok.
x86_64 Linux'ta aynı düğme, whisper-server'ın Dikte'nin kendi yayınladığı
Vulkan derlemesini indirir; upstream'in Linux arşivi yalnızca işlemci için.
- **Sessizlik API'ye gitmez.** Sessize yakın bir ses verildiğinde model boş dize
döndürmez, bir cümle uydurur ("Altyazı M.K.", "Thanks for watching"). *O
kaydın kendi* gürültü tabanının 10 dB üstüne en az 0,3 saniye çıkan bir şey
+22 -10
View File
@@ -48,22 +48,33 @@ LOCAL_TIMEOUT = 3600
# Where a transcription request goes; built by config.Config.transcribe_target().
# `service` is the name the user sees in an error, `provider` the one the code
# branches on.
Target = collections.namedtuple("Target", "provider service api_key base_url model")
# branches on. `file_model` is what a timestamped run asks for instead of
# `model`, where the two differ; empty means the provider's own whisper.
Target = collections.namedtuple(
"Target", "provider service api_key base_url model file_model",
defaults=[""])
# What answers with segment times on OpenRouter when nothing else was chosen.
OPENROUTER_FILE_MODEL = "openai/whisper-1"
def timestamp_model(provider, selected=""):
def timestamp_model(provider, selected="", file_model=""):
"""Which model answers with segment times.
OpenAI keeps them to whisper-1 and OpenRouter namespaces that id. Everything
Groq transcribes with is a whisper, so the model already chosen does it and
the fallback is only for a provider left on its default. So is everything the
local server runs, whatever the file is called, and there asking for another
model would name one it has never heard of.
OpenAI keeps them to whisper-1. Everything Groq transcribes with is a
whisper, so the model already chosen does it and the fallback is only for a
provider left on its default. So is everything the local server runs,
whatever the file is called, and there asking for another model would name
one it has never heard of. OpenRouter fronts several models that do times
and several that do not, and a request to the wrong one gets a transcript
with no segments in it, so the one to use is a setting of its own
(`file_model`) and whisper-1 is only where that setting is left empty.
"""
if provider in ("groq", "local"):
return selected or "whisper-large-v3-turbo"
return "openai/whisper-1" if provider == "openrouter" else "whisper-1"
if provider == "openrouter":
return file_model or OPENROUTER_FILE_MODEL
return "whisper-1"
# What a gateway in front of the model answers of its own accord: the request
@@ -444,7 +455,8 @@ def transcribe_segments(target, audio_path, language="", prompt="", timeout=300,
aborter=None):
"""[(start_seconds, end_seconds, text)] using whisper-1's verbose response."""
data = _transcribe_request(
target._replace(model=timestamp_model(target.provider, target.model)),
target._replace(model=timestamp_model(target.provider, target.model,
target.file_model)),
audio_path, language, prompt, "verbose_json",
granularity="segment", timeout=timeout, aborter=aborter,
)
+5 -1
View File
@@ -397,6 +397,9 @@ DEFAULTS = {
"transcribe_model": "gpt-4o-transcribe", # used when provider is openai
"groq_transcribe_model": "whisper-large-v3-turbo",
"openrouter_transcribe_model": "openai/gpt-4o-transcribe",
# What a timestamped run (subtitles) asks OpenRouter for: not every model
# there returns segment times. Empty -> openai/whisper-1.
"openrouter_file_model": "",
"language": "tr",
"transcribe_prompt": "",
@@ -669,8 +672,9 @@ class Config:
# to land on rather than reading it from there.
name = "openai"
who = TRANSCRIBERS[name]
file_model = self["openrouter_file_model"] if name == "openrouter" else ""
return api.Target(name, who.service, self.api_key(who.key),
self[who.url], self[who.model])
self[who.url], self[who.model], file_model.strip())
def transcribe_ready(self):
"""Whether speech to text could run right now, without opening Settings."""
+71 -3
View File
@@ -76,6 +76,13 @@ Program = collections.namedtuple("Program", "name repo binary health")
WHISPER = Program("whisper", "ggml-org/whisper.cpp", "whisper-server", "")
LLAMA = Program("llama", "ggml-org/llama.cpp", "llama-server", "/health")
DIKTE_REPO = "yusufipk/dikte"
MANAGED_WHISPER_RELEASE = "whisper.cpp-v1.9.3"
MANAGED_WHISPER_VERSION = "v1.9.3"
MANAGED_WHISPER_VULKAN = "whisper-bin-ubuntu-vulkan-x64.tar.gz"
MANAGED_WHISPER_SHA256 = (
"c25ca76504144da488eb74441390a7b9aa7ce547e5f2f391cbd831253c9b54d8"
)
# Where the models are listed. Neither list is written into Dikte: a catalogue
# in the source means a release of Dikte for every model somebody else
@@ -281,6 +288,35 @@ def _wanted_assets(program):
return (f"bin-ubuntu-{arch}.tar.gz",)
def _managed_whisper(program, tag=""):
"""Whether this machine is one Dikte publishes its own whisper-server for.
Linux x86_64 with a Vulkan loader on it, and no version asked for by hand:
a pinned version is upstream's to answer.
"""
return (not tag and program is WHISPER and sys.platform == "linux"
and platform.machine().lower() in ("x86_64", "amd64")
and _has_vulkan())
def _managed_asset(refresh=False):
"""The Vulkan whisper-server Dikte builds itself, or None.
Taken only when the archive's digest is the reviewed one. Anything else,
a release that is not there yet, a GitHub that cannot be reached, a file
that is not the reviewed bytes, leaves upstream's processor build as the
answer, and the install record says which of the two landed.
"""
try:
_, assets = hub.release(DIKTE_REPO, MANAGED_WHISPER_RELEASE,
refresh=refresh)
except hub.HubError:
return None
return next((a for a in assets
if a.name.endswith(MANAGED_WHISPER_VULKAN)
and a.sha256 == MANAGED_WHISPER_SHA256), None)
def _matching_asset(program, assets):
"""The archive this machine wants out of one release's files, or None."""
for ending in _wanted_assets(program):
@@ -294,6 +330,10 @@ def _pick_asset(program, tag="", refresh=False):
"""(tag, Item) for the release archive to install. Item is None when there
is none for this machine.
Dikte's own Vulkan whisper-server comes before upstream's where this
machine is one it is built for, because whisper.cpp publishes no Vulkan
archive for Linux at all.
A named tag is taken as given. For the newest, what GitHub answers is not
always where the builds are: llama.cpp's latest release is a version marker
carrying a single nightly-tag.txt, which names the tag the archives are
@@ -301,6 +341,10 @@ def _pick_asset(program, tag="", refresh=False):
at. The pointer is followed when it is there, and when it is not, the newest
release that does carry a build for this machine is taken instead.
"""
if _managed_whisper(program, tag):
item = _managed_asset(refresh=refresh)
if item:
return MANAGED_WHISPER_VERSION, item
named = bool(tag) and tag != "latest"
missing = None
try:
@@ -363,6 +407,21 @@ def installed_version(program):
return _read_record(program).get("tag") or ""
def vulkan_missing(program):
"""Whether what Dikte installed is the processor build on a machine the
Vulkan one was fetched for.
The Vulkan whisper-server is a release of Dikte's own, published by hand
once the reviewed archive is built, and the install falls back to the
upstream processor build whenever that release, the file in it, or its
reviewed digest is not there. Nothing is wrong with the fallback except
that it is invisible: a graphics card sitting idle looks exactly like a
graphics card being used.
"""
return (bool(installed_program(program))
and _read_record(program).get("backend") == "processor")
def program_path(program, custom=""):
"""Which copy of the program to run, or "" when there is none.
@@ -435,8 +494,10 @@ def install_program(program, tag="", on_progress=None, should_stop=None,
`tag` is empty for whatever the project released last, which is the point:
a version pinned in Dikte's source would mean a release of Dikte every time
whisper.cpp has one.
whisper.cpp has one. The Linux Vulkan whisper-server is the exception, and
_pick_asset says why.
"""
managed = _managed_whisper(program, tag)
try:
tag, item = _pick_asset(program, tag, refresh=refresh)
except hub.HubError as exc:
@@ -505,8 +566,15 @@ def install_program(program, tag="", on_progress=None, should_stop=None,
# Found under the sibling, run from the final directory.
binary = into / binary.relative_to(fresh)
# Written last, so the record never points at anything half-made.
_install_record(program).write_text(
json.dumps({"tag": tag, "binary": str(binary)}), encoding="utf-8")
record = {"tag": tag, "binary": str(binary)}
if managed:
# Which of the two builds this machine ended up with. Only written
# where both were on offer, so an install that never had the
# choice is not made to look like a fallback.
record["backend"] = (
"vulkan" if item.name.endswith(MANAGED_WHISPER_VULKAN)
else "processor")
_install_record(program).write_text(json.dumps(record), encoding="utf-8")
except OSError as exc:
raise LocalError(t("Could not install {name}: {error}",
name=program.name, error=exc)) from exc
+9
View File
@@ -228,6 +228,11 @@ TR = {
"Transcript cleanup": "Transkripti temizleme",
"API key": "API anahtarı",
"Model": "Model",
"Audio file model": "Ses dosyası modeli",
"The model a timestamped audio file (subtitles) is sent to. Not every model on "
"OpenRouter returns segment times; empty means openai/whisper-1.":
"Zaman damgalı bir ses dosyasının (altyazı) gönderildiği model. OpenRouter'daki her "
"model segment zamanı döndürmez; boşsa openai/whisper-1 kullanılır.",
"Provider": "Sağlayıcı",
"sk-… (falls back to OPENAI_API_KEY)": "sk-… (boşsa OPENAI_API_KEY kullanılır)",
"gsk_… (falls back to GROQ_API_KEY)": "gsk_… (boşsa GROQ_API_KEY kullanılır)",
@@ -779,7 +784,11 @@ TR = {
"Local model": "Yerel model",
"Not installed.": "Kurulu değil.",
"Installed on the system: {path}": "Sistemde kurulu: {path}",
"Download again": "Yeniden indir",
"Downloaded, version {version}.": "İndirildi, sürüm {version}.",
"Downloaded, version {version}. There was no Vulkan build, "
"so this one runs on the processor.":
"İndirildi, sürüm {version}. Vulkan sürümü yoktu, bu sürüm işlemcide çalışıyor.",
"Fetching the model list…": "Model listesi çekiliyor…",
"Downloading…": "İndiriliyor…",
"Downloading: {done} of {total}{share}": "İndiriliyor: {done} / {total}{share}",
+39 -2
View File
@@ -358,15 +358,30 @@ class LocalModelBox(QGroupBox):
path = ggml.program_path(self.program)
if not path:
self.program_label.setText(t("Not installed."))
self.install_button.setText(t("Download"))
self.install_button.setVisible(True)
return
self.install_button.setVisible(not ggml.installed_program(self.program)
and not ggml.system_program(self.program))
# A copy that is here is not a copy that is right. whisper.cpp releases
# every few weeks, and a graphics card installed after Dikte was
# changes which build this machine should be running; the button was
# hidden the moment anything landed, and nothing else on this window
# asks for the download again.
self.install_button.setText(t("Download again")
if ggml.installed_program(self.program)
else t("Download"))
self.install_button.setVisible(not ggml.system_program(self.program))
if ggml.system_program(self.program):
# Worth saying which one is running: a distribution package is built
# for this machine and may reach the graphics card, while the
# released binaries carry processor backends only.
self.program_label.setText(t("Installed on the system: {path}", path=path))
elif ggml.vulkan_missing(self.program):
# The download landed the processor build where the graphics card
# one belongs, and nothing else on this window would say so.
self.program_label.setText(
t("Downloaded, version {version}. There was no Vulkan build, "
"so this one runs on the processor.",
version=ggml.installed_version(self.program) or "?"))
else:
self.program_label.setText(
t("Downloaded, version {version}.",
@@ -915,6 +930,15 @@ class SettingsWindow(QDialog):
self.transcribe_model_row = self._row(self.transcribe_model,
self.refresh_transcribe_models)
stt_form.addRow(t("Model"), self.transcribe_model_row)
# OpenRouter only: which of its models a timestamped run asks for.
self.file_model = QComboBox()
self.file_model.setEditable(True)
self.file_model.lineEdit().setPlaceholderText(api.OPENROUTER_FILE_MODEL)
self.file_model.setToolTip(
t("The model a timestamped audio file (subtitles) is sent to. Not every model "
"on OpenRouter returns segment times; empty means openai/whisper-1."))
self.file_model_row = self._row(self.file_model)
stt_form.addRow(t("Audio file model"), self.file_model_row)
# A spanning row: in the narrow field column a wrapped label gets a
# height that fits one line, and the rest of the text is cut off.
self.transcribe_status = QLabel("")
@@ -1798,6 +1822,7 @@ class SettingsWindow(QDialog):
self._shown_provider = ""
self._select_data(self.transcribe_provider, conf["transcribe_provider"])
self._provider_changed() # selecting index 0 fires no signal
self.file_model.setCurrentText(conf["openrouter_file_model"])
self.local_gpu.setChecked(conf["local_gpu"])
self.local_preload.setChecked(conf["local_preload"])
self.local_threads.setValue(int(conf["local_threads"]))
@@ -1915,6 +1940,7 @@ class SettingsWindow(QDialog):
for name, who in cfg.TRANSCRIBERS.items():
conf[who.key] = self._key_fields[name].text().strip()
conf[who.model] = self._models[name].strip() or cfg.DEFAULTS[who.model]
conf["openrouter_file_model"] = self.file_model.currentText().strip()
conf["gemini_api_key"] = self.gemini_key.text().strip()
conf["opencode_api_key"] = self.opencode_key.text().strip()
conf["local_model"] = self.local_whisper.selected()
@@ -2088,6 +2114,7 @@ class SettingsWindow(QDialog):
self._shown_provider = provider
local = provider == "local"
self.stt_form.setRowVisible(self.transcribe_model_row, not local)
self.stt_form.setRowVisible(self.file_model_row, provider == "openrouter")
self.stt_form.setRowVisible(self.transcribe_status, not local)
self.stt_form.setRowVisible(self.local_whisper, local)
self.stt_form.setRowVisible(self.local_options, local)
@@ -2096,8 +2123,16 @@ class SettingsWindow(QDialog):
self.transcribe_model.clear()
self.transcribe_model.addItems(TRANSCRIBE_MODELS[provider])
self.transcribe_model.setCurrentText(self._models[provider])
if provider == "openrouter":
self._fill_file_models(TRANSCRIBE_MODELS[provider])
self.transcribe_status.setText("")
def _fill_file_models(self, models):
current = self.file_model.currentText()
self.file_model.clear()
self.file_model.addItems(models)
self.file_model.setCurrentText(current)
def _load_transcribe_models(self):
"""The model list of whichever provider is selected."""
provider = self.transcribe_provider.currentData() or "openai"
@@ -2126,6 +2161,8 @@ class SettingsWindow(QDialog):
self.transcribe_model.clear()
self.transcribe_model.addItems(models)
self.transcribe_model.setCurrentText(current)
if self._shown_provider == "openrouter":
self._fill_file_models(models)
self.transcribe_status.setText(t("{count} models loaded.", count=len(models)))
def _load_models(self):
+44
View File
@@ -0,0 +1,44 @@
FROM ubuntu@sha256:2edbbc5dc405e9612ba3584ce95480277e3eb374407b5505fe26f17df77c7dbc
ARG DEBIAN_FRONTEND=noninteractive
ARG CMAKE_VERSION=3.31.6
ARG CMAKE_SHA256=5a1133ff103c71eb5120e2cc3de922733e7d8a26a98ae716397e8676adb367bf
COPY lunarg-signing-key-pub.asc /tmp/lunarg.asc
RUN set -eux; \
test "$(sha256sum /tmp/lunarg.asc | cut -d' ' -f1)" = aa1c3c29673140e77f0d6a9aaeed5d9b5621e305ead51c59fae4458bbb4df92b; \
apt-get update; \
apt-get install --no-install-recommends -y \
build-essential=12.9ubuntu3 \
ca-certificates \
curl \
file \
git \
gnupg \
ninja-build=1.10.1-1 \
patchelf=0.14.3-1 \
python3 \
xz-utils; \
install -d -m 0755 /usr/share/keyrings; \
gpg --dearmor -o /usr/share/keyrings/lunarg.gpg /tmp/lunarg.asc; \
printf '%s\n' 'deb [signed-by=/usr/share/keyrings/lunarg.gpg] https://packages.lunarg.com/vulkan jammy main' \
> /etc/apt/sources.list.d/lunarg-vulkan.list; \
apt-get update; \
apt-get install --no-install-recommends -y \
libvulkan-dev=1.4.313.0~rc1-1lunarg22.04-1 \
vulkan-headers=1.4.313.0~rc1-1lunarg22.04-1 \
shaderc=2025.2~rc1-1lunarg22.04-1 \
spirv-headers=1.6.1+1.4.313.0~rc1-1lunarg22.04-1; \
curl --fail --location --retry 3 \
"https://github.com/Kitware/CMake/releases/download/v${CMAKE_VERSION}/cmake-${CMAKE_VERSION}-linux-x86_64.tar.gz" \
-o /tmp/cmake.tar.gz; \
test "$(sha256sum /tmp/cmake.tar.gz | cut -d' ' -f1)" = "$CMAKE_SHA256"; \
tar -xzf /tmp/cmake.tar.gz --strip-components=1 -C /usr/local; \
rm -rf /var/lib/apt/lists/* /tmp/cmake.tar.gz /tmp/lunarg.asc; \
cmake --version; \
glslc --version; \
test -f /usr/include/vulkan/vulkan.h; \
test -f /usr/share/cmake/SPIRV-Headers/SPIRV-HeadersConfig.cmake
WORKDIR /work
@@ -0,0 +1,6 @@
FROM ubuntu@sha256:2edbbc5dc405e9612ba3584ce95480277e3eb374407b5505fe26f17df77c7dbc
ARG DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install --no-install-recommends -y ca-certificates curl libstdc++6 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /bundle
@@ -0,0 +1,9 @@
FROM ubuntu@sha256:2edbbc5dc405e9612ba3584ce95480277e3eb374407b5505fe26f17df77c7dbc
ARG DEBIAN_FRONTEND=noninteractive
# The loader and nothing behind it: the machine that has libvulkan because
# something else pulled it in, and no driver to go with it.
RUN apt-get update \
&& apt-get install --no-install-recommends -y \
ca-certificates curl libstdc++6 libvulkan1 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /bundle
@@ -0,0 +1,7 @@
FROM ubuntu@sha256:2edbbc5dc405e9612ba3584ce95480277e3eb374407b5505fe26f17df77c7dbc
ARG DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install --no-install-recommends -y \
ca-certificates curl libstdc++6 libvulkan1 mesa-vulkan-drivers vulkan-tools \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /bundle
+49
View File
@@ -0,0 +1,49 @@
# The Vulkan whisper-server bundle
whisper.cpp publishes a CPU-only archive for Linux, so the graphics card on a
Linux machine is out of reach through the Download button. This directory
builds the archive upstream does not: `whisper-server` with a dynamic Vulkan
backend next to the CPU ones, for x86_64, against the Ubuntu 22.04 runtime
contract.
It is published as a release of Dikte's own, `whisper.cpp-v<version>`, marked
as a prerelease and kept off Latest so that neither the update check nor the
download page picks it up. `dikte/ggml.py` fetches it by tag and installs it
only when the archive's digest is the reviewed one; anything else falls back
to upstream's CPU archive, and the settings window says when it did.
## Publishing a new bundle
1. Enable GitHub's immutable releases setting for the repository, and give the
`dependency-release` environment a required reviewer. Both are repository
settings, not something this workflow can do for itself.
2. Run **whisper.cpp Vulkan bundle** on `master` with the new version and its
peeled commit, `expected_sha256` empty and `publish: false`. The run builds
the archive and reports its digest; without a reviewed digest it refuses to
publish, which is what the first run is for.
3. Review that digest against a build of your own, then run the workflow again
with the same version and commit, `expected_sha256` set to it, and
`publish: true`. Approve the environment when it asks.
4. Write the same version, tag and digest into `MANAGED_WHISPER_RELEASE`,
`MANAGED_WHISPER_VERSION` and `MANAGED_WHISPER_SHA256` in `dikte/ggml.py`,
and into `REVIEWED_WHISPER_VERSION` and `REVIEWED_WHISPER_SHA256` in the
workflow. `tests/test_packaging.py` holds the two sides together.
5. Ship a Dikte release. Until one goes out, nobody's Dikte knows the new
bundle exists.
## What this costs, and what it does not promise
The digest lives in Dikte's source, so a backend update is a Dikte release.
Linux x86_64 machines with a Vulkan loader stay on the pinned whisper.cpp
version until step 5 happens, while every other platform follows upstream's
newest release on its own. That is the deliberate trade: an executable Dikte
downloads is not allowed to change without a reviewed digest behind it.
The build is deterministic between two runs of the same builder, not across
time. The base image, the CMake tarball, the LunarG packages and the direct
apt packages are pinned by digest or version, but the Ubuntu and LunarG
repository metadata behind them is not, and LunarG drops superseded packages.
A rebuild months later can fail to resolve, or resolve to something that
produces a different digest. Treat the published archive as the artifact, not
as something reproducible on demand: a version bump means building,
validating, reviewing the new digest and updating the pinned tuple together.
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env bash
set -euo pipefail
shopt -s nullglob
: "${SOURCE_DIR:=/src}"
: "${OUT_DIR:=/work/out}"
: "${WHISPER_VERSION:=1.9.3}"
: "${WHISPER_COMMIT:=371b5a7561823ab2bb32142d2751e35e7534727b}"
: "${SOURCE_DATE_EPOCH:=1787219223}"
export SOURCE_DATE_EPOCH TZ=UTC LC_ALL=C LANG=C
asset=whisper-bin-ubuntu-vulkan-x64
build=/work/build
source_copy=/work/source
root="$OUT_DIR/root/$asset"
rm -rf "$build" "$source_copy" "$OUT_DIR"
mkdir -p "$build" "$root/LICENSES"
# Upstream configures bindings/javascript/package.json in the source directory.
# Build a private copy so the checked-out, verified source remains untouched.
cp -a "$SOURCE_DIR" "$source_copy"
chmod -R u+w "$source_copy"
git config --global --add safe.directory "$source_copy"
cmake -S "$source_copy" -B "$build" -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_BUILD_RPATH='$ORIGIN' \
-DCMAKE_INSTALL_RPATH='$ORIGIN' \
-DCMAKE_BUILD_WITH_INSTALL_RPATH=ON \
-DCMAKE_C_FLAGS="-ffile-prefix-map=$source_copy=. -fdebug-prefix-map=$source_copy=. -fmacro-prefix-map=$source_copy=." \
-DCMAKE_CXX_FLAGS="-ffile-prefix-map=$source_copy=. -fdebug-prefix-map=$source_copy=. -fmacro-prefix-map=$source_copy=." \
-DBUILD_SHARED_LIBS=ON \
-DGGML_BACKEND_DL=ON \
-DGGML_CPU_ALL_VARIANTS=ON \
-DGGML_NATIVE=OFF \
-DGGML_CCACHE=OFF \
-DGGML_OPENMP=OFF \
-DGGML_VULKAN=ON \
-DWHISPER_BUILD_EXAMPLES=ON \
-DWHISPER_BUILD_SERVER=ON \
-DWHISPER_BUILD_TESTS=OFF \
-DWHISPER_BUILD_IS_DEV=OFF \
-DWHISPER_CURL=OFF \
-DWHISPER_SDL2=OFF \
-DWHISPER_COMMON_FFMPEG=OFF \
-DWHISPER_BUILD_COMMIT="$WHISPER_COMMIT" \
-DWHISPER_BUILD_NUMBER=0
cmake --build "$build" --target whisper-server --parallel "$(nproc)"
# Package an allowlist, not everything examples/ happens to build in the future.
cp -a "$build/bin/whisper-server" "$root/"
cp -a "$build/bin"/libwhisper.so* "$root/"
cp -a "$build/bin"/libggml.so* "$root/"
cp -a "$build/bin"/libggml-base.so* "$root/"
cp -a "$build/bin"/libggml-cpu*.so* "$root/"
cp -a "$build/bin"/libggml-vulkan.so* "$root/"
# Strip real ELF files only; preserve the SONAME symlink chains.
while IFS= read -r -d '' file; do
if file "$file" | grep -q ELF; then
strip --strip-unneeded "$file"
patchelf --set-rpath '$ORIGIN' "$file"
fi
done < <(find "$root" -type f -print0)
cp "$SOURCE_DIR/LICENSE" "$root/LICENSES/whisper.cpp-MIT.txt"
cp /packaging/licenses/cpp-httplib-MIT.txt "$root/LICENSES/"
cp /packaging/licenses/nlohmann-json-MIT.txt "$root/LICENSES/"
cat > "$root/BUILD-INFO.json" <<EOF
{
"asset": "$asset.tar.gz",
"source": "https://github.com/ggml-org/whisper.cpp",
"source_version": "v$WHISPER_VERSION",
"source_commit": "$WHISPER_COMMIT",
"source_date_epoch": $SOURCE_DATE_EPOCH,
"build_platform": "ubuntu-22.04-x86_64",
"base_image": "ubuntu@sha256:2edbbc5dc405e9612ba3584ce95480277e3eb374407b5505fe26f17df77c7dbc",
"cmake": "3.31.6",
"cmake_flags": [
"BUILD_SHARED_LIBS=ON",
"C/CXX_FILE_PREFIX_MAP=/work/source=.",
"GGML_BACKEND_DL=ON",
"GGML_CPU_ALL_VARIANTS=ON",
"GGML_NATIVE=OFF",
"GGML_CCACHE=OFF",
"GGML_OPENMP=OFF",
"GGML_VULKAN=ON",
"WHISPER_BUILD_EXAMPLES=ON",
"WHISPER_BUILD_SERVER=ON",
"WHISPER_BUILD_TESTS=OFF",
"WHISPER_BUILD_IS_DEV=OFF",
"WHISPER_CURL=OFF",
"WHISPER_SDL2=OFF",
"WHISPER_COMMON_FFMPEG=OFF"
],
"runtime_contract": {
"minimum_glibc": "2.34",
"minimum_glibcxx": "3.4.30",
"required": ["x86_64 Linux", "glibc", "libstdc++.so.6", "libgcc_s.so.1"],
"optional_gpu": ["libvulkan.so.1", "a working Vulkan ICD"],
"cpu_fallback": "dynamic CPU backends are included; -ng forces CPU"
}
}
EOF
# A deterministic CycloneDX sidecar generated from the files actually shipped.
ROOT="$root" VERSION="$WHISPER_VERSION" COMMIT="$WHISPER_COMMIT" EPOCH="$SOURCE_DATE_EPOCH" \
python3 /packaging/make-sbom.py > "$root/$asset.cdx.json"
(
cd "$root"
find . -type f ! -name SHA256SUMS -print0 \
| sort -z \
| xargs -0 sha256sum
) > "$root/SHA256SUMS"
mkdir -p "$OUT_DIR"
tar --sort=name --owner=0 --group=0 --numeric-owner \
--mtime="@$SOURCE_DATE_EPOCH" \
--pax-option=delete=atime,delete=ctime \
-C "$OUT_DIR/root" -cf - "$asset" \
| gzip -n -9 > "$OUT_DIR/$asset.tar.gz"
(
cd "$OUT_DIR"
sha256sum "$asset.tar.gz" > "$asset.tar.gz.sha256"
)
cp "$root/$asset.cdx.json" "$OUT_DIR/$asset.cdx.json"
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2017 yhirose
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2013-2022 Niels Lohmann
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,31 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
mQENBFuOrjYBCADT5MjShtbeSsWHADqVP7PZIp+m/wWkSUA7/FX/qrixhQE9DFyt
XtKSbBdwh+Jg5nsttCUiePtdrrRD1tcyowG256Tus3vOysZzvpfjWA4gcVmTjJXn
gwezKsPZLQi0wvjwQD8ByxnM1i2eiJC4xcMjT21uZkwDfgLTzVO4InWlVyZDB/da
PLJl4r1MqnsI603RKalMQmZzs43YUDssdeOiGOpXvb1Rj0XcsOOqnAEvIwyUWGku
1Hr+b6C9Nj6wksD7TCB10IdOeuwBqFgrVDzicG4fijwnpzA+UUfncIhKYdI/oIvj
mcAPobWzcBkM3uc+Yf/CxlBahzu6jv7AFdT1ABEBAAG0VEx1bmFyRyBTaWduaW5n
IEtleSAoS2V5IHVzZWQgYnkgTHVuYXJHIHRvIHNpZ24gcGFja2FnZXMpIDxsaW51
eC1wYWNrYWdlc0BsdW5hcmcuY29tPokBTgQTAQoAOBYhBAP11iGjcQ+pWpPYm6qE
UggOOD9+BQJbjq42AhsDBQsJCAcDBRUKCQgLBRYCAwEAAh4BAheAAAoJEKqEUggO
OD9+ECgH/Ro6LVB08FifApBS235v0Af3dsJlZGE0miKu2hR12qAvWackE6//E5GN
5xKSNpgLzV6kyylBntQDhcFzW3hLt/AsMLOXvuxYNFcLes2y10DrqVekNeJiR95V
KiTPI2jP8m4eFpcSnY0riHk2MmstN1icehQhYrWFyUtt3VxSsRWiRDeNUfCHC6YP
MjOXonmTWfH7T+UA2IqLFrt9dAsYGiCtMKVgzaZaZwm727c0aqy0e43nsWqjWxmE
EsEA1RvzjKKyzyixwpnzIyQ8dqL8sH0G3E2OYTlS7A8//yfgykRQVHwg2TsTBKfG
LlTmKj7RCT6GqISo+rbYYo/hZ6l2hH25AQ0EW46uNgEIANZfPWerTPzmvswWqp0P
iQvW+0qTBxZH3gQlwq5s6ahpY1pIebfrL/SAYJUGyjJVcjkG+HBXRGyRxtWFDE+D
+WEuziBfKd3aBUXb5DnvWdCiXeyQnFfwUVYNXhU5PlpAB5M409a30p9gGOrYy3Ah
g4VHhpM9wzGUAOzTwQ4WaC2WkR84sZYyqdKoo6C3m4IR4KHMYXF9nRlPSNEckL9U
MZe6I2uvor9FOPIfIOAI8lN+gbj/anf3lfy0ZYPyUtl3EWveGpWAPvdw3LMKg5QN
B8bR9TkPk0YZyQQcWkmN7gLUg0Vba+PYHH9DRlG8w1rH4TKxXJV3wmHo2aZRF1kc
30kAEQEAAYkBNgQYAQoAIBYhBAP11iGjcQ+pWpPYm6qEUggOOD9+BQJbjq42AhsM
AAoJEKqEUggOOD9+MEUH/2pm2QOttjd7DmEaS4LGvaTlEif0xtymRAh3axGuqQhl
KCZbw0jwsQlo/DwMRZwZHYCj1A/5H8mEg9qNGjF35GEpQTFSQI6Mt7F2DK69J86w
61v8tjxs4eO201ndhy+DRwDwG8vryFldx3f0nEdlE7IusgiUdvkcJPc8rX7p0MJJ
istTREAq8bRnvWYJzd4k3tgwHglEDxyjBRwLtqZyQ19XZb3V/aVKygqvZbwdJyXO
RHAZxK81p9Gp/8VkogJHLx6+3V8UlDepJg9/8MUCBQ9wWkdF0Pfqzgu7xtIHSxvW
62EF4nxqVuC946OIeITgXpd4F+iTFVII8w0P+nyCzac=
=nXAe
-----END PGP PUBLIC KEY BLOCK-----
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env python3
import datetime
import hashlib
import json
import os
import uuid
from pathlib import Path
root = Path(os.environ["ROOT"])
version = os.environ["VERSION"]
commit = os.environ["COMMIT"]
epoch = int(os.environ["EPOCH"])
asset = "whisper-bin-ubuntu-vulkan-x64"
sbom_path = root / f"{asset}.cdx.json"
def digest(path):
h = hashlib.sha256()
with path.open("rb") as stream:
for block in iter(lambda: stream.read(1024 * 1024), b""):
h.update(block)
return h.hexdigest()
files = []
for path in sorted(root.rglob("*")):
if path != sbom_path and path.is_file() and not path.is_symlink():
rel = path.relative_to(root).as_posix()
files.append({
"type": "file",
"bom-ref": f"file:{rel}",
"name": rel,
"hashes": [{"alg": "SHA-256", "content": digest(path)}],
})
ts = datetime.datetime.fromtimestamp(
epoch, datetime.timezone.utc,
).isoformat().replace("+00:00", "Z")
root_ref = f"pkg:github/ggml-org/whisper.cpp@{version}?commit={commit}"
ggml_ref = "pkg:github/ggml-org/[email protected]"
httplib_ref = "pkg:github/yhirose/[email protected]"
json_ref = "pkg:github/nlohmann/[email protected]"
sbom = {
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"serialNumber": f"urn:uuid:{uuid.uuid5(uuid.NAMESPACE_URL, root_ref)}",
"version": 1,
"metadata": {
"timestamp": ts,
"tools": {"components": [
{"type": "application", "name": "make-sbom.py", "version": "1"},
{"type": "application", "name": "CMake", "version": "3.31.6"},
{"type": "application", "name": "glslc", "version": "2025.2"},
]},
"component": {
"type": "application",
"bom-ref": root_ref,
"group": "ggml-org",
"name": "whisper-server",
"version": version,
"purl": root_ref,
"licenses": [{"expression": "MIT"}],
"externalReferences": [{
"type": "vcs",
"url": f"https://github.com/ggml-org/whisper.cpp/tree/{commit}",
}],
"properties": [
{"name": "dikte:asset-name", "value": f"{asset}.tar.gz"},
{"name": "dikte:source-commit", "value": commit},
{"name": "dikte:runtime:glibc-minimum", "value": "2.34"},
{"name": "dikte:runtime:glibcxx-minimum", "value": "3.4.30"},
{"name": "dikte:runtime:vulkan-loader", "value": "optional; libvulkan.so.1"},
],
},
},
"components": [
{
"type": "library",
"bom-ref": ggml_ref,
"group": "ggml-org",
"name": "ggml",
"version": "0.20.2",
"purl": ggml_ref,
"licenses": [{"expression": "MIT"}],
"properties": [{
"name": "dikte:source",
"value": "vendored by the pinned whisper.cpp commit",
}],
},
{
"type": "library",
"bom-ref": httplib_ref,
"group": "yhirose",
"name": "cpp-httplib",
"version": "0.20.0",
"purl": httplib_ref,
"licenses": [{"expression": "MIT"}],
},
{
"type": "library",
"bom-ref": json_ref,
"group": "nlohmann",
"name": "json",
"version": "3.11.2",
"purl": json_ref,
"licenses": [{"expression": "MIT"}],
},
*files,
],
"dependencies": [{
"ref": root_ref,
"dependsOn": [ggml_ref, httplib_ref, json_ref]
+ [item["bom-ref"] for item in files],
}],
}
json.dump(sbom, fp=os.sys.stdout, indent=2, sort_keys=True)
print()
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
set -euo pipefail
mode=${1:?usage: smoke-runtime.sh cpu|noicd|vulkan}
: "${OUT_DIR:=work/out}"
: "${FIXTURE_SOURCE:=vendor/whisper.cpp}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
OUT_DIR="$(realpath "$OUT_DIR")"
FIXTURE_SOURCE="$(realpath "$FIXTURE_SOURCE")"
asset=whisper-bin-ubuntu-vulkan-x64
case "$mode" in
cpu) dockerfile=Dockerfile.runtime-cpu; image=dikte-whisper-runtime-cpu:spike ;;
noicd) dockerfile=Dockerfile.runtime-noicd; image=dikte-whisper-runtime-noicd:spike ;;
vulkan) dockerfile=Dockerfile.runtime-vulkan; image=dikte-whisper-runtime-vulkan:spike ;;
*) echo "unknown mode: $mode" >&2; exit 2 ;;
esac
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
tar -xzf "$OUT_DIR/$asset.tar.gz" -C "$tmp"
docker build --pull=false -f "$SCRIPT_DIR/$dockerfile" -t "$image" "$SCRIPT_DIR"
args=("/bundle/$asset/whisper-server" -m /fixtures/model.bin
--host 127.0.0.1 --port 8080
--inference-path /v1/audio/transcriptions -l auto -sns -nlp)
env_args=()
# No -ng anywhere: Dikte passes it only when its GPU setting is off, so the
# run that has to survive a missing loader or a missing device is this one,
# where the backend registry actually goes looking for them.
if [[ "$mode" == vulkan ]]; then
env_args=(-e LIBGL_ALWAYS_SOFTWARE=1
-e VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/lvp_icd.x86_64.json)
fi
docker run --rm --name "dikte-whisper-$mode-smoke" \
-e SMOKE_MODE="$mode" \
"${env_args[@]}" \
-v "$tmp/$asset:/bundle/$asset:ro" \
-v "$FIXTURE_SOURCE/models/for-tests-ggml-base.en.bin:/fixtures/model.bin:ro" \
-v "$FIXTURE_SOURCE/samples/jfk.wav:/fixtures/jfk.wav:ro" \
"$image" bash -ec '
if [ "$SMOKE_MODE" = cpu ] && ldconfig -p | grep -q libvulkan.so.1; then
echo "CPU smoke image unexpectedly has a Vulkan loader" >&2
exit 1
fi
if [ "$SMOKE_MODE" = noicd ]; then
if ! ldconfig -p | grep -q libvulkan.so.1; then
echo "no-ICD smoke image has no Vulkan loader to load" >&2
exit 1
fi
if compgen -G "/usr/share/vulkan/icd.d/*.json" >/dev/null; then
echo "no-ICD smoke image has a driver after all" >&2
exit 1
fi
fi
"$@" >/tmp/server.log 2>&1 &
pid=$!
trap "kill $pid 2>/dev/null || true" EXIT
for _ in $(seq 1 120); do
kill -0 "$pid" 2>/dev/null || { cat /tmp/server.log; exit 1; }
if curl --silent --show-error --fail --max-time 180 \
-F file=@/fixtures/jfk.wav -F response_format=json \
http://127.0.0.1:8080/v1/audio/transcriptions >/tmp/response.json; then
grep -q "\"text\"" /tmp/response.json
if [ "$SMOKE_MODE" = vulkan ]; then
grep -q "loaded Vulkan backend" /tmp/server.log
fi
cat /tmp/response.json
cat /tmp/server.log
exit 0
fi
sleep 1
done
cat /tmp/server.log
exit 1
' bash "${args[@]}"
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env bash
set -euo pipefail
: "${OUT_DIR:=work/out}"
: "${SOURCE_DIR:=whisper.cpp}"
asset=whisper-bin-ubuntu-vulkan-x64
archive="$OUT_DIR/$asset.tar.gz"
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
test -s "$archive"
(cd "$OUT_DIR" && sha256sum --check "$asset.tar.gz.sha256")
ARCHIVE="$archive" ASSET="$asset" python3 - <<'PY'
import os
import posixpath
import tarfile
archive = os.environ["ARCHIVE"]
asset = os.environ["ASSET"]
def under_root(name):
normalized = posixpath.normpath(name)
return (not posixpath.isabs(normalized)
and normalized != ".."
and not normalized.startswith("../")
and normalized.split("/", 1)[0] == asset)
with tarfile.open(archive, "r:gz") as bundle:
for member in bundle:
if not under_root(member.name):
raise SystemExit(f"unsafe archive member: {member.name}")
if member.isdev() or member.isfifo():
raise SystemExit(f"special archive member: {member.name}")
if not (member.isdir() or member.isfile()
or member.issym() or member.islnk()):
raise SystemExit(f"unsupported archive member: {member.name}")
if member.issym():
target = posixpath.join(posixpath.dirname(member.name),
member.linkname)
if not under_root(target):
raise SystemExit(f"unsafe symlink: {member.name}")
if member.islnk() and not under_root(member.linkname):
raise SystemExit(f"unsafe hardlink: {member.name}")
PY
tar -xzf "$archive" -C "$tmp"
root="$tmp/$asset"
test -x "$root/whisper-server"
test -f "$root/libwhisper.so"
test -f "$root/libggml.so"
test -f "$root/libggml-base.so"
test -f "$root/libggml-vulkan.so"
compgen -G "$root/libggml-cpu-*.so" >/dev/null
test -f "$root/LICENSES/whisper.cpp-MIT.txt"
test -f "$root/LICENSES/cpp-httplib-MIT.txt"
test -f "$root/LICENSES/nlohmann-json-MIT.txt"
(cd "$root" && sha256sum --check SHA256SUMS)
# All shipped ELF objects must be relocatable and must not remember /work.
while IFS= read -r -d '' file; do
file "$file" | grep -q ELF || continue
dynamic=$(readelf -d "$file")
if ! grep -Fq 'Library runpath: [$ORIGIN]' <<<"$dynamic"; then
echo "runpath is not \$ORIGIN in $file" >&2
exit 1
fi
if grep -Eq '/(home|tmp|work)/' <<<"$dynamic"; then
echo "build path remains in $file" >&2
exit 1
fi
done < <(find "$root" -type f -print0)
# Vulkan remains a plugin dependency. The executable must start without a loader.
if readelf -d "$root/whisper-server" | grep -q 'libvulkan.so'; then
echo "whisper-server links Vulkan instead of loading it as a plugin" >&2
exit 1
fi
readelf -d "$root/libggml-vulkan.so" | grep -q 'libvulkan.so.1'
# Ubuntu 22.04 establishes the glibc ceiling promised by this artifact.
ROOT="$root" python3 - <<'PY'
import os, pathlib, re, subprocess
root = pathlib.Path(os.environ['ROOT'])
seen = {'GLIBC': set(), 'GLIBCXX': set(), 'CXXABI': set()}
external = {
'libc.so.6', 'libgcc_s.so.1', 'libm.so.6', 'libstdc++.so.6',
'libvulkan.so.1', 'ld-linux-x86-64.so.2',
}
for path in root.iterdir():
if not path.is_file() or path.is_symlink():
continue
header = subprocess.run(['readelf', '-h', path], text=True,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL).stdout
if not header:
continue
if 'Machine: Advanced Micro Devices X86-64' not in header:
raise SystemExit(f'wrong ELF architecture: {path.name}')
dynamic = subprocess.run(['readelf', '-d', path], text=True,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL).stdout
needed = re.findall(r'\(NEEDED\).*\[(.*?)\]', dynamic)
unexpected = [name for name in needed
if name not in external
and not re.fullmatch(
r'lib(?:whisper|ggml(?:-base)?)\.so\.\d+', name)]
if unexpected:
raise SystemExit(
f'unexpected DT_NEEDED in {path.name}: {unexpected}')
if path.name != 'libggml-vulkan.so' and 'libvulkan.so.1' in needed:
raise SystemExit(f'Vulkan is not plugin-only in {path.name}')
contents = path.read_bytes()
for marker in (b'/home/', b'/tmp/', b'/work/'):
if marker in contents:
raise SystemExit(
f'build path {marker!r} remains in {path.name}')
text = subprocess.run(['objdump', '-T', path], text=True,
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL).stdout
for family in seen:
pattern = rf'{family}_([0-9]+(?:\.[0-9]+)+)'
seen[family].update(tuple(map(int, version.split('.')))
for version in re.findall(pattern, text))
assert seen['GLIBC'] and max(seen['GLIBC']) <= (2, 34), max(seen['GLIBC'])
assert seen['GLIBCXX'] and max(seen['GLIBCXX']) <= (3, 4, 30), max(seen['GLIBCXX'])
assert seen['CXXABI'] and max(seen['CXXABI']) <= (1, 3, 13), max(seen['CXXABI'])
for family, versions in seen.items():
print(f'maximum {family} symbol:', '.'.join(map(str, max(versions))))
PY
python3 - "$root/$asset.cdx.json" <<'PY'
import json, sys
with open(sys.argv[1], encoding='utf-8') as stream:
doc = json.load(stream)
assert doc['bomFormat'] == 'CycloneDX'
assert doc['specVersion'] == '1.6'
assert doc['metadata']['component']['name'] == 'whisper-server'
assert len(doc['components']) >= 3
print('SBOM components:', len(doc['components']))
PY
LD_LIBRARY_PATH='' "$root/whisper-server" --help >/dev/null 2>&1
echo "structure: PASS"
+17
View File
@@ -53,6 +53,16 @@ class TimestampModel(unittest.TestCase):
self.assertEqual(api.timestamp_model("openai", "gpt-4o-transcribe"),
"whisper-1")
def test_openrouter_takes_the_file_model_that_was_set(self):
self.assertEqual(
api.timestamp_model("openrouter", "openai/gpt-4o-transcribe",
"openai/whisper-large-v3"),
"openai/whisper-large-v3")
def test_openrouter_with_no_file_model_falls_back_to_whisper(self):
self.assertEqual(api.timestamp_model("openrouter", "openai/gpt-4o-transcribe", ""),
"openai/whisper-1")
class Explain(DikteTest):
def error(self, status):
@@ -318,6 +328,13 @@ class TranscribeSegments(DikteTest):
api.transcribe_segments(OPENROUTER, self.wav)
self.assertEqual(multipart_fields(calls[0])["model"], "openai/whisper-1")
def test_openrouter_asks_for_the_file_model_when_one_is_set(self):
target = OPENROUTER._replace(file_model="mistralai/voxtral-mini-transcribe")
with fake_urlopen(self.reply([{"start": 0, "end": 1, "text": "hi"}])) as calls:
api.transcribe_segments(target, self.wav)
self.assertEqual(multipart_fields(calls[0])["model"],
"mistralai/voxtral-mini-transcribe")
def test_groq_stays_on_the_model_it_was_given(self):
target = GROQ._replace(model="whisper-large-v3")
with fake_urlopen(self.reply([{"start": 0, "end": 1, "text": "hi"}])) as calls:
+13
View File
@@ -220,6 +220,19 @@ class TranscribeTarget(DikteTest):
self.assertEqual(target.service, "OpenRouter")
self.assertEqual(target.api_key, "sk-or-test")
self.assertEqual(target.model, "openai/whisper-1")
self.assertEqual(target.file_model, "")
def test_openrouter_carries_its_file_model(self):
conf = self.config(transcribe_provider="openrouter",
openrouter_api_key="sk-or-test",
openrouter_file_model=" openai/whisper-large-v3 ")
self.assertEqual(conf.transcribe_target().file_model,
"openai/whisper-large-v3")
def test_only_openrouter_has_a_file_model(self):
conf = self.config(transcribe_provider="openai", openai_api_key="sk-test",
openrouter_file_model="openai/whisper-large-v3")
self.assertEqual(conf.transcribe_target().file_model, "")
def test_groq_when_it_is_picked(self):
conf = self.config(transcribe_provider="groq", groq_api_key="gsk-test",
+119
View File
@@ -205,6 +205,7 @@ class InstallProgram(Local):
# These fixtures are Ubuntu release archives. Keep checking that path
# on every host, including the Mac that checks the macOS backend.
self.patch_attr(sys, "platform", "linux")
self.patch_attr(ggml.platform, "machine", lambda: "x86_64")
# Built once, because the release listing has to publish its checksum
# and a tarball is not the same bytes twice.
self.archive = tarball({
@@ -221,6 +222,7 @@ class InstallProgram(Local):
def install(self, *names, archive=None):
self.patch_attr(ggml, "_arch", lambda: "x64")
self.patch_attr(ggml, "_has_vulkan", lambda: False)
blob = self.archive if archive is None else archive
with serving(self.release(*names, archive=blob), blob) as calls:
path = ggml.install_program(ggml.WHISPER)
@@ -276,6 +278,123 @@ class InstallProgram(Local):
tag, found = ggml._pick_asset(ggml.LLAMA)
self.assertEqual(tag, "b1")
self.assertEqual(found.name, "llama-b1-bin-ubuntu-x64.tar.gz")
def test_linux_x64_with_vulkan_takes_diktes_accelerated_build(self):
self.patch_attr(ggml, "_arch", lambda: "x64")
self.patch_attr(ggml, "_has_vulkan", lambda: True)
listing = self.release("whisper-bin-ubuntu-vulkan-x64.tar.gz")
listing["tag_name"] = "whisper.cpp-v1.9.3"
managed_sha = hashlib.sha256(self.archive).hexdigest()
with mock.patch.object(ggml, "MANAGED_WHISPER_SHA256", managed_sha,
create=True):
with fake_urlopen(listing, body(self.archive)) as calls:
path = ggml.install_program(ggml.WHISPER)
urls = [call.full_url for call in calls]
self.assertIn(
"/repos/yusufipk/dikte/releases/tags/whisper.cpp-v1.9.3",
urls[0],
)
self.assertTrue(urls[1].endswith(
"whisper-bin-ubuntu-vulkan-x64.tar.gz"))
self.assertTrue(os.path.isfile(path))
self.assertEqual("v1.9.3", ggml.installed_version(ggml.WHISPER))
self.assertFalse(ggml.vulkan_missing(ggml.WHISPER))
def test_an_explicit_whisper_version_still_comes_from_upstream(self):
self.patch_attr(ggml, "_arch", lambda: "x64")
self.patch_attr(ggml, "_has_vulkan", lambda: True)
listing = self.release("whisper-bin-ubuntu-x64.tar.gz")
with fake_urlopen(listing, body(self.archive)) as calls:
ggml.install_program(ggml.WHISPER, tag="v1.9.1")
self.assertIn(
"/repos/ggml-org/whisper.cpp/releases/tags/v1.9.1",
calls[0].full_url,
)
def test_linux_arm64_keeps_using_the_upstream_cpu_build(self):
self.patch_attr(ggml, "_arch", lambda: "arm64")
self.patch_attr(ggml.platform, "machine", lambda: "aarch64")
self.patch_attr(ggml, "_has_vulkan", lambda: True)
listing = self.release("whisper-bin-ubuntu-arm64.tar.gz")
with fake_urlopen(listing, body(self.archive)) as calls:
ggml.install_program(ggml.WHISPER)
self.assertIn(
"/repos/ggml-org/whisper.cpp/releases/latest",
calls[0].full_url,
)
def test_linux_non_x86_does_not_try_the_managed_x64_build(self):
self.patch_attr(ggml, "_has_vulkan", lambda: True)
listing = self.release("whisper-bin-ubuntu-arm64.tar.gz")
with mock.patch("platform.machine", return_value="ppc64le"):
with fake_urlopen(listing, listing) as calls:
with self.assertRaises(ggml.LocalError):
ggml.install_program(ggml.WHISPER)
self.assertIn(
"/repos/ggml-org/whisper.cpp/releases/latest",
calls[0].full_url,
)
def test_a_missing_managed_build_falls_back_to_upstream_cpu(self):
self.patch_attr(ggml, "_arch", lambda: "x64")
self.patch_attr(ggml, "_has_vulkan", lambda: True)
managed = self.release("Dikte-1.1.0-x86_64.AppImage")
managed["tag_name"] = "whisper.cpp-v1.9.3"
upstream = self.release("whisper-bin-ubuntu-x64.tar.gz")
with fake_urlopen(managed, upstream, body(self.archive)) as calls:
path = ggml.install_program(ggml.WHISPER)
urls = [call.full_url for call in calls]
self.assertIn(
"/repos/yusufipk/dikte/releases/tags/whisper.cpp-v1.9.3",
urls[0],
)
self.assertIn("/repos/ggml-org/whisper.cpp/releases/latest", urls[1])
self.assertTrue(urls[2].endswith("whisper-bin-ubuntu-x64.tar.gz"))
self.assertTrue(os.path.isfile(path))
def test_a_managed_build_with_an_unreviewed_digest_falls_back(self):
self.patch_attr(ggml, "_has_vulkan", lambda: True)
managed = self.release("whisper-bin-ubuntu-vulkan-x64.tar.gz")
managed["assets"][0]["digest"] = "sha256:" + "0" * 64
upstream = self.release("whisper-bin-ubuntu-x64.tar.gz")
with fake_urlopen(managed, upstream, body(self.archive)) as calls:
try:
path = ggml.install_program(ggml.WHISPER)
except ggml.LocalError as exc:
self.fail(f"unreviewed digest did not fall back: {exc}")
urls = [call.full_url for call in calls]
self.assertEqual(3, len(urls))
self.assertTrue(urls[2].endswith("whisper-bin-ubuntu-x64.tar.gz"))
self.assertTrue(os.path.isfile(path))
def test_an_unavailable_managed_release_falls_back_to_upstream_cpu(self):
self.patch_attr(ggml, "_arch", lambda: "x64")
self.patch_attr(ggml, "_has_vulkan", lambda: True)
upstream = self.release("whisper-bin-ubuntu-x64.tar.gz")
with fake_urlopen(http_error(404), upstream,
body(self.archive)) as calls:
path = ggml.install_program(ggml.WHISPER)
self.assertEqual(3, len(calls))
self.assertTrue(calls[2].full_url.endswith(
"whisper-bin-ubuntu-x64.tar.gz"))
self.assertTrue(os.path.isfile(path))
def test_a_fallback_to_the_processor_build_is_there_to_be_shown(self):
"""Until the Vulkan package is published every download lands the
processor build, and a graphics card sitting idle looks exactly like
one being used. The window asks this and says so."""
self.patch_attr(ggml, "_arch", lambda: "x64")
self.patch_attr(ggml, "_has_vulkan", lambda: True)
managed = self.release("Dikte-1.1.0-x86_64.AppImage")
managed["tag_name"] = "whisper.cpp-v1.9.3"
upstream = self.release("whisper-bin-ubuntu-x64.tar.gz")
with fake_urlopen(managed, upstream, body(self.archive)):
ggml.install_program(ggml.WHISPER)
self.assertTrue(ggml.vulkan_missing(ggml.WHISPER))
def test_a_machine_with_no_vulkan_is_not_told_it_is_missing_one(self):
# Nothing was on offer to fall back from, so there is nothing to say.
self.install("whisper-bin-ubuntu-x64.tar.gz")
self.assertFalse(ggml.vulkan_missing(ggml.WHISPER))
def test_a_release_with_nothing_for_this_machine_says_so(self):
self.patch_attr(ggml, "_arch", lambda: "x64")
+217
View File
@@ -0,0 +1,217 @@
"""The release build that makes Linux Vulkan a one-click install."""
import hashlib
import io
import json
import os
import pathlib
import shutil
import subprocess
import sys
import tarfile
import tempfile
import unittest
from dikte import ggml
ROOT = pathlib.Path(__file__).parents[1]
PACKAGING = ROOT / "packaging" / "whisper-vulkan"
WORKFLOW = ROOT / ".github" / "workflows" / "whisper-vulkan.yml"
class WhisperVulkanPackaging(unittest.TestCase):
@unittest.skipUnless(sys.platform != "win32" and shutil.which("bash"),
"bash syntax check is unavailable")
def test_the_release_scripts_parse_as_shell(self):
for name in ("build-package.sh", "validate-package.sh",
"smoke-runtime.sh"):
script = PACKAGING / name
checked = subprocess.run(
["bash", "-n", script], capture_output=True, text=True,
)
self.assertEqual("", checked.stderr)
self.assertEqual(0, checked.returncode)
def test_the_workflow_builds_validates_smokes_and_publishes(self):
workflow = WORKFLOW.read_text(encoding="utf-8")
for step in ("Build deterministic archive",
"Verify reviewed archive digest",
"Validate archive and ELF contract",
"CPU fallback smoke test (no Vulkan loader)",
"Vulkan loader present, no device smoke test",
"Vulkan plugin-load smoke test (Mesa llvmpipe)",
"Publish dependency release"):
self.assertIn(step, workflow)
self.assertNotRegex(workflow, r"uses: [^\n]+@v\d+(?:\s|$)")
def test_publish_is_safe_for_dikte_and_limited_to_reviewed_master(self):
workflow = WORKFLOW.read_text(encoding="utf-8")
self.assertGreaterEqual(workflow.count("persist-credentials: false"), 2)
self.assertIn("github.ref == 'refs/heads/master'", workflow)
self.assertIn("--prerelease", workflow)
self.assertIn("--latest=false", workflow)
self.assertIn("--verify-tag", workflow)
self.assertIn("refusing to replace existing tag", workflow)
self.assertIn("^[0-9]+\\.[0-9]+\\.[0-9]+$", workflow)
self.assertIn("^[0-9a-f]{40}$", workflow)
publish_script = workflow.split(" - name: Publish dependency release", 1)[1]
publish_script = publish_script.split(" run: |", 1)[1]
self.assertNotIn("${{ inputs.", publish_script)
def test_bundle_ci_runs_only_for_what_the_bundle_is_built_from(self):
"""A 45 minute build on a README typo is a tax on every other change.
What ties ggml.py to the release is checked in this file instead, and
this file runs on every pull request in milliseconds."""
workflow = WORKFLOW.read_text(encoding="utf-8")
trigger = workflow.split("workflow_dispatch:", 1)[0]
self.assertIn("- packaging/whisper-vulkan/**", trigger)
self.assertIn("- .github/workflows/whisper-vulkan.yml", trigger)
for path in ("dikte/ggml.py", "tests/test_ggml.py",
"tests/test_packaging.py", "README.md", "README.tr.md"):
self.assertNotIn(f"- {path}", trigger)
def test_the_smoke_tests_run_what_dikte_runs(self):
"""-ng is what Dikte passes when its GPU setting is off, and a run
with it never asks for a backend at all. The three runs that have to
hold are the ones without it: no loader, a loader with nothing behind
it, and a working device."""
script = (PACKAGING / "smoke-runtime.sh").read_text(encoding="utf-8")
code = "\n".join(line for line in script.splitlines()
if not line.lstrip().startswith("#"))
self.assertNotIn("-ng", code)
for mode in ("cpu)", "noicd)", "vulkan)"):
self.assertIn(mode, script)
self.assertTrue((PACKAGING / "Dockerfile.runtime-noicd").is_file())
def test_an_unreviewed_version_is_reported_and_never_published(self):
"""The digest of a version nobody has reviewed cannot be known before
it is built, so the gate cannot be the only way through."""
workflow = WORKFLOW.read_text(encoding="utf-8")
self.assertIn("expected_sha256", workflow)
self.assertIn(
"refusing to publish an archive whose digest has not been reviewed",
workflow)
def test_the_shape_of_the_inputs_is_checked_before_they_are_used(self):
workflow = WORKFLOW.read_text(encoding="utf-8")
self.assertLess(workflow.index("- name: Validate source coordinates"),
workflow.index("- name: Check out pinned whisper.cpp"))
def test_the_validator_checks_tar_links_before_extraction(self):
validator = (PACKAGING / "validate-package.sh").read_text(
encoding="utf-8")
for check in ("member.issym()", "member.islnk()", "member.isdev()"):
self.assertIn(check, validator)
@unittest.skipUnless(sys.platform == "linux" and shutil.which("bash"),
"Linux packaging test is unavailable")
def test_the_validator_rejects_an_escaping_symlink(self):
asset = "whisper-bin-ubuntu-vulkan-x64"
with tempfile.TemporaryDirectory() as temporary:
output = pathlib.Path(temporary)
archive = output / f"{asset}.tar.gz"
with tarfile.open(archive, "w:gz") as bundle:
link = tarfile.TarInfo(f"{asset}/whisper-server")
link.type = tarfile.SYMTYPE
link.linkname = "/etc/passwd"
bundle.addfile(link, io.BytesIO())
digest = hashlib.sha256(archive.read_bytes()).hexdigest()
(output / f"{asset}.tar.gz.sha256").write_text(
f"{digest} {asset}.tar.gz\n", encoding="utf-8",
)
checked = subprocess.run(
["bash", PACKAGING / "validate-package.sh"],
env=os.environ | {"OUT_DIR": str(output)},
capture_output=True, text=True,
)
self.assertNotEqual(0, checked.returncode)
self.assertIn("unsafe symlink", checked.stderr)
def test_the_validator_checks_elf_architecture_dependencies_and_paths(self):
validator = (PACKAGING / "validate-package.sh").read_text(
encoding="utf-8")
for check in ("Advanced Micro Devices X86-64", "unexpected DT_NEEDED",
"path.read_bytes()"):
self.assertIn(check, validator)
def test_the_builder_and_its_downloads_are_pinned(self):
dockerfile = (PACKAGING / "Dockerfile.build").read_text(
encoding="utf-8")
self.assertRegex(dockerfile, r"FROM ubuntu@sha256:[0-9a-f]{64}")
self.assertIn("CMAKE_SHA256=", dockerfile)
self.assertIn("libvulkan-dev=", dockerfile)
self.assertIn("shaderc=", dockerfile)
key = (PACKAGING / "lunarg-signing-key-pub.asc").read_bytes()
key = key.replace(b"\r\n", b"\n")
self.assertEqual(
"aa1c3c29673140e77f0d6a9aaeed5d9b5621e305ead51c59fae4458bbb4df92b",
hashlib.sha256(key).hexdigest(),
)
def test_the_bundle_has_portable_dynamic_backends(self):
script = (PACKAGING / "build-package.sh").read_text(
encoding="utf-8")
for flag in ("GGML_BACKEND_DL=ON", "GGML_CPU_ALL_VARIANTS=ON",
"GGML_NATIVE=OFF", "GGML_OPENMP=OFF",
"GGML_VULKAN=ON"):
self.assertIn(flag, script)
self.assertIn("libggml-cpu*.so", script)
self.assertIn("libggml-vulkan.so", script)
def test_the_dependency_release_matches_the_installer(self):
workflow = WORKFLOW.read_text(encoding="utf-8")
script = (PACKAGING / "build-package.sh").read_text(
encoding="utf-8")
self.assertEqual("whisper.cpp-v1.9.3",
ggml.MANAGED_WHISPER_RELEASE)
self.assertEqual("v1.9.3", ggml.MANAGED_WHISPER_VERSION)
self.assertIn("RELEASE_TAG: whisper.cpp-v${{ inputs.whisper_version }}",
workflow)
self.assertIn("WHISPER_VERSION:=1.9.3", script)
commit = "371b5a7561823ab2bb32142d2751e35e7534727b"
self.assertIn(f"WHISPER_COMMIT:={commit}", script)
self.assertIn(commit, workflow)
self.assertIn(ggml.MANAGED_WHISPER_VULKAN, workflow)
self.assertIn(ggml.MANAGED_WHISPER_SHA256, workflow)
def test_the_bundle_carries_metadata_and_all_required_licenses(self):
script = (PACKAGING / "build-package.sh").read_text(
encoding="utf-8")
for name in ("BUILD-INFO.json", "SHA256SUMS", ".cdx.json"):
self.assertIn(name, script)
for name in ("cpp-httplib-MIT.txt", "nlohmann-json-MIT.txt"):
self.assertTrue((PACKAGING / "licenses" / name).is_file())
def _make_test_sbom(self):
with tempfile.TemporaryDirectory() as temporary:
root = pathlib.Path(temporary)
(root / "whisper-server").write_bytes(b"elf")
sbom = root / "whisper-bin-ubuntu-vulkan-x64.cdx.json"
environment = os.environ | {
"ROOT": str(root),
"VERSION": "1.9.3",
"COMMIT": "371b5a7561823ab2bb32142d2751e35e7534727b",
"EPOCH": "1787219223",
}
with sbom.open("w", encoding="utf-8") as output:
subprocess.run(
[sys.executable, PACKAGING / "make-sbom.py"],
env=environment, stdout=output, check=True,
)
return json.loads(sbom.read_text(encoding="utf-8")), sbom.name
def test_the_sbom_does_not_record_the_file_being_written(self):
document, sbom_name = self._make_test_sbom()
names = {component["name"] for component in document["components"]}
self.assertNotIn(sbom_name, names)
def test_the_sbom_lists_ggml(self):
document, _ = self._make_test_sbom()
names = {component["name"] for component in document["components"]}
self.assertIn("ggml", names)
if __name__ == "__main__":
unittest.main()
+64
View File
@@ -6,6 +6,7 @@ save, so a setting added to one half and not the other is silently reset the
next time anybody presses Save. That is the failure this catches.
"""
import json
import os
import sys
import time
@@ -28,6 +29,7 @@ from dikte import overlay as overlay_module
from dikte import paste
from dikte import settings_ui
from dikte import update
from dikte.i18n import t
from tests.support import DikteTest, only_these_tools
# The harness below replaces this method on the class so that opening a window
@@ -506,6 +508,20 @@ class Settings(DikteTest):
self.assertEqual(conf["transcribe_model"], "gpt-4o-transcribe")
self.assertEqual(conf["groq_transcribe_model"], "whisper-large-v3")
def test_the_file_model_is_saved_and_only_shown_for_openrouter(self):
self.write_config({"transcribe_provider": "openrouter",
"openrouter_file_model": "openai/whisper-large-v3"})
conf = cfg.Config()
window = self.window(conf)
self.assertEqual(window.file_model.currentText(), "openai/whisper-large-v3")
self.assertTrue(window.stt_form.isRowVisible(window.file_model_row))
window.file_model.setCurrentText(" deepgram/nova-3 ")
window._save()
self.assertEqual(conf["openrouter_file_model"], "deepgram/nova-3")
window.transcribe_provider.setCurrentIndex(
window.transcribe_provider.findData("openai"))
self.assertFalse(window.stt_form.isRowVisible(window.file_model_row))
def test_the_provider_box_offers_every_provider_config_knows(self):
window = self.window(cfg.Config())
offered = [window.transcribe_provider.itemData(i)
@@ -1284,6 +1300,54 @@ class LocalModels(DikteTest):
time.sleep(0.05)
_app.processEvents()
self.assertEqual(fetch.call_count, 1)
def test_a_processor_build_where_the_vulkan_one_belongs_says_so(self):
# The Vulkan whisper-server is published by hand, and until it is
# there the download lands upstream's processor build. Said nowhere,
# an idle graphics card looks exactly like one that is being used.
binary = self.path("bin/whisper/v1.9.3/whisper-server")
binary.parent.mkdir(parents=True)
binary.write_text("")
binary.chmod(0o755)
self.path("bin/whisper/installed.json").write_text(json.dumps(
{"tag": "v1.9.3", "binary": str(binary), "backend": "processor"}))
# A whisper-server on this machine's PATH would win over the download.
self.patch_attr(ggml.shutil, "which", lambda name: None)
label = self.window(cfg.Config()).local_whisper.program_label.text()
self.assertIn("v1.9.3", label)
self.assertIn("Vulkan", label)
def test_an_ordinary_install_is_reported_without_a_word_about_vulkan(self):
binary = self.path("bin/whisper/v1.9.3/whisper-server")
binary.parent.mkdir(parents=True)
binary.write_text("")
binary.chmod(0o755)
self.path("bin/whisper/installed.json").write_text(json.dumps(
{"tag": "v1.9.3", "binary": str(binary)}))
self.patch_attr(ggml.shutil, "which", lambda name: None)
label = self.window(cfg.Config()).local_whisper.program_label.text()
self.assertIn("v1.9.3", label)
self.assertNotIn("Vulkan", label)
def test_a_downloaded_program_can_still_be_asked_for_again(self):
# The button used to disappear the moment anything landed, which left
# no way to pick up a newer whisper.cpp, or the Vulkan build on a
# machine whose driver was installed after Dikte was.
binary = self.path("bin/whisper/v1.9.3/whisper-server")
binary.parent.mkdir(parents=True)
binary.write_text("")
binary.chmod(0o755)
self.path("bin/whisper/installed.json").write_text(json.dumps(
{"tag": "v1.9.3", "binary": str(binary)}))
self.patch_attr(ggml.shutil, "which", lambda name: None)
box = self.window(cfg.Config()).local_whisper
self.assertTrue(box.install_button.isVisibleTo(box))
self.assertEqual(box.install_button.text(), t("Download again"))
def test_a_system_copy_is_not_offered_for_download(self):
# Nothing Dikte downloads would be run while one is on the PATH.
self.patch_attr(ggml.shutil, "which", lambda name: "/usr/bin/" + name)
box = self.window(cfg.Config()).local_whisper
self.assertFalse(box.install_button.isVisibleTo(box))
def test_only_the_chosen_transcriber_is_on_screen(self):
window = self.window(self.config(transcribe_provider="openai"))