mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 19:06:11 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2af5ec671c | ||
|
|
825f089fe9 | ||
|
|
5a4ae8c315 | ||
|
|
22d2a40341 | ||
|
|
44db26c459 | ||
|
|
3e3cb21bc3 | ||
|
|
70bc4c16fa | ||
|
|
b13b08fc38 | ||
|
|
e282e6b0cf | ||
|
|
06e578d901 | ||
|
|
fedb4fe5c0 | ||
|
|
84c79b2d68 | ||
|
|
74e17cbd15 | ||
|
|
f36348d536 | ||
|
|
08fc2e4a9d | ||
|
|
4d0c0e29f1 | ||
|
|
4ae44720c8 | ||
|
|
74c37c0112 | ||
|
|
fb80d85332 | ||
|
|
fff9cd1c55 | ||
|
|
cfeed2af8c | ||
|
|
156d8bf8e8 | ||
|
|
5f6e4ad782 | ||
|
|
10ef4e62a9 | ||
|
|
00a5283adb | ||
|
|
c3bf328eef | ||
|
|
3ad41d84f9 | ||
|
|
59180b8eff | ||
|
|
956c3eaf3c | ||
|
|
1f57455a34 | ||
|
|
e0eae4d8fe | ||
|
|
eda1398a2b | ||
|
|
6e307bd8d0 | ||
|
|
310ef8d7cf | ||
|
|
4f304e3d94 |
@@ -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"
|
||||||
@@ -161,9 +161,14 @@ running.
|
|||||||
- **It all runs on this machine by default.** Speech to text on whisper.cpp and
|
- **It all runs on this machine by default.** Speech to text on whisper.cpp and
|
||||||
cleanup on llama.cpp, neither installed beforehand: the settings window fetches
|
cleanup on llama.cpp, neither installed beforehand: the settings window fetches
|
||||||
the program and the model, verifies the sha256 and refuses a download published
|
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
|
without one, then keeps a server alive while you dictate and hands the memory
|
||||||
|
back once it has sat unused for ten minutes. The model list is
|
||||||
|
grouped by model rather than by file size, and the row this machine's memory
|
||||||
|
and graphics can take is marked. The graphics card is
|
||||||
reached through CUDA, ROCm or Vulkan where the build allows. No key, no
|
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
|
- **Silence never reaches the API.** Handed near-silence, a transcription model
|
||||||
invents a sentence instead of returning nothing ("Thanks for watching", or in
|
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
|
Turkish "Altyazı M.K."). A recording is dropped when nothing rose 10 dB above
|
||||||
|
|||||||
+6
-1
@@ -158,8 +158,13 @@ olmasını ister.
|
|||||||
whisper.cpp, temizleme llama.cpp üzerinde; ikisini de önceden kurman gerekmez:
|
whisper.cpp, temizleme llama.cpp üzerinde; ikisini de önceden kurman gerekmez:
|
||||||
ayarlar penceresi programı ve modeli indirir, sha256'sını doğrular,
|
ayarlar penceresi programı ve modeli indirir, sha256'sını doğrular,
|
||||||
checksum'suz yayınlanmış bir indirmeyi reddeder, sen dikte ettikçe sunucuyu
|
checksum'suz yayınlanmış bir indirmeyi reddeder, sen dikte ettikçe sunucuyu
|
||||||
ayakta tutar. Derleme destekliyorsa ekran kartına CUDA, ROCm ya da Vulkan
|
ayakta tutar ve on dakika kullanılmayan modelin belleğini geri verir. Model
|
||||||
|
listesi dosya boyutuna değil modele göre gruplanır ve bu
|
||||||
|
makinenin belleğine ve ekran kartına uyan satır işaretlenir. Derleme
|
||||||
|
destekliyorsa ekran kartına CUDA, ROCm ya da Vulkan
|
||||||
üzerinden ulaşılır. Anahtar yok, hesap yok, makineden çıkan bir şey yok.
|
ü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
|
- **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
|
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
|
kaydın kendi* gürültü tabanının 10 dB üstüne en az 0,3 saniye çıkan bir şey
|
||||||
|
|||||||
+1
-1
@@ -10,4 +10,4 @@ business loading Qt to answer one question.
|
|||||||
# both the .dmg's Info.plist and the AppImage's file name are built from it. A
|
# both the .dmg's Info.plist and the AppImage's file name are built from it. A
|
||||||
# build off master rather than off a tag appends the commit to it, so that a
|
# build off master rather than off a tag appends the commit to it, so that a
|
||||||
# bug report from someone running "latest" names a commit.
|
# bug report from someone running "latest" names a commit.
|
||||||
__version__ = "1.0.2"
|
__version__ = "1.1.0"
|
||||||
|
|||||||
+94
-21
@@ -48,22 +48,33 @@ LOCAL_TIMEOUT = 3600
|
|||||||
|
|
||||||
# Where a transcription request goes; built by config.Config.transcribe_target().
|
# 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
|
# `service` is the name the user sees in an error, `provider` the one the code
|
||||||
# branches on.
|
# branches on. `file_model` is what a timestamped run asks for instead of
|
||||||
Target = collections.namedtuple("Target", "provider service api_key base_url model")
|
# `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.
|
"""Which model answers with segment times.
|
||||||
|
|
||||||
OpenAI keeps them to whisper-1 and OpenRouter namespaces that id. Everything
|
OpenAI keeps them to whisper-1. Everything Groq transcribes with is a
|
||||||
Groq transcribes with is a whisper, so the model already chosen does it and
|
whisper, so the model already chosen does it and the fallback is only for a
|
||||||
the fallback is only for a provider left on its default. So is everything the
|
provider left on its default. So is everything the local server runs,
|
||||||
local server runs, whatever the file is called, and there asking for another
|
whatever the file is called, and there asking for another model would name
|
||||||
model would name one it has never heard of.
|
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"):
|
if provider in ("groq", "local"):
|
||||||
return selected or "whisper-large-v3-turbo"
|
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
|
# What a gateway in front of the model answers of its own accord: the request
|
||||||
@@ -376,12 +387,17 @@ def _transcribe_request(target, audio_path, language, prompt, response_format,
|
|||||||
if granularity:
|
if granularity:
|
||||||
fields.append(("timestamp_granularities[]", granularity))
|
fields.append(("timestamp_granularities[]", granularity))
|
||||||
body, ctype = _multipart(fields, "file", audio_path)
|
body, ctype = _multipart(fields, "file", audio_path)
|
||||||
|
# An hour of meeting takes the local server a while, and the idle unload has
|
||||||
|
# to count that as the model being used rather than as nobody wanting it.
|
||||||
|
held = (ggml.whisper.busy() if target.provider == "local"
|
||||||
|
else contextlib.nullcontext())
|
||||||
try:
|
try:
|
||||||
return _request(
|
with held:
|
||||||
f"{target.base_url.rstrip('/')}/audio/transcriptions", body,
|
return _request(
|
||||||
_headers(target.provider, target.api_key, ctype), timeout=timeout,
|
f"{target.base_url.rstrip('/')}/audio/transcriptions", body,
|
||||||
aborter=aborter,
|
_headers(target.provider, target.api_key, ctype), timeout=timeout,
|
||||||
)
|
aborter=aborter,
|
||||||
|
)
|
||||||
except ApiError as exc:
|
except ApiError as exc:
|
||||||
if target.provider == "local":
|
if target.provider == "local":
|
||||||
raise local_failure(target.service, ggml.whisper, exc) from None
|
raise local_failure(target.service, ggml.whisper, exc) from None
|
||||||
@@ -444,7 +460,8 @@ def transcribe_segments(target, audio_path, language="", prompt="", timeout=300,
|
|||||||
aborter=None):
|
aborter=None):
|
||||||
"""[(start_seconds, end_seconds, text)] using whisper-1's verbose response."""
|
"""[(start_seconds, end_seconds, text)] using whisper-1's verbose response."""
|
||||||
data = _transcribe_request(
|
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",
|
audio_path, language, prompt, "verbose_json",
|
||||||
granularity="segment", timeout=timeout, aborter=aborter,
|
granularity="segment", timeout=timeout, aborter=aborter,
|
||||||
)
|
)
|
||||||
@@ -506,22 +523,65 @@ def _thinking(payload, provider, reasoning):
|
|||||||
payload["reasoning"] = {"effort": reasoning, "exclude": True}
|
payload["reasoning"] = {"effort": reasoning, "exclude": True}
|
||||||
|
|
||||||
|
|
||||||
def local_ceiling(text):
|
# Room for the thinking on this machine, one budget per rung of the settings
|
||||||
|
# ladder. llama.cpp counts the thinking towards max_tokens along with the answer
|
||||||
|
# it precedes, so a ceiling sized for the answer alone leaves a model that
|
||||||
|
# thinks nothing to answer with. The rungs double, starting where a small model
|
||||||
|
# lands when it barely thinks at all: cleanup is punctuation, and locally every
|
||||||
|
# one of these tokens is also a second of somebody standing in front of the
|
||||||
|
# screen, so the low rungs are the ones meant to be used.
|
||||||
|
THINKING_ROOM = {
|
||||||
|
"minimal": 256, "low": 512, "medium": 1024,
|
||||||
|
"high": 2048, "xhigh": 4096, "max": 8192,
|
||||||
|
}
|
||||||
|
# An empty setting leaves it to the model, and the templates that can think
|
||||||
|
# think by default. Room for a middling amount of it, since there is no way to
|
||||||
|
# ask which kind of model this is.
|
||||||
|
DEFAULT_THINKING_ROOM = THINKING_ROOM["medium"]
|
||||||
|
|
||||||
|
|
||||||
|
def local_ceiling(text, reasoning="", context=0, prompt=""):
|
||||||
"""How much of a reply is worth waiting for from a model on this machine.
|
"""How much of a reply is worth waiting for from a model on this machine.
|
||||||
|
|
||||||
Cleanup gives back what it was given, near enough, so a reply several times
|
Cleanup gives back what it was given, near enough, so a reply several times
|
||||||
the length of the transcript is a model that has lost the thread rather than
|
the length of the transcript is a model that has lost the thread rather than
|
||||||
one doing the job. A small one will happily repeat the transcript until the
|
one doing the job. A small one will happily repeat the transcript until the
|
||||||
context is full, and every one of those tokens is a second of somebody
|
context is full, and every one of those tokens is a second of somebody
|
||||||
waiting. A hosted model is left alone: there the same runaway is rare, and a
|
waiting, with only the hour-long local timeout underneath. A hosted model is
|
||||||
ceiling would cut the minutes short instead.
|
left alone: there the same runaway is rare, and a ceiling would cut the
|
||||||
|
minutes short instead.
|
||||||
|
|
||||||
|
The answer's share is the transcript's length in characters spent as a
|
||||||
|
budget in tokens, so what it really allows is two to four times the
|
||||||
|
transcript depending on how well the language tokenises. Turkish sits at the
|
||||||
|
tight end of that and still has room to spare for a reply that is meant to
|
||||||
|
come back the same length it went in.
|
||||||
|
|
||||||
|
Thinking is added on top of that share rather than taken out of it. Sharing
|
||||||
|
one budget is what makes turning thinking up quietly cost the answer, and on
|
||||||
|
a short dictation the 512 floor is the whole budget, so the answer is what
|
||||||
|
goes missing first.
|
||||||
|
|
||||||
|
`context` is what the server was started with, and the whole of it is the
|
||||||
|
real limit whatever is asked for here: a ceiling above it is not a ceiling,
|
||||||
|
because the runaway it exists to stop would run to the end of the context
|
||||||
|
instead. So the ceiling is held below what the prompt leaves. Two characters
|
||||||
|
to the token is under any tokeniser's rate for natural language, Turkish
|
||||||
|
included, which makes the reserve an over-estimate rather than a promise of
|
||||||
|
room that is not there.
|
||||||
"""
|
"""
|
||||||
return max(512, len(text))
|
answer = max(512, len(text))
|
||||||
|
if reasoning != "none":
|
||||||
|
answer += THINKING_ROOM.get(reasoning, DEFAULT_THINKING_ROOM)
|
||||||
|
context = int(context or 0)
|
||||||
|
if not context:
|
||||||
|
return answer
|
||||||
|
return max(256, min(answer, context - (len(prompt) + len(text)) // 2))
|
||||||
|
|
||||||
|
|
||||||
def cleanup(text, api_key, model, system_prompt, reasoning="",
|
def cleanup(text, api_key, model, system_prompt, reasoning="",
|
||||||
base_url=OPENROUTER_URL, timeout=180, provider="openrouter",
|
base_url=OPENROUTER_URL, timeout=180, provider="openrouter",
|
||||||
service="OpenRouter", aborter=None):
|
service="OpenRouter", aborter=None, context=0):
|
||||||
if not api_key and provider != "local-llm":
|
if not api_key and provider != "local-llm":
|
||||||
raise ApiError(t("{service} API key is empty. Add it in Settings.",
|
raise ApiError(t("{service} API key is empty. Add it in Settings.",
|
||||||
service=service))
|
service=service))
|
||||||
@@ -534,7 +594,8 @@ def cleanup(text, api_key, model, system_prompt, reasoning="",
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
if provider == "local-llm":
|
if provider == "local-llm":
|
||||||
payload["max_tokens"] = local_ceiling(text)
|
payload["max_tokens"] = local_ceiling(text, reasoning, context,
|
||||||
|
system_prompt)
|
||||||
_thinking(payload, provider, reasoning)
|
_thinking(payload, provider, reasoning)
|
||||||
try:
|
try:
|
||||||
data = _request(
|
data = _request(
|
||||||
@@ -558,6 +619,13 @@ def cleanup(text, api_key, model, system_prompt, reasoning="",
|
|||||||
raise ApiError(t("The cleanup model spent its whole reply on "
|
raise ApiError(t("The cleanup model spent its whole reply on "
|
||||||
"thinking. Set Thinking to \u201cOff\u201d."))
|
"thinking. Set Thinking to \u201cOff\u201d."))
|
||||||
raise ApiError(t("The cleanup model returned an empty reply."))
|
raise ApiError(t("The cleanup model returned an empty reply."))
|
||||||
|
if choices[0].get("finish_reason") == "length":
|
||||||
|
# Cut off at somebody's ceiling: ours locally, the provider's otherwise.
|
||||||
|
# What came back is a sentence that stops mid-word, and cleanup is meant
|
||||||
|
# to hand back the whole dictation, so the half is refused rather than
|
||||||
|
# returned. The callers keep the transcript they started with, which is
|
||||||
|
# the better of the two.
|
||||||
|
raise ApiError(t("The cleanup model was cut off before it finished."))
|
||||||
return content
|
return content
|
||||||
|
|
||||||
|
|
||||||
@@ -593,6 +661,11 @@ def chat(messages, api_key, model, system_prompt, reasoning="",
|
|||||||
content = ((choices[0].get("message") or {}).get("content") or "").strip()
|
content = ((choices[0].get("message") or {}).get("content") or "").strip()
|
||||||
if not content:
|
if not content:
|
||||||
raise ApiError(t("The model returned an empty reply."))
|
raise ApiError(t("The model returned an empty reply."))
|
||||||
|
if choices[0].get("finish_reason") == "length":
|
||||||
|
# An answer that stops mid-sentence reads like a whole one once it has
|
||||||
|
# been pasted, so it is refused here for the same reason cleanup refuses
|
||||||
|
# a half transcript.
|
||||||
|
raise ApiError(t("The model was cut off before it finished."))
|
||||||
return content
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+44
-6
@@ -164,12 +164,14 @@ class Dikte:
|
|||||||
self._front_watch = None
|
self._front_watch = None
|
||||||
|
|
||||||
self.overlay = Overlay(self.conf["overlay_corner"],
|
self.overlay = Overlay(self.conf["overlay_corner"],
|
||||||
screen_name=self.conf["overlay_screen"])
|
screen_name=self.conf["overlay_screen"],
|
||||||
|
follow_pointer=self.conf["overlay_follows_pointer"])
|
||||||
# The agent's indicator sits on top of the dictation one when both are
|
# The agent's indicator sits on top of the dictation one when both are
|
||||||
# up, and drops into the corner when it is alone there.
|
# up, and drops into the corner when it is alone there.
|
||||||
self.ask_overlay = Overlay(self.conf["overlay_corner"], below=self.overlay,
|
self.ask_overlay = Overlay(self.conf["overlay_corner"], below=self.overlay,
|
||||||
dismissable=True,
|
dismissable=True,
|
||||||
screen_name=self.conf["overlay_screen"])
|
screen_name=self.conf["overlay_screen"],
|
||||||
|
follow_pointer=self.conf["overlay_follows_pointer"])
|
||||||
self.recorder = audio.Recorder()
|
self.recorder = audio.Recorder()
|
||||||
self.pipeline = Pipeline(self.conf)
|
self.pipeline = Pipeline(self.conf)
|
||||||
self.ask_pipeline = Pipeline(self.conf)
|
self.ask_pipeline = Pipeline(self.conf)
|
||||||
@@ -287,6 +289,11 @@ class Dikte:
|
|||||||
self.update_action.triggered.connect(self.open_release_page)
|
self.update_action.triggered.connect(self.open_release_page)
|
||||||
self.menu.addAction(self.update_action)
|
self.menu.addAction(self.update_action)
|
||||||
|
|
||||||
|
# Named in _refresh_tray, which is where the loaded models are known.
|
||||||
|
self.unload_action = QAction("", self.menu)
|
||||||
|
self.unload_action.triggered.connect(self.unload_models)
|
||||||
|
self.menu.addAction(self.unload_action)
|
||||||
|
|
||||||
self.settings_action = QAction(t("Settings…"), self.menu)
|
self.settings_action = QAction(t("Settings…"), self.menu)
|
||||||
self.settings_action.triggered.connect(self.open_settings)
|
self.settings_action.triggered.connect(self.open_settings)
|
||||||
self.menu.addAction(self.settings_action)
|
self.menu.addAction(self.settings_action)
|
||||||
@@ -301,6 +308,10 @@ class Dikte:
|
|||||||
self.menu.addAction(self.quit_action)
|
self.menu.addAction(self.quit_action)
|
||||||
|
|
||||||
self.tray.setContextMenu(self.menu)
|
self.tray.setContextMenu(self.menu)
|
||||||
|
# A model unloads itself in the background, so what the unload row says
|
||||||
|
# goes stale between state changes. Refreshed as the menu opens, which
|
||||||
|
# is the only moment anybody reads it.
|
||||||
|
self.menu.aboutToShow.connect(self._refresh_tray)
|
||||||
self.tray.setToolTip(t("Dikte: ready"))
|
self.tray.setToolTip(t("Dikte: ready"))
|
||||||
self.tray.activated.connect(self._tray_clicked)
|
self.tray.activated.connect(self._tray_clicked)
|
||||||
self._refresh_update()
|
self._refresh_update()
|
||||||
@@ -396,6 +407,21 @@ class Dikte:
|
|||||||
)
|
)
|
||||||
self.ask_cancel_action.setEnabled(self.ask_state == BUSY)
|
self.ask_cancel_action.setEnabled(self.ask_state == BUSY)
|
||||||
|
|
||||||
|
# A local model holds its memory whether or not anything is using it, so
|
||||||
|
# the menu says which of the two are loaded and offers to give it back.
|
||||||
|
# Hidden on a machine that runs neither: there is nothing to unload and
|
||||||
|
# nothing to report.
|
||||||
|
loaded = [server for server in (ggml.whisper, ggml.llm) if server.running]
|
||||||
|
self.unload_action.setVisible(
|
||||||
|
self.conf["transcribe_provider"] == "local" or self.conf.uses_local_llm()
|
||||||
|
)
|
||||||
|
self.unload_action.setText(
|
||||||
|
t("Unload the models") if len(loaded) > 1
|
||||||
|
else t("Unload the model") if loaded
|
||||||
|
else t("No model loaded")
|
||||||
|
)
|
||||||
|
self.unload_action.setEnabled(bool(loaded))
|
||||||
|
|
||||||
# The agent speaks through the icon only when dictation has nothing to
|
# The agent speaks through the icon only when dictation has nothing to
|
||||||
# say, since dictation is the one being waited on in front of a screen.
|
# say, since dictation is the one being waited on in front of a screen.
|
||||||
if self.state == IDLE and self.ask_state != IDLE:
|
if self.state == IDLE and self.ask_state != IDLE:
|
||||||
@@ -1207,6 +1233,18 @@ class Dikte:
|
|||||||
QDesktopServices.openUrl(
|
QDesktopServices.openUrl(
|
||||||
QUrl(release.url if release is not None else update.RELEASES_PAGE))
|
QUrl(release.url if release is not None else update.RELEASES_PAGE))
|
||||||
|
|
||||||
|
def unload_models(self):
|
||||||
|
"""Give the memory back now rather than when the idle window closes."""
|
||||||
|
held = [server for server in (ggml.whisper, ggml.llm)
|
||||||
|
if not server.unload()]
|
||||||
|
self._refresh_tray()
|
||||||
|
if held:
|
||||||
|
self.tray.showMessage(
|
||||||
|
"Dikte",
|
||||||
|
t("A model is loading or answering right now. Try again in a "
|
||||||
|
"moment."),
|
||||||
|
QSystemTrayIcon.MessageIcon.Information, 5000)
|
||||||
|
|
||||||
# ---- settings ---------------------------------------------------------
|
# ---- settings ---------------------------------------------------------
|
||||||
|
|
||||||
def open_settings(self):
|
def open_settings(self):
|
||||||
@@ -1297,10 +1335,10 @@ class Dikte:
|
|||||||
threading.Thread(target=warm, daemon=True).start()
|
threading.Thread(target=warm, daemon=True).start()
|
||||||
|
|
||||||
def _apply_settings(self):
|
def _apply_settings(self):
|
||||||
self.overlay.corner = self.conf["overlay_corner"]
|
for indicator in (self.overlay, self.ask_overlay):
|
||||||
self.overlay.screen_name = self.conf["overlay_screen"]
|
indicator.corner = self.conf["overlay_corner"]
|
||||||
self.ask_overlay.corner = self.conf["overlay_corner"]
|
indicator.screen_name = self.conf["overlay_screen"]
|
||||||
self.ask_overlay.screen_name = self.conf["overlay_screen"]
|
indicator.follow_pointer = self.conf["overlay_follows_pointer"]
|
||||||
self._apply_local()
|
self._apply_local()
|
||||||
self._build_tray()
|
self._build_tray()
|
||||||
self._refresh_tray()
|
self._refresh_tray()
|
||||||
|
|||||||
+14
-7
@@ -115,13 +115,20 @@ def _local(text, conf, system_prompt, timeout, aborter=None):
|
|||||||
"""
|
"""
|
||||||
service = t("Local model")
|
service = t("Local model")
|
||||||
try:
|
try:
|
||||||
return api.cleanup(
|
# Held for the length of the request so that the idle unload does not
|
||||||
text, "", conf["local_llm_model"], system_prompt,
|
# take the model away from a block still being cleaned up.
|
||||||
reasoning=conf["local_llm_reasoning"],
|
with ggml.llm.busy():
|
||||||
base_url=api.serving(ggml.llm),
|
return api.cleanup(
|
||||||
timeout=max(timeout, api.LOCAL_TIMEOUT),
|
text, "", conf["local_llm_model"], system_prompt,
|
||||||
provider="local-llm", service=service, aborter=aborter,
|
reasoning=conf["local_llm_reasoning"],
|
||||||
)
|
base_url=api.serving(ggml.llm),
|
||||||
|
timeout=max(timeout, api.LOCAL_TIMEOUT),
|
||||||
|
provider="local-llm", service=service, aborter=aborter,
|
||||||
|
# The ceiling is only a ceiling while it sits under what the
|
||||||
|
# server was started with; above that the context is what stops
|
||||||
|
# the reply.
|
||||||
|
context=ggml.llm.settings()["context"],
|
||||||
|
)
|
||||||
except api.ApiError as exc:
|
except api.ApiError as exc:
|
||||||
# A server that died mid-request would otherwise report only that the
|
# A server that died mid-request would otherwise report only that the
|
||||||
# connection dropped, when the reason is in its own output.
|
# connection dropped, when the reason is in its own output.
|
||||||
|
|||||||
+25
-1
@@ -397,6 +397,9 @@ DEFAULTS = {
|
|||||||
"transcribe_model": "gpt-4o-transcribe", # used when provider is openai
|
"transcribe_model": "gpt-4o-transcribe", # used when provider is openai
|
||||||
"groq_transcribe_model": "whisper-large-v3-turbo",
|
"groq_transcribe_model": "whisper-large-v3-turbo",
|
||||||
"openrouter_transcribe_model": "openai/gpt-4o-transcribe",
|
"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",
|
"language": "tr",
|
||||||
"transcribe_prompt": "",
|
"transcribe_prompt": "",
|
||||||
|
|
||||||
@@ -439,6 +442,15 @@ DEFAULTS = {
|
|||||||
# Off rather than empty: a model trained to think will, and 300 tokens of
|
# Off rather than empty: a model trained to think will, and 300 tokens of
|
||||||
# reasoning about a comma is 300 tokens of waiting.
|
# reasoning about a comma is 300 tokens of waiting.
|
||||||
"local_llm_reasoning": "none",
|
"local_llm_reasoning": "none",
|
||||||
|
|
||||||
|
# --- what happens to both of them when nothing is using them -------------
|
||||||
|
# One pair for the two servers rather than a pair each: what is being
|
||||||
|
# decided is whether a machine keeps gigabytes tied up between dictations,
|
||||||
|
# and nobody wants that answered one model at a time. On by default because
|
||||||
|
# a reload costs seconds and the memory costs the rest of the desktop.
|
||||||
|
"local_idle_unload": True,
|
||||||
|
"local_idle_minutes": 10,
|
||||||
|
|
||||||
"cleanup_prompt": "", # empty -> language-specific default
|
"cleanup_prompt": "", # empty -> language-specific default
|
||||||
"auto_paste": True,
|
"auto_paste": True,
|
||||||
"paste_shortcut": paste.desktop().shortcuts[0], # cmd+v on a Mac
|
"paste_shortcut": paste.desktop().shortcuts[0], # cmd+v on a Mac
|
||||||
@@ -467,6 +479,9 @@ DEFAULTS = {
|
|||||||
"evdev_hotkey": False,
|
"evdev_hotkey": False,
|
||||||
"overlay_corner": "bottom-left",
|
"overlay_corner": "bottom-left",
|
||||||
"overlay_screen": "",
|
"overlay_screen": "",
|
||||||
|
# Off, so that an indicator stays where it appeared unless it is asked to
|
||||||
|
# keep up with the pointer. Nothing to say when a screen is named above.
|
||||||
|
"overlay_follows_pointer": False,
|
||||||
"keep_audio": False,
|
"keep_audio": False,
|
||||||
"history_limit": 200,
|
"history_limit": 200,
|
||||||
# A look at the releases page once a day, and nothing more than a look:
|
# A look at the releases page once a day, and nothing more than a look:
|
||||||
@@ -669,8 +684,9 @@ class Config:
|
|||||||
# to land on rather than reading it from there.
|
# to land on rather than reading it from there.
|
||||||
name = "openai"
|
name = "openai"
|
||||||
who = TRANSCRIBERS[name]
|
who = TRANSCRIBERS[name]
|
||||||
|
file_model = self["openrouter_file_model"] if name == "openrouter" else ""
|
||||||
return api.Target(name, who.service, self.api_key(who.key),
|
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):
|
def transcribe_ready(self):
|
||||||
"""Whether speech to text could run right now, without opening Settings."""
|
"""Whether speech to text could run right now, without opening Settings."""
|
||||||
@@ -703,6 +719,14 @@ class Config:
|
|||||||
binary=self["local_llm_binary"],
|
binary=self["local_llm_binary"],
|
||||||
context=int(self["local_llm_context"]),
|
context=int(self["local_llm_context"]),
|
||||||
)
|
)
|
||||||
|
ggml.whisper.set_idle(self.idle_seconds())
|
||||||
|
ggml.llm.set_idle(self.idle_seconds())
|
||||||
|
|
||||||
|
def idle_seconds(self):
|
||||||
|
"""How long a loaded model may sit unused. 0 means it is kept."""
|
||||||
|
if not self["local_idle_unload"]:
|
||||||
|
return 0
|
||||||
|
return max(1, int(self["local_idle_minutes"])) * 60
|
||||||
|
|
||||||
def uses_local_llm(self):
|
def uses_local_llm(self):
|
||||||
"""Whether anything is set to run the local cleanup model."""
|
"""Whether anything is set to run the local cleanup model."""
|
||||||
|
|||||||
+600
-20
@@ -26,6 +26,8 @@ interface already knows how to show.
|
|||||||
|
|
||||||
import atexit
|
import atexit
|
||||||
import collections
|
import collections
|
||||||
|
import contextlib
|
||||||
|
import ctypes
|
||||||
import ctypes.util
|
import ctypes.util
|
||||||
import hashlib
|
import hashlib
|
||||||
import http.client
|
import http.client
|
||||||
@@ -67,6 +69,10 @@ STARTUP_TIMEOUT = 180.0
|
|||||||
# to load takes longer than this to be read in first. The line between "worth
|
# to load takes longer than this to be read in first. The line between "worth
|
||||||
# another port" and "would fail the same way again" is drawn on time.
|
# another port" and "would fail the same way again" is drawn on time.
|
||||||
EARLY_EXIT_WINDOW = 5.0
|
EARLY_EXIT_WINDOW = 5.0
|
||||||
|
# How often the watcher looks at a model it has been asked to unload when idle.
|
||||||
|
# Short next to any window worth setting, so the memory goes back within seconds
|
||||||
|
# of the window closing rather than a minute after it.
|
||||||
|
IDLE_CHECK_SECONDS = 5.0
|
||||||
DOWNLOAD_CHUNK = 1 << 20
|
DOWNLOAD_CHUNK = 1 << 20
|
||||||
|
|
||||||
# `health` is the path that answers only once the model is in memory. whisper
|
# `health` is the path that answers only once the model is in memory. whisper
|
||||||
@@ -76,6 +82,13 @@ Program = collections.namedtuple("Program", "name repo binary health")
|
|||||||
|
|
||||||
WHISPER = Program("whisper", "ggml-org/whisper.cpp", "whisper-server", "")
|
WHISPER = Program("whisper", "ggml-org/whisper.cpp", "whisper-server", "")
|
||||||
LLAMA = Program("llama", "ggml-org/llama.cpp", "llama-server", "/health")
|
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
|
# 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
|
# in the source means a release of Dikte for every model somebody else
|
||||||
@@ -83,32 +96,139 @@ LLAMA = Program("llama", "ggml-org/llama.cpp", "llama-server", "/health")
|
|||||||
WHISPER_MODELS_REPO = "ggerganov/whisper.cpp"
|
WHISPER_MODELS_REPO = "ggerganov/whisper.cpp"
|
||||||
LLM_AUTHOR = "ggml-org"
|
LLM_AUTHOR = "ggml-org"
|
||||||
|
|
||||||
|
# The file llama.cpp attaches to its version releases in place of the binaries:
|
||||||
|
# a line naming the nightly tag those are published under.
|
||||||
|
NIGHTLY_TAG = "nightly-tag.txt"
|
||||||
|
|
||||||
# What the whisper repository holds besides models: Core ML encoders for Apple
|
# What the whisper repository holds besides models: Core ML encoders for Apple
|
||||||
# hardware and the odd loose file.
|
# hardware and the odd loose file.
|
||||||
WHISPER_PREFIX = "ggml-"
|
WHISPER_PREFIX = "ggml-"
|
||||||
WHISPER_SUFFIX = ".bin"
|
WHISPER_SUFFIX = ".bin"
|
||||||
|
# The mark on the whisper models trained on English alone. They are half of the
|
||||||
|
# list, and they belong under the model they are a variant of rather than
|
||||||
|
# scattered through it by size.
|
||||||
|
ENGLISH_ONLY = ".en"
|
||||||
|
|
||||||
|
# Full-precision weights, however they are spelled. Several times the memory of
|
||||||
|
# a quantisation of the same model, for a difference dictation and cleanup
|
||||||
|
# cannot see, so nothing here ever points at one.
|
||||||
|
SIXTEEN_BIT = ("bf16", "f16", "fp16")
|
||||||
|
|
||||||
|
# How many bits a weight is stored in, read off the file name. Every one of
|
||||||
|
# these lists spells it differently, `q5_1` and `Q4_K_M` and `MXFP4` and
|
||||||
|
# `BF16`, and the only part of that anybody choosing between two rows needs is
|
||||||
|
# the number. Longest mark first, so `bf16` is not read as `f16`.
|
||||||
|
BIT_DEPTHS = (("mxfp4", 4), ("bf16", 16), ("fp16", 16), ("f16", 16),
|
||||||
|
("q2", 2), ("q3", 3), ("q4", 4), ("q5", 5), ("q6", 6), ("q8", 8))
|
||||||
|
|
||||||
# What a GGUF repository holds besides the model: mmproj is the vision half of a
|
# What a GGUF repository holds besides the model: mmproj is the vision half of a
|
||||||
# multimodal model, mtp a draft head for speculative decoding. Neither is a model
|
# multimodal model, and mtp, dflash, dspark and eagle3 are draft heads for
|
||||||
# a server can be started on, and offering them is offering a failure.
|
# speculative decoding. None of them is a model a server can be started on, and
|
||||||
GGUF_SKIP = ("mmproj", "mtp-")
|
# they are the small files in the repository, so a list sorted by size puts them
|
||||||
|
# at the top where they are likeliest to be clicked.
|
||||||
|
GGUF_SKIP = ("mmproj", "mtp-", "dflash-", "dspark-", "eagle3-", "draft-")
|
||||||
# Big enough for a 12B at Q4 and far past anything cleanup wants; the point is
|
# Big enough for a 12B at Q4 and far past anything cleanup wants; the point is
|
||||||
# to keep a 400 GB frontier model out of a list somebody might click.
|
# to keep a 400 GB frontier model out of a list somebody might click.
|
||||||
GGUF_MAX_BYTES = 16 << 30
|
GGUF_MAX_BYTES = 16 << 30
|
||||||
|
|
||||||
|
# Repositories that carry GGUF files but nothing a cleanup server can be started
|
||||||
|
# on: a vision or audio tower with no text half worth running, a speech model,
|
||||||
|
# and the base models, which continue text rather than following an instruction
|
||||||
|
# and answer a cleanup prompt by carrying on writing the transcript.
|
||||||
|
# Matched as plain substrings, so every one of these carries its own
|
||||||
|
# delimiters: an unanchored "test-" is also inside "Latest-" and would drop a
|
||||||
|
# publisher that is perfectly usable.
|
||||||
|
LLM_REPO_SKIP = ("-Base-GGUF", "-VL-", "-Vision-", "-Omni-", "-Video-",
|
||||||
|
"-TTS-", "parakeet", "/test-")
|
||||||
|
|
||||||
|
GB = 1 << 30
|
||||||
|
|
||||||
# Suggestions, not a catalogue: the list itself is fetched, and these are only
|
# Suggestions, not a catalogue: the list itself is fetched, and these are only
|
||||||
# the rows that float to the top of it. Small instruction-following models,
|
# the rows that float to the top of it. Cleanup is punctuation, capitals and
|
||||||
# because cleanup is punctuation and filler words rather than anything that
|
# filler words rather than anything that wants thinking about, so what it is
|
||||||
# wants thinking about.
|
# picked on is instruction following at a size a desktop can spare. Gemma 4
|
||||||
|
# scores 94.6 on IFEval at E2B and 96.7 at E4B, and E2B leads here rather than
|
||||||
|
# E4B because two points of instruction following is not worth twice the
|
||||||
|
# weights on a job that runs while somebody waits for their sentence to appear.
|
||||||
|
# SmolLM3 and Gemma 3 are the older pair below them. Qwen3.5 0.8B is for the
|
||||||
|
# machines nothing else fits on; it thinks before it answers, which is what the
|
||||||
|
# Thinking box in the settings window turns off.
|
||||||
SUGGESTED_LLM = (
|
SUGGESTED_LLM = (
|
||||||
"ggml-org/gemma-3-4b-it-GGUF",
|
|
||||||
"ggml-org/gemma-4-E2B-it-GGUF",
|
"ggml-org/gemma-4-E2B-it-GGUF",
|
||||||
"ggml-org/gemma-4-E4B-it-GGUF",
|
"ggml-org/gemma-4-E4B-it-GGUF",
|
||||||
|
"ggml-org/gemma-3-4b-it-GGUF",
|
||||||
"ggml-org/SmolLM3-3B-GGUF",
|
"ggml-org/SmolLM3-3B-GGUF",
|
||||||
|
"ggml-org/Qwen3.5-0.8B-GGUF",
|
||||||
)
|
)
|
||||||
|
# Roughly what each of those weighs at the quantisation cleanup would run, to
|
||||||
|
# the nearest half gigabyte. Not a catalogue of files: the sizes on the rows
|
||||||
|
# come from the publisher, and this only decides which suggestion is offered
|
||||||
|
# first on a machine that has room for some of them and not others.
|
||||||
|
SUGGESTED_LLM_SIZE = {
|
||||||
|
"ggml-org/gemma-4-E2B-it-GGUF": 3 * GB,
|
||||||
|
"ggml-org/gemma-4-E4B-it-GGUF": 5 * GB,
|
||||||
|
"ggml-org/gemma-3-4b-it-GGUF": 5 * GB // 2,
|
||||||
|
"ggml-org/SmolLM3-3B-GGUF": 2 * GB,
|
||||||
|
"ggml-org/Qwen3.5-0.8B-GGUF": GB // 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
# What each of them is, in the words somebody choosing between them would
|
||||||
|
# use. A repository id says the publisher, the parameter count, the shape of
|
||||||
|
# the weights and nothing at all about whether it is the one to click, and
|
||||||
|
# `ggml-org/gemma-4-E2B-it-GGUF` reads as four pieces of jargon to everybody
|
||||||
|
# who has not been reading model cards all year.
|
||||||
|
SUGGESTED_LLM_NOTE = {
|
||||||
|
"ggml-org/gemma-4-E2B-it-GGUF":
|
||||||
|
"Google Gemma 4, the small one. The default: nothing else this size "
|
||||||
|
"follows an instruction as closely, and cleanup is all instruction.",
|
||||||
|
"ggml-org/gemma-4-E4B-it-GGUF":
|
||||||
|
"The same model one size up. A little more accurate, about twice the "
|
||||||
|
"weights and twice the wait.",
|
||||||
|
"ggml-org/gemma-3-4b-it-GGUF":
|
||||||
|
"The previous Gemma. Still good, and the smallest of the Gemmas here.",
|
||||||
|
"ggml-org/SmolLM3-3B-GGUF":
|
||||||
|
"Hugging Face's own small model, for a machine the Gemmas crowd.",
|
||||||
|
"ggml-org/Qwen3.5-0.8B-GGUF":
|
||||||
|
"The smallest of them, for a machine nothing else fits on. It thinks "
|
||||||
|
"before it answers unless Thinking below is off.",
|
||||||
|
}
|
||||||
|
|
||||||
# Turbo at q5_0 is smaller than `small` and better than it, which makes the
|
# Turbo at q5_0 is smaller than `small` and better than it, which makes the
|
||||||
# usual "start small" advice point at the same file as "start good".
|
# usual "start small" advice point at the same file as "start good". It is
|
||||||
|
# large-v3 with the decoder cut from 32 layers to 4: several times faster, at
|
||||||
|
# one to two points of word error in English and about two and a half in the
|
||||||
|
# other languages.
|
||||||
SUGGESTED_WHISPER = "ggml-large-v3-turbo-q5_0.bin"
|
SUGGESTED_WHISPER = "ggml-large-v3-turbo-q5_0.bin"
|
||||||
|
# Those two and a half points back, for twice the file and several times the
|
||||||
|
# work per second. Only suggested where there is a card to do the work and
|
||||||
|
# memory to hold it, because that is where the trade stops costing anything a
|
||||||
|
# person waiting for a dictation would notice.
|
||||||
|
ACCURATE_WHISPER = "ggml-large-v3-q5_0.bin"
|
||||||
|
# What to point at instead on a machine the turbo model would crowd. Same
|
||||||
|
# quantisation ladder, one rung down in size and in accuracy.
|
||||||
|
SMALL_MACHINE_WHISPER = "ggml-small-q5_1.bin"
|
||||||
|
# Under this much system memory, a 600 MB model plus the rest of a desktop is
|
||||||
|
# already tight, so the suggestion drops to the smaller one. Over the other,
|
||||||
|
# the accurate model is the one to point at.
|
||||||
|
SMALL_MACHINE = 4 * GB
|
||||||
|
# Fifteen and not sixteen: what the machine reports is what is left after the
|
||||||
|
# firmware and the graphics have taken their reservations out of it, and a
|
||||||
|
# 16 GB machine answers about 15.4. A threshold written at the number on the
|
||||||
|
# box is one no machine sold as that size ever reaches.
|
||||||
|
ROOMY_MACHINE = 15 * GB
|
||||||
|
# What a model may take of this machine's memory before it is called too big:
|
||||||
|
# half of it, less a gigabyte for the context and the runtime around the
|
||||||
|
# weights. A rule of thumb rather than a measurement, and deliberately a
|
||||||
|
# cautious one, because the failure it is guarding against is a machine that
|
||||||
|
# swaps itself to a standstill rather than a model that refuses to load.
|
||||||
|
MEMORY_SHARE = 0.5
|
||||||
|
MEMORY_OVERHEAD = GB
|
||||||
|
# What is left to offer on a machine too small for the sum above to leave
|
||||||
|
# anything. Enough for the smallest whisper models and for a sub-billion
|
||||||
|
# cleanup model, which is what such a machine can run.
|
||||||
|
MEMORY_FLOOR = GB // 2
|
||||||
|
# What total_memory() read the one time it asked. None until it has.
|
||||||
|
_MEMORY = None
|
||||||
|
|
||||||
|
|
||||||
class LocalError(Exception):
|
class LocalError(Exception):
|
||||||
@@ -277,6 +397,102 @@ def _wanted_assets(program):
|
|||||||
return (f"bin-ubuntu-{arch}.tar.gz",)
|
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):
|
||||||
|
item = next((a for a in assets if a.name.endswith(ending)), None)
|
||||||
|
if item:
|
||||||
|
return item
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
actually attached to, and those are prereleases that "latest" never points
|
||||||
|
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:
|
||||||
|
tag, assets = hub.release(program.repo, tag or "latest", refresh=refresh)
|
||||||
|
except hub.HubError as exc:
|
||||||
|
# A release carrying no files at all is the case the search below exists
|
||||||
|
# for, not a reason to stop before it: the build for this machine may be
|
||||||
|
# attached to a prerelease that "latest" never points at. The failure is
|
||||||
|
# kept rather than dropped, because an unreachable GitHub arrives here
|
||||||
|
# the same way and that one is the message the caller wants.
|
||||||
|
if named:
|
||||||
|
raise
|
||||||
|
missing, assets = exc, []
|
||||||
|
item = _matching_asset(program, assets)
|
||||||
|
if item or named:
|
||||||
|
return tag, item
|
||||||
|
# Best effort from here on: a machine this project publishes nothing for is
|
||||||
|
# not a failed lookup, and the caller's message about that is the useful
|
||||||
|
# one. Whatever goes wrong while looking further leaves it standing.
|
||||||
|
try:
|
||||||
|
pointer = next((a for a in assets if a.name == NIGHTLY_TAG), None)
|
||||||
|
if pointer:
|
||||||
|
nightly = hub.text(pointer.url).strip()
|
||||||
|
if nightly:
|
||||||
|
found, assets = hub.release(program.repo, nightly, refresh=refresh)
|
||||||
|
item = _matching_asset(program, assets)
|
||||||
|
if item:
|
||||||
|
return found, item
|
||||||
|
for found, assets in hub.releases(program.repo, refresh=refresh):
|
||||||
|
item = _matching_asset(program, assets)
|
||||||
|
if item:
|
||||||
|
return found, item
|
||||||
|
except hub.HubError:
|
||||||
|
pass
|
||||||
|
if missing is not None:
|
||||||
|
raise missing
|
||||||
|
return tag, None
|
||||||
|
|
||||||
|
|
||||||
def _install_record(program):
|
def _install_record(program):
|
||||||
return BIN_DIR / program.name / "installed.json"
|
return BIN_DIR / program.name / "installed.json"
|
||||||
|
|
||||||
@@ -300,6 +516,21 @@ def installed_version(program):
|
|||||||
return _read_record(program).get("tag") or ""
|
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=""):
|
def program_path(program, custom=""):
|
||||||
"""Which copy of the program to run, or "" when there is none.
|
"""Which copy of the program to run, or "" when there is none.
|
||||||
|
|
||||||
@@ -372,18 +603,15 @@ def install_program(program, tag="", on_progress=None, should_stop=None,
|
|||||||
|
|
||||||
`tag` is empty for whatever the project released last, which is the point:
|
`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
|
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:
|
try:
|
||||||
tag, assets = hub.release(program.repo, tag or "latest", refresh=refresh)
|
tag, item = _pick_asset(program, tag, refresh=refresh)
|
||||||
except hub.HubError as exc:
|
except hub.HubError as exc:
|
||||||
raise LocalError(str(exc)) from exc
|
raise LocalError(str(exc)) from exc
|
||||||
|
|
||||||
item = None
|
|
||||||
for ending in _wanted_assets(program):
|
|
||||||
item = next((a for a in assets if a.name.endswith(ending)), None)
|
|
||||||
if item:
|
|
||||||
break
|
|
||||||
if item is None:
|
if item is None:
|
||||||
# Nothing to download and nothing to install for you: whisper.cpp
|
# Nothing to download and nothing to install for you: whisper.cpp
|
||||||
# publishes no macOS binary, and Homebrew's whisper-cpp is configured
|
# publishes no macOS binary, and Homebrew's whisper-cpp is configured
|
||||||
@@ -447,8 +675,15 @@ def install_program(program, tag="", on_progress=None, should_stop=None,
|
|||||||
# Found under the sibling, run from the final directory.
|
# Found under the sibling, run from the final directory.
|
||||||
binary = into / binary.relative_to(fresh)
|
binary = into / binary.relative_to(fresh)
|
||||||
# Written last, so the record never points at anything half-made.
|
# Written last, so the record never points at anything half-made.
|
||||||
_install_record(program).write_text(
|
record = {"tag": tag, "binary": str(binary)}
|
||||||
json.dumps({"tag": tag, "binary": str(binary)}), encoding="utf-8")
|
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:
|
except OSError as exc:
|
||||||
raise LocalError(t("Could not install {name}: {error}",
|
raise LocalError(t("Could not install {name}: {error}",
|
||||||
name=program.name, error=exc)) from exc
|
name=program.name, error=exc)) from exc
|
||||||
@@ -479,9 +714,212 @@ def _drop_old_versions(program, keep):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# --- what this machine can run --------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def total_memory():
|
||||||
|
"""Bytes of memory on this machine, or 0 when it cannot be read.
|
||||||
|
|
||||||
|
Zero is a real answer and not a failure: every caller treats an unknown
|
||||||
|
machine as one big enough for whatever it is looking at, because a wrong
|
||||||
|
"too big" is worse advice than none.
|
||||||
|
|
||||||
|
Read once and kept. The memory in a machine does not change while Dikte
|
||||||
|
runs, and a list of thirty rows asks this question seventy times: on the
|
||||||
|
Mac path below, where the answer comes from a program rather than a
|
||||||
|
library call, that was seventy processes started on the interface thread
|
||||||
|
every time a list was drawn.
|
||||||
|
"""
|
||||||
|
global _MEMORY
|
||||||
|
if _MEMORY is None:
|
||||||
|
_MEMORY = max(_read_memory(), 0)
|
||||||
|
return _MEMORY
|
||||||
|
|
||||||
|
|
||||||
|
def _read_memory():
|
||||||
|
"""What the system says, which on a bad day is a negative number.
|
||||||
|
|
||||||
|
sysconf answers -1 for a limit it holds to be indeterminate, and CPython
|
||||||
|
hands that straight back rather than raising, so the product below can
|
||||||
|
come out negative. The caller floors it at zero, which is the answer for
|
||||||
|
a machine nothing could be read from: a 64 GB workstation whose sysconf
|
||||||
|
shrugged was otherwise being told every model past 512 MB was too big
|
||||||
|
for it.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES")
|
||||||
|
except (AttributeError, ValueError, OSError):
|
||||||
|
pass
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
# Not every build of Python on a Mac has SC_PHYS_PAGES in its sysconf
|
||||||
|
# table, and this is the number the system itself is asked for.
|
||||||
|
try:
|
||||||
|
out = subprocess.run(["sysctl", "-n", "hw.memsize"], check=True,
|
||||||
|
capture_output=True, text=True, timeout=5)
|
||||||
|
return int(out.stdout.strip())
|
||||||
|
except (OSError, ValueError, subprocess.SubprocessError):
|
||||||
|
return 0
|
||||||
|
if sys.platform != "win32":
|
||||||
|
return 0
|
||||||
|
|
||||||
|
class Status(ctypes.Structure):
|
||||||
|
_fields_ = [("dwLength", ctypes.c_ulong),
|
||||||
|
("dwMemoryLoad", ctypes.c_ulong),
|
||||||
|
("ullTotalPhys", ctypes.c_ulonglong),
|
||||||
|
("ullAvailPhys", ctypes.c_ulonglong),
|
||||||
|
("ullTotalPageFile", ctypes.c_ulonglong),
|
||||||
|
("ullAvailPageFile", ctypes.c_ulonglong),
|
||||||
|
("ullTotalVirtual", ctypes.c_ulonglong),
|
||||||
|
("ullAvailVirtual", ctypes.c_ulonglong),
|
||||||
|
("ullAvailExtendedVirtual", ctypes.c_ulonglong)]
|
||||||
|
|
||||||
|
try:
|
||||||
|
status = Status()
|
||||||
|
status.dwLength = ctypes.sizeof(Status)
|
||||||
|
if ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(status)):
|
||||||
|
return int(status.ullTotalPhys)
|
||||||
|
except (AttributeError, OSError, ValueError):
|
||||||
|
pass
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def accelerator():
|
||||||
|
"""The graphics interface this machine offers, or "".
|
||||||
|
|
||||||
|
The machine's half of the answer only. Whether a card is actually reached
|
||||||
|
also depends on which build landed, and the program line above says that:
|
||||||
|
a processor build ignores the card whatever is installed here. What this
|
||||||
|
is for is the other half, which nothing else on the window says at all.
|
||||||
|
"""
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
return "Metal"
|
||||||
|
return "Vulkan" if _has_vulkan() else ""
|
||||||
|
|
||||||
|
|
||||||
|
def memory_budget(memory=None):
|
||||||
|
"""What a model may weigh on this machine, or 0 when that is unknown.
|
||||||
|
|
||||||
|
Floored rather than allowed to reach zero: on a 2 GB machine the share
|
||||||
|
less the overhead is nothing at all, and a budget of nothing is the same
|
||||||
|
number this returns for a machine it could not read, which would turn the
|
||||||
|
tightest machine there is into the one where everything is offered.
|
||||||
|
"""
|
||||||
|
memory = total_memory() if memory is None else memory
|
||||||
|
if not memory:
|
||||||
|
return 0
|
||||||
|
return max(int(memory * MEMORY_SHARE) - MEMORY_OVERHEAD, MEMORY_FLOOR)
|
||||||
|
|
||||||
|
|
||||||
|
def fits(size, memory=None):
|
||||||
|
"""Whether a model of this size is worth offering on this machine."""
|
||||||
|
budget = memory_budget(memory)
|
||||||
|
return not budget or size <= budget
|
||||||
|
|
||||||
|
|
||||||
|
def suggested_whisper(memory=None, graphics=None):
|
||||||
|
"""The whisper model to point at here, by name.
|
||||||
|
|
||||||
|
Three machines. One with no room, which gets the model that leaves some.
|
||||||
|
One with a card and memory to spare, which gets the accurate model, because
|
||||||
|
the several times the work it is per second is several times a fraction of
|
||||||
|
a second there. Everything in between gets turbo, which is the answer
|
||||||
|
almost every time somebody asks.
|
||||||
|
|
||||||
|
A Vulkan or Metal loader is not proof of a fast card, so the accurate model
|
||||||
|
waits on the memory as well: a machine with 16 GB in it and a driver
|
||||||
|
installed is one that will not notice either way.
|
||||||
|
"""
|
||||||
|
memory = total_memory() if memory is None else memory
|
||||||
|
graphics = accelerator() if graphics is None else graphics
|
||||||
|
if memory and memory < SMALL_MACHINE:
|
||||||
|
return SMALL_MACHINE_WHISPER
|
||||||
|
if graphics and memory >= ROOMY_MACHINE:
|
||||||
|
return ACCURATE_WHISPER
|
||||||
|
return SUGGESTED_WHISPER
|
||||||
|
|
||||||
|
|
||||||
|
def suggested_llm(memory=None):
|
||||||
|
"""The suggested cleanup repositories, the ones that fit here first.
|
||||||
|
|
||||||
|
The order they are written in is the order they are worth having. What
|
||||||
|
this changes is only which of them a machine that cannot hold the best one
|
||||||
|
is shown first, and nothing is dropped: a model that does not fit today
|
||||||
|
fits once something else is closed.
|
||||||
|
"""
|
||||||
|
return sorted(SUGGESTED_LLM,
|
||||||
|
key=lambda repo: not fits(SUGGESTED_LLM_SIZE.get(repo, 0),
|
||||||
|
memory))
|
||||||
|
|
||||||
|
|
||||||
|
def recommended(items, want="", memory=None):
|
||||||
|
"""The one row out of `items` worth pointing at here, or "".
|
||||||
|
|
||||||
|
`want` is taken when it is on offer and fits. Without it, which is the
|
||||||
|
cleanup list, the smallest file that does is taken: q4 is where these
|
||||||
|
lists start, and every rung above it is roughly twice the memory and twice
|
||||||
|
the wait for a difference neither dictation nor cleanup can see. The
|
||||||
|
16-bit weights are left out for the same reason, twice over.
|
||||||
|
"""
|
||||||
|
fitting = [i for i in items if fits(i.size, memory)]
|
||||||
|
if want and any(i.name == want for i in fitting):
|
||||||
|
return want
|
||||||
|
usable = [i for i in fitting
|
||||||
|
if not any(mark in i.name.lower() for mark in SIXTEEN_BIT)]
|
||||||
|
return min(usable, key=lambda i: i.size).name if usable else ""
|
||||||
|
|
||||||
|
|
||||||
# --- the models -----------------------------------------------------------
|
# --- the models -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def bit_depth(name):
|
||||||
|
"""The bits per weight the file name says, or 0 when it says nothing."""
|
||||||
|
lowered = name.lower()
|
||||||
|
for mark, bits in BIT_DEPTHS:
|
||||||
|
if mark in lowered:
|
||||||
|
return bits
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def whisper_family(name):
|
||||||
|
"""The model a whisper file belongs to: ggml-small.en-q5_1.bin is `small`.
|
||||||
|
|
||||||
|
The list arrives sorted by size and nothing else, which interleaves the
|
||||||
|
families: `large-v3-turbo-q5_0` lands between the two `medium`
|
||||||
|
quantisations, half a screen from the turbo model it is a copy of. Grouping
|
||||||
|
is what puts the choice between models above the choice of quantisation,
|
||||||
|
which is the order somebody actually makes them in.
|
||||||
|
"""
|
||||||
|
stem = name
|
||||||
|
if stem.startswith(WHISPER_PREFIX):
|
||||||
|
stem = stem[len(WHISPER_PREFIX):]
|
||||||
|
if stem.endswith(WHISPER_SUFFIX):
|
||||||
|
stem = stem[:-len(WHISPER_SUFFIX)]
|
||||||
|
head, _, last = stem.rpartition("-")
|
||||||
|
# q5_0, q5_1, q8_0. `turbo` is the other thing a last chunk can be, and it
|
||||||
|
# is part of the model's name rather than a quantisation of it.
|
||||||
|
if head and last.startswith("q") and last[1:].replace("_", "").isdigit():
|
||||||
|
stem = head
|
||||||
|
return stem[:-len(ENGLISH_ONLY)] if stem.endswith(ENGLISH_ONLY) else stem
|
||||||
|
|
||||||
|
|
||||||
|
def whisper_groups(items):
|
||||||
|
"""[(family, [Item])] for a whisper list: one group per model.
|
||||||
|
|
||||||
|
Groups by how big the model gets rather than by a ladder written down
|
||||||
|
here, so a family published next year sorts itself. Inside one, the
|
||||||
|
multilingual files come before the English-only ones and the small
|
||||||
|
quantisations before the large.
|
||||||
|
"""
|
||||||
|
groups = {}
|
||||||
|
for item in items:
|
||||||
|
groups.setdefault(whisper_family(item.name), []).append(item)
|
||||||
|
ordered = sorted(groups.items(),
|
||||||
|
key=lambda pair: (max(i.size for i in pair[1]), pair[0]))
|
||||||
|
return [(family, sorted(files,
|
||||||
|
key=lambda i: (ENGLISH_ONLY in i.name, i.size)))
|
||||||
|
for family, files in ordered]
|
||||||
|
|
||||||
|
|
||||||
def whisper_models(refresh=False):
|
def whisper_models(refresh=False):
|
||||||
"""[hub.Item] for every whisper model on offer, smallest first."""
|
"""[hub.Item] for every whisper model on offer, smallest first."""
|
||||||
try:
|
try:
|
||||||
@@ -494,10 +932,23 @@ def whisper_models(refresh=False):
|
|||||||
return sorted(models, key=lambda f: f.size)
|
return sorted(models, key=lambda f: f.size)
|
||||||
|
|
||||||
|
|
||||||
|
def can_clean(repo):
|
||||||
|
"""Whether a repository could hold a model cleanup can be started on.
|
||||||
|
|
||||||
|
By name, because the alternative is a file listing per repository and the
|
||||||
|
list is forty of them. It catches the kinds that are never a cleanup model
|
||||||
|
rather than the ones that are too big, which the file sizes answer exactly
|
||||||
|
once a publisher is chosen.
|
||||||
|
"""
|
||||||
|
lowered = repo.lower()
|
||||||
|
return not any(mark.lower() in lowered for mark in LLM_REPO_SKIP)
|
||||||
|
|
||||||
|
|
||||||
def llm_repos(refresh=False):
|
def llm_repos(refresh=False):
|
||||||
"""Repository ids for the GGUF models on offer, suggestions first."""
|
"""Repository ids for the GGUF models on offer, suggestions first."""
|
||||||
try:
|
try:
|
||||||
found = [r.id for r in hub.repos(author=LLM_AUTHOR, refresh=refresh)]
|
found = [r.id for r in hub.repos(author=LLM_AUTHOR, refresh=refresh)
|
||||||
|
if can_clean(r.id)]
|
||||||
except hub.HubError:
|
except hub.HubError:
|
||||||
# A menu rather than a catalogue: with nothing to show, the suggestions
|
# A menu rather than a catalogue: with nothing to show, the suggestions
|
||||||
# are still worth showing, and whatever is wrong with the network will
|
# are still worth showing, and whatever is wrong with the network will
|
||||||
@@ -505,6 +956,14 @@ def llm_repos(refresh=False):
|
|||||||
found = []
|
found = []
|
||||||
if not found:
|
if not found:
|
||||||
return list(SUGGESTED_LLM)
|
return list(SUGGESTED_LLM)
|
||||||
|
# Gemma publishes its base models under the instruction-tuned one's name
|
||||||
|
# with the `-it` taken out, so the two sit next to each other in the list
|
||||||
|
# and the wrong one answers a cleanup prompt by carrying on writing the
|
||||||
|
# transcript. Dropped only where the tuned sibling is here to drop it for.
|
||||||
|
tuned = set(found)
|
||||||
|
found = [r for r in found
|
||||||
|
if not r.endswith("-GGUF")
|
||||||
|
or r[:-len("-GGUF")] + "-it-GGUF" not in tuned]
|
||||||
first = [r for r in SUGGESTED_LLM if r in found]
|
first = [r for r in SUGGESTED_LLM if r in found]
|
||||||
return first + [r for r in found if r not in first]
|
return first + [r for r in found if r not in first]
|
||||||
|
|
||||||
@@ -662,6 +1121,15 @@ class Server:
|
|||||||
# The pid this instance last wrote to its pid file, so _forget never
|
# The pid this instance last wrote to its pid file, so _forget never
|
||||||
# removes a file some other Dikte wrote after us.
|
# removes a file some other Dikte wrote after us.
|
||||||
self._pid = 0
|
self._pid = 0
|
||||||
|
# The idle unload. `_idle` is the window in seconds, zero meaning the
|
||||||
|
# model stays loaded until something else stops it; `_used` is when the
|
||||||
|
# address was last handed out or a request last finished; `_busy` counts
|
||||||
|
# the requests still in flight. The count is there because a file being
|
||||||
|
# transcribed is one address lookup and then minutes of work, which to a
|
||||||
|
# clock started at the lookup looks exactly like a model nobody wants.
|
||||||
|
self._idle = 0.0
|
||||||
|
self._used = 0.0
|
||||||
|
self._busy = 0
|
||||||
|
|
||||||
# ---- settings --------------------------------------------------------
|
# ---- settings --------------------------------------------------------
|
||||||
|
|
||||||
@@ -679,6 +1147,23 @@ class Server:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
return dict(self._settings)
|
return dict(self._settings)
|
||||||
|
|
||||||
|
def set_idle(self, seconds):
|
||||||
|
"""How long a loaded model may sit unused before the memory goes back.
|
||||||
|
|
||||||
|
Deliberately not one of the settings above: those describe the server
|
||||||
|
that is running, and changing one has to restart it. This describes how
|
||||||
|
long to keep it, which the server it is applied to never needs to know.
|
||||||
|
Zero keeps the model until something else stops it.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
self._idle = max(0.0, float(seconds))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def idle(self):
|
||||||
|
"""The window `set_idle` was last given, in seconds."""
|
||||||
|
with self._lock:
|
||||||
|
return self._idle
|
||||||
|
|
||||||
def _settings_key(self):
|
def _settings_key(self):
|
||||||
"""What a running server would have to be restarted for."""
|
"""What a running server would have to be restarted for."""
|
||||||
return json.dumps(self._settings, sort_keys=True, default=str)
|
return json.dumps(self._settings, sort_keys=True, default=str)
|
||||||
@@ -718,13 +1203,83 @@ class Server:
|
|||||||
proc, port, log = self._launch(settings)
|
proc, port, log = self._launch(settings)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._proc, self._port, self._log, self._key = proc, port, log, key
|
self._proc, self._port, self._log, self._key = proc, port, log, key
|
||||||
|
# Only the clock. The count is not this launch's to reset: a
|
||||||
|
# caller that took a hold and then asked for the address, which
|
||||||
|
# is what the local cleanup does, would have it wiped here and
|
||||||
|
# spend the whole request unprotected.
|
||||||
|
self._used = time.monotonic()
|
||||||
|
threading.Thread(target=self._watch, args=(proc,), daemon=True).start()
|
||||||
return self.base_url()
|
return self.base_url()
|
||||||
|
|
||||||
def _current_url(self):
|
def _current_url(self):
|
||||||
|
"""The address of a server running the current settings, or "".
|
||||||
|
|
||||||
|
Asking counts as using it. Everything that asks is about to send a
|
||||||
|
request, and the idle watcher reads the same clock, so the stamp has to
|
||||||
|
be set here rather than where the answer comes back.
|
||||||
|
"""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
up = self._proc is not None and self._proc.poll() is None
|
up = self._proc is not None and self._proc.poll() is None
|
||||||
return (f"http://{HOST}:{self._port}/v1"
|
if not (up and self._key == self._settings_key()):
|
||||||
if up and self._key == self._settings_key() else "")
|
return ""
|
||||||
|
self._used = time.monotonic()
|
||||||
|
return f"http://{HOST}:{self._port}/v1"
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def busy(self):
|
||||||
|
"""Hold the model for the length of one request.
|
||||||
|
|
||||||
|
A dictation is over a second after the address was handed out, but a
|
||||||
|
file is minutes of it, and an hour of meeting is longer still. Without
|
||||||
|
the count the watcher would unload the model out from under the request
|
||||||
|
that started it.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
self._busy += 1
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
with self._lock:
|
||||||
|
# Nothing else moves the count, so every hold that was taken is
|
||||||
|
# given back here and it stays balanced across a restart. A hold
|
||||||
|
# outliving the server it was taken against only keeps the next
|
||||||
|
# one loaded a moment longer, which is the safe way round.
|
||||||
|
self._busy -= 1
|
||||||
|
self._used = time.monotonic()
|
||||||
|
|
||||||
|
def _idle_now(self, proc):
|
||||||
|
"""Whether `proc` is still ours and has been sitting unused long enough."""
|
||||||
|
with self._lock:
|
||||||
|
if self._proc is not proc or not self._idle or self._busy:
|
||||||
|
return False
|
||||||
|
return time.monotonic() - self._used >= self._idle
|
||||||
|
|
||||||
|
def _watch(self, proc):
|
||||||
|
"""Give the memory back when nothing has asked anything for a while.
|
||||||
|
|
||||||
|
One thread per launch, holding the process it was started for, so that a
|
||||||
|
server stopped and started again is watched by the new thread alone and
|
||||||
|
this one leaves on the first pass that finds its own process gone.
|
||||||
|
|
||||||
|
Started whatever the window is, zero included: turning the unload on in
|
||||||
|
Settings has to reach a model that is already loaded, and a thread that
|
||||||
|
wakes every few seconds to read one number is cheaper than the machinery
|
||||||
|
for starting one later.
|
||||||
|
"""
|
||||||
|
while True:
|
||||||
|
time.sleep(IDLE_CHECK_SECONDS)
|
||||||
|
with self._lock:
|
||||||
|
if self._proc is not proc:
|
||||||
|
return # stopped, or replaced by a later launch
|
||||||
|
if not self._idle_now(proc):
|
||||||
|
continue
|
||||||
|
with self._starting:
|
||||||
|
# Asked once more under the lock a start has to take. An address
|
||||||
|
# handed out while this thread waited its turn stamps _used, and
|
||||||
|
# the request behind it must not arrive at a server killed here.
|
||||||
|
if self._idle_now(proc):
|
||||||
|
self._stop_now()
|
||||||
|
return
|
||||||
|
|
||||||
def _launch(self, settings):
|
def _launch(self, settings):
|
||||||
args = self._build(settings) # raises LocalError when unusable
|
args = self._build(settings) # raises LocalError when unusable
|
||||||
@@ -831,6 +1386,31 @@ class Server:
|
|||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def unload(self):
|
||||||
|
"""Stop the server unless it is in the middle of something.
|
||||||
|
|
||||||
|
The same rule the idle watcher goes by, taken by hand from the menu, and
|
||||||
|
it says no for the same reason: the memory is worth having back, but not
|
||||||
|
at the price of the dictation waiting on it. A model still being read in
|
||||||
|
counts as in the middle of something too, and that is why the lock is
|
||||||
|
asked for rather than waited on: this runs on the interface's own
|
||||||
|
thread, and a start holds _starting for as long as the load takes, which
|
||||||
|
for a large model on a cold cache is most of a minute. True when nothing
|
||||||
|
is loaded any more, either way.
|
||||||
|
"""
|
||||||
|
if not self._starting.acquire(blocking=False):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
with self._lock:
|
||||||
|
if self._proc is None:
|
||||||
|
return True
|
||||||
|
if self._busy:
|
||||||
|
return False
|
||||||
|
self._stop_now()
|
||||||
|
return True
|
||||||
|
finally:
|
||||||
|
self._starting.release()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
# Taking _starting means a stop cannot slide past a launch in flight:
|
# Taking _starting means a stop cannot slide past a launch in flight:
|
||||||
# serve() finishes registering its child first, and the child is then
|
# serve() finishes registering its child first, and the child is then
|
||||||
|
|||||||
+48
-4
@@ -121,6 +121,13 @@ def _digest(value):
|
|||||||
return value.split(":", 1)[1] if value.startswith("sha256:") else value
|
return value.split(":", 1)[1] if value.startswith("sha256:") else value
|
||||||
|
|
||||||
|
|
||||||
|
def _assets(data):
|
||||||
|
return [Item(a.get("name") or "", a.get("browser_download_url") or "",
|
||||||
|
int(a.get("size") or 0), _digest(a.get("digest")))
|
||||||
|
for a in (data.get("assets") or [])
|
||||||
|
if a.get("browser_download_url")]
|
||||||
|
|
||||||
|
|
||||||
def release(repo, tag="latest", refresh=False):
|
def release(repo, tag="latest", refresh=False):
|
||||||
"""(tag, [Item]) for one GitHub release, newest when no tag is given."""
|
"""(tag, [Item]) for one GitHub release, newest when no tag is given."""
|
||||||
where = "latest" if tag in ("", "latest") else f"tags/{tag}"
|
where = "latest" if tag in ("", "latest") else f"tags/{tag}"
|
||||||
@@ -128,10 +135,47 @@ def release(repo, tag="latest", refresh=False):
|
|||||||
f"{GITHUB_API}/repos/{repo}/releases/{where}", refresh=refresh)
|
f"{GITHUB_API}/repos/{repo}/releases/{where}", refresh=refresh)
|
||||||
if not isinstance(data, dict) or not data.get("assets"):
|
if not isinstance(data, dict) or not data.get("assets"):
|
||||||
raise HubError(t("{repo} has no downloadable release.", repo=repo))
|
raise HubError(t("{repo} has no downloadable release.", repo=repo))
|
||||||
assets = [Item(a.get("name") or "", a.get("browser_download_url") or "",
|
return data.get("tag_name") or tag, _assets(data)
|
||||||
int(a.get("size") or 0), _digest(a.get("digest")))
|
|
||||||
for a in data["assets"] if a.get("browser_download_url")]
|
|
||||||
return data.get("tag_name") or tag, assets
|
def releases(repo, limit=20, refresh=False):
|
||||||
|
"""[(tag, [Item])] for the recent releases, newest first, with their files.
|
||||||
|
|
||||||
|
"latest" is one release and this is the list behind it, prereleases
|
||||||
|
included: a project that attaches its builds to a prerelease is invisible
|
||||||
|
to release() above, and its newest usable build is in here.
|
||||||
|
"""
|
||||||
|
data = _fetch(f"gh-list-{repo}-{limit}",
|
||||||
|
f"{GITHUB_API}/repos/{repo}/releases?per_page={limit}",
|
||||||
|
refresh=refresh)
|
||||||
|
if not isinstance(data, list):
|
||||||
|
raise HubError(t("{repo} has no downloadable release.", repo=repo))
|
||||||
|
out = []
|
||||||
|
for entry in data:
|
||||||
|
tag, items = entry.get("tag_name") or "", _assets(entry)
|
||||||
|
if tag and items:
|
||||||
|
out.append((tag, items))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def text(url, limit=4096, timeout=20):
|
||||||
|
"""A small text file from a release, as a string.
|
||||||
|
|
||||||
|
Not cached and not checksummed, because what it carries is a pointer: a few
|
||||||
|
bytes naming the release the actual archives are attached to, read once on
|
||||||
|
the way to a download that is checked in full.
|
||||||
|
"""
|
||||||
|
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||||
|
return response.read(limit).decode("utf-8", "replace")
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
exc.close()
|
||||||
|
raise HubError(t("{url} answered HTTP {code}.",
|
||||||
|
url=urllib.parse.urlsplit(url).netloc, code=exc.code)) from exc
|
||||||
|
except (urllib.error.URLError, OSError, ValueError) as exc:
|
||||||
|
raise HubError(t("Could not reach {url}: {error}",
|
||||||
|
url=urllib.parse.urlsplit(url).netloc, error=exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
def newest_release(repo, refresh=False):
|
def newest_release(repo, refresh=False):
|
||||||
|
|||||||
+112
-1
@@ -189,8 +189,10 @@ TR = {
|
|||||||
"Restore the previous clipboard after pasting":
|
"Restore the previous clipboard after pasting":
|
||||||
"Yapıştırdıktan sonra eski pano içeriğini geri koy",
|
"Yapıştırdıktan sonra eski pano içeriğini geri koy",
|
||||||
"Indicator screen": "Gösterge ekranı",
|
"Indicator screen": "Gösterge ekranı",
|
||||||
"Follow the mouse pointer": "Fare imlecini takip et",
|
"Follow the active screen": "Etkin ekranı takip et",
|
||||||
"{name} (not connected)": "{name} (bağlı değil)",
|
"{name} (not connected)": "{name} (bağlı değil)",
|
||||||
|
"Move it when the active screen changes":
|
||||||
|
"Etkin ekran değiştiğinde göstergeyi de taşı",
|
||||||
"Indicator corner": "Gösterge köşesi",
|
"Indicator corner": "Gösterge köşesi",
|
||||||
"bottom-left": "sol-alt",
|
"bottom-left": "sol-alt",
|
||||||
"bottom-right": "sağ-alt",
|
"bottom-right": "sağ-alt",
|
||||||
@@ -228,6 +230,11 @@ TR = {
|
|||||||
"Transcript cleanup": "Transkripti temizleme",
|
"Transcript cleanup": "Transkripti temizleme",
|
||||||
"API key": "API anahtarı",
|
"API key": "API anahtarı",
|
||||||
"Model": "Model",
|
"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ı",
|
"Provider": "Sağlayıcı",
|
||||||
"sk-… (falls back to OPENAI_API_KEY)": "sk-… (boşsa OPENAI_API_KEY kullanılır)",
|
"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)",
|
"gsk_… (falls back to GROQ_API_KEY)": "gsk_… (boşsa GROQ_API_KEY kullanılır)",
|
||||||
@@ -775,20 +782,120 @@ TR = {
|
|||||||
"On this machine": "Bu makinede",
|
"On this machine": "Bu makinede",
|
||||||
"Use the graphics card": "Ekran kartını kullan",
|
"Use the graphics card": "Ekran kartını kullan",
|
||||||
"Load the model when Dikte starts": "Modeli Dikte açılırken yükle",
|
"Load the model when Dikte starts": "Modeli Dikte açılırken yükle",
|
||||||
|
"Models on this machine": "Bu makinedeki modeller",
|
||||||
|
"Unload a model that is sitting unused": "Kullanılmayan modeli bellekten çıkar",
|
||||||
|
"A loaded model holds its memory whether anything is using it or "
|
||||||
|
"not: over a gigabyte for whisper, several for an LLM. Unloading "
|
||||||
|
"gives that back to the rest of the desktop, and the next "
|
||||||
|
"dictation loads it again at the cost of the seconds that takes.":
|
||||||
|
"Yüklü bir model, kullanılsa da kullanılmasa da belleği tutar: whisper "
|
||||||
|
"için bir gigabaytın üzerinde, bir LLM için birkaç gigabayt. Bellekten "
|
||||||
|
"çıkarmak bunu masaüstünün geri kalanına iade eder, sonraki dikte de "
|
||||||
|
"modeli birkaç saniye bekleyerek yeniden yükler.",
|
||||||
|
" minute": " dakika",
|
||||||
|
" minutes": " dakika",
|
||||||
|
"After": "Şu kadar sonra",
|
||||||
|
"Unload the model": "Modeli bellekten çıkar",
|
||||||
|
"Unload the models": "Modelleri bellekten çıkar",
|
||||||
|
"No model loaded": "Yüklü model yok",
|
||||||
|
"A model is loading or answering right now. Try again in a "
|
||||||
|
"moment.":
|
||||||
|
"Bir model şu anda yükleniyor ya da cevap veriyor. Az sonra tekrar "
|
||||||
|
"deneyin.",
|
||||||
"Local whisper": "Yerel whisper",
|
"Local whisper": "Yerel whisper",
|
||||||
"Local model": "Yerel model",
|
"Local model": "Yerel model",
|
||||||
"Not installed.": "Kurulu değil.",
|
"Not installed.": "Kurulu değil.",
|
||||||
"Installed on the system: {path}": "Sistemde kurulu: {path}",
|
"Installed on the system: {path}": "Sistemde kurulu: {path}",
|
||||||
|
"Download again": "Yeniden indir",
|
||||||
"Downloaded, version {version}.": "İndirildi, sürüm {version}.",
|
"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…",
|
"Fetching the model list…": "Model listesi çekiliyor…",
|
||||||
"Downloading…": "İndiriliyor…",
|
"Downloading…": "İndiriliyor…",
|
||||||
|
"Starting the download…": "İndirme başlatılıyor…",
|
||||||
"Downloading: {done} of {total}{share}": "İndiriliyor: {done} / {total}{share}",
|
"Downloading: {done} of {total}{share}": "İndiriliyor: {done} / {total}{share}",
|
||||||
"Download stopped.": "İndirme durduruldu.",
|
"Download stopped.": "İndirme durduruldu.",
|
||||||
"Ready: {name}.": "Hazır: {name}.",
|
"Ready: {name}.": "Hazır: {name}.",
|
||||||
"Nothing downloaded yet.": "Henüz bir şey indirilmedi.",
|
"Nothing downloaded yet.": "Henüz bir şey indirilmedi.",
|
||||||
"{name} has not been downloaded yet.": "{name} henüz indirilmedi.",
|
"{name} has not been downloaded yet.": "{name} henüz indirilmedi.",
|
||||||
|
"{name} is here, but the program above is not. Download it first.":
|
||||||
|
"{name} burada, ama yukarıdaki program değil. Önce onu indirin.",
|
||||||
|
"{name} is not on this machine and this publisher does not offer it. "
|
||||||
|
"Choose another model, or another publisher.":
|
||||||
|
"{name} bu makinede yok ve bu yayıncı da sunmuyor. Başka bir model, "
|
||||||
|
"ya da başka bir yayıncı seçin.",
|
||||||
"downloaded": "indirildi",
|
"downloaded": "indirildi",
|
||||||
"not downloaded": "indirilmedi",
|
"not downloaded": "indirilmedi",
|
||||||
|
"recommended": "önerilen",
|
||||||
|
"{bits}-bit": "{bits} bit",
|
||||||
|
"English only": "yalnızca İngilizce",
|
||||||
|
"All": "Tümü",
|
||||||
|
"Everything ggml-org publishes, including the models that are too big to "
|
||||||
|
"run here and the ones that are not for cleaning up text.":
|
||||||
|
"ggml-org'un yayımladığı her şey; burada çalıştırılamayacak kadar "
|
||||||
|
"büyük olanlar ve metin temizlemek için olmayanlar dahil.",
|
||||||
|
"Google Gemma 4, the small one. The default: nothing else this size "
|
||||||
|
"follows an instruction as closely, and cleanup is all instruction.":
|
||||||
|
"Google Gemma 4'ün küçüğü. Varsayılan: bu boyutta verilen yönergeyi "
|
||||||
|
"bu kadar iyi izleyen başka bir model yok, temizleme de baştan sona "
|
||||||
|
"yönerge demek.",
|
||||||
|
"The same model one size up. A little more accurate, about twice the "
|
||||||
|
"weights and twice the wait.":
|
||||||
|
"Aynı modelin bir boy büyüğü. Biraz daha isabetli, yaklaşık iki katı "
|
||||||
|
"ağırlık ve iki katı bekleyiş.",
|
||||||
|
"The previous Gemma. Still good, and the smallest of the Gemmas here.":
|
||||||
|
"Bir önceki Gemma. Hâlâ iyi ve buradaki Gemma'ların en küçüğü.",
|
||||||
|
"Hugging Face's own small model, for a machine the Gemmas crowd.":
|
||||||
|
"Hugging Face'in kendi küçük modeli; Gemma'ların sıkıştırdığı bir "
|
||||||
|
"makine için.",
|
||||||
|
"The smallest of them, for a machine nothing else fits on. It thinks "
|
||||||
|
"before it answers unless Thinking below is off.":
|
||||||
|
"En küçükleri; başka hiçbir şeyin sığmadığı bir makine için. "
|
||||||
|
"Aşağıdaki Düşünme kapalı değilse cevaplamadan önce düşünür.",
|
||||||
|
"too big for this machine": "bu makine için fazla büyük",
|
||||||
|
"This machine": "Bu makine",
|
||||||
|
"Graphics: {name}.": "Ekran kartı: {name}.",
|
||||||
|
"No graphics interface found, so this runs on the processor.":
|
||||||
|
"Ekran kartı arayüzü bulunamadı, bu yüzden işlemcide çalışıyor.",
|
||||||
|
"Memory: {size}.": "Bellek: {size}.",
|
||||||
|
"A model may take half of this memory, less a gigabyte for the context "
|
||||||
|
"around the weights. Anything past that is marked too big; it may still "
|
||||||
|
"load, on a machine with nothing else open.":
|
||||||
|
"Bir model bu belleğin yarısını, ağırlıkların çevresindeki bağlam için "
|
||||||
|
"bir gigabayt düşülerek kullanabilir. Bunu aşan modeller fazla büyük "
|
||||||
|
"diye işaretlenir; başka hiçbir şeyin açık olmadığı bir makinede yine "
|
||||||
|
"de yüklenebilirler.",
|
||||||
|
"Recommended for this machine": "Bu makine için önerilen",
|
||||||
|
"Everything this publisher offers": "Bu yayıncının sunduğu her şey",
|
||||||
|
"Already on this machine": "Bu makinede zaten var",
|
||||||
|
"Chosen, but not downloaded": "Seçili, ama indirilmedi",
|
||||||
|
"{repo} publishes nothing that can be run here. Its models are split "
|
||||||
|
"across files, larger than {cap}, or pieces of a model rather than one. "
|
||||||
|
"Choose another publisher.":
|
||||||
|
"{repo} burada çalıştırılabilecek bir şey yayımlamıyor. Modelleri "
|
||||||
|
"birden çok dosyaya bölünmüş, {cap} boyutundan büyük ya da modelin "
|
||||||
|
"kendisi değil parçaları. Başka bir yayıncı seçin.",
|
||||||
|
"large-v3 makes the fewest mistakes and is the slowest of them. "
|
||||||
|
"large-v3-turbo is that model with a four layer decoder in place of a "
|
||||||
|
"thirty-two layer one: several times faster, at one to two points of word "
|
||||||
|
"error in English and about two and a half in the other languages. Below "
|
||||||
|
"those, every step down the list trades accuracy for size, and the .en "
|
||||||
|
"models are trained on English alone.":
|
||||||
|
"En az hatayı large-v3 yapar, en yavaşı da odur. large-v3-turbo, aynı "
|
||||||
|
"modelin otuz iki katmanlı çözücüsü yerine dört katmanlı bir çözücü "
|
||||||
|
"konmuş hâli: birkaç kat hızlı, karşılığında İngilizcede bir iki "
|
||||||
|
"puan, diğer dillerde yaklaşık iki buçuk puan kelime hatası. Bunların "
|
||||||
|
"altında listede her basamak, doğruluğu boyuta değişir; .en modelleri "
|
||||||
|
"ise yalnızca İngilizce ile eğitilmiştir.",
|
||||||
|
"Cleanup is punctuation, capitals and filler words, so what these are "
|
||||||
|
"picked on is following an instruction rather than knowing anything. "
|
||||||
|
"Start at a q4 file; the 16-bit ones are several times the memory for a "
|
||||||
|
"difference this job cannot see.":
|
||||||
|
"Temizleme; noktalama, büyük harf ve dolgu sözcükleri demek, yani bu "
|
||||||
|
"modeller bir şey bilmelerine değil verilen yönergeyi izlemelerine "
|
||||||
|
"göre seçilir. Bir q4 dosyasından başlayın; 16 bitlik olanlar, bu işin "
|
||||||
|
"göremeyeceği bir fark için kat kat bellek ister.",
|
||||||
"Delete model": "Modeli sil",
|
"Delete model": "Modeli sil",
|
||||||
"Delete {name} from this machine?": "{name} bu makineden silinsin mi?",
|
"Delete {name} from this machine?": "{name} bu makineden silinsin mi?",
|
||||||
"Runs on this machine, on llama.cpp.": "Bu makinede, llama.cpp üzerinde çalışır.",
|
"Runs on this machine, on llama.cpp.": "Bu makinede, llama.cpp üzerinde çalışır.",
|
||||||
@@ -872,6 +979,10 @@ TR = {
|
|||||||
"“Off”.":
|
"“Off”.":
|
||||||
"Temizleme modeli bütün yanıtını düşünmeye harcadı. Düşünme'yi "
|
"Temizleme modeli bütün yanıtını düşünmeye harcadı. Düşünme'yi "
|
||||||
"“Kapalı” yap.",
|
"“Kapalı” yap.",
|
||||||
|
"The cleanup model was cut off before it finished.":
|
||||||
|
"Temizleme modeli bitiremeden kesildi.",
|
||||||
|
"The model was cut off before it finished.":
|
||||||
|
"Model bitiremeden kesildi.",
|
||||||
|
|
||||||
# --- this pass's new messages ---------------------------------------
|
# --- this pass's new messages ---------------------------------------
|
||||||
"Audio recorder stopped before receiving sound":
|
"Audio recorder stopped before receiving sound":
|
||||||
|
|||||||
+104
-10
@@ -1,6 +1,7 @@
|
|||||||
"""The small recording indicator that appears in a screen corner without taking focus."""
|
"""The small recording indicator that appears in a screen corner without taking focus."""
|
||||||
|
|
||||||
import math
|
import math
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from PyQt6.QtCore import Qt, QTimer, QRectF, QPointF
|
from PyQt6.QtCore import Qt, QTimer, QRectF, QPointF
|
||||||
@@ -15,6 +16,7 @@ MIN_WIDTH = 210
|
|||||||
MAX_WIDTH = 460
|
MAX_WIDTH = 460
|
||||||
MARGIN = 28
|
MARGIN = 28
|
||||||
GAP = 10 # between two indicators sharing a corner
|
GAP = 10 # between two indicators sharing a corner
|
||||||
|
FOLLOW_EVERY = 8 # ticks between two looks for the pointer: about four a second
|
||||||
|
|
||||||
BG = QColor(22, 24, 29, 238)
|
BG = QColor(22, 24, 29, 238)
|
||||||
BORDER = QColor(255, 255, 255, 28)
|
BORDER = QColor(255, 255, 255, 28)
|
||||||
@@ -37,16 +39,68 @@ STATE_COLORS = {"recording": REC, "asking": ASK, "meeting": REC, "busy": BUSY,
|
|||||||
LIVE = ("recording", "asking", "meeting")
|
LIVE = ("recording", "asking", "meeting")
|
||||||
|
|
||||||
|
|
||||||
|
# KWin's interface, kept once one has been built. See _compositor_screen.
|
||||||
|
_kwin = None
|
||||||
|
|
||||||
|
|
||||||
|
def _compositor_screen():
|
||||||
|
"""The screen KWin says the session is on, or None where nothing says.
|
||||||
|
|
||||||
|
Wayland tells a client where the pointer is only while it is over one of
|
||||||
|
that client's own windows, and the indicator is never under the pointer, so
|
||||||
|
QCursor.pos() answers with a stale point or, when the pointer has never
|
||||||
|
been over a window of ours, with the origin. Either way the indicator lands
|
||||||
|
in the corner of whichever screen holds 0,0 instead of the one being worked
|
||||||
|
on, and on a two-monitor desk that is the wrong screen most of the time.
|
||||||
|
KWin does know, and it names outputs the way Qt names screens, by
|
||||||
|
connector, natively and through XWayland alike. No other Wayland desktop
|
||||||
|
answers this, so the rest are left with the pointer, which is right on X11
|
||||||
|
and wrong on Wayland exactly as before.
|
||||||
|
|
||||||
|
What it answers with is the active output, which is the one under the
|
||||||
|
pointer only where Plasma is set to let the active screen follow the mouse.
|
||||||
|
Under the default, click to focus, it is the focused window's screen, so
|
||||||
|
the indicator lands where the typing is going rather than where the mouse
|
||||||
|
was left. Which is why nothing here, and nothing in the settings window,
|
||||||
|
promises the pointer.
|
||||||
|
"""
|
||||||
|
global _kwin
|
||||||
|
if _kwin is None or not _kwin.isValid():
|
||||||
|
# Which also leaves macOS and Windows out, where nothing sets it and
|
||||||
|
# the pointer can be asked where it is like anywhere else.
|
||||||
|
desktop = os.environ.get("XDG_CURRENT_DESKTOP", "").lower()
|
||||||
|
if "kde" not in desktop and "plasma" not in desktop:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
from PyQt6.QtDBus import QDBusConnection, QDBusInterface
|
||||||
|
_kwin = QDBusInterface("org.kde.KWin", "/KWin", "org.kde.KWin",
|
||||||
|
QDBusConnection.sessionBus())
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if not _kwin.isValid():
|
||||||
|
return None
|
||||||
|
# A compositor busy enough not to answer in a fifth of a second is one
|
||||||
|
# the indicator should stop waiting for, not one it should freeze with.
|
||||||
|
_kwin.setTimeout(200)
|
||||||
|
answer = _kwin.call("activeOutputName").arguments()
|
||||||
|
name = answer[0] if answer else ""
|
||||||
|
return next((item for item in QApplication.screens() if item.name() == name),
|
||||||
|
None)
|
||||||
|
|
||||||
|
|
||||||
class Overlay(QWidget):
|
class Overlay(QWidget):
|
||||||
"""One indicator. Give it `below` and it stacks on top of that one instead
|
"""One indicator. Give it `below` and it stacks on top of that one instead
|
||||||
of covering it, which is what lets a dictation and a command to the agent be
|
of covering it, which is what lets a dictation and a command to the agent be
|
||||||
under way at the same time and still both be visible."""
|
under way at the same time and still both be visible."""
|
||||||
|
|
||||||
def __init__(self, corner="bottom-left", below=None, dismissable=False,
|
def __init__(self, corner="bottom-left", below=None, dismissable=False,
|
||||||
screen_name=""):
|
screen_name="", follow_pointer=False):
|
||||||
super().__init__(None)
|
super().__init__(None)
|
||||||
self.corner = corner
|
self.corner = corner
|
||||||
self.screen_name = screen_name
|
self.screen_name = screen_name
|
||||||
|
# Whether it goes on following the pointer once it is up, rather than
|
||||||
|
# settling on the screen it appeared on.
|
||||||
|
self.follow_pointer = follow_pointer
|
||||||
self.below = below
|
self.below = below
|
||||||
# A job that can run for ten minutes should not have to be watched for
|
# A job that can run for ten minutes should not have to be watched for
|
||||||
# ten minutes. Clicking such an indicator puts the progress away; the
|
# ten minutes. Clicking such an indicator puts the progress away; the
|
||||||
@@ -65,6 +119,8 @@ class Overlay(QWidget):
|
|||||||
self.seconds = 0.0
|
self.seconds = 0.0
|
||||||
self._phase = 0.0
|
self._phase = 0.0
|
||||||
self._concealed = True
|
self._concealed = True
|
||||||
|
self._shown_on = "" # the screen it was last put on, by name
|
||||||
|
self._looks = 0 # ticks since the pointer was last looked for
|
||||||
|
|
||||||
flags = (
|
flags = (
|
||||||
Qt.WindowType.FramelessWindowHint
|
Qt.WindowType.FramelessWindowHint
|
||||||
@@ -252,16 +308,52 @@ class Overlay(QWidget):
|
|||||||
min(MAX_WIDTH, metrics.horizontalAdvance(self.message) + extra))
|
min(MAX_WIDTH, metrics.horizontalAdvance(self.message) + extra))
|
||||||
self.resize(width, HEIGHT)
|
self.resize(width, HEIGHT)
|
||||||
|
|
||||||
def _reposition(self):
|
def _screen(self):
|
||||||
# The screen the settings name, or, when none is named or it is not
|
"""The screen this indicator belongs on right now.
|
||||||
# plugged in right now, where the user actually is. Names are connector
|
|
||||||
# names on X11 and model names on macOS, where two identical monitors
|
The one the settings name, or, when none is named or it is not plugged
|
||||||
# can share one; the first then wins.
|
in right now, where the user actually is. Names are connector names on
|
||||||
screen = next(
|
X11 and model names on macOS, where two identical monitors can share
|
||||||
|
one; the first then wins.
|
||||||
|
|
||||||
|
One stacking on another belongs on that one's screen and nowhere else.
|
||||||
|
Asked for itself it would answer where the user is now, which is not
|
||||||
|
where the ribbon it stacks on was put a minute ago, and the pair would
|
||||||
|
end up a monitor apart with this one raised over nothing.
|
||||||
|
"""
|
||||||
|
if self.below is not None and self.below.showing:
|
||||||
|
under = next((item for item in QApplication.screens()
|
||||||
|
if item.name() == self.below._shown_on), None)
|
||||||
|
if under is not None:
|
||||||
|
return under
|
||||||
|
named = next(
|
||||||
(item for item in QApplication.screens() if item.name() == self.screen_name),
|
(item for item in QApplication.screens() if item.name() == self.screen_name),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
screen = screen or QApplication.screenAt(QCursor.pos()) or QApplication.primaryScreen()
|
return (named or _compositor_screen()
|
||||||
|
or QApplication.screenAt(QCursor.pos())
|
||||||
|
or QApplication.primaryScreen())
|
||||||
|
|
||||||
|
def _wandered_off(self):
|
||||||
|
"""Whether the pointer has left the screen the indicator is on.
|
||||||
|
|
||||||
|
Only asked while it is following, and only every few ticks: the answer
|
||||||
|
costs a word with the compositor, and a hand moving a mouse across a
|
||||||
|
desk is slow next to a 33 ms ribbon. Every tick for one that stacks on
|
||||||
|
another, where the answer is free and waiting a third of a second for
|
||||||
|
it would leave the pair split over two monitors for that long.
|
||||||
|
"""
|
||||||
|
if not self.follow_pointer or self.screen_name:
|
||||||
|
return False
|
||||||
|
if self.below is None or not self.below.showing:
|
||||||
|
self._looks = (self._looks + 1) % FOLLOW_EVERY
|
||||||
|
if self._looks:
|
||||||
|
return False
|
||||||
|
return self._screen().name() != self._shown_on
|
||||||
|
|
||||||
|
def _reposition(self):
|
||||||
|
screen = self._screen()
|
||||||
|
self._shown_on = screen.name()
|
||||||
area = screen.availableGeometry()
|
area = screen.availableGeometry()
|
||||||
left = "left" in self.corner
|
left = "left" in self.corner
|
||||||
top = "top" in self.corner
|
top = "top" in self.corner
|
||||||
@@ -277,8 +369,10 @@ class Overlay(QWidget):
|
|||||||
def _tick(self):
|
def _tick(self):
|
||||||
self._phase += 0.12
|
self._phase += 0.12
|
||||||
# The one underneath can come and go while this one is up; drop back to
|
# The one underneath can come and go while this one is up; drop back to
|
||||||
# the corner when it does rather than leaving a gap where it was.
|
# the corner when it does rather than leaving a gap where it was. And
|
||||||
if self.below is not None and self.below.showing != self._stacked:
|
# the screen under the pointer can change while it is up too.
|
||||||
|
moved = self.below is not None and self.below.showing != self._stacked
|
||||||
|
if moved or self._wandered_off():
|
||||||
self._reposition()
|
self._reposition()
|
||||||
if self.state in LIVE and not self.paused:
|
if self.state in LIVE and not self.paused:
|
||||||
# keep the ribbon moving even through a pause in speech
|
# keep the ribbon moving even through a pause in speech
|
||||||
|
|||||||
+432
-50
@@ -6,7 +6,7 @@ import shutil
|
|||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
from PyQt6.QtCore import QEvent, QObject, QRect, Qt, QUrl, pyqtSignal
|
from PyQt6.QtCore import QEvent, QObject, QRect, Qt, QTimer, QUrl, pyqtSignal
|
||||||
from PyQt6.QtGui import QDesktopServices, QGuiApplication, QKeySequence, QShortcut
|
from PyQt6.QtGui import QDesktopServices, QGuiApplication, QKeySequence, QShortcut
|
||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QAbstractItemView, QAbstractSpinBox, QCheckBox, QComboBox, QDialog,
|
QAbstractItemView, QAbstractSpinBox, QCheckBox, QComboBox, QDialog,
|
||||||
@@ -181,18 +181,34 @@ class WrappedLabel(QLabel):
|
|||||||
super().setText(text)
|
super().setText(text)
|
||||||
self._fit()
|
self._fit()
|
||||||
|
|
||||||
|
def showEvent(self, event):
|
||||||
|
# Text set while the window was still being built was measured against
|
||||||
|
# nothing; this is the first moment the width means anything.
|
||||||
|
super().showEvent(event)
|
||||||
|
self._fit()
|
||||||
|
|
||||||
def resizeEvent(self, event):
|
def resizeEvent(self, event):
|
||||||
super().resizeEvent(event)
|
super().resizeEvent(event)
|
||||||
self._fit()
|
self._fit()
|
||||||
|
|
||||||
def _fit(self):
|
def _fit(self):
|
||||||
|
# A label the layout has not placed yet is a handful of pixels wide,
|
||||||
|
# and wrapping a sentence against that width invents a hundred lines.
|
||||||
|
# The minimum set from it does not stay a minimum either: QLabel folds
|
||||||
|
# it into its own cached size hints and clears that cache only when the
|
||||||
|
# text changes, so the row stands thousands of pixels tall and carries
|
||||||
|
# the model box and everything under it off the bottom of the window
|
||||||
|
# until another publisher is picked. Nothing to measure against yet
|
||||||
|
# means nothing to claim yet, and the show and resize above come back
|
||||||
|
# for it.
|
||||||
|
if not self.isVisible() or self.width() <= 0:
|
||||||
|
return
|
||||||
# Measured off the font rather than asked of the label, whose own answer
|
# Measured off the font rather than asked of the label, whose own answer
|
||||||
# is floored by the minimum set here a moment ago and so only ever grows.
|
# is floored by the minimum set here a moment ago and so only ever grows.
|
||||||
if self.width() > 0:
|
wrap = Qt.TextFlag.TextWordWrap | Qt.TextFlag.TextWrapAnywhere
|
||||||
wrap = Qt.TextFlag.TextWordWrap | Qt.TextFlag.TextWrapAnywhere
|
box = QRect(0, 0, self.width(), 0)
|
||||||
box = QRect(0, 0, self.width(), 0)
|
self.setMinimumHeight(
|
||||||
self.setMinimumHeight(
|
self.fontMetrics().boundingRect(box, wrap, self.text()).height())
|
||||||
self.fontMetrics().boundingRect(box, wrap, self.text()).height())
|
|
||||||
|
|
||||||
|
|
||||||
class WheelGuard(QObject):
|
class WheelGuard(QObject):
|
||||||
@@ -247,6 +263,20 @@ class LocalModelBox(QGroupBox):
|
|||||||
self._pending = False
|
self._pending = False
|
||||||
self._stop = False
|
self._stop = False
|
||||||
self._wanted = "" # the model to select once a list arrives
|
self._wanted = "" # the model to select once a list arrives
|
||||||
|
self._chosen_in = "" # the publisher the selected model is from
|
||||||
|
# Whether a list for the publisher on screen has come back. An empty
|
||||||
|
# box before one has is a box nobody has asked anything yet, and the
|
||||||
|
# two read the same without this.
|
||||||
|
self._answered = False
|
||||||
|
# What the last publisher listing held, so that the switch beside the
|
||||||
|
# box can be flipped without asking for it again.
|
||||||
|
self._found_repos = []
|
||||||
|
# Typing or arrowing through the publisher box changes its text a
|
||||||
|
# character at a time, and each of those would otherwise be a request.
|
||||||
|
self._later = QTimer(self)
|
||||||
|
self._later.setSingleShot(True)
|
||||||
|
self._later.setInterval(400)
|
||||||
|
self._later.timeout.connect(self._later_fetch)
|
||||||
|
|
||||||
form = QFormLayout(self)
|
form = QFormLayout(self)
|
||||||
|
|
||||||
@@ -256,15 +286,55 @@ class LocalModelBox(QGroupBox):
|
|||||||
form.addRow(t("Program"), self._side_by_side(self.program_label,
|
form.addRow(t("Program"), self._side_by_side(self.program_label,
|
||||||
self.install_button))
|
self.install_button))
|
||||||
|
|
||||||
|
# What the model rows are judged against, said out loud. Without it,
|
||||||
|
# "too big for this machine" and the recommendation above the list are
|
||||||
|
# a verdict with no visible reason behind them.
|
||||||
|
self.machine_label = WrappedLabel()
|
||||||
|
self.machine_label.setToolTip(
|
||||||
|
t("A model may take half of this memory, less a gigabyte for the "
|
||||||
|
"context around the weights. Anything past that is marked too "
|
||||||
|
"big; it may still load, on a machine with nothing else open."))
|
||||||
|
form.addRow(t("This machine"), self.machine_label)
|
||||||
|
self._show_machine()
|
||||||
|
|
||||||
if self._repos is not None:
|
if self._repos is not None:
|
||||||
self.repo = QComboBox()
|
self.repo = QComboBox()
|
||||||
self.repo.setEditable(True)
|
self.repo.setEditable(True)
|
||||||
self.repo.setToolTip(t("A Hugging Face repository of GGUF files. The "
|
self.repo.setToolTip(t("A Hugging Face repository of GGUF files. The "
|
||||||
"list is fetched; any other one can be typed in."))
|
"list is fetched; any other one can be typed in."))
|
||||||
self.repo.currentTextChanged.connect(self._repo_changed)
|
self.repo.currentTextChanged.connect(self._repo_changed)
|
||||||
form.addRow(t("Publisher"), self.repo)
|
# Forty repository ids is not a choice anybody can make. The few
|
||||||
|
# that were picked for this job are what the box holds until
|
||||||
|
# somebody asks for the rest.
|
||||||
|
self.every_repo = QCheckBox(t("All"))
|
||||||
|
self.every_repo.setToolTip(
|
||||||
|
t("Everything ggml-org publishes, including the models that "
|
||||||
|
"are too big to run here and the ones that are not for "
|
||||||
|
"cleaning up text."))
|
||||||
|
self.every_repo.toggled.connect(self._every_repo_changed)
|
||||||
|
form.addRow(t("Publisher"),
|
||||||
|
self._side_by_side(self.repo, self.every_repo))
|
||||||
|
# A repository id names the publisher, the parameter count and the
|
||||||
|
# shape of the weights, and says nothing about whether it is the
|
||||||
|
# one to click.
|
||||||
|
self.repo_note = WrappedLabel()
|
||||||
|
form.addRow("", self.repo_note)
|
||||||
|
|
||||||
self.model = QComboBox()
|
self.model = QComboBox()
|
||||||
|
self.model.setToolTip(
|
||||||
|
t("large-v3 makes the fewest mistakes and is the slowest of them. "
|
||||||
|
"large-v3-turbo is that model with a four layer decoder in place "
|
||||||
|
"of a thirty-two layer one: several times faster, at one to two "
|
||||||
|
"points of word error in English and about two and a half in "
|
||||||
|
"the other languages. Below those, every step down the list "
|
||||||
|
"trades accuracy for size, and the .en models are trained on "
|
||||||
|
"English alone.")
|
||||||
|
if program is ggml.WHISPER else
|
||||||
|
t("Cleanup is punctuation, capitals and filler words, so what "
|
||||||
|
"these are picked on is following an instruction rather than "
|
||||||
|
"knowing anything. Start at a q4 file; the 16-bit ones are "
|
||||||
|
"several times the memory for a difference this job cannot "
|
||||||
|
"see."))
|
||||||
self.download_button = QPushButton(t("Download"))
|
self.download_button = QPushButton(t("Download"))
|
||||||
self.download_button.clicked.connect(self._download)
|
self.download_button.clicked.connect(self._download)
|
||||||
self.delete_button = QPushButton(t("Delete"))
|
self.delete_button = QPushButton(t("Delete"))
|
||||||
@@ -327,14 +397,16 @@ class LocalModelBox(QGroupBox):
|
|||||||
"""
|
"""
|
||||||
self._wanted = model
|
self._wanted = model
|
||||||
self._pending = True
|
self._pending = True
|
||||||
|
self._answered = False
|
||||||
self._show_program()
|
self._show_program()
|
||||||
|
self._chosen_in = ""
|
||||||
if self._repos is not None:
|
if self._repos is not None:
|
||||||
|
suggested = ggml.suggested_llm()
|
||||||
|
self._chosen_in = repo or suggested[0]
|
||||||
self.repo.blockSignals(True)
|
self.repo.blockSignals(True)
|
||||||
self.repo.clear()
|
self.repo.setCurrentText(self._chosen_in)
|
||||||
self.repo.addItems(list(ggml.SUGGESTED_LLM))
|
|
||||||
self.repo.setCurrentText(repo or ggml.SUGGESTED_LLM[0])
|
|
||||||
self.repo.blockSignals(False)
|
self.repo.blockSignals(False)
|
||||||
self._fit_popup(self.repo)
|
self._fill_repos_box(suggested)
|
||||||
self._fill_models([])
|
self._fill_models([])
|
||||||
|
|
||||||
def showEvent(self, event):
|
def showEvent(self, event):
|
||||||
@@ -349,88 +421,263 @@ class LocalModelBox(QGroupBox):
|
|||||||
path = ggml.program_path(self.program)
|
path = ggml.program_path(self.program)
|
||||||
if not path:
|
if not path:
|
||||||
self.program_label.setText(t("Not installed."))
|
self.program_label.setText(t("Not installed."))
|
||||||
|
self.install_button.setText(t("Download"))
|
||||||
self.install_button.setVisible(True)
|
self.install_button.setVisible(True)
|
||||||
return
|
return
|
||||||
self.install_button.setVisible(not ggml.installed_program(self.program)
|
# A copy that is here is not a copy that is right. whisper.cpp releases
|
||||||
and not ggml.system_program(self.program))
|
# 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):
|
if ggml.system_program(self.program):
|
||||||
# Worth saying which one is running: a distribution package is built
|
# Worth saying which one is running: a distribution package is built
|
||||||
# for this machine and may reach the graphics card, while the
|
# for this machine and may reach the graphics card, while the
|
||||||
# released binaries carry processor backends only.
|
# released binaries carry processor backends only.
|
||||||
self.program_label.setText(t("Installed on the system: {path}", path=path))
|
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:
|
else:
|
||||||
self.program_label.setText(
|
self.program_label.setText(
|
||||||
t("Downloaded, version {version}.",
|
t("Downloaded, version {version}.",
|
||||||
version=ggml.installed_version(self.program) or "?"))
|
version=ggml.installed_version(self.program) or "?"))
|
||||||
|
|
||||||
|
def _show_machine(self):
|
||||||
|
where = ggml.accelerator()
|
||||||
|
memory = ggml.total_memory()
|
||||||
|
parts = [t("Graphics: {name}.", name=where) if where else
|
||||||
|
t("No graphics interface found, so this runs on the processor.")]
|
||||||
|
if memory:
|
||||||
|
parts.append(t("Memory: {size}.", size=ggml.human_size(memory)))
|
||||||
|
self.machine_label.setText(" ".join(parts))
|
||||||
|
|
||||||
# ---- the lists -------------------------------------------------------
|
# ---- the lists -------------------------------------------------------
|
||||||
|
|
||||||
def _fill_repos(self, current):
|
def _fill_repos(self, current):
|
||||||
def work():
|
def work():
|
||||||
self._listed.emit([("repos", ggml.llm_repos())], "")
|
self._listed.emit([("repos", ggml.llm_repos(), "")], "")
|
||||||
|
|
||||||
threading.Thread(target=work, daemon=True).start()
|
threading.Thread(target=work, daemon=True).start()
|
||||||
|
|
||||||
|
def _fill_repos_box(self, found):
|
||||||
|
"""The publishers, with the suggested ones kept apart from the rest.
|
||||||
|
|
||||||
|
Forty repositories in one run is a list nobody reads to the end of, and
|
||||||
|
the few worth starting from are lost in it. A separator rather than a
|
||||||
|
heading, because this box is typed into as well as chosen from and a
|
||||||
|
heading would land in the field as though it were a repository.
|
||||||
|
"""
|
||||||
|
self._found_repos = found
|
||||||
|
current = self.repo.currentText()
|
||||||
|
# Every suggestion, whether or not it came back in the listing: that
|
||||||
|
# listing is the forty repositories touched most recently, and a
|
||||||
|
# publisher that has not been updated in a season falls off it while
|
||||||
|
# still being the one to point at.
|
||||||
|
first = list(ggml.suggested_llm())
|
||||||
|
rest = [r for r in found if r not in first]
|
||||||
|
if not self.every_repo.isChecked():
|
||||||
|
# The one being used stays on offer whatever the switch says, so
|
||||||
|
# that a repository somebody typed in is not dropped out from
|
||||||
|
# under them by the next fetch.
|
||||||
|
rest = [r for r in rest if r == current]
|
||||||
|
self.repo.blockSignals(True)
|
||||||
|
self.repo.clear()
|
||||||
|
self.repo.addItems(first)
|
||||||
|
if first and rest:
|
||||||
|
self.repo.insertSeparator(self.repo.count())
|
||||||
|
self.repo.addItems(rest)
|
||||||
|
self.repo.setCurrentText(current)
|
||||||
|
self.repo.blockSignals(False)
|
||||||
|
self._fit_popup(self.repo)
|
||||||
|
self._show_repo_note()
|
||||||
|
|
||||||
def _repo_changed(self):
|
def _repo_changed(self):
|
||||||
|
self._show_repo_note()
|
||||||
|
if not self._downloading:
|
||||||
|
self._later.start()
|
||||||
|
|
||||||
|
def _show_repo_note(self):
|
||||||
|
note = ggml.SUGGESTED_LLM_NOTE.get(self.repository(), "")
|
||||||
|
self.repo_note.setText(t(note) if note else "")
|
||||||
|
|
||||||
|
def _every_repo_changed(self):
|
||||||
|
self._fill_repos_box(self._found_repos)
|
||||||
|
|
||||||
|
def _later_fetch(self):
|
||||||
|
# A download that started inside the wait was not there to be seen when
|
||||||
|
# the timer went off, and rebuilding the rows underneath one is exactly
|
||||||
|
# what the guard above is for.
|
||||||
if not self._downloading:
|
if not self._downloading:
|
||||||
self._fetch_models(self.repository())
|
self._fetch_models(self.repository())
|
||||||
|
|
||||||
def _fetch_models(self, repo=""):
|
def _fetch_models(self, repo=""):
|
||||||
|
self._answered = False
|
||||||
self.status.setText(t("Fetching the model list…"))
|
self.status.setText(t("Fetching the model list…"))
|
||||||
|
|
||||||
def work():
|
def work():
|
||||||
try:
|
try:
|
||||||
found = self._models(repo) if self._repos is not None else self._models()
|
found = self._models(repo) if self._repos is not None else self._models()
|
||||||
self._listed.emit([("models", found)], "")
|
self._listed.emit([("models", found, repo)], "")
|
||||||
except ggml.LocalError as exc:
|
except ggml.LocalError as exc:
|
||||||
self._listed.emit([], str(exc))
|
self._listed.emit([("models", [], repo)], str(exc))
|
||||||
|
|
||||||
threading.Thread(target=work, daemon=True).start()
|
threading.Thread(target=work, daemon=True).start()
|
||||||
|
|
||||||
def _on_listed(self, payload, error):
|
def _on_listed(self, payload, error):
|
||||||
|
kind, found, repo = payload[0] if payload else ("repos", [], "")
|
||||||
|
# A publisher changed while its predecessor's list was still on the way
|
||||||
|
# would otherwise be answered with the wrong models, whichever request
|
||||||
|
# happened to come back last.
|
||||||
|
if kind == "models" and repo != self.repository():
|
||||||
|
return
|
||||||
if error:
|
if error:
|
||||||
self.status.setText(error)
|
# The list is the publisher's, so a failed one leaves the box no
|
||||||
|
# longer showing this publisher's models: emptying it is what keeps
|
||||||
|
# the two boxes saying the same thing. The message goes on after,
|
||||||
|
# because filling the box writes a status of its own.
|
||||||
|
if kind == "models":
|
||||||
|
self._fill_models([])
|
||||||
self._refresh_buttons()
|
self._refresh_buttons()
|
||||||
|
self.status.setText(error)
|
||||||
return
|
return
|
||||||
kind, found = payload[0]
|
|
||||||
if kind == "repos":
|
if kind == "repos":
|
||||||
current = self.repo.currentText()
|
self._fill_repos_box(found)
|
||||||
self.repo.blockSignals(True)
|
|
||||||
self.repo.clear()
|
|
||||||
self.repo.addItems(found)
|
|
||||||
self.repo.setCurrentText(current)
|
|
||||||
self.repo.blockSignals(False)
|
|
||||||
self._fit_popup(self.repo)
|
|
||||||
return
|
return
|
||||||
|
self._answered = True
|
||||||
self._fill_models(found)
|
self._fill_models(found)
|
||||||
|
|
||||||
|
def _sections(self, items, best):
|
||||||
|
"""[(heading, [Item])] for the rows to show, in the order to show them.
|
||||||
|
|
||||||
|
The list arrives sorted by size and nothing else, which for whisper
|
||||||
|
interleaves the models: `large-v3-turbo-q5_0` lands between the two
|
||||||
|
`medium` quantisations, half a screen away from the turbo model it is a
|
||||||
|
copy of. Grouping puts the choice of model above the choice of
|
||||||
|
quantisation, and the row this machine should take goes on top, where
|
||||||
|
somebody who does not want to make either choice can stop reading.
|
||||||
|
"""
|
||||||
|
if not items:
|
||||||
|
return []
|
||||||
|
groups = (ggml.whisper_groups(items) if self.program is ggml.WHISPER
|
||||||
|
else [("", items)])
|
||||||
|
# A publisher with one file on offer is not a choice, and a row of its
|
||||||
|
# own above the only row there is would be the same model twice.
|
||||||
|
top = [i for i in items if i.name == best] if len(items) > 1 else []
|
||||||
|
if not top:
|
||||||
|
return groups
|
||||||
|
if len(groups) == 1 and not groups[0][0]:
|
||||||
|
groups = [(t("Everything this publisher offers"), groups[0][1])]
|
||||||
|
return [(t("Recommended for this machine"), top)] + groups
|
||||||
|
|
||||||
|
def _suggested(self):
|
||||||
|
"""The name to prefer when it is on offer, or "" for whatever fits."""
|
||||||
|
if self.program is not ggml.WHISPER:
|
||||||
|
return ""
|
||||||
|
# A Vulkan loader on the machine is not a card in play when what was
|
||||||
|
# installed is the processor build: recommending the accurate model
|
||||||
|
# off the loader alone would put a 1 GB model on a processor and the
|
||||||
|
# wait for it in front of somebody who asked for a sentence.
|
||||||
|
return ggml.suggested_whisper(
|
||||||
|
graphics="" if ggml.vulkan_missing(self.program) else None)
|
||||||
|
|
||||||
|
def _add_heading(self, text):
|
||||||
|
"""A row that names the group under it and cannot be chosen."""
|
||||||
|
self.model.addItem(text)
|
||||||
|
row = self.model.count() - 1
|
||||||
|
font = self.model.font()
|
||||||
|
font.setBold(True)
|
||||||
|
self.model.setItemData(row, font, Qt.ItemDataRole.FontRole)
|
||||||
|
listing = self.model.model()
|
||||||
|
entry = listing.item(row) if hasattr(listing, "item") else None
|
||||||
|
if entry is not None:
|
||||||
|
entry.setEnabled(False)
|
||||||
|
|
||||||
|
def _add_model(self, name, item, best):
|
||||||
|
"""One row: the file, what it weighs, and whether it is worth taking."""
|
||||||
|
here = ggml.have_model(self._model_path(name))
|
||||||
|
if here:
|
||||||
|
marks = [t("downloaded")]
|
||||||
|
elif item is None:
|
||||||
|
# Chosen but neither here nor on offer: the file was deleted from
|
||||||
|
# underneath, or the settings came from another machine.
|
||||||
|
marks = [t("not downloaded")]
|
||||||
|
else:
|
||||||
|
marks = [ggml.human_size(item.size)]
|
||||||
|
# `q5_1`, `Q4_K_M`, `MXFP4`, `BF16`: four spellings of the same thing
|
||||||
|
# in one list, and the number is the whole of what any of them says. A
|
||||||
|
# whisper file with no mark at all is the full 16-bit model, which is
|
||||||
|
# the one convention here that a name does not carry.
|
||||||
|
bits = ggml.bit_depth(name) or (16 if self.program is ggml.WHISPER
|
||||||
|
else 0)
|
||||||
|
if bits:
|
||||||
|
marks.append(t("{bits}-bit", bits=bits))
|
||||||
|
if ggml.ENGLISH_ONLY in name:
|
||||||
|
marks.append(t("English only"))
|
||||||
|
# The verdicts last, after everything the row is: what to do about the
|
||||||
|
# row rather than what it holds.
|
||||||
|
if item is not None and not here and not ggml.fits(item.size):
|
||||||
|
marks.append(t("too big for this machine"))
|
||||||
|
if name == best:
|
||||||
|
marks.append(t("recommended"))
|
||||||
|
self.model.addItem(f"{name} ({', '.join(marks)})", name)
|
||||||
|
self.model.setItemData(self.model.count() - 1, item,
|
||||||
|
Qt.ItemDataRole.UserRole + 1)
|
||||||
|
|
||||||
|
def _first_model(self):
|
||||||
|
"""The first row that is a model rather than a heading."""
|
||||||
|
for row in range(self.model.count()):
|
||||||
|
if self.model.itemData(row):
|
||||||
|
return row
|
||||||
|
return -1
|
||||||
|
|
||||||
def _fill_models(self, items):
|
def _fill_models(self, items):
|
||||||
"""One row per model, saying what it weighs and whether it is here."""
|
"""One row per model, grouped, saying what it weighs and where it is."""
|
||||||
wanted = self._wanted or self.selected()
|
# The selection is only worth carrying over within the publisher it was
|
||||||
here = [name for name in (self._model_path(i.name).name for i in items)]
|
# made in. Carried across one, a model this repository does not publish
|
||||||
|
# would be added back as "not downloaded" and selected again, and
|
||||||
|
# changing the publisher would leave the model box looking untouched.
|
||||||
|
same = self._repos is None or self.repository() == self._chosen_in
|
||||||
|
wanted = self._wanted or (self.selected() if same else "")
|
||||||
|
best = ggml.recommended(items, self._suggested()) if items else ""
|
||||||
self.model.blockSignals(True)
|
self.model.blockSignals(True)
|
||||||
self.model.clear()
|
self.model.clear()
|
||||||
for item, name in zip(items, here):
|
listed = set()
|
||||||
mark = (t("downloaded") if ggml.have_model(self._model_path(item.name))
|
for heading, group in self._sections(items, best):
|
||||||
else ggml.human_size(item.size))
|
if heading:
|
||||||
self.model.addItem(f"{name} ({mark})", name)
|
self._add_heading(heading)
|
||||||
self.model.setItemData(self.model.count() - 1, item, Qt.ItemDataRole.UserRole + 1)
|
for item in group:
|
||||||
|
name = self._model_path(item.name).name
|
||||||
|
self._add_model(name, item, best)
|
||||||
|
listed.add(name)
|
||||||
# A model that was downloaded and then dropped from the list upstream is
|
# A model that was downloaded and then dropped from the list upstream is
|
||||||
# still on this disk and still works, so it stays on offer.
|
# still on this disk and still works, so it stays on offer. So does one
|
||||||
for name in self._on_disk():
|
# that is chosen but not here: Save reads this box, and a row missing
|
||||||
if self.model.findData(name) < 0:
|
# here would quietly empty the setting rather than showing that the
|
||||||
self.model.addItem(f"{name} ({t('downloaded')})", name)
|
# model needs downloading again.
|
||||||
# And one that is chosen but not here, because the file was deleted from
|
extras = [(t("Already on this machine"),
|
||||||
# underneath or the settings came from another machine, stays chosen:
|
[name for name in self._on_disk() if name not in listed])]
|
||||||
# Save reads this box, and a row missing here would quietly empty the
|
if wanted and wanted not in listed \
|
||||||
# setting rather than showing that the model needs downloading again.
|
and not ggml.have_model(self._model_path(wanted)):
|
||||||
if wanted and self.model.findData(wanted) < 0:
|
extras.append((t("Chosen, but not downloaded"), [wanted]))
|
||||||
self.model.addItem(f"{wanted} ({t('not downloaded')})", wanted)
|
for heading, names in extras:
|
||||||
|
if names and listed:
|
||||||
|
self._add_heading(heading)
|
||||||
|
for name in names:
|
||||||
|
self._add_model(name, None, best)
|
||||||
index = self.model.findData(wanted)
|
index = self.model.findData(wanted)
|
||||||
self.model.setCurrentIndex(max(index, 0))
|
self.model.setCurrentIndex(index if index >= 0 else self._first_model())
|
||||||
self.model.blockSignals(False)
|
self.model.blockSignals(False)
|
||||||
self._fit_popup(self.model)
|
self._fit_popup(self.model)
|
||||||
self._wanted = ""
|
self._wanted = ""
|
||||||
|
self._chosen_in = self.repository()
|
||||||
self._model_changed()
|
self._model_changed()
|
||||||
|
|
||||||
def _on_disk(self):
|
def _on_disk(self):
|
||||||
@@ -459,6 +706,9 @@ class LocalModelBox(QGroupBox):
|
|||||||
self._show_program()
|
self._show_program()
|
||||||
if error:
|
if error:
|
||||||
self.program_label.setText(error)
|
self.program_label.setText(error)
|
||||||
|
# The model line says whether the program is here, so installing one
|
||||||
|
# changes what it should read.
|
||||||
|
self._refresh_buttons()
|
||||||
self.changed.emit()
|
self.changed.emit()
|
||||||
|
|
||||||
def _current_item(self):
|
def _current_item(self):
|
||||||
@@ -467,12 +717,21 @@ class LocalModelBox(QGroupBox):
|
|||||||
def _download(self):
|
def _download(self):
|
||||||
if self._downloading:
|
if self._downloading:
|
||||||
self._stop = True
|
self._stop = True
|
||||||
|
# The flag is only read between blocks, and the wait for the server
|
||||||
|
# to answer is not between blocks: a click during it changes
|
||||||
|
# nothing on screen for as long as the connection takes.
|
||||||
|
self.status.setText(t("Stopping…"))
|
||||||
return
|
return
|
||||||
item = self._current_item()
|
item = self._current_item()
|
||||||
if item is None:
|
if item is None:
|
||||||
return
|
return
|
||||||
self._downloading, self._stop = True, False
|
self._downloading, self._stop = True, False
|
||||||
self._refresh_buttons()
|
self._refresh_buttons()
|
||||||
|
# Opening the connection can take ten or twenty seconds, and the first
|
||||||
|
# byte counts are what the line below would otherwise wait for. Left
|
||||||
|
# saying "not downloaded yet" beside a button that now reads Stop, a
|
||||||
|
# download that started looks like a click that did not register.
|
||||||
|
self.status.setText(t("Starting the download…"))
|
||||||
|
|
||||||
def work():
|
def work():
|
||||||
try:
|
try:
|
||||||
@@ -518,10 +777,17 @@ class LocalModelBox(QGroupBox):
|
|||||||
|
|
||||||
def _fill_models_from_current(self):
|
def _fill_models_from_current(self):
|
||||||
"""Redraw the rows without asking anybody anything again."""
|
"""Redraw the rows without asking anybody anything again."""
|
||||||
items = [self.model.itemData(i, Qt.ItemDataRole.UserRole + 1)
|
# By name, because the recommended model has a row of its own at the
|
||||||
for i in range(self.model.count())]
|
# top as well as one in its group, and reading the rows back twice
|
||||||
|
# would double it in the list every time a download finished.
|
||||||
|
items, seen = [], set()
|
||||||
|
for row in range(self.model.count()):
|
||||||
|
item = self.model.itemData(row, Qt.ItemDataRole.UserRole + 1)
|
||||||
|
if item is not None and item.name not in seen:
|
||||||
|
seen.add(item.name)
|
||||||
|
items.append(item)
|
||||||
self._wanted = self.selected()
|
self._wanted = self.selected()
|
||||||
self._fill_models([i for i in items if i is not None])
|
self._fill_models(items)
|
||||||
|
|
||||||
def _delete(self):
|
def _delete(self):
|
||||||
name = self.selected()
|
name = self.selected()
|
||||||
@@ -545,15 +811,41 @@ class LocalModelBox(QGroupBox):
|
|||||||
def _refresh_buttons(self):
|
def _refresh_buttons(self):
|
||||||
name = self.selected()
|
name = self.selected()
|
||||||
here = bool(name) and ggml.have_model(self._model_path(name))
|
here = bool(name) and ggml.have_model(self._model_path(name))
|
||||||
|
# A row carries what it takes to fetch it. The ones that do not are the
|
||||||
|
# models found on this disk and the one the settings name but the list
|
||||||
|
# does not offer: there is nothing to press Download for on those, and
|
||||||
|
# a button that can only do nothing is worse than one that is out.
|
||||||
|
item = self._current_item()
|
||||||
self.delete_button.setEnabled(here and not self._downloading)
|
self.delete_button.setEnabled(here and not self._downloading)
|
||||||
self.download_button.setText(t("Stop") if self._downloading else t("Download"))
|
self.download_button.setText(t("Stop") if self._downloading else t("Download"))
|
||||||
self.download_button.setEnabled(self._downloading or (bool(name) and not here))
|
self.download_button.setEnabled(self._downloading or (item is not None
|
||||||
|
and not here))
|
||||||
if self._downloading:
|
if self._downloading:
|
||||||
return
|
return
|
||||||
if not name:
|
if not name and self._repos is not None and self._answered \
|
||||||
|
and self._first_model() < 0:
|
||||||
|
# An empty box under a publisher that answered perfectly well: what
|
||||||
|
# it publishes is split across files, past the size cap, or a
|
||||||
|
# projector or draft head rather than a model of its own. Said
|
||||||
|
# nowhere, it read as though the click had not registered.
|
||||||
|
self.status.setText(
|
||||||
|
t("{repo} publishes nothing that can be run here. Its models "
|
||||||
|
"are split across files, larger than {cap}, or pieces of a "
|
||||||
|
"model rather than one. Choose another publisher.",
|
||||||
|
repo=self.repository(), cap=ggml.human_size(ggml.GGUF_MAX_BYTES)))
|
||||||
|
elif not name:
|
||||||
self.status.setText(t("Nothing downloaded yet."))
|
self.status.setText(t("Nothing downloaded yet."))
|
||||||
|
elif here and not ggml.program_path(self.program):
|
||||||
|
# The model alone runs nothing, and "Ready" over a missing program
|
||||||
|
# reads as though it does.
|
||||||
|
self.status.setText(t("{name} is here, but the program above is "
|
||||||
|
"not. Download it first.", name=name))
|
||||||
elif here:
|
elif here:
|
||||||
self.status.setText(t("Ready: {name}.", name=name))
|
self.status.setText(t("Ready: {name}.", name=name))
|
||||||
|
elif item is None:
|
||||||
|
self.status.setText(t("{name} is not on this machine and this "
|
||||||
|
"publisher does not offer it. Choose another "
|
||||||
|
"model, or another publisher.", name=name))
|
||||||
else:
|
else:
|
||||||
self.status.setText(t("{name} has not been downloaded yet.", name=name))
|
self.status.setText(t("{name} has not been downloaded yet.", name=name))
|
||||||
|
|
||||||
@@ -801,7 +1093,11 @@ class SettingsWindow(QDialog):
|
|||||||
form = QFormLayout(page)
|
form = QFormLayout(page)
|
||||||
|
|
||||||
self.indicator_screen = QComboBox()
|
self.indicator_screen = QComboBox()
|
||||||
self.indicator_screen.addItem(t("Follow the mouse pointer"), "")
|
# The active screen rather than the pointer, for the reason in
|
||||||
|
# overlay._compositor_screen: it is what a compositor will answer for,
|
||||||
|
# and on Plasma the two are one screen only where the active screen is
|
||||||
|
# set to follow the mouse.
|
||||||
|
self.indicator_screen.addItem(t("Follow the active screen"), "")
|
||||||
for screen in QGuiApplication.screens():
|
for screen in QGuiApplication.screens():
|
||||||
# The native resolution, so that a scaled 4K screen reads
|
# The native resolution, so that a scaled 4K screen reads
|
||||||
# 3840 × 2160 and not the 1920 × 1080 Qt sees through the scale.
|
# 3840 × 2160 and not the 1920 × 1080 Qt sees through the scale.
|
||||||
@@ -815,12 +1111,26 @@ class SettingsWindow(QDialog):
|
|||||||
)
|
)
|
||||||
form.addRow(t("Indicator screen"), self.indicator_screen)
|
form.addRow(t("Indicator screen"), self.indicator_screen)
|
||||||
|
|
||||||
|
# Only the screen it appeared on is decided when it appears; this is
|
||||||
|
# what makes it keep up with a session that moves to another one
|
||||||
|
# mid-recording. The active screen and not the pointer, because that is
|
||||||
|
# what a compositor will answer for: on Plasma the two are the same
|
||||||
|
# screen only where the active screen is set to follow the mouse, and
|
||||||
|
# otherwise it is the focused window that decides. Nothing to offer
|
||||||
|
# when a screen is named above, since that name is the whole answer.
|
||||||
|
self.follow_pointer = QCheckBox(t("Move it when the active screen changes"))
|
||||||
|
self.indicator_screen.currentIndexChanged.connect(self._sync_follow_pointer)
|
||||||
|
form.addRow("", self.follow_pointer)
|
||||||
|
|
||||||
self.corner = QComboBox()
|
self.corner = QComboBox()
|
||||||
for value in CORNERS:
|
for value in CORNERS:
|
||||||
self.corner.addItem(t(value), value)
|
self.corner.addItem(t(value), value)
|
||||||
form.addRow(t("Indicator corner"), self.corner)
|
form.addRow(t("Indicator corner"), self.corner)
|
||||||
return page
|
return page
|
||||||
|
|
||||||
|
def _sync_follow_pointer(self):
|
||||||
|
self.follow_pointer.setEnabled(not self.indicator_screen.currentData())
|
||||||
|
|
||||||
def _api_tab(self):
|
def _api_tab(self):
|
||||||
page = QWidget()
|
page = QWidget()
|
||||||
outer = QVBoxLayout(page)
|
outer = QVBoxLayout(page)
|
||||||
@@ -864,6 +1174,15 @@ class SettingsWindow(QDialog):
|
|||||||
self.transcribe_model_row = self._row(self.transcribe_model,
|
self.transcribe_model_row = self._row(self.transcribe_model,
|
||||||
self.refresh_transcribe_models)
|
self.refresh_transcribe_models)
|
||||||
stt_form.addRow(t("Model"), self.transcribe_model_row)
|
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
|
# 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.
|
# height that fits one line, and the rest of the text is cut off.
|
||||||
self.transcribe_status = QLabel("")
|
self.transcribe_status = QLabel("")
|
||||||
@@ -1006,9 +1325,47 @@ class SettingsWindow(QDialog):
|
|||||||
orr_form.addRow(self.local_llm_options)
|
orr_form.addRow(self.local_llm_options)
|
||||||
|
|
||||||
outer.addWidget(orr)
|
outer.addWidget(orr)
|
||||||
|
|
||||||
|
# One box for both servers rather than a row inside each: what is being
|
||||||
|
# decided is whether this machine keeps gigabytes tied up between
|
||||||
|
# dictations, and that is not a question anybody wants to answer once
|
||||||
|
# per model.
|
||||||
|
self.local_box = QGroupBox(t("Models on this machine"))
|
||||||
|
local_form = QFormLayout(self.local_box)
|
||||||
|
self.local_idle_unload = QCheckBox(t("Unload a model that is sitting unused"))
|
||||||
|
self.local_idle_unload.setToolTip(
|
||||||
|
t("A loaded model holds its memory whether anything is using it or "
|
||||||
|
"not: over a gigabyte for whisper, several for an LLM. Unloading "
|
||||||
|
"gives that back to the rest of the desktop, and the next "
|
||||||
|
"dictation loads it again at the cost of the seconds that takes."))
|
||||||
|
self.local_idle_minutes = QSpinBox()
|
||||||
|
self.local_idle_minutes.setRange(1, 720)
|
||||||
|
self.local_idle_minutes.valueChanged.connect(self._idle_suffix)
|
||||||
|
self._idle_suffix(self.local_idle_minutes.value())
|
||||||
|
self.local_idle_unload.toggled.connect(self.local_idle_minutes.setEnabled)
|
||||||
|
local_form.addRow("", self.local_idle_unload)
|
||||||
|
local_form.addRow(t("After"), self.local_idle_minutes)
|
||||||
|
outer.addWidget(self.local_box)
|
||||||
|
|
||||||
outer.addStretch(1)
|
outer.addStretch(1)
|
||||||
return page
|
return page
|
||||||
|
|
||||||
|
def _idle_suffix(self, minutes):
|
||||||
|
"""The spin box's own noun, since its lowest value is one of them.
|
||||||
|
|
||||||
|
Turkish is handed both and translates them the same: a number there is
|
||||||
|
followed by the singular however many it counts.
|
||||||
|
"""
|
||||||
|
self.local_idle_minutes.setSuffix(
|
||||||
|
t(" minute") if minutes == 1 else t(" minutes"))
|
||||||
|
|
||||||
|
def _refresh_local_box(self):
|
||||||
|
"""The idle unload is only on screen when something here runs locally."""
|
||||||
|
self.local_box.setVisible(
|
||||||
|
(self.transcribe_provider.currentData() or "local") == "local"
|
||||||
|
or (self.cleanup_provider.currentData() or "openrouter") == "local"
|
||||||
|
)
|
||||||
|
|
||||||
def _prompt_tab(self):
|
def _prompt_tab(self):
|
||||||
page = QWidget()
|
page = QWidget()
|
||||||
layout = QVBoxLayout(page)
|
layout = QVBoxLayout(page)
|
||||||
@@ -1730,6 +2087,8 @@ class SettingsWindow(QDialog):
|
|||||||
if screen_name and self.indicator_screen.findData(screen_name) < 0:
|
if screen_name and self.indicator_screen.findData(screen_name) < 0:
|
||||||
self.indicator_screen.addItem(t("{name} (not connected)", name=screen_name), screen_name)
|
self.indicator_screen.addItem(t("{name} (not connected)", name=screen_name), screen_name)
|
||||||
self._select_data(self.indicator_screen, screen_name)
|
self._select_data(self.indicator_screen, screen_name)
|
||||||
|
self.follow_pointer.setChecked(conf["overlay_follows_pointer"])
|
||||||
|
self._sync_follow_pointer()
|
||||||
self._select_data(self.corner, conf["overlay_corner"])
|
self._select_data(self.corner, conf["overlay_corner"])
|
||||||
self.max_seconds.setValue(conf["max_seconds"])
|
self.max_seconds.setValue(conf["max_seconds"])
|
||||||
self.skip_silent.setChecked(conf["skip_silent"])
|
self.skip_silent.setChecked(conf["skip_silent"])
|
||||||
@@ -1747,6 +2106,7 @@ class SettingsWindow(QDialog):
|
|||||||
self._shown_provider = ""
|
self._shown_provider = ""
|
||||||
self._select_data(self.transcribe_provider, conf["transcribe_provider"])
|
self._select_data(self.transcribe_provider, conf["transcribe_provider"])
|
||||||
self._provider_changed() # selecting index 0 fires no signal
|
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_gpu.setChecked(conf["local_gpu"])
|
||||||
self.local_preload.setChecked(conf["local_preload"])
|
self.local_preload.setChecked(conf["local_preload"])
|
||||||
self.local_threads.setValue(int(conf["local_threads"]))
|
self.local_threads.setValue(int(conf["local_threads"]))
|
||||||
@@ -1772,6 +2132,9 @@ class SettingsWindow(QDialog):
|
|||||||
self.local_llm_preload.setChecked(conf["local_llm_preload"])
|
self.local_llm_preload.setChecked(conf["local_llm_preload"])
|
||||||
self._select_data(self.local_llm_reasoning, conf["local_llm_reasoning"])
|
self._select_data(self.local_llm_reasoning, conf["local_llm_reasoning"])
|
||||||
self.local_llm.load(conf["local_llm_model"], conf["local_llm_repo"])
|
self.local_llm.load(conf["local_llm_model"], conf["local_llm_repo"])
|
||||||
|
self.local_idle_unload.setChecked(conf["local_idle_unload"])
|
||||||
|
self.local_idle_minutes.setValue(int(conf["local_idle_minutes"]))
|
||||||
|
self.local_idle_minutes.setEnabled(conf["local_idle_unload"])
|
||||||
# The defaults as they read NOW, kept for the save comparison: after a
|
# The defaults as they read NOW, kept for the save comparison: after a
|
||||||
# language switch the boxes still hold the old language's default, and
|
# language switch the boxes still hold the old language's default, and
|
||||||
# comparing against the new one would store that text as a custom
|
# comparing against the new one would store that text as a custom
|
||||||
@@ -1849,6 +2212,9 @@ class SettingsWindow(QDialog):
|
|||||||
conf["paste_shortcut"] = self.paste_shortcut.currentText().strip()
|
conf["paste_shortcut"] = self.paste_shortcut.currentText().strip()
|
||||||
conf["restore_clipboard"] = self.restore_clipboard.isChecked()
|
conf["restore_clipboard"] = self.restore_clipboard.isChecked()
|
||||||
conf["overlay_screen"] = self.indicator_screen.currentData() or ""
|
conf["overlay_screen"] = self.indicator_screen.currentData() or ""
|
||||||
|
# Read even while it is greyed out, so that naming a screen and taking
|
||||||
|
# the name back again does not clear a preference nobody touched.
|
||||||
|
conf["overlay_follows_pointer"] = self.follow_pointer.isChecked()
|
||||||
conf["overlay_corner"] = self.corner.currentData() or "bottom-left"
|
conf["overlay_corner"] = self.corner.currentData() or "bottom-left"
|
||||||
conf["max_seconds"] = self.max_seconds.value()
|
conf["max_seconds"] = self.max_seconds.value()
|
||||||
conf["skip_silent"] = self.skip_silent.isChecked()
|
conf["skip_silent"] = self.skip_silent.isChecked()
|
||||||
@@ -1864,6 +2230,7 @@ class SettingsWindow(QDialog):
|
|||||||
for name, who in cfg.TRANSCRIBERS.items():
|
for name, who in cfg.TRANSCRIBERS.items():
|
||||||
conf[who.key] = self._key_fields[name].text().strip()
|
conf[who.key] = self._key_fields[name].text().strip()
|
||||||
conf[who.model] = self._models[name].strip() or cfg.DEFAULTS[who.model]
|
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["gemini_api_key"] = self.gemini_key.text().strip()
|
||||||
conf["opencode_api_key"] = self.opencode_key.text().strip()
|
conf["opencode_api_key"] = self.opencode_key.text().strip()
|
||||||
conf["local_model"] = self.local_whisper.selected()
|
conf["local_model"] = self.local_whisper.selected()
|
||||||
@@ -1899,6 +2266,8 @@ class SettingsWindow(QDialog):
|
|||||||
conf["local_llm_gpu"] = self.local_llm_gpu.isChecked()
|
conf["local_llm_gpu"] = self.local_llm_gpu.isChecked()
|
||||||
conf["local_llm_preload"] = self.local_llm_preload.isChecked()
|
conf["local_llm_preload"] = self.local_llm_preload.isChecked()
|
||||||
conf["local_llm_reasoning"] = self.local_llm_reasoning.currentData() or ""
|
conf["local_llm_reasoning"] = self.local_llm_reasoning.currentData() or ""
|
||||||
|
conf["local_idle_unload"] = self.local_idle_unload.isChecked()
|
||||||
|
conf["local_idle_minutes"] = self.local_idle_minutes.value()
|
||||||
|
|
||||||
# Store an empty prompt when it matches a default: the one it was
|
# Store an empty prompt when it matches a default: the one it was
|
||||||
# loaded with, or today's (a Reset click in a session that switched
|
# loaded with, or today's (a Reset click in a session that switched
|
||||||
@@ -2037,16 +2406,26 @@ class SettingsWindow(QDialog):
|
|||||||
self._shown_provider = provider
|
self._shown_provider = provider
|
||||||
local = provider == "local"
|
local = provider == "local"
|
||||||
self.stt_form.setRowVisible(self.transcribe_model_row, not 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.transcribe_status, not local)
|
||||||
self.stt_form.setRowVisible(self.local_whisper, local)
|
self.stt_form.setRowVisible(self.local_whisper, local)
|
||||||
self.stt_form.setRowVisible(self.local_options, local)
|
self.stt_form.setRowVisible(self.local_options, local)
|
||||||
|
self._refresh_local_box()
|
||||||
if local:
|
if local:
|
||||||
return
|
return
|
||||||
self.transcribe_model.clear()
|
self.transcribe_model.clear()
|
||||||
self.transcribe_model.addItems(TRANSCRIBE_MODELS[provider])
|
self.transcribe_model.addItems(TRANSCRIBE_MODELS[provider])
|
||||||
self.transcribe_model.setCurrentText(self._models[provider])
|
self.transcribe_model.setCurrentText(self._models[provider])
|
||||||
|
if provider == "openrouter":
|
||||||
|
self._fill_file_models(TRANSCRIBE_MODELS[provider])
|
||||||
self.transcribe_status.setText("")
|
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):
|
def _load_transcribe_models(self):
|
||||||
"""The model list of whichever provider is selected."""
|
"""The model list of whichever provider is selected."""
|
||||||
provider = self.transcribe_provider.currentData() or "openai"
|
provider = self.transcribe_provider.currentData() or "openai"
|
||||||
@@ -2075,6 +2454,8 @@ class SettingsWindow(QDialog):
|
|||||||
self.transcribe_model.clear()
|
self.transcribe_model.clear()
|
||||||
self.transcribe_model.addItems(models)
|
self.transcribe_model.addItems(models)
|
||||||
self.transcribe_model.setCurrentText(current)
|
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)))
|
self.transcribe_status.setText(t("{count} models loaded.", count=len(models)))
|
||||||
|
|
||||||
def _load_models(self):
|
def _load_models(self):
|
||||||
@@ -2538,6 +2919,7 @@ class SettingsWindow(QDialog):
|
|||||||
provider != "local")
|
provider != "local")
|
||||||
self.cleanup_form.setRowVisible(self.local_llm, provider == "local")
|
self.cleanup_form.setRowVisible(self.local_llm, provider == "local")
|
||||||
self.cleanup_form.setRowVisible(self.local_llm_options, provider == "local")
|
self.cleanup_form.setRowVisible(self.local_llm_options, provider == "local")
|
||||||
|
self._refresh_local_box()
|
||||||
binary = cleanup.executable(provider)
|
binary = cleanup.executable(provider)
|
||||||
found = shutil.which(binary) if binary else ""
|
found = shutil.which(binary) if binary else ""
|
||||||
if provider == "local":
|
if provider == "local":
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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.
|
||||||
Executable
+128
@@ -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-----
|
||||||
Executable
+116
@@ -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()
|
||||||
Executable
+76
@@ -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[@]}"
|
||||||
Executable
+145
@@ -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"
|
||||||
@@ -24,6 +24,7 @@ from unittest import mock
|
|||||||
|
|
||||||
from dikte import assistant
|
from dikte import assistant
|
||||||
from dikte import config as cfg
|
from dikte import config as cfg
|
||||||
|
from dikte import ggml
|
||||||
from dikte import i18n
|
from dikte import i18n
|
||||||
from dikte import update
|
from dikte import update
|
||||||
|
|
||||||
@@ -95,6 +96,10 @@ class DikteTest(unittest.TestCase):
|
|||||||
i18n.set_language("en")
|
i18n.set_language("en")
|
||||||
self.addCleanup(i18n.set_language, "en")
|
self.addCleanup(i18n.set_language, "en")
|
||||||
|
|
||||||
|
# Read once and kept for the life of the process, which across a test
|
||||||
|
# run means one test's machine answering for the next one's.
|
||||||
|
self.patch_attr(ggml, "_MEMORY", None)
|
||||||
|
|
||||||
# cli.launch_gui replaces this process with the application when no
|
# cli.launch_gui replaces this process with the application when no
|
||||||
# instance is running. A test that reaches it would take the whole run
|
# instance is running. A test that reaches it would take the whole run
|
||||||
# with it and hang, so it fails loudly here instead.
|
# with it and hang, so it fails loudly here instead.
|
||||||
|
|||||||
+64
-1
@@ -9,6 +9,7 @@ is blocked on, and a faked urlopen has no socket to cut, so those tests talk to
|
|||||||
a server of their own on the loopback interface.
|
a server of their own on the loopback interface.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import http.server
|
import http.server
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -53,6 +54,16 @@ class TimestampModel(unittest.TestCase):
|
|||||||
self.assertEqual(api.timestamp_model("openai", "gpt-4o-transcribe"),
|
self.assertEqual(api.timestamp_model("openai", "gpt-4o-transcribe"),
|
||||||
"whisper-1")
|
"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):
|
class Explain(DikteTest):
|
||||||
def error(self, status):
|
def error(self, status):
|
||||||
@@ -318,6 +329,13 @@ class TranscribeSegments(DikteTest):
|
|||||||
api.transcribe_segments(OPENROUTER, self.wav)
|
api.transcribe_segments(OPENROUTER, self.wav)
|
||||||
self.assertEqual(multipart_fields(calls[0])["model"], "openai/whisper-1")
|
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):
|
def test_groq_stays_on_the_model_it_was_given(self):
|
||||||
target = GROQ._replace(model="whisper-large-v3")
|
target = GROQ._replace(model="whisper-large-v3")
|
||||||
with fake_urlopen(self.reply([{"start": 0, "end": 1, "text": "hi"}])) as calls:
|
with fake_urlopen(self.reply([{"start": 0, "end": 1, "text": "hi"}])) as calls:
|
||||||
@@ -446,6 +464,29 @@ class Cleanup(DikteTest):
|
|||||||
with fake_urlopen(chat_reply(" ")), self.assertRaises(api.ApiError):
|
with fake_urlopen(chat_reply(" ")), self.assertRaises(api.ApiError):
|
||||||
api.cleanup("hello", "k", "m", "p")
|
api.cleanup("hello", "k", "m", "p")
|
||||||
|
|
||||||
|
def test_a_reply_cut_off_at_a_ceiling_is_refused_rather_than_pasted(self):
|
||||||
|
# Half a sentence looks like a cleaned-up transcript and is not one. The
|
||||||
|
# caller keeps what it was given, which is the whole dictation.
|
||||||
|
reply = {"choices": [{"message": {"content": "Hello, and then the"},
|
||||||
|
"finish_reason": "length"}]}
|
||||||
|
with fake_urlopen(reply), self.assertRaises(api.ApiError) as caught:
|
||||||
|
api.cleanup("hello", "k", "m", "p")
|
||||||
|
self.assertIn("cut off", str(caught.exception))
|
||||||
|
|
||||||
|
def test_a_reply_that_stopped_on_its_own_is_kept(self):
|
||||||
|
reply = {"choices": [{"message": {"content": "Hello."},
|
||||||
|
"finish_reason": "stop"}]}
|
||||||
|
with fake_urlopen(reply):
|
||||||
|
self.assertEqual(api.cleanup("hello", "k", "m", "p"), "Hello.")
|
||||||
|
|
||||||
|
def test_all_thinking_is_named_before_the_ceiling_it_was_cut_at(self):
|
||||||
|
"""Both are true at once, and only one of them says what to change."""
|
||||||
|
reply = {"choices": [{"message": {"content": "", "reasoning": "hmm"},
|
||||||
|
"finish_reason": "length"}]}
|
||||||
|
with fake_urlopen(reply), self.assertRaises(api.ApiError) as caught:
|
||||||
|
api.cleanup("hello", "k", "m", "p")
|
||||||
|
self.assertIn("Thinking", str(caught.exception))
|
||||||
|
|
||||||
def test_a_rate_limit_is_explained(self):
|
def test_a_rate_limit_is_explained(self):
|
||||||
with fake_urlopen(http_error(429)), \
|
with fake_urlopen(http_error(429)), \
|
||||||
self.assertRaises(api.ApiError) as caught:
|
self.assertRaises(api.ApiError) as caught:
|
||||||
@@ -454,6 +495,14 @@ class Cleanup(DikteTest):
|
|||||||
|
|
||||||
|
|
||||||
class Chat(DikteTest):
|
class Chat(DikteTest):
|
||||||
|
def test_an_answer_cut_off_at_a_ceiling_is_refused_rather_than_pasted(self):
|
||||||
|
# Half an answer reads like a whole one once it is on the screen.
|
||||||
|
reply = {"choices": [{"message": {"content": "Booked it for the"},
|
||||||
|
"finish_reason": "length"}]}
|
||||||
|
with fake_urlopen(reply), self.assertRaises(api.ApiError) as caught:
|
||||||
|
api.chat([{"role": "user", "content": "book it"}], "k", "m", "p")
|
||||||
|
self.assertIn("cut off", str(caught.exception))
|
||||||
|
|
||||||
def test_the_history_is_sent_after_the_system_prompt(self):
|
def test_the_history_is_sent_after_the_system_prompt(self):
|
||||||
history = [{"role": "user", "content": "book it"},
|
history = [{"role": "user", "content": "book it"},
|
||||||
{"role": "assistant", "content": "done"}]
|
{"role": "assistant", "content": "done"}]
|
||||||
@@ -599,11 +648,14 @@ if __name__ == "__main__":
|
|||||||
class FakeServer:
|
class FakeServer:
|
||||||
"""A ggml.Server as far as api.py is concerned."""
|
"""A ggml.Server as far as api.py is concerned."""
|
||||||
|
|
||||||
def __init__(self, url="http://127.0.0.1:9999/v1", fails="", log=""):
|
def __init__(self, url="http://127.0.0.1:9999/v1", fails="", log="",
|
||||||
|
context=8192):
|
||||||
self.url = url
|
self.url = url
|
||||||
self.fails = fails
|
self.fails = fails
|
||||||
self.log = log
|
self.log = log
|
||||||
self.starts = 0
|
self.starts = 0
|
||||||
|
self.held = 0
|
||||||
|
self.context = context
|
||||||
|
|
||||||
def serve(self):
|
def serve(self):
|
||||||
self.starts += 1
|
self.starts += 1
|
||||||
@@ -611,9 +663,20 @@ class FakeServer:
|
|||||||
raise ggml.LocalError(self.fails)
|
raise ggml.LocalError(self.fails)
|
||||||
return self.url
|
return self.url
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def busy(self):
|
||||||
|
self.held += 1
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
self.held -= 1
|
||||||
|
|
||||||
def error(self):
|
def error(self):
|
||||||
return self.log
|
return self.log
|
||||||
|
|
||||||
|
def settings(self):
|
||||||
|
return {"context": self.context}
|
||||||
|
|
||||||
|
|
||||||
LOCAL = api.Target("local", "Local whisper", "", "", "ggml-base.bin")
|
LOCAL = api.Target("local", "Local whisper", "", "", "ggml-base.bin")
|
||||||
|
|
||||||
|
|||||||
@@ -411,6 +411,52 @@ class Here(DikteTest):
|
|||||||
cleanup.run("uh, done", self.conf, "the rules")
|
cleanup.run("uh, done", self.conf, "the rules")
|
||||||
self.assertEqual(sent_json(calls[0])["max_tokens"], 512)
|
self.assertEqual(sent_json(calls[0])["max_tokens"], 512)
|
||||||
|
|
||||||
|
def test_thinking_is_given_room_of_its_own_rather_than_the_answer_s(self):
|
||||||
|
# llama.cpp counts the thinking towards the same ceiling, so a rung that
|
||||||
|
# took its budget out of the answer would leave a short dictation with
|
||||||
|
# nothing to reply with. On a context roomy enough that the clamp the
|
||||||
|
# top rung would otherwise meet is not what is being measured.
|
||||||
|
self.patch_attr(ggml, "llm", FakeServer(context=32768))
|
||||||
|
for rung, room in api.THINKING_ROOM.items():
|
||||||
|
with self.subTest(rung=rung):
|
||||||
|
self.conf["local_llm_reasoning"] = rung
|
||||||
|
with fake_urlopen(chat_reply("Done.")) as calls:
|
||||||
|
cleanup.run("uh, done", self.conf, "the rules")
|
||||||
|
self.assertEqual(sent_json(calls[0])["max_tokens"], 512 + room)
|
||||||
|
|
||||||
|
def test_each_rung_of_the_ladder_thinks_longer_than_the_one_below(self):
|
||||||
|
rungs = [api.THINKING_ROOM[name] for name in
|
||||||
|
("minimal", "low", "medium", "high", "xhigh", "max")]
|
||||||
|
self.assertEqual(rungs, sorted(rungs))
|
||||||
|
self.assertEqual(len(set(rungs)), len(rungs))
|
||||||
|
|
||||||
|
def test_the_models_own_default_is_given_room_to_think_in_too(self):
|
||||||
|
# Nothing is sent, so a template that thinks will think, and the ceiling
|
||||||
|
# has to survive that as well.
|
||||||
|
self.conf["local_llm_reasoning"] = ""
|
||||||
|
with fake_urlopen(chat_reply("Done.")) as calls:
|
||||||
|
cleanup.run("uh, done", self.conf, "the rules")
|
||||||
|
self.assertEqual(sent_json(calls[0])["max_tokens"],
|
||||||
|
512 + api.DEFAULT_THINKING_ROOM)
|
||||||
|
|
||||||
|
def test_the_ceiling_stays_under_the_context_the_server_was_started_with(self):
|
||||||
|
# Above the context there is no ceiling at all: the runaway would run to
|
||||||
|
# the end of the context instead of stopping where this says.
|
||||||
|
self.patch_attr(ggml, "llm", FakeServer(context=2048))
|
||||||
|
self.conf["local_llm_reasoning"] = "max"
|
||||||
|
with fake_urlopen(chat_reply("Done.")) as calls:
|
||||||
|
cleanup.run("uh, done", self.conf, "the rules")
|
||||||
|
self.assertLess(sent_json(calls[0])["max_tokens"], 2048)
|
||||||
|
|
||||||
|
def test_the_prompt_keeps_its_share_of_a_small_context(self):
|
||||||
|
self.patch_attr(ggml, "llm", FakeServer(context=2048))
|
||||||
|
self.conf["local_llm_reasoning"] = "max"
|
||||||
|
with fake_urlopen(chat_reply("Done.")) as calls:
|
||||||
|
cleanup.run("x" * 2000, self.conf, "the rules")
|
||||||
|
# 2048 less half the characters of prompt and transcript together.
|
||||||
|
self.assertEqual(sent_json(calls[0])["max_tokens"],
|
||||||
|
2048 - (len("the rules") + 2000) // 2)
|
||||||
|
|
||||||
def test_a_reply_that_was_all_thinking_names_the_setting_that_fixes_it(self):
|
def test_a_reply_that_was_all_thinking_names_the_setting_that_fixes_it(self):
|
||||||
reply = {"choices": [{"message": {"content": "", "reasoning": "hmm"}}]}
|
reply = {"choices": [{"message": {"content": "", "reasoning": "hmm"}}]}
|
||||||
with fake_urlopen(reply), self.assertRaises(api.ApiError) as caught:
|
with fake_urlopen(reply), self.assertRaises(api.ApiError) as caught:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ socket is faked, and everything that runs locally runs for real.
|
|||||||
import contextlib
|
import contextlib
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
import webbrowser
|
import webbrowser
|
||||||
from typing import ClassVar
|
from typing import ClassVar
|
||||||
@@ -668,7 +669,11 @@ class WithoutAnInstance(DikteTest):
|
|||||||
def run_verb(self, argv):
|
def run_verb(self, argv):
|
||||||
# launch_gui replaces this process with the application, so it never
|
# launch_gui replaces this process with the application, so it never
|
||||||
# comes back in real use and must not be allowed to here.
|
# comes back in real use and must not be allowed to here.
|
||||||
|
# `ask` with no text reads what was piped in, and the runner's own
|
||||||
|
# stdin is not that: under pytest it is an object that refuses to be
|
||||||
|
# read at all.
|
||||||
with mock.patch.object(ipc, "send", return_value=None), \
|
with mock.patch.object(ipc, "send", return_value=None), \
|
||||||
|
mock.patch.object(sys, "stdin", io.StringIO()), \
|
||||||
mock.patch.object(cli, "launch_gui") as launch, \
|
mock.patch.object(cli, "launch_gui") as launch, \
|
||||||
captured() as (out, err):
|
captured() as (out, err):
|
||||||
code = cli.run(argv)
|
code = cli.run(argv)
|
||||||
|
|||||||
@@ -220,6 +220,19 @@ class TranscribeTarget(DikteTest):
|
|||||||
self.assertEqual(target.service, "OpenRouter")
|
self.assertEqual(target.service, "OpenRouter")
|
||||||
self.assertEqual(target.api_key, "sk-or-test")
|
self.assertEqual(target.api_key, "sk-or-test")
|
||||||
self.assertEqual(target.model, "openai/whisper-1")
|
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):
|
def test_groq_when_it_is_picked(self):
|
||||||
conf = self.config(transcribe_provider="groq", groq_api_key="gsk-test",
|
conf = self.config(transcribe_provider="groq", groq_api_key="gsk-test",
|
||||||
@@ -676,3 +689,24 @@ class ReadyToRun(DikteTest):
|
|||||||
self.assertEqual(ggml.whisper.settings()["threads"], 4)
|
self.assertEqual(ggml.whisper.settings()["threads"], 4)
|
||||||
self.assertFalse(ggml.whisper.settings()["gpu"])
|
self.assertFalse(ggml.whisper.settings()["gpu"])
|
||||||
self.assertEqual(ggml.llm.settings()["context"], 4096)
|
self.assertEqual(ggml.llm.settings()["context"], 4096)
|
||||||
|
|
||||||
|
def test_the_idle_window_is_in_seconds(self):
|
||||||
|
conf = self.config(local_idle_unload=True, local_idle_minutes=15)
|
||||||
|
self.assertEqual(conf.idle_seconds(), 900)
|
||||||
|
|
||||||
|
def test_an_unchecked_box_keeps_the_model(self):
|
||||||
|
conf = self.config(local_idle_unload=False, local_idle_minutes=15)
|
||||||
|
self.assertEqual(conf.idle_seconds(), 0)
|
||||||
|
|
||||||
|
def test_a_window_of_no_minutes_is_still_a_window(self):
|
||||||
|
"""The spin box will not go below one; a config edited by hand can."""
|
||||||
|
conf = self.config(local_idle_unload=True, local_idle_minutes=0)
|
||||||
|
self.assertEqual(conf.idle_seconds(), 60)
|
||||||
|
|
||||||
|
def test_both_servers_are_told_the_window(self):
|
||||||
|
conf = self.config(local_idle_unload=True, local_idle_minutes=3)
|
||||||
|
self.addCleanup(ggml.llm.set_idle, 0)
|
||||||
|
self.addCleanup(ggml.whisper.set_idle, 0)
|
||||||
|
conf.apply_local()
|
||||||
|
self.assertEqual(ggml.whisper.idle, 180)
|
||||||
|
self.assertEqual(ggml.llm.idle, 180)
|
||||||
|
|||||||
+533
-1
@@ -49,6 +49,11 @@ def item(name, data, url="https://example.invalid/f", sha=True):
|
|||||||
hashlib.sha256(data).hexdigest() if sha else "")
|
hashlib.sha256(data).hexdigest() if sha else "")
|
||||||
|
|
||||||
|
|
||||||
|
def listed(name, size):
|
||||||
|
"""A row as a listing hands it over: a name and a size, no bytes."""
|
||||||
|
return hub.Item(name, f"https://example.invalid/{name}", size, "a" * 64)
|
||||||
|
|
||||||
|
|
||||||
@contextlib.contextmanager
|
@contextlib.contextmanager
|
||||||
def serving(release, archive):
|
def serving(release, archive):
|
||||||
"""Answer by what is being asked for rather than by what came before.
|
"""Answer by what is being asked for rather than by what came before.
|
||||||
@@ -205,6 +210,7 @@ class InstallProgram(Local):
|
|||||||
# These fixtures are Ubuntu release archives. Keep checking that path
|
# These fixtures are Ubuntu release archives. Keep checking that path
|
||||||
# on every host, including the Mac that checks the macOS backend.
|
# on every host, including the Mac that checks the macOS backend.
|
||||||
self.patch_attr(sys, "platform", "linux")
|
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
|
# Built once, because the release listing has to publish its checksum
|
||||||
# and a tarball is not the same bytes twice.
|
# and a tarball is not the same bytes twice.
|
||||||
self.archive = tarball({
|
self.archive = tarball({
|
||||||
@@ -221,6 +227,7 @@ class InstallProgram(Local):
|
|||||||
|
|
||||||
def install(self, *names, archive=None):
|
def install(self, *names, archive=None):
|
||||||
self.patch_attr(ggml, "_arch", lambda: "x64")
|
self.patch_attr(ggml, "_arch", lambda: "x64")
|
||||||
|
self.patch_attr(ggml, "_has_vulkan", lambda: False)
|
||||||
blob = self.archive if archive is None else archive
|
blob = self.archive if archive is None else archive
|
||||||
with serving(self.release(*names, archive=blob), blob) as calls:
|
with serving(self.release(*names, archive=blob), blob) as calls:
|
||||||
path = ggml.install_program(ggml.WHISPER)
|
path = ggml.install_program(ggml.WHISPER)
|
||||||
@@ -238,6 +245,162 @@ class InstallProgram(Local):
|
|||||||
"whisper-bin-ubuntu-x64.tar.gz")
|
"whisper-bin-ubuntu-x64.tar.gz")
|
||||||
self.assertTrue(urls[1].endswith("whisper-bin-ubuntu-x64.tar.gz"))
|
self.assertTrue(urls[1].endswith("whisper-bin-ubuntu-x64.tar.gz"))
|
||||||
|
|
||||||
|
def test_the_nightly_pointer_is_followed_to_where_the_builds_are(self):
|
||||||
|
"""llama.cpp's latest release carries a tag name, not the binaries."""
|
||||||
|
self.patch_attr(ggml, "_arch", lambda: "x64")
|
||||||
|
self.patch_attr(ggml, "_has_vulkan", lambda: False)
|
||||||
|
marker = self.release(ggml.NIGHTLY_TAG)
|
||||||
|
nightly = dict(self.release("llama-b10809-bin-ubuntu-x64.tar.gz"),
|
||||||
|
tag_name="b10809")
|
||||||
|
|
||||||
|
def opener(request, timeout=None):
|
||||||
|
url = request.full_url
|
||||||
|
if url.endswith("/releases/latest"):
|
||||||
|
return json_body(marker)
|
||||||
|
if url.endswith("/releases/tags/b10809"):
|
||||||
|
return json_body(nightly)
|
||||||
|
if url.endswith(ggml.NIGHTLY_TAG):
|
||||||
|
return body(b"b10809\n")
|
||||||
|
return body(self.archive)
|
||||||
|
|
||||||
|
with mock.patch("urllib.request.urlopen", side_effect=opener):
|
||||||
|
tag, found = ggml._pick_asset(ggml.LLAMA)
|
||||||
|
self.assertEqual(tag, "b10809")
|
||||||
|
self.assertEqual(found.name, "llama-b10809-bin-ubuntu-x64.tar.gz")
|
||||||
|
|
||||||
|
def test_without_a_pointer_the_newest_release_that_has_a_build_is_taken(self):
|
||||||
|
self.patch_attr(ggml, "_arch", lambda: "x64")
|
||||||
|
self.patch_attr(ggml, "_has_vulkan", lambda: False)
|
||||||
|
marker = self.release("source.zip")
|
||||||
|
listing = [dict(self.release("llama-b2-bin-win-cpu-x64.zip"), tag_name="b2"),
|
||||||
|
dict(self.release("llama-b1-bin-ubuntu-x64.tar.gz"), tag_name="b1")]
|
||||||
|
|
||||||
|
def opener(request, timeout=None):
|
||||||
|
url = request.full_url
|
||||||
|
return json_body(listing if "per_page" in url else marker)
|
||||||
|
|
||||||
|
with mock.patch("urllib.request.urlopen", side_effect=opener):
|
||||||
|
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):
|
def test_a_release_with_nothing_for_this_machine_says_so(self):
|
||||||
self.patch_attr(ggml, "_arch", lambda: "x64")
|
self.patch_attr(ggml, "_arch", lambda: "x64")
|
||||||
with fake_urlopen(self.release("whisper-bin-Win32.zip")):
|
with fake_urlopen(self.release("whisper-bin-Win32.zip")):
|
||||||
@@ -506,6 +669,53 @@ class Catalogue(Local):
|
|||||||
with self.assertRaises(ggml.LocalError):
|
with self.assertRaises(ggml.LocalError):
|
||||||
ggml.whisper_models()
|
ggml.whisper_models()
|
||||||
|
|
||||||
|
def test_the_speculative_decoding_heads_are_not_models(self):
|
||||||
|
# They are the small files in a repository, so a list sorted by size
|
||||||
|
# puts them first, where the eye lands and the click goes.
|
||||||
|
tree = GGUF_TREE + [
|
||||||
|
{"type": "file", "path": "dflash-Qwen3-8B-Q8_0.gguf",
|
||||||
|
"size": 1_120_000_000, "lfs": {"oid": "f" * 64}},
|
||||||
|
{"type": "file", "path": "eagle3-gpt-oss-20b-Q8_0.gguf",
|
||||||
|
"size": 920_000_000, "lfs": {"oid": "0" * 64}},
|
||||||
|
]
|
||||||
|
with fake_urlopen(tree):
|
||||||
|
names = [q.name for q in ggml.llm_quants("ggml-org/x-GGUF")]
|
||||||
|
self.assertEqual(names,
|
||||||
|
["gemma-3-4b-it-Q4_K_M.gguf", "gemma-3-4b-it-Q8_0.gguf"])
|
||||||
|
|
||||||
|
def test_a_speech_or_vision_repository_is_not_a_cleanup_publisher(self):
|
||||||
|
listing = [{"id": "ggml-org/parakeet-GGUF"},
|
||||||
|
{"id": "ggml-org/Qwen3-TTS-12Hz-1.7B-Base-GGUF"},
|
||||||
|
{"id": "ggml-org/SmolVLM2-256M-Video-Instruct-GGUF"},
|
||||||
|
{"id": "ggml-org/Qwen3-8B-Base-GGUF"},
|
||||||
|
{"id": "ggml-org/SmolLM3-3B-GGUF"}]
|
||||||
|
with fake_urlopen(listing):
|
||||||
|
found = ggml.llm_repos()
|
||||||
|
self.assertEqual([r for r in found if r.startswith("ggml-org/Smol")],
|
||||||
|
["ggml-org/SmolLM3-3B-GGUF"])
|
||||||
|
self.assertNotIn("ggml-org/parakeet-GGUF", found)
|
||||||
|
self.assertNotIn("ggml-org/Qwen3-8B-Base-GGUF", found)
|
||||||
|
|
||||||
|
def test_a_publisher_is_not_dropped_for_a_word_it_happens_to_contain(self):
|
||||||
|
# The skip marks are matched as plain substrings, and an unanchored
|
||||||
|
# "test-" is also inside "Latest-".
|
||||||
|
self.assertTrue(ggml.can_clean("ggml-org/Qwen3-Latest-GGUF"))
|
||||||
|
self.assertFalse(ggml.can_clean("ggml-org/test-model-router-download"))
|
||||||
|
|
||||||
|
def test_a_base_model_beside_its_tuned_twin_is_dropped(self):
|
||||||
|
# Gemma names the base model after the tuned one with the `-it` taken
|
||||||
|
# out, so the two sit next to each other and the wrong one answers a
|
||||||
|
# cleanup prompt by carrying on writing the transcript.
|
||||||
|
listing = [{"id": "ggml-org/gemma-4-E2B-GGUF"},
|
||||||
|
{"id": "ggml-org/gemma-4-E2B-it-GGUF"},
|
||||||
|
{"id": "ggml-org/Qwen3-0.6B-GGUF"}]
|
||||||
|
with fake_urlopen(listing):
|
||||||
|
found = ggml.llm_repos()
|
||||||
|
self.assertNotIn("ggml-org/gemma-4-E2B-GGUF", found)
|
||||||
|
self.assertIn("ggml-org/gemma-4-E2B-it-GGUF", found)
|
||||||
|
# Nothing named it, so nothing says it is the wrong half of a pair.
|
||||||
|
self.assertIn("ggml-org/Qwen3-0.6B-GGUF", found)
|
||||||
|
|
||||||
def test_what_is_on_disk_is_read_from_disk(self):
|
def test_what_is_on_disk_is_read_from_disk(self):
|
||||||
self.assertEqual(ggml.installed_whisper_models(), [])
|
self.assertEqual(ggml.installed_whisper_models(), [])
|
||||||
path = ggml.whisper_model_path("ggml-base.bin")
|
path = ggml.whisper_model_path("ggml-base.bin")
|
||||||
@@ -573,7 +783,9 @@ STAND_IN = textwrap.dedent("""
|
|||||||
""")
|
""")
|
||||||
|
|
||||||
|
|
||||||
class Servers(Local):
|
class ServerCase(Local):
|
||||||
|
"""The stand-in server and the fixture around it, with no tests of its own."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
self.path("data").mkdir(parents=True, exist_ok=True)
|
self.path("data").mkdir(parents=True, exist_ok=True)
|
||||||
@@ -596,6 +808,8 @@ class Servers(Local):
|
|||||||
self.addCleanup(made.stop)
|
self.addCleanup(made.stop)
|
||||||
return made
|
return made
|
||||||
|
|
||||||
|
|
||||||
|
class Servers(ServerCase):
|
||||||
def test_a_started_server_hands_back_its_address(self):
|
def test_a_started_server_hands_back_its_address(self):
|
||||||
server = self.server()
|
server = self.server()
|
||||||
url = server.serve()
|
url = server.serve()
|
||||||
@@ -827,6 +1041,122 @@ class Servers(Local):
|
|||||||
self.assertFalse(server.sweep()) # and the pid file went with it
|
self.assertFalse(server.sweep()) # and the pid file went with it
|
||||||
|
|
||||||
|
|
||||||
|
class IdleUnload(ServerCase):
|
||||||
|
"""Giving the memory back when nothing has asked anything for a while."""
|
||||||
|
|
||||||
|
IDLE = 0.3
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
# The real check runs every five seconds against a window of minutes.
|
||||||
|
# Both are scaled down here; what is being tested is the decision, and
|
||||||
|
# nothing in it reads the clock in units of its own.
|
||||||
|
self.patch_attr(ggml, "IDLE_CHECK_SECONDS", 0.05)
|
||||||
|
|
||||||
|
def idle_server(self, seconds=None, **settings):
|
||||||
|
server = self.server(**settings)
|
||||||
|
server.set_idle(self.IDLE if seconds is None else seconds)
|
||||||
|
return server
|
||||||
|
|
||||||
|
def wait_for(self, predicate, timeout=5.0):
|
||||||
|
"""True as soon as `predicate` holds, False once the wait runs out."""
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
if predicate():
|
||||||
|
return True
|
||||||
|
time.sleep(0.02)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def test_a_model_nobody_is_using_is_unloaded(self):
|
||||||
|
server = self.idle_server()
|
||||||
|
server.serve()
|
||||||
|
self.assertTrue(self.wait_for(lambda: not server.running))
|
||||||
|
|
||||||
|
def test_the_default_is_to_keep_it(self):
|
||||||
|
"""A server nobody set a window on stays until something stops it."""
|
||||||
|
server = self.server()
|
||||||
|
server.serve()
|
||||||
|
self.assertFalse(self.wait_for(lambda: not server.running, timeout=0.6))
|
||||||
|
|
||||||
|
def test_a_window_of_zero_keeps_it_too(self):
|
||||||
|
server = self.idle_server(0)
|
||||||
|
server.serve()
|
||||||
|
self.assertFalse(self.wait_for(lambda: not server.running, timeout=0.6))
|
||||||
|
|
||||||
|
def test_a_request_in_flight_holds_the_model(self):
|
||||||
|
"""A file is one address lookup and then minutes of work: the clock
|
||||||
|
alone would call that idle and unload it mid-transcription."""
|
||||||
|
server = self.idle_server()
|
||||||
|
server.serve()
|
||||||
|
with server.busy():
|
||||||
|
self.assertFalse(
|
||||||
|
self.wait_for(lambda: not server.running, timeout=self.IDLE * 3))
|
||||||
|
self.assertTrue(self.wait_for(lambda: not server.running))
|
||||||
|
|
||||||
|
def test_asking_for_the_address_puts_the_window_back(self):
|
||||||
|
server = self.idle_server()
|
||||||
|
first = server.serve()
|
||||||
|
for _ in range(4):
|
||||||
|
time.sleep(self.IDLE / 2)
|
||||||
|
self.assertEqual(server.serve(), first) # never restarted
|
||||||
|
self.assertTrue(server.running)
|
||||||
|
|
||||||
|
def test_the_next_request_loads_it_again(self):
|
||||||
|
server = self.idle_server()
|
||||||
|
first = server.serve()
|
||||||
|
self.assertTrue(self.wait_for(lambda: not server.running))
|
||||||
|
second = server.serve()
|
||||||
|
self.assertTrue(server.running)
|
||||||
|
self.assertNotEqual(second, first) # a new process, a new port
|
||||||
|
|
||||||
|
def test_the_watcher_of_a_stopped_server_does_not_touch_the_next_one(self):
|
||||||
|
server = self.idle_server()
|
||||||
|
server.serve()
|
||||||
|
server.stop()
|
||||||
|
server.set_idle(0)
|
||||||
|
server.serve()
|
||||||
|
self.assertFalse(self.wait_for(lambda: not server.running, timeout=0.6))
|
||||||
|
|
||||||
|
def test_unloading_by_hand_does_not_wait_for_the_window(self):
|
||||||
|
server = self.idle_server(0)
|
||||||
|
server.serve()
|
||||||
|
self.assertTrue(server.unload())
|
||||||
|
self.assertFalse(server.running)
|
||||||
|
|
||||||
|
def test_a_hold_taken_before_the_start_survives_it(self):
|
||||||
|
"""The local cleanup takes the hold and only then asks for the address,
|
||||||
|
so the start it triggers must not be what drops the hold."""
|
||||||
|
server = self.idle_server()
|
||||||
|
with server.busy():
|
||||||
|
server.serve()
|
||||||
|
self.assertFalse(
|
||||||
|
self.wait_for(lambda: not server.running, timeout=self.IDLE * 3))
|
||||||
|
self.assertTrue(self.wait_for(lambda: not server.running))
|
||||||
|
|
||||||
|
def test_unloading_is_refused_while_the_model_is_still_loading(self):
|
||||||
|
"""It runs on the interface's thread, and a start holds its lock for as
|
||||||
|
long as the load takes: waiting there would freeze the whole window."""
|
||||||
|
server = self.idle_server(0, extra=["--wait", "0.6"])
|
||||||
|
thread = threading.Thread(target=server.serve)
|
||||||
|
thread.start()
|
||||||
|
try:
|
||||||
|
began = time.monotonic()
|
||||||
|
self.assertFalse(server.unload())
|
||||||
|
self.assertLess(time.monotonic() - began, 0.2)
|
||||||
|
finally:
|
||||||
|
thread.join(timeout=10)
|
||||||
|
|
||||||
|
def test_unloading_is_refused_while_a_request_is_in_flight(self):
|
||||||
|
server = self.idle_server(0)
|
||||||
|
server.serve()
|
||||||
|
with server.busy():
|
||||||
|
self.assertFalse(server.unload())
|
||||||
|
self.assertTrue(server.running)
|
||||||
|
|
||||||
|
def test_unloading_nothing_is_not_a_refusal(self):
|
||||||
|
self.assertTrue(self.server().unload())
|
||||||
|
|
||||||
|
|
||||||
class Arguments(Local):
|
class Arguments(Local):
|
||||||
"""What the two command lines say, since neither program is here to say it."""
|
"""What the two command lines say, since neither program is here to say it."""
|
||||||
|
|
||||||
@@ -1025,3 +1355,205 @@ class WindowsOwnership(Local):
|
|||||||
# from here", and only one of those makes the pid file safe to drop.
|
# from here", and only one of those makes the pid file safe to drop.
|
||||||
self.image("")
|
self.image("")
|
||||||
self.assertIsNone(self.made._is_ours(1234))
|
self.assertIsNone(self.made._is_ours(1234))
|
||||||
|
|
||||||
|
|
||||||
|
class Machine(Local):
|
||||||
|
"""What this machine can hold, and what that makes worth pointing at."""
|
||||||
|
|
||||||
|
def _sysconf(self, phys_pages, page_size=4096):
|
||||||
|
"""Stand where sysconf answers whatever this test wants it to.
|
||||||
|
|
||||||
|
`create` because Windows has no os.sysconf at all, and a patch that
|
||||||
|
insists on the real attribute fails there before the test runs. What
|
||||||
|
the code under test does about that absence is two lines down from
|
||||||
|
what these are checking, and it is checked on its own below.
|
||||||
|
"""
|
||||||
|
return mock.patch.object(
|
||||||
|
ggml.os, "sysconf", create=True,
|
||||||
|
side_effect=lambda name: (page_size if name == "SC_PAGE_SIZE"
|
||||||
|
else phys_pages))
|
||||||
|
|
||||||
|
def test_the_memory_is_read_the_way_each_system_reports_it(self):
|
||||||
|
# Linux and most Macs answer through sysconf.
|
||||||
|
with self._sysconf(4_194_304):
|
||||||
|
self.assertEqual(ggml.total_memory(), 16 * ggml.GB)
|
||||||
|
|
||||||
|
def test_a_mac_without_the_page_count_is_asked_for_the_number(self):
|
||||||
|
# Not every build of Python on a Mac carries SC_PHYS_PAGES, and a Mac
|
||||||
|
# that answered nothing would be a Mac with none of this on it.
|
||||||
|
def answer(args, **kwargs):
|
||||||
|
self.assertEqual(args, ["sysctl", "-n", "hw.memsize"])
|
||||||
|
return mock.Mock(stdout=f"{32 * ggml.GB}\n")
|
||||||
|
|
||||||
|
with mock.patch.object(ggml.os, "sysconf", create=True,
|
||||||
|
side_effect=ValueError), \
|
||||||
|
mock.patch.object(sys, "platform", "darwin"), \
|
||||||
|
mock.patch.object(ggml.subprocess, "run", answer):
|
||||||
|
self.assertEqual(ggml.total_memory(), 32 * ggml.GB)
|
||||||
|
|
||||||
|
def test_a_sysconf_that_shrugs_is_an_unknown_machine_and_not_a_tiny_one(self):
|
||||||
|
# sysconf answers -1 for a limit it holds to be indeterminate and
|
||||||
|
# CPython hands that back rather than raising, so the product came out
|
||||||
|
# negative: a 64 GB workstation was told every model past 512 MB was
|
||||||
|
# too big for it, and the machine line read "Memory: -4096 B".
|
||||||
|
with self._sysconf(-1):
|
||||||
|
self.assertEqual(ggml.total_memory(), 0)
|
||||||
|
self.assertTrue(ggml.fits(574 << 20, memory=0))
|
||||||
|
|
||||||
|
def test_the_memory_is_read_once_and_kept(self):
|
||||||
|
# A list of thirty rows asks seventy times, and on the Mac path the
|
||||||
|
# answer comes from a program rather than a library call.
|
||||||
|
calls = []
|
||||||
|
with mock.patch.object(ggml, "_read_memory",
|
||||||
|
lambda: calls.append(1) or 16 * ggml.GB):
|
||||||
|
self.assertEqual(ggml.total_memory(), 16 * ggml.GB)
|
||||||
|
self.assertEqual(ggml.total_memory(), 16 * ggml.GB)
|
||||||
|
self.assertEqual(len(calls), 1)
|
||||||
|
|
||||||
|
def test_a_system_that_answers_nothing_is_an_unknown_machine(self):
|
||||||
|
with mock.patch.object(ggml.os, "sysconf", create=True,
|
||||||
|
side_effect=ValueError), \
|
||||||
|
mock.patch.object(sys, "platform", "linux"):
|
||||||
|
self.assertEqual(ggml.total_memory(), 0)
|
||||||
|
|
||||||
|
def test_a_mac_is_taken_to_have_a_graphics_interface(self):
|
||||||
|
with mock.patch.object(sys, "platform", "darwin"):
|
||||||
|
self.assertEqual(ggml.accelerator(), "Metal")
|
||||||
|
|
||||||
|
def test_elsewhere_the_vulkan_loader_is_what_says_so(self):
|
||||||
|
with mock.patch.object(sys, "platform", "linux"), \
|
||||||
|
mock.patch.object(ggml.ctypes.util, "find_library",
|
||||||
|
lambda name: "/usr/lib/libvulkan.so.1"):
|
||||||
|
self.assertEqual(ggml.accelerator(), "Vulkan")
|
||||||
|
with mock.patch.object(sys, "platform", "linux"), \
|
||||||
|
mock.patch.object(ggml.ctypes.util, "find_library",
|
||||||
|
lambda name: None):
|
||||||
|
self.assertEqual(ggml.accelerator(), "")
|
||||||
|
|
||||||
|
def test_a_model_is_measured_against_half_the_memory(self):
|
||||||
|
self.assertTrue(ggml.fits(2 * ggml.GB, memory=8 * ggml.GB))
|
||||||
|
self.assertFalse(ggml.fits(4 * ggml.GB, memory=8 * ggml.GB))
|
||||||
|
|
||||||
|
def test_a_machine_whose_memory_could_not_be_read_holds_anything(self):
|
||||||
|
# A wrong "too big" is worse advice than none.
|
||||||
|
self.assertTrue(ggml.fits(40 * ggml.GB, memory=0))
|
||||||
|
|
||||||
|
def test_the_smallest_machine_is_not_the_one_where_everything_fits(self):
|
||||||
|
# Half of 2 GB less the gigabyte of overhead is nothing, and a budget
|
||||||
|
# of nothing used to read as the unknown machine above.
|
||||||
|
self.assertFalse(ggml.fits(3 * ggml.GB, memory=2 * ggml.GB))
|
||||||
|
|
||||||
|
def test_a_crowded_machine_is_pointed_at_the_smaller_model(self):
|
||||||
|
self.assertEqual(ggml.suggested_whisper(memory=3 * ggml.GB, graphics=""),
|
||||||
|
ggml.SMALL_MACHINE_WHISPER)
|
||||||
|
|
||||||
|
def test_a_card_and_the_memory_for_it_are_pointed_at_the_accurate_one(self):
|
||||||
|
self.assertEqual(
|
||||||
|
ggml.suggested_whisper(memory=32 * ggml.GB, graphics="Vulkan"),
|
||||||
|
ggml.ACCURATE_WHISPER)
|
||||||
|
|
||||||
|
def test_memory_without_a_card_is_pointed_at_the_fast_one(self):
|
||||||
|
# Several times the work per second is several times a long wait on a
|
||||||
|
# processor, whatever there is room for.
|
||||||
|
self.assertEqual(
|
||||||
|
ggml.suggested_whisper(memory=32 * ggml.GB, graphics=""),
|
||||||
|
ggml.SUGGESTED_WHISPER)
|
||||||
|
|
||||||
|
def test_a_sixteen_gigabyte_machine_counts_as_a_roomy_one(self):
|
||||||
|
# What a machine reports is what the firmware and the graphics left
|
||||||
|
# of it: 16 GB answers about 15.4, and a threshold written at the
|
||||||
|
# number on the box is one no machine ever reaches.
|
||||||
|
self.assertEqual(
|
||||||
|
ggml.suggested_whisper(memory=int(15.4 * ggml.GB), graphics="Metal"),
|
||||||
|
ggml.ACCURATE_WHISPER)
|
||||||
|
|
||||||
|
def test_the_suggestion_that_fits_is_offered_first(self):
|
||||||
|
first = ggml.suggested_llm(memory=6 * ggml.GB)[0]
|
||||||
|
self.assertTrue(ggml.fits(ggml.SUGGESTED_LLM_SIZE[first],
|
||||||
|
memory=6 * ggml.GB))
|
||||||
|
# Nothing is dropped: what does not fit today fits once something else
|
||||||
|
# is closed.
|
||||||
|
self.assertEqual(sorted(ggml.suggested_llm(memory=6 * ggml.GB)),
|
||||||
|
sorted(ggml.SUGGESTED_LLM))
|
||||||
|
|
||||||
|
def test_the_wanted_model_wins_when_there_is_room_for_it(self):
|
||||||
|
items = [listed("ggml-tiny.bin", 70 << 20),
|
||||||
|
listed("ggml-large-v3-turbo-q5_0.bin", 574 << 20)]
|
||||||
|
self.assertEqual(
|
||||||
|
ggml.recommended(items, "ggml-large-v3-turbo-q5_0.bin",
|
||||||
|
memory=16 * ggml.GB),
|
||||||
|
"ggml-large-v3-turbo-q5_0.bin")
|
||||||
|
|
||||||
|
def test_a_model_too_big_for_the_machine_is_not_recommended(self):
|
||||||
|
items = [listed("small.gguf", 1 << 30), listed("huge.gguf", 12 * ggml.GB)]
|
||||||
|
self.assertEqual(ggml.recommended(items, "huge.gguf",
|
||||||
|
memory=8 * ggml.GB), "small.gguf")
|
||||||
|
|
||||||
|
def test_the_full_precision_weights_are_never_the_recommendation(self):
|
||||||
|
# Twice the memory and twice the wait for a difference this job
|
||||||
|
# cannot see.
|
||||||
|
items = [listed("model-Q4_0.gguf", 2 * ggml.GB),
|
||||||
|
listed("model-BF16.gguf", 3 * ggml.GB)]
|
||||||
|
self.assertEqual(ggml.recommended(items, memory=32 * ggml.GB),
|
||||||
|
"model-Q4_0.gguf")
|
||||||
|
|
||||||
|
def test_nothing_is_recommended_when_nothing_fits(self):
|
||||||
|
self.assertEqual(
|
||||||
|
ggml.recommended([listed("huge.gguf", 40 * ggml.GB)],
|
||||||
|
memory=8 * ggml.GB), "")
|
||||||
|
|
||||||
|
|
||||||
|
class Grouping(Local):
|
||||||
|
"""One group per model, rather than one long list sorted by size."""
|
||||||
|
|
||||||
|
def test_every_spelling_of_a_quantisation_reads_as_its_number(self):
|
||||||
|
# One list holds q5_1, Q4_K_M, MXFP4 and BF16, and the number is the
|
||||||
|
# whole of what any of them says to somebody choosing a row.
|
||||||
|
self.assertEqual(ggml.bit_depth("ggml-small-q5_1.bin"), 5)
|
||||||
|
self.assertEqual(ggml.bit_depth("SmolLM3-Q4_K_M.gguf"), 4)
|
||||||
|
self.assertEqual(ggml.bit_depth("gpt-oss-20b-MXFP4.gguf"), 4)
|
||||||
|
self.assertEqual(ggml.bit_depth("gemma-4-E2B-it-Q8_0.gguf"), 8)
|
||||||
|
# bf16 is not f16 read badly.
|
||||||
|
self.assertEqual(ggml.bit_depth("gemma-4-E2B-it-BF16.gguf"), 16)
|
||||||
|
self.assertEqual(ggml.bit_depth("mmproj-model-f16.gguf"), 16)
|
||||||
|
# A whisper file with no mark is the full model, and its name is the
|
||||||
|
# one convention here that does not carry the answer.
|
||||||
|
self.assertEqual(ggml.bit_depth("ggml-large-v3-turbo.bin"), 0)
|
||||||
|
|
||||||
|
def test_a_quantisation_belongs_to_the_model_it_is_a_copy_of(self):
|
||||||
|
self.assertEqual(ggml.whisper_family("ggml-small.en-q5_1.bin"), "small")
|
||||||
|
self.assertEqual(ggml.whisper_family("ggml-large-v3-q5_0.bin"),
|
||||||
|
"large-v3")
|
||||||
|
self.assertEqual(ggml.whisper_family("ggml-large-v3-turbo.bin"),
|
||||||
|
"large-v3-turbo")
|
||||||
|
self.assertEqual(ggml.whisper_family("ggml-medium.en.bin"), "medium")
|
||||||
|
|
||||||
|
def test_turbo_is_a_model_and_not_a_quantisation(self):
|
||||||
|
# The last chunk of the name is a quantisation for most of the list
|
||||||
|
# and part of the model's name here.
|
||||||
|
self.assertEqual(ggml.whisper_family("ggml-large-v3-turbo-q8_0.bin"),
|
||||||
|
"large-v3-turbo")
|
||||||
|
|
||||||
|
def test_the_turbo_files_are_not_scattered_through_the_medium_ones(self):
|
||||||
|
# Sorted by size alone, large-v3-turbo-q5_0 lands between the two
|
||||||
|
# medium quantisations, half a screen from the model it is a copy of.
|
||||||
|
models = [listed("ggml-medium-q5_0.bin", 539 << 20),
|
||||||
|
listed("ggml-large-v3-turbo-q5_0.bin", 574 << 20),
|
||||||
|
listed("ggml-medium-q8_0.bin", 823 << 20),
|
||||||
|
listed("ggml-large-v3-turbo.bin", 1624 << 20)]
|
||||||
|
groups = dict(ggml.whisper_groups(models))
|
||||||
|
self.assertEqual([i.name for i in groups["large-v3-turbo"]],
|
||||||
|
["ggml-large-v3-turbo-q5_0.bin",
|
||||||
|
"ggml-large-v3-turbo.bin"])
|
||||||
|
self.assertEqual([i.name for i in groups["medium"]],
|
||||||
|
["ggml-medium-q5_0.bin", "ggml-medium-q8_0.bin"])
|
||||||
|
|
||||||
|
def test_the_smallest_model_comes_first_and_the_english_ones_last(self):
|
||||||
|
models = [listed("ggml-small.en-q5_1.bin", 190 << 20),
|
||||||
|
listed("ggml-small-q5_1.bin", 190 << 20),
|
||||||
|
listed("ggml-tiny.bin", 77 << 20)]
|
||||||
|
groups = ggml.whisper_groups(models)
|
||||||
|
self.assertEqual([family for family, _ in groups], ["tiny", "small"])
|
||||||
|
self.assertEqual([i.name for _, group in groups for i in group],
|
||||||
|
["ggml-tiny.bin", "ggml-small-q5_1.bin",
|
||||||
|
"ggml-small.en-q5_1.bin"])
|
||||||
|
|||||||
@@ -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()
|
||||||
+5
-2
@@ -42,9 +42,12 @@ class Directories(unittest.TestCase):
|
|||||||
|
|
||||||
def test_a_mac_does_not_read_the_xdg_variables(self):
|
def test_a_mac_does_not_read_the_xdg_variables(self):
|
||||||
"""A Mac with them set from some other tool still stores in one place."""
|
"""A Mac with them set from some other tool still stores in one place."""
|
||||||
with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": "/c"}):
|
# Something no temporary directory can be called: the home this runs
|
||||||
|
# under is a mkdtemp path, and a two-letter needle matched the "/c" in
|
||||||
|
# somebody's TMPDIR rather than the variable being read.
|
||||||
|
with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": "/xdg-elsewhere"}):
|
||||||
config_dir, _ = paths.directories("darwin")
|
config_dir, _ = paths.directories("darwin")
|
||||||
self.assertNotIn("/c", config_dir.as_posix())
|
self.assertNotIn("xdg-elsewhere", config_dir.as_posix())
|
||||||
|
|
||||||
def test_windows_keeps_the_models_out_of_the_roaming_profile(self):
|
def test_windows_keeps_the_models_out_of_the_roaming_profile(self):
|
||||||
"""Settings roam with the account; several gigabytes must not."""
|
"""Settings roam with the account; several gigabytes must not."""
|
||||||
|
|||||||
+525
-1
@@ -6,13 +6,15 @@ 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.
|
next time anybody presses Save. That is the failure this catches.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
from typing import ClassVar
|
from typing import ClassVar
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from PyQt6.QtCore import QPoint, QPointF, Qt
|
from PyQt6.QtCore import QPoint, QPointF, QRect, Qt
|
||||||
from PyQt6.QtGui import QWheelEvent
|
from PyQt6.QtGui import QWheelEvent
|
||||||
from PyQt6.QtWidgets import QApplication, QMessageBox
|
from PyQt6.QtWidgets import QApplication, QMessageBox
|
||||||
|
|
||||||
@@ -21,11 +23,13 @@ from dikte import cleanup
|
|||||||
from dikte import config as cfg
|
from dikte import config as cfg
|
||||||
from dikte import ggml
|
from dikte import ggml
|
||||||
from dikte import hotkey
|
from dikte import hotkey
|
||||||
|
from dikte import hub
|
||||||
from dikte import ipc
|
from dikte import ipc
|
||||||
from dikte import overlay as overlay_module
|
from dikte import overlay as overlay_module
|
||||||
from dikte import paste
|
from dikte import paste
|
||||||
from dikte import settings_ui
|
from dikte import settings_ui
|
||||||
from dikte import update
|
from dikte import update
|
||||||
|
from dikte.i18n import t
|
||||||
from tests.support import DikteTest, only_these_tools
|
from tests.support import DikteTest, only_these_tools
|
||||||
|
|
||||||
# The harness below replaces this method on the class so that opening a window
|
# The harness below replaces this method on the class so that opening a window
|
||||||
@@ -48,6 +52,7 @@ CHANGED = {
|
|||||||
"restore_clipboard": True,
|
"restore_clipboard": True,
|
||||||
"overlay_corner": "top-right",
|
"overlay_corner": "top-right",
|
||||||
"overlay_screen": "DP-1",
|
"overlay_screen": "DP-1",
|
||||||
|
"overlay_follows_pointer": True,
|
||||||
"max_seconds": 120,
|
"max_seconds": 120,
|
||||||
"skip_silent": False,
|
"skip_silent": False,
|
||||||
"silence_db": -42.0,
|
"silence_db": -42.0,
|
||||||
@@ -78,6 +83,8 @@ CHANGED = {
|
|||||||
"local_llm_gpu": False,
|
"local_llm_gpu": False,
|
||||||
"local_llm_preload": True,
|
"local_llm_preload": True,
|
||||||
"local_llm_reasoning": "low",
|
"local_llm_reasoning": "low",
|
||||||
|
"local_idle_unload": False,
|
||||||
|
"local_idle_minutes": 45,
|
||||||
"cleanup_prompt": "Only fix the punctuation.",
|
"cleanup_prompt": "Only fix the punctuation.",
|
||||||
"file_cleanup_prompt": "Keep the stamps where they are.",
|
"file_cleanup_prompt": "Keep the stamps where they are.",
|
||||||
"transcribe_prompt": "Paraşüt, OpenFrame",
|
"transcribe_prompt": "Paraşüt, OpenFrame",
|
||||||
@@ -239,6 +246,39 @@ class Settings(DikteTest):
|
|||||||
label.resize(2000, line)
|
label.resize(2000, line)
|
||||||
self.assertLessEqual(label.minimumHeight(), line)
|
self.assertLessEqual(label.minimumHeight(), line)
|
||||||
|
|
||||||
|
def test_a_label_written_before_the_layout_places_it_claims_nothing(self):
|
||||||
|
# The publisher note is written while the settings window is still
|
||||||
|
# being built, when the label is a handful of pixels wide. Wrapped
|
||||||
|
# against that width the sentence became a hundred lines, and the
|
||||||
|
# minimum taken from it did not stay a minimum: QLabel folds it into
|
||||||
|
# its own cached size hints and clears that cache only when the text
|
||||||
|
# changes. The group box stood thousands of pixels tall, with the
|
||||||
|
# model box and everything under it off the bottom of the window,
|
||||||
|
# until another publisher was picked.
|
||||||
|
label = settings_ui.WrappedLabel()
|
||||||
|
self.addCleanup(label.deleteLater)
|
||||||
|
line = label.fontMetrics().height()
|
||||||
|
label.resize(8, line)
|
||||||
|
label.setText("Google Gemma 4, the small one. The default: nothing "
|
||||||
|
"else this size follows an instruction as closely, and "
|
||||||
|
"cleanup is all instruction.")
|
||||||
|
self.assertEqual(label.minimumHeight(), 0)
|
||||||
|
# Placed and shown, which is the first width worth measuring against.
|
||||||
|
# The room the wrapping needs is claimed then, and it is the lines the
|
||||||
|
# sentence takes at this width rather than at the last one. Counted
|
||||||
|
# off the font rather than written down here, because how many lines
|
||||||
|
# 400 pixels hold is a different answer on every machine.
|
||||||
|
label.resize(400, line)
|
||||||
|
label.show()
|
||||||
|
wrap = Qt.TextFlag.TextWordWrap | Qt.TextFlag.TextWrapAnywhere
|
||||||
|
needed = label.fontMetrics().boundingRect(
|
||||||
|
QRect(0, 0, 400, 0), wrap, label.text()).height()
|
||||||
|
self.assertGreater(needed, line) # or the sentence never wrapped
|
||||||
|
self.assertEqual(label.minimumHeight(), needed)
|
||||||
|
# And the label's own hints are the wrapping at this width too, not
|
||||||
|
# the hundred lines the eight pixel one asked for.
|
||||||
|
self.assertLessEqual(label.sizeHint().height(), 3 * needed)
|
||||||
|
|
||||||
def test_saving_without_touching_anything_changes_nothing(self):
|
def test_saving_without_touching_anything_changes_nothing(self):
|
||||||
"""Every widget has to load what is stored, or Save writes its default
|
"""Every widget has to load what is stored, or Save writes its default
|
||||||
over it. This says so for the whole table at once."""
|
over it. This says so for the whole table at once."""
|
||||||
@@ -504,6 +544,20 @@ class Settings(DikteTest):
|
|||||||
self.assertEqual(conf["transcribe_model"], "gpt-4o-transcribe")
|
self.assertEqual(conf["transcribe_model"], "gpt-4o-transcribe")
|
||||||
self.assertEqual(conf["groq_transcribe_model"], "whisper-large-v3")
|
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):
|
def test_the_provider_box_offers_every_provider_config_knows(self):
|
||||||
window = self.window(cfg.Config())
|
window = self.window(cfg.Config())
|
||||||
offered = [window.transcribe_provider.itemData(i)
|
offered = [window.transcribe_provider.itemData(i)
|
||||||
@@ -1051,6 +1105,121 @@ class Overlay(DikteTest):
|
|||||||
screen_at.assert_not_called()
|
screen_at.assert_not_called()
|
||||||
self.assertEqual(widget.pos(), QPoint(1948, 995))
|
self.assertEqual(widget.pos(), QPoint(1948, 995))
|
||||||
|
|
||||||
|
def _screen(self, name, area):
|
||||||
|
screen = mock.Mock()
|
||||||
|
screen.name.return_value = name
|
||||||
|
screen.availableGeometry.return_value = area
|
||||||
|
return screen
|
||||||
|
|
||||||
|
def _kwin(self, *answer):
|
||||||
|
kwin = mock.Mock()
|
||||||
|
kwin.isValid.return_value = True
|
||||||
|
kwin.call.return_value.arguments.return_value = list(answer)
|
||||||
|
return kwin
|
||||||
|
|
||||||
|
def test_the_compositor_says_which_screen_the_pointer_is_on(self):
|
||||||
|
"""Wayland tells a client where the pointer is only while it is over one
|
||||||
|
of that client's own windows, so QCursor.pos() comes back at the origin
|
||||||
|
and every indicator lands on whichever screen holds it. KWin knows."""
|
||||||
|
screens = [self._screen("DP-1", settings_ui.QRect(0, 0, 1920, 1080)),
|
||||||
|
self._screen("DP-2", settings_ui.QRect(1920, 0, 1920, 1080))]
|
||||||
|
widget = self.overlay()
|
||||||
|
with mock.patch.object(overlay_module, "_kwin", self._kwin("DP-2")), \
|
||||||
|
mock.patch.object(QApplication, "screens", return_value=screens), \
|
||||||
|
mock.patch.object(QApplication, "screenAt") as screen_at:
|
||||||
|
widget._reposition()
|
||||||
|
screen_at.assert_not_called()
|
||||||
|
self.assertEqual(widget.pos(), QPoint(1948, 995))
|
||||||
|
|
||||||
|
def test_the_pointer_decides_when_the_compositor_will_not_say(self):
|
||||||
|
"""Every desktop but Plasma, and Plasma while KWin is being replaced."""
|
||||||
|
screens = [self._screen("DP-1", settings_ui.QRect(0, 0, 1920, 1080))]
|
||||||
|
widget = self.overlay()
|
||||||
|
with mock.patch.object(overlay_module, "_kwin", self._kwin()), \
|
||||||
|
mock.patch.object(QApplication, "screens", return_value=screens), \
|
||||||
|
mock.patch.object(QApplication, "screenAt",
|
||||||
|
return_value=screens[0]) as screen_at:
|
||||||
|
widget._reposition()
|
||||||
|
screen_at.assert_called()
|
||||||
|
self.assertEqual(widget.pos(), QPoint(28, 995))
|
||||||
|
|
||||||
|
def _two_screens(self):
|
||||||
|
return [self._screen("DP-1", settings_ui.QRect(0, 0, 1920, 1080)),
|
||||||
|
self._screen("DP-2", settings_ui.QRect(1920, 0, 1920, 1080))]
|
||||||
|
|
||||||
|
def _ticks_on(self, widget, screens, kwin):
|
||||||
|
"""Run the ribbon long enough for one look at where the pointer is."""
|
||||||
|
with mock.patch.object(overlay_module, "_kwin", kwin), \
|
||||||
|
mock.patch.object(QApplication, "screens", return_value=screens), \
|
||||||
|
mock.patch.object(QApplication, "screenAt", return_value=screens[0]):
|
||||||
|
for _ in range(overlay_module.FOLLOW_EVERY):
|
||||||
|
widget._tick()
|
||||||
|
|
||||||
|
def test_it_can_be_told_to_keep_up_with_the_pointer(self):
|
||||||
|
"""The screen it started on is not always the screen you end up on."""
|
||||||
|
screens = self._two_screens()
|
||||||
|
kwin = self._kwin("DP-2")
|
||||||
|
widget = self.overlay(follow_pointer=True)
|
||||||
|
with mock.patch.object(overlay_module, "_kwin", kwin), \
|
||||||
|
mock.patch.object(QApplication, "screens", return_value=screens):
|
||||||
|
widget.show_recording()
|
||||||
|
self.assertEqual(widget.pos(), QPoint(1948, 995))
|
||||||
|
kwin.call.return_value.arguments.return_value = ["DP-1"]
|
||||||
|
self._ticks_on(widget, screens, kwin)
|
||||||
|
self.assertEqual(widget.pos(), QPoint(28, 995))
|
||||||
|
|
||||||
|
def test_it_stays_where_it_appeared_unless_it_was_told_otherwise(self):
|
||||||
|
"""Left off, because an indicator that jumps desks mid-sentence is one
|
||||||
|
more thing moving while you are trying to talk."""
|
||||||
|
screens = self._two_screens()
|
||||||
|
kwin = self._kwin("DP-2")
|
||||||
|
widget = self.overlay()
|
||||||
|
with mock.patch.object(overlay_module, "_kwin", kwin), \
|
||||||
|
mock.patch.object(QApplication, "screens", return_value=screens):
|
||||||
|
widget.show_recording()
|
||||||
|
kwin.call.return_value.arguments.return_value = ["DP-1"]
|
||||||
|
self._ticks_on(widget, screens, kwin)
|
||||||
|
self.assertEqual(widget.pos(), QPoint(1948, 995))
|
||||||
|
|
||||||
|
def test_a_named_screen_is_never_left_for_the_pointer(self):
|
||||||
|
"""Naming one is the whole answer; following it would undo the naming."""
|
||||||
|
screens = self._two_screens()
|
||||||
|
kwin = self._kwin("DP-2")
|
||||||
|
widget = self.overlay(screen_name="DP-1", follow_pointer=True)
|
||||||
|
with mock.patch.object(QApplication, "screens", return_value=screens):
|
||||||
|
widget.show_recording()
|
||||||
|
self._ticks_on(widget, screens, kwin)
|
||||||
|
kwin.call.assert_not_called()
|
||||||
|
self.assertEqual(widget.pos(), QPoint(28, 995))
|
||||||
|
|
||||||
|
def test_the_one_on_top_goes_where_the_one_underneath_is(self):
|
||||||
|
"""Asking for itself would put the pair on two monitors, with this one
|
||||||
|
raised over a ribbon that is not underneath it."""
|
||||||
|
screens = self._two_screens()
|
||||||
|
kwin = self._kwin("DP-2")
|
||||||
|
first = self.overlay()
|
||||||
|
with mock.patch.object(overlay_module, "_kwin", kwin), \
|
||||||
|
mock.patch.object(QApplication, "screens", return_value=screens):
|
||||||
|
first.show_recording()
|
||||||
|
kwin.call.return_value.arguments.return_value = ["DP-1"]
|
||||||
|
second = self.overlay(below=first)
|
||||||
|
second.show_busy("Asking Claude…")
|
||||||
|
self.assertEqual(first.pos(), QPoint(1948, 995))
|
||||||
|
self.assertEqual(second.pos(), QPoint(1948, 929))
|
||||||
|
|
||||||
|
def test_the_compositor_is_asked_only_now_and_then(self):
|
||||||
|
"""Every tick would be thirty conversations a second about a hand
|
||||||
|
moving a mouse."""
|
||||||
|
screens = self._two_screens()
|
||||||
|
kwin = self._kwin("DP-2")
|
||||||
|
widget = self.overlay(follow_pointer=True)
|
||||||
|
with mock.patch.object(overlay_module, "_kwin", kwin), \
|
||||||
|
mock.patch.object(QApplication, "screens", return_value=screens):
|
||||||
|
widget.show_recording()
|
||||||
|
kwin.call.reset_mock()
|
||||||
|
self._ticks_on(widget, screens, kwin)
|
||||||
|
self.assertEqual(kwin.call.call_count, 1)
|
||||||
|
|
||||||
def test_a_warning_and_an_error_both_show(self):
|
def test_a_warning_and_an_error_both_show(self):
|
||||||
widget = self.overlay()
|
widget = self.overlay()
|
||||||
widget.show_warning("cleanup failed")
|
widget.show_warning("cleanup failed")
|
||||||
@@ -1202,6 +1371,28 @@ class LocalModels(DikteTest):
|
|||||||
self.assertIn("10", box.program_label.text())
|
self.assertIn("10", box.program_label.text())
|
||||||
self.assertIn("20", box.status.text())
|
self.assertIn("20", box.status.text())
|
||||||
|
|
||||||
|
def test_a_download_says_something_before_the_first_byte(self):
|
||||||
|
# Opening the connection takes ten or twenty seconds, and the byte
|
||||||
|
# counts only start after it. The line underneath still read "has not
|
||||||
|
# been downloaded yet" beside a button that now said Stop, so a
|
||||||
|
# download that had started looked like a click that had not landed.
|
||||||
|
box = self.window(cfg.Config()).local_llm
|
||||||
|
box.load("", "ggml-org/SmolLM3-3B-GGUF")
|
||||||
|
box.repo.blockSignals(True)
|
||||||
|
box.repo.setCurrentText("ggml-org/SmolLM3-3B-GGUF")
|
||||||
|
box.repo.blockSignals(False)
|
||||||
|
box._on_listed([("models", [self._item("SmolLM3-Q4_K_M.gguf")],
|
||||||
|
"ggml-org/SmolLM3-3B-GGUF")], "")
|
||||||
|
with mock.patch.object(settings_ui.threading, "Thread"):
|
||||||
|
box._download()
|
||||||
|
self.assertIn("Starting", box.status.text())
|
||||||
|
# And the same again for the stop, which is read between blocks and so
|
||||||
|
# not read at all while the connection is still being opened.
|
||||||
|
with mock.patch.object(settings_ui.threading, "Thread"):
|
||||||
|
box._download()
|
||||||
|
self.assertTrue(box._stop)
|
||||||
|
self.assertIn("Stopping", box.status.text())
|
||||||
|
|
||||||
def test_a_long_model_name_is_not_cut_in_half(self):
|
def test_a_long_model_name_is_not_cut_in_half(self):
|
||||||
# The list under a combo box takes the box's width and elides what does
|
# The list under a combo box takes the box's width and elides what does
|
||||||
# not fit, in the middle: "ggml-org/Qwen....7B-Base-GGUF".
|
# not fit, in the middle: "ggml-org/Qwen....7B-Base-GGUF".
|
||||||
@@ -1214,6 +1405,319 @@ class LocalModels(DikteTest):
|
|||||||
for row in range(box.repo.count()))
|
for row in range(box.repo.count()))
|
||||||
self.assertGreaterEqual(view.minimumWidth(), widest)
|
self.assertGreaterEqual(view.minimumWidth(), widest)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _item(name, size=1 << 20):
|
||||||
|
return hub.Item(name, f"https://example.invalid/{name}", size, "")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _rows(box):
|
||||||
|
"""Every row's text, headings included."""
|
||||||
|
return [box.model.itemText(row) for row in range(box.model.count())]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _repos(box):
|
||||||
|
return [box.repo.itemText(row) for row in range(box.repo.count())]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _roomy():
|
||||||
|
"""Stand on a machine with room for every suggestion.
|
||||||
|
|
||||||
|
The order the publishers come in follows the memory, so a test that
|
||||||
|
reads it has to say which machine it is standing on. A build runner
|
||||||
|
with 7 GB in it puts the two Gemma 4 rows last and is right to.
|
||||||
|
"""
|
||||||
|
return mock.patch.object(ggml, "total_memory", return_value=64 << 30)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _offered(box):
|
||||||
|
"""The model names in the box, headings and duplicates left out."""
|
||||||
|
names = []
|
||||||
|
for row in range(box.model.count()):
|
||||||
|
name = box.model.itemData(row)
|
||||||
|
if name and name not in names:
|
||||||
|
names.append(name)
|
||||||
|
return names
|
||||||
|
|
||||||
|
def test_a_row_with_nothing_to_fetch_does_not_offer_a_download(self):
|
||||||
|
# The model the settings name is not in the list any more, so its row
|
||||||
|
# was rebuilt from the name alone and carries no file to fetch. The
|
||||||
|
# button stayed lit and the press did nothing at all.
|
||||||
|
box = self.window(self.config(local_llm_model="gone.gguf")).local_llm
|
||||||
|
box.load("gone.gguf", "ggml-org/SmolLM3-3B-GGUF")
|
||||||
|
self.assertEqual(box.selected(), "gone.gguf")
|
||||||
|
self.assertFalse(box.download_button.isEnabled())
|
||||||
|
self.assertIn("gone.gguf", box.status.text())
|
||||||
|
self.assertIn("publisher", box.status.text())
|
||||||
|
|
||||||
|
def test_a_model_without_its_program_does_not_say_it_is_ready(self):
|
||||||
|
# The model runs on the program above it, and "Ready" over a missing
|
||||||
|
# one is what had people asking why nothing transcribed.
|
||||||
|
box = self.window(cfg.Config()).local_whisper
|
||||||
|
path = ggml.whisper_model_path("ggml-small.bin")
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_bytes(b"not really a model")
|
||||||
|
box.load("ggml-small.bin")
|
||||||
|
self.assertFalse(ggml.program_path(ggml.WHISPER))
|
||||||
|
self.assertNotIn("Ready", box.status.text())
|
||||||
|
self.assertIn("program", box.status.text())
|
||||||
|
|
||||||
|
def test_changing_the_publisher_changes_the_model(self):
|
||||||
|
# The model chosen under the old publisher is not published by the new
|
||||||
|
# one. Carried over, it was added back as "not downloaded" and selected
|
||||||
|
# again, and the box looked as though the change had not taken.
|
||||||
|
box = self.window(self.config(local_llm_model="gemma-3-4b-it-Q4_K_M.gguf",
|
||||||
|
local_llm_repo="ggml-org/gemma-3-4b-it-GGUF")).local_llm
|
||||||
|
box.load("gemma-3-4b-it-Q4_K_M.gguf", "ggml-org/gemma-3-4b-it-GGUF")
|
||||||
|
box.repo.blockSignals(True)
|
||||||
|
box.repo.setCurrentText("ggml-org/SmolLM3-3B-GGUF")
|
||||||
|
box.repo.blockSignals(False)
|
||||||
|
box._on_listed([("models", [self._item("SmolLM3-Q4_K_M.gguf")],
|
||||||
|
"ggml-org/SmolLM3-3B-GGUF")], "")
|
||||||
|
self.assertEqual(box.selected(), "SmolLM3-Q4_K_M.gguf")
|
||||||
|
self.assertEqual(self._offered(box), ["SmolLM3-Q4_K_M.gguf"])
|
||||||
|
|
||||||
|
def test_a_list_for_a_publisher_that_is_no_longer_chosen_is_dropped(self):
|
||||||
|
# Every change starts its own request, and they do not come back in the
|
||||||
|
# order they went out.
|
||||||
|
box = self.window(cfg.Config()).local_llm
|
||||||
|
box.load("", "ggml-org/SmolLM3-3B-GGUF")
|
||||||
|
box.repo.blockSignals(True)
|
||||||
|
box.repo.setCurrentText("ggml-org/SmolLM3-3B-GGUF")
|
||||||
|
box.repo.blockSignals(False)
|
||||||
|
box._on_listed([("models", [self._item("SmolLM3-Q4_K_M.gguf")],
|
||||||
|
"ggml-org/SmolLM3-3B-GGUF")], "")
|
||||||
|
box._on_listed([("models", [self._item("gemma-3-4b-it-Q4_K_M.gguf")],
|
||||||
|
"ggml-org/gemma-3-4b-it-GGUF")], "")
|
||||||
|
self.assertEqual(box.selected(), "SmolLM3-Q4_K_M.gguf")
|
||||||
|
|
||||||
|
def test_the_publisher_box_is_not_asked_on_every_keystroke(self):
|
||||||
|
box = self.window(cfg.Config()).local_llm
|
||||||
|
with mock.patch.object(box, "_fetch_models") as fetch:
|
||||||
|
for text in ("g", "gg", "ggm", "ggml-org/SmolLM3-3B-GGUF"):
|
||||||
|
box.repo.setCurrentText(text)
|
||||||
|
fetch.assert_not_called()
|
||||||
|
box._later.setInterval(0)
|
||||||
|
box._later.start()
|
||||||
|
_app.processEvents()
|
||||||
|
time.sleep(0.05)
|
||||||
|
_app.processEvents()
|
||||||
|
self.assertEqual(fetch.call_count, 1)
|
||||||
|
def test_the_models_are_grouped_by_the_model_rather_than_by_size(self):
|
||||||
|
# Sorted by size alone, the turbo files land between the two medium
|
||||||
|
# ones, half a screen from the model they are a copy of.
|
||||||
|
box = self.window(cfg.Config()).local_whisper
|
||||||
|
with mock.patch.object(ggml, "total_memory", return_value=8 << 30), \
|
||||||
|
mock.patch.object(ggml, "accelerator", return_value=""):
|
||||||
|
box._on_listed([("models", [
|
||||||
|
self._item("ggml-medium-q5_0.bin", 539 << 20),
|
||||||
|
self._item("ggml-large-v3-turbo-q5_0.bin", 574 << 20),
|
||||||
|
self._item("ggml-medium-q8_0.bin", 823 << 20),
|
||||||
|
self._item("ggml-large-v3-turbo.bin", 1624 << 20),
|
||||||
|
], "")], "")
|
||||||
|
rows = self._rows(box)
|
||||||
|
# The two medium files under one heading, the two turbo ones under
|
||||||
|
# theirs, and the model rather than the file deciding the order.
|
||||||
|
self.assertEqual(rows[rows.index("medium"):],
|
||||||
|
["medium",
|
||||||
|
"ggml-medium-q5_0.bin (539.0 MB, 5-bit)",
|
||||||
|
"ggml-medium-q8_0.bin (823.0 MB, 8-bit)",
|
||||||
|
"large-v3-turbo",
|
||||||
|
"ggml-large-v3-turbo-q5_0.bin "
|
||||||
|
"(574.0 MB, 5-bit, recommended)",
|
||||||
|
"ggml-large-v3-turbo.bin (1.6 GB, 16-bit)"])
|
||||||
|
# A heading is not a model, and nothing can be saved from one.
|
||||||
|
self.assertIsNone(box.model.itemData(rows.index("medium")))
|
||||||
|
|
||||||
|
def test_the_row_for_this_machine_is_on_top_and_says_so(self):
|
||||||
|
box = self.window(cfg.Config()).local_whisper
|
||||||
|
with mock.patch.object(ggml, "total_memory", return_value=8 << 30), \
|
||||||
|
mock.patch.object(ggml, "accelerator", return_value=""):
|
||||||
|
box._on_listed([("models", [
|
||||||
|
self._item("ggml-tiny.bin", 77 << 20),
|
||||||
|
self._item("ggml-large-v3-turbo-q5_0.bin", 574 << 20),
|
||||||
|
], "")], "")
|
||||||
|
self.assertEqual(box.selected(), "ggml-large-v3-turbo-q5_0.bin")
|
||||||
|
self.assertEqual(box.model.itemData(1), "ggml-large-v3-turbo-q5_0.bin")
|
||||||
|
self.assertIn(t("recommended"), box.model.itemText(1))
|
||||||
|
|
||||||
|
def test_a_model_the_memory_cannot_hold_says_so_on_its_row(self):
|
||||||
|
box = self.window(cfg.Config()).local_llm
|
||||||
|
box.repo.blockSignals(True)
|
||||||
|
box.repo.setCurrentText("ggml-org/x-GGUF")
|
||||||
|
box.repo.blockSignals(False)
|
||||||
|
with mock.patch.object(ggml, "total_memory", return_value=8 << 30):
|
||||||
|
box._on_listed([("models", [
|
||||||
|
self._item("small-Q4_0.gguf", 1 << 30),
|
||||||
|
self._item("huge-Q8_0.gguf", 12 << 30),
|
||||||
|
], "ggml-org/x-GGUF")], "")
|
||||||
|
rows = {box.model.itemData(row): box.model.itemText(row)
|
||||||
|
for row in range(box.model.count())}
|
||||||
|
self.assertNotIn(t("too big for this machine"), rows["small-Q4_0.gguf"])
|
||||||
|
self.assertIn(t("too big for this machine"), rows["huge-Q8_0.gguf"])
|
||||||
|
|
||||||
|
def test_a_recommended_row_is_not_listed_twice_after_a_download(self):
|
||||||
|
# It has a row of its own on top as well as one in its group, and
|
||||||
|
# reading the rows back the way a finished download does was doubling
|
||||||
|
# it in the list every time.
|
||||||
|
box = self.window(cfg.Config()).local_whisper
|
||||||
|
with self._roomy():
|
||||||
|
box._on_listed([("models", [
|
||||||
|
self._item("ggml-tiny.bin", 77 << 20),
|
||||||
|
self._item("ggml-large-v3-turbo-q5_0.bin", 574 << 20),
|
||||||
|
], "")], "")
|
||||||
|
before = self._offered(box)
|
||||||
|
box._fill_models_from_current()
|
||||||
|
self.assertEqual(self._offered(box), before)
|
||||||
|
names = [box.model.itemData(row) for row in range(box.model.count())]
|
||||||
|
self.assertEqual(len([n for n in names if n]), len(before) + 1)
|
||||||
|
|
||||||
|
def test_a_processor_build_is_not_recommended_the_accurate_model(self):
|
||||||
|
# The Vulkan loader is on the machine but what was installed is the
|
||||||
|
# processor build, so there is no card in play whatever the loader
|
||||||
|
# says, and a 1 GB model on a processor is a wait somebody is sitting
|
||||||
|
# through with a sentence half typed.
|
||||||
|
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"}))
|
||||||
|
self.patch_attr(ggml.shutil, "which", lambda name: None)
|
||||||
|
box = self.window(cfg.Config()).local_whisper
|
||||||
|
with mock.patch.object(ggml, "total_memory", return_value=32 << 30), \
|
||||||
|
mock.patch.object(ggml, "accelerator", return_value="Vulkan"):
|
||||||
|
self.assertEqual(box._suggested(), ggml.SUGGESTED_WHISPER)
|
||||||
|
|
||||||
|
def test_a_publisher_with_nothing_to_offer_says_why(self):
|
||||||
|
# Half of what ggml-org publishes is split across files or past the
|
||||||
|
# size cap, and an empty box read as though the click had not landed.
|
||||||
|
box = self.window(cfg.Config()).local_llm
|
||||||
|
box.repo.blockSignals(True)
|
||||||
|
box.repo.setCurrentText("ggml-org/gpt-oss-120b-GGUF")
|
||||||
|
box.repo.blockSignals(False)
|
||||||
|
box._on_listed([("models", [], "ggml-org/gpt-oss-120b-GGUF")], "")
|
||||||
|
self.assertIn("ggml-org/gpt-oss-120b-GGUF", box.status.text())
|
||||||
|
self.assertIn("publisher", box.status.text())
|
||||||
|
|
||||||
|
def test_an_empty_box_nobody_has_asked_yet_is_not_a_publisher_fault(self):
|
||||||
|
box = self.window(cfg.Config()).local_llm
|
||||||
|
box.load("", "ggml-org/SmolLM3-3B-GGUF")
|
||||||
|
self.assertNotIn("publisher", box.status.text())
|
||||||
|
|
||||||
|
def test_only_the_suggested_publishers_are_offered_to_start_with(self):
|
||||||
|
# Forty repository ids is not a choice anybody can make.
|
||||||
|
box = self.window(cfg.Config()).local_llm
|
||||||
|
with self._roomy():
|
||||||
|
box._on_listed([("repos", [ggml.SUGGESTED_LLM[0],
|
||||||
|
"ggml-org/something-else-GGUF"], "")], "")
|
||||||
|
self.assertEqual(self._repos(box), list(ggml.SUGGESTED_LLM))
|
||||||
|
|
||||||
|
def test_a_suggestion_missing_from_the_listing_is_still_offered(self):
|
||||||
|
# The listing is the forty repositories touched most recently, and a
|
||||||
|
# publisher that has not been updated in a season falls off it while
|
||||||
|
# still being the one to point at.
|
||||||
|
box = self.window(cfg.Config()).local_llm
|
||||||
|
box._on_listed([("repos", ["ggml-org/something-else-GGUF"], "")], "")
|
||||||
|
self.assertIn(ggml.SUGGESTED_LLM[0], self._repos(box))
|
||||||
|
|
||||||
|
def test_the_switch_brings_the_rest_and_keeps_them_apart(self):
|
||||||
|
box = self.window(cfg.Config()).local_llm
|
||||||
|
with self._roomy():
|
||||||
|
box._on_listed([("repos", [ggml.SUGGESTED_LLM[0],
|
||||||
|
"ggml-org/something-else-GGUF"], "")], "")
|
||||||
|
box.every_repo.setChecked(True)
|
||||||
|
rows = self._repos(box)
|
||||||
|
self.assertEqual(rows[:len(ggml.SUGGESTED_LLM)],
|
||||||
|
list(ggml.SUGGESTED_LLM))
|
||||||
|
# A separator rather than a heading: the box is typed into as well as
|
||||||
|
# chosen from, and a heading would land in the field as a repository.
|
||||||
|
self.assertEqual(rows[len(ggml.SUGGESTED_LLM)], "")
|
||||||
|
self.assertEqual(rows[-1], "ggml-org/something-else-GGUF")
|
||||||
|
|
||||||
|
def test_a_publisher_typed_in_is_not_dropped_by_the_next_fetch(self):
|
||||||
|
box = self.window(cfg.Config()).local_llm
|
||||||
|
box.repo.blockSignals(True)
|
||||||
|
box.repo.setCurrentText("ggml-org/something-else-GGUF")
|
||||||
|
box.repo.blockSignals(False)
|
||||||
|
box._on_listed([("repos", [ggml.SUGGESTED_LLM[0],
|
||||||
|
"ggml-org/something-else-GGUF"], "")], "")
|
||||||
|
self.assertFalse(box.every_repo.isChecked())
|
||||||
|
self.assertIn("ggml-org/something-else-GGUF", self._repos(box))
|
||||||
|
self.assertEqual(box.repository(), "ggml-org/something-else-GGUF")
|
||||||
|
|
||||||
|
def test_the_chosen_publisher_is_said_in_words(self):
|
||||||
|
# A repository id names the publisher, the parameter count and the
|
||||||
|
# shape of the weights, and none of that says whether to click it.
|
||||||
|
box = self.window(cfg.Config()).local_llm
|
||||||
|
box.repo.setCurrentText(ggml.SUGGESTED_LLM[0])
|
||||||
|
self.assertTrue(box.repo_note.text())
|
||||||
|
box.repo.setCurrentText("ggml-org/nobody-wrote-a-note-GGUF")
|
||||||
|
self.assertEqual(box.repo_note.text(), "")
|
||||||
|
|
||||||
|
def test_the_box_says_what_this_machine_will_run_on(self):
|
||||||
|
box = self.window(cfg.Config()).local_whisper
|
||||||
|
with mock.patch.object(ggml, "accelerator", return_value="Vulkan"), \
|
||||||
|
mock.patch.object(ggml, "total_memory", return_value=32 << 30):
|
||||||
|
box._show_machine()
|
||||||
|
self.assertIn("Vulkan", box.machine_label.text())
|
||||||
|
self.assertIn("32.0 GB", box.machine_label.text())
|
||||||
|
|
||||||
|
def test_a_machine_with_no_card_is_told_it_is_on_the_processor(self):
|
||||||
|
box = self.window(cfg.Config()).local_whisper
|
||||||
|
with mock.patch.object(ggml, "accelerator", return_value=""), \
|
||||||
|
mock.patch.object(ggml, "total_memory", return_value=8 << 30):
|
||||||
|
box._show_machine()
|
||||||
|
self.assertIn("processor", box.machine_label.text())
|
||||||
|
|
||||||
|
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):
|
def test_only_the_chosen_transcriber_is_on_screen(self):
|
||||||
window = self.window(self.config(transcribe_provider="openai"))
|
window = self.window(self.config(transcribe_provider="openai"))
|
||||||
self.assertTrue(window.stt_form.isRowVisible(window.transcribe_model_row))
|
self.assertTrue(window.stt_form.isRowVisible(window.transcribe_model_row))
|
||||||
@@ -1232,6 +1736,26 @@ class LocalModels(DikteTest):
|
|||||||
# Its own thinking box, because the two default to opposite things.
|
# Its own thinking box, because the two default to opposite things.
|
||||||
self.assertFalse(window.cleanup_form.isRowVisible(window.cleanup_reasoning))
|
self.assertFalse(window.cleanup_form.isRowVisible(window.cleanup_reasoning))
|
||||||
|
|
||||||
|
def test_the_idle_unload_is_offered_to_whoever_runs_a_model_here(self):
|
||||||
|
for transcriber, cleaner in (("local", "openrouter"),
|
||||||
|
("openai", "local"),
|
||||||
|
("local", "local")):
|
||||||
|
with self.subTest(transcriber=transcriber, cleaner=cleaner):
|
||||||
|
window = self.window(self.config(transcribe_provider=transcriber,
|
||||||
|
cleanup_provider=cleaner))
|
||||||
|
self.assertTrue(window.local_box.isVisibleTo(window))
|
||||||
|
|
||||||
|
def test_a_machine_that_runs_neither_is_not_asked_about_memory(self):
|
||||||
|
window = self.window(self.config(transcribe_provider="openai",
|
||||||
|
cleanup_provider="openrouter"))
|
||||||
|
self.assertFalse(window.local_box.isVisibleTo(window))
|
||||||
|
|
||||||
|
def test_the_minutes_follow_the_checkbox(self):
|
||||||
|
window = self.window(self.config(local_idle_unload=False))
|
||||||
|
self.assertFalse(window.local_idle_minutes.isEnabled())
|
||||||
|
window.local_idle_unload.setChecked(True)
|
||||||
|
self.assertTrue(window.local_idle_minutes.isEnabled())
|
||||||
|
|
||||||
def test_each_cleaner_brings_its_own_model_row_and_no_other(self):
|
def test_each_cleaner_brings_its_own_model_row_and_no_other(self):
|
||||||
window = self.window(cfg.Config())
|
window = self.window(cfg.Config())
|
||||||
rows = {"openrouter": window.cleanup_model_row,
|
rows = {"openrouter": window.cleanup_model_row,
|
||||||
|
|||||||
Reference in New Issue
Block a user