diff --git a/.github/workflows/whisper-vulkan.yml b/.github/workflows/whisper-vulkan.yml new file mode 100644 index 0000000..eb9bc32 --- /dev/null +++ b/.github/workflows/whisper-vulkan.yml @@ -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" diff --git a/README.md b/README.md index 8853b8f..3a2b14e 100644 --- a/README.md +++ b/README.md @@ -161,9 +161,14 @@ running. - **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 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 - account, nothing leaving the machine. + account, nothing leaving the machine. On x86_64 Linux the same button fetches + a Vulkan build of whisper-server that Dikte publishes itself, because + upstream's Linux archive is processor-only. - **Silence never reaches the API.** Handed near-silence, a transcription model invents a sentence instead of returning nothing ("Thanks for watching", or in Turkish "Altyazı M.K."). A recording is dropped when nothing rose 10 dB above @@ -215,6 +220,11 @@ running. written for subtitles, so the lines keep their place and nothing is shortened. - **History** of every dictation under Settings → History, with a size limit and right-click to delete. +- **The speech language is detected, not picked.** Auto is the default: whisper + on this machine says what it heard, the hosted providers transcribe in + whatever language comes in without being told, and the detected language + lands in the history and decides which cleanup prompt (Turkish or the + language-agnostic one) a run gets. A fixed language still overrides it. - **Turkish and English interface**, following the system locale by default. ## The global shortcuts, and the logout KDE needs diff --git a/README.tr.md b/README.tr.md index 2e6a461..86a4b82 100644 --- a/README.tr.md +++ b/README.tr.md @@ -158,8 +158,13 @@ olmasını ister. whisper.cpp, temizleme llama.cpp üzerinde; ikisini de önceden kurman gerekmez: ayarlar penceresi programı ve modeli indirir, sha256'sını doğrular, 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. + x86_64 Linux'ta aynı düğme, whisper-server'ın Dikte'nin kendi yayınladığı + Vulkan derlemesini indirir; upstream'in Linux arşivi yalnızca işlemci için. - **Sessizlik API'ye gitmez.** Sessize yakın bir ses verildiğinde model boş dize döndürmez, bir cümle uydurur ("Altyazı M.K.", "Thanks for watching"). *O kaydın kendi* gürültü tabanının 10 dB üstüne en az 0,3 saniye çıkan bir şey @@ -209,6 +214,11 @@ olmasını ister. yerinde kalır, hiçbir şey kısaltılmaz. - **Geçmiş** Ayarlar → Geçmiş sekmesinde; boyut sınırı var, sağ tıklayıp silebilirsin. +- **Konuşma dili seçilmez, algılanır.** Varsayılan otomatiktir: bu makinedeki + whisper ne duyduğunu söyler, bulut sağlayıcılar söylenmeden de hangi dilde + konuşuluyorsa o dilde yazar; algılanan dil geçmişe düşer ve bir kaydın hangi + temizleme promptunu alacağını belirler (Türkçe mi, dile duyarsız olanı mı). + Sabit bir dil yine de bunun önüne geçer. - **Türkçe ve İngilizce arayüz**, varsayılan olarak sistem dilini izler. ## Global kısayollar ve KDE'nin istediği oturum kapatma diff --git a/dikte/__init__.py b/dikte/__init__.py index 9cd7362..cb137c2 100644 --- a/dikte/__init__.py +++ b/dikte/__init__.py @@ -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 # 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. -__version__ = "1.1.0" +__version__ = "1.2.0" diff --git a/dikte/api.py b/dikte/api.py index 61e0857..504167e 100644 --- a/dikte/api.py +++ b/dikte/api.py @@ -48,22 +48,33 @@ LOCAL_TIMEOUT = 3600 # Where a transcription request goes; built by config.Config.transcribe_target(). # `service` is the name the user sees in an error, `provider` the one the code -# branches on. -Target = collections.namedtuple("Target", "provider service api_key base_url model") +# branches on. `file_model` is what a timestamped run asks for instead of +# `model`, where the two differ; empty means the provider's own whisper. +Target = collections.namedtuple( + "Target", "provider service api_key base_url model file_model", + defaults=[""]) + +# What answers with segment times on OpenRouter when nothing else was chosen. +OPENROUTER_FILE_MODEL = "openai/whisper-1" -def timestamp_model(provider, selected=""): +def timestamp_model(provider, selected="", file_model=""): """Which model answers with segment times. - OpenAI keeps them to whisper-1 and OpenRouter namespaces that id. Everything - Groq transcribes with is a whisper, so the model already chosen does it and - the fallback is only for a provider left on its default. So is everything the - local server runs, whatever the file is called, and there asking for another - model would name one it has never heard of. + OpenAI keeps them to whisper-1. Everything Groq transcribes with is a + whisper, so the model already chosen does it and the fallback is only for a + provider left on its default. So is everything the local server runs, + whatever the file is called, and there asking for another model would name + one it has never heard of. OpenRouter fronts several models that do times + and several that do not, and a request to the wrong one gets a transcript + with no segments in it, so the one to use is a setting of its own + (`file_model`) and whisper-1 is only where that setting is left empty. """ if provider in ("groq", "local"): return selected or "whisper-large-v3-turbo" - return "openai/whisper-1" if provider == "openrouter" else "whisper-1" + if provider == "openrouter": + return file_model or OPENROUTER_FILE_MODEL + return "whisper-1" # What a gateway in front of the model answers of its own accord: the request @@ -356,7 +367,8 @@ def local_failure(service, server, exc): def _transcribe_request(target, audio_path, language, prompt, response_format, - granularity=None, timeout=300, aborter=None): + granularity=None, timeout=300, aborter=None, + detect_language=False): if target.provider == "local": # The timeouts here are sized for a hosted API, where a slow answer is a # bill running. Locally the only thing being spent is time. @@ -368,20 +380,31 @@ def _transcribe_request(target, audio_path, language, prompt, response_format, fields = [("model", target.model), ("response_format", response_format)] if language and language != "auto": fields.append(("language", language)) + if detect_language: + # whisper.cpp was started with -nlp, which keeps the language + # probability sweep off every request. Detection is only worth that + # sweep for the run that asked for it, so it is switched back on here, + # per request, and reported in the verbose_json answer. + fields.append(("no_language_probabilities", "false")) # OpenRouter takes the hint field and throws it away, so spare it the bytes. # The same words still reach the cleanup model as a glossary. whisper.cpp # takes it as the initial prompt, the way OpenAI does. if prompt and target.provider != "openrouter": fields.append(("prompt", prompt)) - if granularity: - fields.append(("timestamp_granularities[]", granularity)) + for level in granularity or (): + fields.append(("timestamp_granularities[]", level)) 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: - return _request( - f"{target.base_url.rstrip('/')}/audio/transcriptions", body, - _headers(target.provider, target.api_key, ctype), timeout=timeout, - aborter=aborter, - ) + with held: + return _request( + f"{target.base_url.rstrip('/')}/audio/transcriptions", body, + _headers(target.provider, target.api_key, ctype), timeout=timeout, + aborter=aborter, + ) except ApiError as exc: if target.provider == "local": raise local_failure(target.service, ggml.whisper, exc) from None @@ -427,6 +450,96 @@ def _merge_word_splits(segments): return merged +# A cue built here is one a reader has time for: about two lines of subtitle, +# and no longer on screen than a sentence takes to say. Neither is a hard rule +# for a sentence that ends early, only the point past which one is broken. +MAX_CUE_SECONDS = 7.0 +MAX_CUE_CHARS = 84 +# The other end of it: a cue nobody can read because it was gone before they +# looked. A full stop this early in a cue is not the end of anything worth +# breaking on, which is what "1." and "Dr." are, and a cue that ends up short +# anyway is held on screen until the next one needs the space. +MIN_CUE_SECONDS = 1.2 +# No whisper segment is longer than the window it was heard in, so a segment +# that runs past this came from a model that is not marking segments at all. +WHISPER_WINDOW = 30.0 +SENTENCE_END = ".!?…" + + +def _too_coarse(segments): + """Whether these segments are too long to be cues, or are not there at all. + + Not every model behind /audio/transcriptions marks segments the way whisper + does. Some fill the field with one entry per paragraph, or with a single one + covering the whole file, which turns a fourteen minute video into three + subtitles. Word times are what those models do give, and cues built from + them are better than what the segments would have been. + """ + if not segments: + return True + return any(float(seg.get("end") or 0.0) - float(seg.get("start") or 0.0) + > WHISPER_WINDOW for seg in segments) + + +def cues_from_words(words): + """[(start, end, text)] cut out of word times, where segments were no use. + + A cue ends where a sentence does, and failing that wherever it has grown too + long to read or too long to leave up. Nothing is ever cut between two words: + the times that arrive are per word, and so are the ones that leave. + """ + cues = [] + start = end = 0.0 + current = [] + + def flush(): + nonlocal current + if current: + cues.append((start, max(end, start), " ".join(current))) + current = [] + + for word in words: + text = (word.get("word") or "").strip() + if not text: + continue + at = float(word.get("start") or 0.0) + until = float(word.get("end") or at) + if current: + grown = len(" ".join(current)) + 1 + len(text) + if grown > MAX_CUE_CHARS or until - start > MAX_CUE_SECONDS: + flush() + if not current: + start = at + current.append(text) + end = until + # A sentence can end inside the punctuation that closes a quote. What + # is too short to have been a sentence is a list marker or a shortened + # word, and the cue goes on rather than ending on it. + if (end - start >= MIN_CUE_SECONDS + and text.rstrip("\"')]»”’").endswith(tuple(SENTENCE_END))): + flush() + flush() + return _held(cues) + + +def _held(cues): + """Keep a cue that is still too short on screen, without covering the next. + + A one word sentence is a fifth of a second of audio and so a fifth of a + second of subtitle, which is a flicker. It stays up until the cue after it + starts, or for as long as it takes to read, whichever comes first. + """ + out = [] + for index, (start, end, text) in enumerate(cues): + if end - start < MIN_CUE_SECONDS: + room = start + MIN_CUE_SECONDS + if index + 1 < len(cues): + room = min(room, cues[index + 1][0]) + end = max(end, room) + out.append((start, end, text)) + return out + + def transcribe(target, audio_path, language="", prompt="", timeout=300, aborter=None): data = _transcribe_request( target, audio_path, language, prompt, "json", timeout=timeout, aborter=aborter @@ -440,17 +553,75 @@ def transcribe(target, audio_path, language="", prompt="", timeout=300, aborter= return text +# whisper.cpp reports what it heard as a lowercase full name ("turkish", +# "english", "german"…); the settings and the cleanup prompt speak in two-letter +# codes. Only the handful Dikte offers as a fixed choice get a code; anything +# else is left as the empty string, which the caller reads as "unknown" rather +# than guessing at a language it has no label for. +_DETECTED_TO_CODE = { + "english": "en", "turkish": "tr", "german": "de", + "french": "fr", "spanish": "es", "arabic": "ar", +} + + +def transcribe_detected(target, audio_path, language="", prompt="", timeout=300, + aborter=None): + """(text, code) with the language the model heard. + + The spoken language is only knowable when the transcription model reports + it, and only whisper.cpp does: the hosted endpoints accept "auto" but never + say what they heard. So detection is asked for exactly where it can be + answered, the local server in auto mode, and every other run transcribes + as before and hands back an empty code. + """ + if target.provider == "local" and language == "auto": + data = _transcribe_request( + target, audio_path, language, prompt, "verbose_json", + detect_language=True, timeout=timeout, aborter=aborter, + ) + text = _local_text(data.get("text") or "").strip() + if not text: + raise ApiError(t("Transcript came back empty.")) + detected = data.get("detected_language") + code = _DETECTED_TO_CODE.get( + detected.strip().lower(), "") if isinstance(detected, str) else "" + return text, code + text = transcribe(target, audio_path, language=language, prompt=prompt, + timeout=timeout, aborter=aborter) + return text, "" + + def transcribe_segments(target, audio_path, language="", prompt="", timeout=300, aborter=None): """[(start_seconds, end_seconds, text)] using whisper-1's verbose response.""" - data = _transcribe_request( - target._replace(model=timestamp_model(target.provider, target.model)), - audio_path, language, prompt, "verbose_json", - granularity="segment", timeout=timeout, aborter=aborter, - ) + target = target._replace(model=timestamp_model(target.provider, target.model, + target.file_model)) + ask = dict(language=language, prompt=prompt, response_format="verbose_json", + timeout=timeout, aborter=aborter) + # Word times are the way out of a model that does not mark segments, and + # whisper.cpp is not one of those, so the local server is only ever asked + # for what it has always been asked for. A hosted model that refuses the + # field says so with a 400, and the request it used to answer is still + # there to fall back on rather than losing the run over a field it did not + # need in the first place. + if target.provider == "local": + data = _transcribe_request(target, audio_path, granularity=("segment",), **ask) + else: + try: + data = _transcribe_request(target, audio_path, + granularity=("segment", "word"), **ask) + except ApiError as exc: + if exc.status != 400: + raise + data = _transcribe_request(target, audio_path, + granularity=("segment",), **ask) segments = data.get("segments") or [] if target.provider == "local": segments = _merge_word_splits(segments) + if _too_coarse(segments): + cues = cues_from_words(data.get("words") or []) + if cues: + return cues out = [] for seg in segments: text = (seg.get("text") or "").strip() @@ -506,22 +677,65 @@ def _thinking(payload, provider, reasoning): 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. 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 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 - waiting. A hosted model is left alone: there the same runaway is rare, and a - ceiling would cut the minutes short instead. + waiting, with only the hour-long local timeout underneath. A hosted model is + 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="", 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": raise ApiError(t("{service} API key is empty. Add it in Settings.", service=service)) @@ -534,7 +748,8 @@ def cleanup(text, api_key, model, system_prompt, reasoning="", ], } if provider == "local-llm": - payload["max_tokens"] = local_ceiling(text) + payload["max_tokens"] = local_ceiling(text, reasoning, context, + system_prompt) _thinking(payload, provider, reasoning) try: data = _request( @@ -558,6 +773,13 @@ def cleanup(text, api_key, model, system_prompt, reasoning="", raise ApiError(t("The cleanup model spent its whole reply on " "thinking. Set Thinking to \u201cOff\u201d.")) 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 @@ -593,6 +815,11 @@ def chat(messages, api_key, model, system_prompt, reasoning="", content = ((choices[0].get("message") or {}).get("content") or "").strip() if not content: 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 diff --git a/dikte/app.py b/dikte/app.py index 1fbd24b..f024ed4 100644 --- a/dikte/app.py +++ b/dikte/app.py @@ -164,12 +164,14 @@ class Dikte: self._front_watch = None 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 # up, and drops into the corner when it is alone there. self.ask_overlay = Overlay(self.conf["overlay_corner"], below=self.overlay, 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.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.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.triggered.connect(self.open_settings) self.menu.addAction(self.settings_action) @@ -301,6 +308,10 @@ class Dikte: self.menu.addAction(self.quit_action) 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.activated.connect(self._tray_clicked) self._refresh_update() @@ -396,6 +407,21 @@ class Dikte: ) 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 # say, since dictation is the one being waited on in front of a screen. if self.state == IDLE and self.ask_state != IDLE: @@ -1073,7 +1099,7 @@ class Dikte: if not self._transcripts_pending: self._settle(DICTATION, payload) - def _on_finished(self, _raw, text, warning): + def _on_finished(self, _raw, text, warning, speech_language): if warning: # The text was still pasted, but cleanup did not run. Say so loudly: # a rejected key otherwise looks exactly like working dictation. @@ -1094,9 +1120,10 @@ class Dikte: t("{action}: {preview}", action=action, preview=_preview(text)) ) self._transcript_settled({"ok": True, "text": text, "raw": _raw, - "warning": warning}) + "warning": warning, + "speech_language": speech_language}) - def _on_ask_finished(self, _raw, text, warning): + def _on_ask_finished(self, _raw, text, warning, speech_language): agent = assistant.display_name(self.conf) if warning: # A tool the agent was not allowed to touch otherwise looks exactly @@ -1117,7 +1144,8 @@ class Dikte: ) self._set_ask_state(IDLE) self._settle(ASK, {"ok": True, "answer": text, "question": _raw, - "warning": warning, "agent": agent}) + "warning": warning, "agent": agent, + "speech_language": speech_language}) def _on_ask_cancelled(self): self.ask_overlay.show_done(t("Stopped."), 2000) @@ -1222,6 +1250,18 @@ class Dikte: QDesktopServices.openUrl( 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 --------------------------------------------------------- def open_settings(self): @@ -1312,10 +1352,10 @@ class Dikte: threading.Thread(target=warm, daemon=True).start() def _apply_settings(self): - self.overlay.corner = self.conf["overlay_corner"] - self.overlay.screen_name = self.conf["overlay_screen"] - self.ask_overlay.corner = self.conf["overlay_corner"] - self.ask_overlay.screen_name = self.conf["overlay_screen"] + for indicator in (self.overlay, self.ask_overlay): + indicator.corner = self.conf["overlay_corner"] + indicator.screen_name = self.conf["overlay_screen"] + indicator.follow_pointer = self.conf["overlay_follows_pointer"] self._apply_local() self._build_tray() self._refresh_tray() diff --git a/dikte/cleanup.py b/dikte/cleanup.py index a1bced3..77f59c0 100644 --- a/dikte/cleanup.py +++ b/dikte/cleanup.py @@ -115,13 +115,20 @@ def _local(text, conf, system_prompt, timeout, aborter=None): """ service = t("Local model") try: - return api.cleanup( - text, "", conf["local_llm_model"], system_prompt, - reasoning=conf["local_llm_reasoning"], - base_url=api.serving(ggml.llm), - timeout=max(timeout, api.LOCAL_TIMEOUT), - provider="local-llm", service=service, aborter=aborter, - ) + # Held for the length of the request so that the idle unload does not + # take the model away from a block still being cleaned up. + with ggml.llm.busy(): + return api.cleanup( + text, "", conf["local_llm_model"], system_prompt, + 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: # A server that died mid-request would otherwise report only that the # connection dropped, when the reason is in its own output. diff --git a/dikte/cli.py b/dikte/cli.py index 01eab45..4a06b17 100644 --- a/dikte/cli.py +++ b/dikte/cli.py @@ -854,24 +854,16 @@ def _local_where(entry): return where + (f" ({detail})" if detail else "") -def _local_note(name, entry): - """Why the card is not in use, for a setup that asked for it and got none. - - Three answers, because the thing to do about it differs: no card here, a - build that could never use one, and a build Dikte downloaded that could - never use one, which is the only case with a fix worth naming. - """ +def _local_note(entry): + """What the log establishes when GPU use was requested but unavailable.""" if not entry.get("gpu_wanted"): return "" if ggml.accel_kind({**entry, "running": True}) != "cpu": return "" - if not ggml.cpu_only_build(entry): - return " - the graphics card is switched on and none was found" - if entry.get("downloaded"): - return (f" - the graphics card is switched on and the downloaded build " - f"has no GPU backend; a {name}-server on your system would be " - f"used ahead of it") - return " - the graphics card is switched on and this build carries none" + if not ggml.cpu_only_loaded(entry): + return " - the graphics card is switched on but could not be used" + return (" - only the CPU backend was loaded; check the server log for " + "graphics backend or driver errors") def _local_line(name, entry): @@ -879,7 +871,7 @@ def _local_line(name, entry): return "not loaded" model = entry.get("model") or "" return (f"loaded on {_local_where(entry)}" - + (f", {model}" if model else "") + _local_note(name, entry)) + + (f", {model}" if model else "") + _local_note(entry)) def _last_local(conf): @@ -888,25 +880,20 @@ def _last_local(conf): For a command line asking while nothing is running: there is no process to put the question to, and the log outlives the process that wrote it. Every entry says `running` is false, because this is an account of the last start - rather than a reading of a live one. + rather than a reading of a live one. The logs do not record the binary path + or requested GPU setting, so current settings cannot explain that run. """ rows = {} - for program, used, gpu, custom in ( - (ggml.WHISPER, conf["transcribe_provider"] == "local", - bool(conf["local_gpu"]), conf["local_binary"]), - (ggml.LLAMA, conf.uses_local_llm(), bool(conf["local_llm_gpu"]), - conf["local_llm_binary"])): + for program, used in ( + (ggml.WHISPER, conf["transcribe_provider"] == "local"), + (ggml.LLAMA, conf.uses_local_llm())): accel = ggml.last_accel(program) - binary = ggml.program_path(program, custom) rows[program.name] = { - # Which copy would run now, since the log does not say which one - # wrote it; the advice only differs for Dikte's own download. - "downloaded": ggml.is_downloaded(binary), # Whether one ever started here at all, which the backend cannot # say on its own: a server that ran and named no backend and one # that never ran both leave it empty. "ran": ggml.server_log(program).exists(), - "running": False, "used": used, "gpu_wanted": gpu, + "running": False, "used": used, "backend": accel.backend, "device": accel.device, "layers": accel.layers, "available": list(accel.available), } @@ -1035,7 +1022,7 @@ def cmd_doctor(opts): lines.append(f"· {name:14} not loaded") elif entry.get("backend"): lines.append(f"· {name:14} last run on " - f"{_local_where(entry)}{_local_note(name, entry)}") + f"{_local_where(entry)}") elif entry.get("ran"): lines.append(f"· {name:14} last run said nothing about what it " f"was running on") diff --git a/dikte/config.py b/dikte/config.py index 9aa2228..9a7d10a 100644 --- a/dikte/config.py +++ b/dikte/config.py @@ -397,7 +397,12 @@ DEFAULTS = { "transcribe_model": "gpt-4o-transcribe", # used when provider is openai "groq_transcribe_model": "whisper-large-v3-turbo", "openrouter_transcribe_model": "openai/gpt-4o-transcribe", - "language": "tr", + # What a timestamped run (subtitles) asks OpenRouter for: not every model + # there returns segment times. Empty -> openai/whisper-1. + "openrouter_file_model": "", + # A stored language overrides this default. Hosted providers receive no + # language hint in auto mode; local whisper also reports the detected code. + "language": "auto", "transcribe_prompt": "", # --- whisper.cpp, on this machine --------------------------------------- @@ -439,6 +444,15 @@ DEFAULTS = { # Off rather than empty: a model trained to think will, and 300 tokens of # reasoning about a comma is 300 tokens of waiting. "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 "auto_paste": True, "paste_shortcut": paste.desktop().shortcuts[0], # cmd+v on a Mac @@ -467,6 +481,9 @@ DEFAULTS = { "evdev_hotkey": False, "overlay_corner": "bottom-left", "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, "history_limit": 200, # A look at the releases page once a day, and nothing more than a look: @@ -669,8 +686,9 @@ class Config: # to land on rather than reading it from there. name = "openai" who = TRANSCRIBERS[name] + file_model = self["openrouter_file_model"] if name == "openrouter" else "" return api.Target(name, who.service, self.api_key(who.key), - self[who.url], self[who.model]) + self[who.url], self[who.model], file_model.strip()) def transcribe_ready(self): """Whether speech to text could run right now, without opening Settings.""" @@ -703,19 +721,37 @@ class Config: binary=self["local_llm_binary"], 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): """Whether anything is set to run the local cleanup model.""" return self["cleanup_provider"] == "local" def cleanup_prompt(self, with_timestamps=False, with_speakers=False, - subtitles=False): - turkish = i18n.language() == "tr" + subtitles=False, speech=""): + """`speech` is the two-letter code of the language that was heard, when + the transcription model reported one. The default prompts and the + glossary rule only exist in Turkish and English, so a detected Turkish + recording gets the Turkish prompt and any other detected language, or + none at all, the English one, which is written not to care what + language the transcript is in. Nothing else calls this with it, so the + interface language keeps deciding everywhere the speech was not asked + about.""" + turkish = (speech == "tr") if speech else i18n.language() == "tr" if subtitles: prompt = (self["file_cleanup_prompt"].strip() - or default_file_cleanup_prompt()) + or (FILE_CLEANUP_PROMPT_TR if turkish + else FILE_CLEANUP_PROMPT_EN)) else: - prompt = self["cleanup_prompt"].strip() or default_cleanup_prompt() + prompt = (self["cleanup_prompt"].strip() + or (CLEANUP_PROMPT_TR if turkish else CLEANUP_PROMPT_EN)) glossary = self["transcribe_prompt"].strip() if with_speakers: glossary = "\n".join(x for x in (glossary, self.participants()) if x) diff --git a/dikte/filetranscribe.py b/dikte/filetranscribe.py index c69bc2f..62430fd 100644 --- a/dikte/filetranscribe.py +++ b/dikte/filetranscribe.py @@ -283,11 +283,20 @@ def to_srt(text, segments): hours, minutes, secs = (int(g or 0) for g in match.groups()) cues.append([hours * 3600 + minutes * 60 + secs, None, body]) + # Several cues can share a whole second, so a second holds every segment + # that began in it and they are handed out in the order they were spoken. timing = {} for start, end, _ in segments: - timing.setdefault(int(start), (start, end)) + timing.setdefault(int(start), []).append((start, end)) for cue in cues: - cue[0], cue[1] = timing.get(cue[0], (float(cue[0]), 0.0)) + found = timing.get(cue[0]) + if found: + # The last one stays, so a second with more lines than it has + # timings hands the last of them out again rather than falling back + # to the bare second, which would run backwards from the line above. + cue[0], cue[1] = found.pop(0) if len(found) > 1 else found[0] + else: + cue[0], cue[1] = float(cue[0]), 0.0 for index, cue in enumerate(cues): following = cues[index + 1][0] if index + 1 < len(cues) else 0.0 if following > cue[0]: diff --git a/dikte/ggml.py b/dikte/ggml.py index f1356a4..ad39401 100644 --- a/dikte/ggml.py +++ b/dikte/ggml.py @@ -26,6 +26,8 @@ interface already knows how to show. import atexit import collections +import contextlib +import ctypes import ctypes.util import hashlib import http.client @@ -68,6 +70,10 @@ STARTUP_TIMEOUT = 180.0 # 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. 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 # `health` is the path that answers only once the model is in memory. whisper @@ -77,6 +83,13 @@ Program = collections.namedtuple("Program", "name repo binary health") WHISPER = Program("whisper", "ggml-org/whisper.cpp", "whisper-server", "") LLAMA = Program("llama", "ggml-org/llama.cpp", "llama-server", "/health") +DIKTE_REPO = "yusufipk/dikte" +MANAGED_WHISPER_RELEASE = "whisper.cpp-v1.9.3" +MANAGED_WHISPER_VERSION = "v1.9.3" +MANAGED_WHISPER_VULKAN = "whisper-bin-ubuntu-vulkan-x64.tar.gz" +MANAGED_WHISPER_SHA256 = ( + "c25ca76504144da488eb74441390a7b9aa7ce547e5f2f391cbd831253c9b54d8" +) # Where the models are listed. Neither list is written into Dikte: a catalogue # in the source means a release of Dikte for every model somebody else @@ -84,32 +97,139 @@ LLAMA = Program("llama", "ggml-org/llama.cpp", "llama-server", "/health") WHISPER_MODELS_REPO = "ggerganov/whisper.cpp" 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 # hardware and the odd loose file. WHISPER_PREFIX = "ggml-" 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 -# multimodal model, mtp a draft head for speculative decoding. Neither is a model -# a server can be started on, and offering them is offering a failure. -GGUF_SKIP = ("mmproj", "mtp-") +# multimodal model, and mtp, dflash, dspark and eagle3 are draft heads for +# speculative decoding. None of them is a model a server can be started on, and +# 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 # to keep a 400 GB frontier model out of a list somebody might click. 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 -# the rows that float to the top of it. Small instruction-following models, -# because cleanup is punctuation and filler words rather than anything that -# wants thinking about. +# the rows that float to the top of it. Cleanup is punctuation, capitals and +# filler words rather than anything that wants thinking about, so what it is +# 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 = ( - "ggml-org/gemma-3-4b-it-GGUF", "ggml-org/gemma-4-E2B-it-GGUF", "ggml-org/gemma-4-E4B-it-GGUF", + "ggml-org/gemma-3-4b-it-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 -# 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" +# 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): @@ -278,6 +398,102 @@ def _wanted_assets(program): return (f"bin-ubuntu-{arch}.tar.gz",) +def _managed_whisper(program, tag=""): + """Whether this machine is one Dikte publishes its own whisper-server for. + + Linux x86_64 with a Vulkan loader on it, and no version asked for by hand: + a pinned version is upstream's to answer. + """ + return (not tag and program is WHISPER and sys.platform == "linux" + and platform.machine().lower() in ("x86_64", "amd64") + and _has_vulkan()) + + +def _managed_asset(refresh=False): + """The Vulkan whisper-server Dikte builds itself, or None. + + Taken only when the archive's digest is the reviewed one. Anything else, + a release that is not there yet, a GitHub that cannot be reached, a file + that is not the reviewed bytes, leaves upstream's processor build as the + answer, and the install record says which of the two landed. + """ + try: + _, assets = hub.release(DIKTE_REPO, MANAGED_WHISPER_RELEASE, + refresh=refresh) + except hub.HubError: + return None + return next((a for a in assets + if a.name.endswith(MANAGED_WHISPER_VULKAN) + and a.sha256 == MANAGED_WHISPER_SHA256), None) + + +def _matching_asset(program, assets): + """The archive this machine wants out of one release's files, or None.""" + for ending in _wanted_assets(program): + 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): return BIN_DIR / program.name / "installed.json" @@ -301,6 +517,21 @@ def installed_version(program): return _read_record(program).get("tag") or "" +def vulkan_missing(program): + """Whether what Dikte installed is the processor build on a machine the + Vulkan one was fetched for. + + The Vulkan whisper-server is a release of Dikte's own, published by hand + once the reviewed archive is built, and the install falls back to the + upstream processor build whenever that release, the file in it, or its + reviewed digest is not there. Nothing is wrong with the fallback except + that it is invisible: a graphics card sitting idle looks exactly like a + graphics card being used. + """ + return (bool(installed_program(program)) + and _read_record(program).get("backend") == "processor") + + def program_path(program, custom=""): """Which copy of the program to run, or "" when there is none. @@ -373,18 +604,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: a version pinned in Dikte's source would mean a release of Dikte every time - whisper.cpp has one. + whisper.cpp has one. The Linux Vulkan whisper-server is the exception, and + _pick_asset says why. """ + managed = _managed_whisper(program, tag) try: - tag, assets = hub.release(program.repo, tag or "latest", refresh=refresh) + tag, item = _pick_asset(program, tag, refresh=refresh) except hub.HubError as 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: # Nothing to download and nothing to install for you: whisper.cpp # publishes no macOS binary, and Homebrew's whisper-cpp is configured @@ -448,8 +676,15 @@ def install_program(program, tag="", on_progress=None, should_stop=None, # Found under the sibling, run from the final directory. binary = into / binary.relative_to(fresh) # Written last, so the record never points at anything half-made. - _install_record(program).write_text( - json.dumps({"tag": tag, "binary": str(binary)}), encoding="utf-8") + record = {"tag": tag, "binary": str(binary)} + if managed: + # Which of the two builds this machine ended up with. Only written + # where both were on offer, so an install that never had the + # choice is not made to look like a fallback. + record["backend"] = ( + "vulkan" if item.name.endswith(MANAGED_WHISPER_VULKAN) + else "processor") + _install_record(program).write_text(json.dumps(record), encoding="utf-8") except OSError as exc: raise LocalError(t("Could not install {name}: {error}", name=program.name, error=exc)) from exc @@ -480,9 +715,212 @@ def _drop_old_versions(program, keep): 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 ----------------------------------------------------------- +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): """[hub.Item] for every whisper model on offer, smallest first.""" try: @@ -495,10 +933,23 @@ def whisper_models(refresh=False): 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): """Repository ids for the GGUF models on offer, suggestions first.""" 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: # A menu rather than a catalogue: with nothing to show, the suggestions # are still worth showing, and whatever is wrong with the network will @@ -506,6 +957,14 @@ def llm_repos(refresh=False): found = [] if not found: 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] return first + [r for r in found if r not in first] @@ -621,18 +1080,20 @@ NO_ACCEL = Accel("", "", "", ()) # whether it found a card, and llama says how many layers went onto it. _BACKEND_LOADED = re.compile(r"^load_backend: loaded (\w+) backend", re.M) _WHISPER_NO_GPU = "whisper_backend_init_gpu: no GPU found" -# What whisper committed to, which is not what it enumerated: it lists every -# device it can see, then tries them, and a card that fails to initialise sends -# it back to the processor. "using" is printed only once one has worked, and the -# model buffer is named after wherever the weights actually ended up. -_WHISPER_USING = re.compile( - r"^whisper_backend_init_gpu: using (\S+) backend", re.M) +# "using" precedes the attempt to initialise the backend. A later failure +# invalidates it, even when model weights were already put on that device. +_WHISPER_ATTEMPT = re.compile( + r"^whisper_backend_init_gpu: (using|failed to initialize) (\S+) backend", + re.M) _WHISPER_BUFFER = re.compile(r"^whisper_model_load:\s+(\S+) total size", re.M) # The device listing, read for the card's name rather than for the verdict. _WHISPER_DEVICE = re.compile( r"^whisper_backend_init_gpu: device (\d+): (.+?) \(type: (\d+)\)", re.M) _LLAMA_OFFLOAD = re.compile( r"^load_tensors: offloaded (\d+)/(\d+) layers to GPU", re.M) +_LLAMA_BUFFER = re.compile( + r"^load_tensors:\s+(\S+) model buffer size\s*=\s*(\d+(?:\.\d+)?) MiB", + re.M) # whisper names the device by its ggml handle, "Vulkan0" or "CUDA0", which says # which slot rather than which card. Each backend prints the real name as it @@ -711,13 +1172,17 @@ def _read_accel(program, log_path): available = tuple(dict.fromkeys(_BACKEND_LOADED.findall(text))) cards = [name for name in available if name.upper() != "CPU"] if program is WHISPER: - # The handle whisper settled on. "using" is printed once a backend has - # initialised, so a card that was listed and then failed never reaches - # here; the model buffer is the same answer from the other end, named - # after wherever the weights were actually put. - using = _WHISPER_USING.search(text) + handle, failed = "", False + for event, device in _WHISPER_ATTEMPT.findall(text): + if event == "using": + handle, failed = device, False + elif not handle or device == handle: + handle, failed = "", True + if failed: + return Accel("CPU", "", "", available) buffered = _WHISPER_BUFFER.search(text) - handle = (using or buffered).group(1) if (using or buffered) else "" + if not handle and buffered: + handle = buffered.group(1) if _WHISPER_NO_GPU in text or handle.upper().startswith("CPU"): return Accel("CPU", handle or "", "", available) if handle: @@ -738,9 +1203,19 @@ def _read_accel(program, log_path): if found: layers = f"{found.group(1)}/{found.group(2)}" if int(found.group(1)) > 0: - backend = cards[0] if cards else "GPU" - return Accel(backend, _card_name(text, f"{backend}0"), layers, - available) + # Loaded libraries do not identify the device holding the model. + # Host buffers such as CUDA_Host are not GPU allocations. When + # several devices hold weights, do not pretend only one ran. + handles = list(dict.fromkeys( + handle for handle, size in _LLAMA_BUFFER.findall(text) + if float(size) > 0 and _BARE.fullmatch(handle) + and not handle.upper().startswith("CPU"))) + if len(handles) == 1: + handle = handles[0] + backend = _HANDLE.fullmatch(handle).group(1) + return Accel(backend, _card_name(text, handle), layers, + available) + return Accel("GPU", "", layers, available) return Accel("CPU", "", layers, available) if available and not cards: return Accel("CPU", "", "", available) @@ -810,12 +1285,20 @@ class Server: # a model this server is not running. self._live = {} # The copy that is running, resolved rather than configured: the - # setting is usually empty, meaning whichever one program_path finds, - # and which one it found decides what advice is worth giving. + # setting is usually empty, meaning whichever one program_path finds. self._binary = "" # The pid this instance last wrote to its pid file, so _forget never # removes a file some other Dikte wrote after us. 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 -------------------------------------------------------- @@ -833,6 +1316,23 @@ class Server: with self._lock: 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): """What a running server would have to be restarted for.""" return json.dumps(self._settings, sort_keys=True, default=str) @@ -903,13 +1403,83 @@ class Server: self._accel, self._live = accel, settings self._binary = program_path(self.program, settings.get("binary", "")) + # 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() 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: up = self._proc is not None and self._proc.poll() is None - return (f"http://{HOST}:{self._port}/v1" - if up and self._key == self._settings_key() else "") + if not (up and self._key == self._settings_key()): + 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): args = self._build(settings) # raises LocalError when unusable @@ -1018,6 +1588,31 @@ class Server: except subprocess.TimeoutExpired: 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): # Taking _starting means a stop cannot slide past a launch in flight: # serve() finishes registering its child first, and the child is then @@ -1156,12 +1751,13 @@ def _whisper_args(settings): binary, "-m", str(model), "--inference-path", INFERENCE_PATH, # Whatever language the request does not name. api.py leaves the field - # out when the language is "auto", and the server's own default is - # English rather than detection. + # out when the language is "auto", and the server's own language is + # set here: "auto" makes whisper.cpp detect what it hears. "-l", "auto", # Stock phrases invented for near-silence come from non-speech tokens, # and verbose_json otherwise pays for a language probability sweep - # nothing here reads. + # nobody asked for. A request that wants the detected language switches + # that back on per request. "-sns", "-nlp", ] if int(settings["threads"]) > 0: @@ -1277,12 +1873,11 @@ def accel_detail(state): return ", ".join(out) -def cpu_only_build(state): - """Whether the binary that is running carries no GPU backend at all. +def cpu_only_loaded(state): + """Whether CPU is the only backend the log says was loaded. - The difference between "no card was found" and "this build could never have - used one", which is the difference between a machine to shrug at and a - download to replace. + A missing GPU backend and one that failed to load look the same here. + This cannot establish which backends the binary was built to support. """ if isinstance(state, Accel): state = state._asdict() diff --git a/dikte/hub.py b/dikte/hub.py index f5da71a..8c19f5f 100644 --- a/dikte/hub.py +++ b/dikte/hub.py @@ -121,6 +121,13 @@ def _digest(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): """(tag, [Item]) for one GitHub release, newest when no tag is given.""" 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) if not isinstance(data, dict) or not data.get("assets"): raise HubError(t("{repo} has no downloadable release.", repo=repo)) - assets = [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["assets"] if a.get("browser_download_url")] - return data.get("tag_name") or tag, assets + return data.get("tag_name") or tag, _assets(data) + + +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): diff --git a/dikte/i18n.py b/dikte/i18n.py index 6f04115..777fe23 100644 --- a/dikte/i18n.py +++ b/dikte/i18n.py @@ -189,8 +189,10 @@ TR = { "Restore the previous clipboard after pasting": "Yapıştırdıktan sonra eski pano içeriğini geri koy", "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)", + "Move it when the active screen changes": + "Etkin ekran değiştiğinde göstergeyi de taşı", "Indicator corner": "Gösterge köşesi", "bottom-left": "sol-alt", "bottom-right": "sağ-alt", @@ -228,6 +230,11 @@ TR = { "Transcript cleanup": "Transkripti temizleme", "API key": "API anahtarı", "Model": "Model", + "Audio file model": "Ses dosyası modeli", + "The model a timestamped audio file (subtitles) is sent to. Not every model on " + "OpenRouter returns segment times; empty means openai/whisper-1.": + "Zaman damgalı bir ses dosyasının (altyazı) gönderildiği model. OpenRouter'daki her " + "model segment zamanı döndürmez; boşsa openai/whisper-1 kullanılır.", "Provider": "Sağlayıcı", "sk-… (falls back to OPENAI_API_KEY)": "sk-… (boşsa OPENAI_API_KEY kullanılır)", "gsk_… (falls back to GROQ_API_KEY)": "gsk_… (boşsa GROQ_API_KEY kullanılır)", @@ -775,6 +782,26 @@ TR = { "On this machine": "Bu makinede", "Use the graphics card": "Ekran kartını kullan", "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 model": "Yerel model", "Not loaded.": "Yüklü değil.", @@ -783,31 +810,105 @@ TR = { "Loaded on the graphics card ({detail}).": "Ekran kartına yüklendi ({detail}).", "Loaded on the processor ({detail}).": "İşlemciye yüklendi ({detail}).", - "Loaded on the processor: the build Dikte downloaded carries no graphics " - "backend. A {binary} from your own system is used ahead of it, so " - "installing one is what reaches the card.": - "İşlemciye yüklendi: Dikte'nin indirdiği sürümde ekran kartı arka ucu " - "yok. Sistemdeki bir {binary} indirilene tercih edilir, yani karta " - "ulaşmanın yolu onu kurmak.", - "Loaded on the processor: this {binary} carries no graphics backend, so " - "the box above cannot change that.": - "İşlemciye yüklendi: bu {binary} ekran kartı arka ucu taşımıyor, " - "yukarıdaki kutu bunu değiştiremez.", - "Loaded on the processor: the graphics card is switched on, but none was " - "found.": - "İşlemciye yüklendi: ekran kartı açık, ama bulunamadı.", + "Loaded on the processor: only the CPU backend was loaded. Check the " + "server log for graphics backend or driver errors.": + "İşlemciye yüklendi: yalnızca CPU arka ucu yüklendi. Ekran kartı arka " + "ucu veya sürücü hataları için sunucu günlüğünü kontrol edin.", + "Loaded on the processor: the graphics card is switched on, but could not " + "be used.": + "İşlemciye yüklendi: ekran kartı açık, ama kullanılamadı.", "Not installed.": "Kurulu değil.", "Installed on the system: {path}": "Sistemde kurulu: {path}", + "Download again": "Yeniden indir", "Downloaded, version {version}.": "İndirildi, sürüm {version}.", + "Downloaded, version {version}. There was no Vulkan build, " + "so this one runs on the processor.": + "İndirildi, sürüm {version}. Vulkan sürümü yoktu, bu sürüm işlemcide çalışıyor.", "Fetching the model list…": "Model listesi çekiliyor…", "Downloading…": "İndiriliyor…", + "Starting the download…": "İndirme başlatılıyor…", "Downloading: {done} of {total}{share}": "İndiriliyor: {done} / {total}{share}", "Download stopped.": "İndirme durduruldu.", "Ready: {name}.": "Hazır: {name}.", "Nothing downloaded yet.": "Henüz bir şey 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", "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 {name} from this machine?": "{name} bu makineden silinsin mi?", "Runs on this machine, on llama.cpp.": "Bu makinede, llama.cpp üzerinde çalışır.", @@ -891,6 +992,10 @@ TR = { "“Off”.": "Temizleme modeli bütün yanıtını düşünmeye harcadı. Düşünme'yi " "“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 --------------------------------------- "Audio recorder stopped before receiving sound": diff --git a/dikte/overlay.py b/dikte/overlay.py index 4993681..79601c7 100644 --- a/dikte/overlay.py +++ b/dikte/overlay.py @@ -1,6 +1,7 @@ """The small recording indicator that appears in a screen corner without taking focus.""" import math +import os import sys from PyQt6.QtCore import Qt, QTimer, QRectF, QPointF @@ -15,6 +16,7 @@ MIN_WIDTH = 210 MAX_WIDTH = 460 MARGIN = 28 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) BORDER = QColor(255, 255, 255, 28) @@ -37,16 +39,68 @@ STATE_COLORS = {"recording": REC, "asking": ASK, "meeting": REC, "busy": BUSY, 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): """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 under way at the same time and still both be visible.""" def __init__(self, corner="bottom-left", below=None, dismissable=False, - screen_name=""): + screen_name="", follow_pointer=False): super().__init__(None) self.corner = corner 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 # 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 @@ -65,6 +119,8 @@ class Overlay(QWidget): self.seconds = 0.0 self._phase = 0.0 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 = ( Qt.WindowType.FramelessWindowHint @@ -252,16 +308,52 @@ class Overlay(QWidget): min(MAX_WIDTH, metrics.horizontalAdvance(self.message) + extra)) self.resize(width, HEIGHT) - def _reposition(self): - # The screen the settings name, or, when none is named or it is not - # plugged in right now, where the user actually is. Names are connector - # names on X11 and model names on macOS, where two identical monitors - # can share one; the first then wins. - screen = next( + def _screen(self): + """The screen this indicator belongs on right now. + + The one the settings name, or, when none is named or it is not plugged + in right now, where the user actually is. Names are connector names on + 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), 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() left = "left" in self.corner top = "top" in self.corner @@ -277,8 +369,10 @@ class Overlay(QWidget): def _tick(self): self._phase += 0.12 # 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. - if self.below is not None and self.below.showing != self._stacked: + # the corner when it does rather than leaving a gap where it was. And + # 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() if self.state in LIVE and not self.paused: # keep the ribbon moving even through a pause in speech diff --git a/dikte/settings_ui.py b/dikte/settings_ui.py index 02d1671..f9cab12 100644 --- a/dikte/settings_ui.py +++ b/dikte/settings_ui.py @@ -181,18 +181,34 @@ class WrappedLabel(QLabel): super().setText(text) 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): super().resizeEvent(event) self._fit() 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 # 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 - box = QRect(0, 0, self.width(), 0) - self.setMinimumHeight( - self.fontMetrics().boundingRect(box, wrap, self.text()).height()) + wrap = Qt.TextFlag.TextWordWrap | Qt.TextFlag.TextWrapAnywhere + box = QRect(0, 0, self.width(), 0) + self.setMinimumHeight( + self.fontMetrics().boundingRect(box, wrap, self.text()).height()) class WheelGuard(QObject): @@ -247,8 +263,24 @@ class LocalModelBox(QGroupBox): self._pending = False self._stop = False 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.setFieldGrowthPolicy( + QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) self.program_label = WrappedLabel() self.install_button = QPushButton(t("Download")) @@ -256,15 +288,55 @@ class LocalModelBox(QGroupBox): form.addRow(t("Program"), self._side_by_side(self.program_label, 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: self.repo = QComboBox() self.repo.setEditable(True) self.repo.setToolTip(t("A Hugging Face repository of GGUF files. The " "list is fetched; any other one can be typed in.")) 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.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.clicked.connect(self._download) self.delete_button = QPushButton(t("Delete")) @@ -327,14 +399,16 @@ class LocalModelBox(QGroupBox): """ self._wanted = model self._pending = True + self._answered = False self._show_program() + self._chosen_in = "" if self._repos is not None: + suggested = ggml.suggested_llm() + self._chosen_in = repo or suggested[0] self.repo.blockSignals(True) - self.repo.clear() - self.repo.addItems(list(ggml.SUGGESTED_LLM)) - self.repo.setCurrentText(repo or ggml.SUGGESTED_LLM[0]) + self.repo.setCurrentText(self._chosen_in) self.repo.blockSignals(False) - self._fit_popup(self.repo) + self._fill_repos_box(suggested) self._fill_models([]) def showEvent(self, event): @@ -349,88 +423,263 @@ class LocalModelBox(QGroupBox): path = ggml.program_path(self.program) if not path: self.program_label.setText(t("Not installed.")) + self.install_button.setText(t("Download")) self.install_button.setVisible(True) return - self.install_button.setVisible(not ggml.installed_program(self.program) - and not ggml.system_program(self.program)) + # A copy that is here is not a copy that is right. whisper.cpp releases + # every few weeks, and a graphics card installed after Dikte was + # changes which build this machine should be running; the button was + # hidden the moment anything landed, and nothing else on this window + # asks for the download again. + self.install_button.setText(t("Download again") + if ggml.installed_program(self.program) + else t("Download")) + self.install_button.setVisible(not ggml.system_program(self.program)) if ggml.system_program(self.program): # Worth saying which one is running: a distribution package is built # for this machine and may reach the graphics card, while the # released binaries carry processor backends only. self.program_label.setText(t("Installed on the system: {path}", path=path)) + elif ggml.vulkan_missing(self.program): + # The download landed the processor build where the graphics card + # one belongs, and nothing else on this window would say so. + self.program_label.setText( + t("Downloaded, version {version}. There was no Vulkan build, " + "so this one runs on the processor.", + version=ggml.installed_version(self.program) or "?")) else: self.program_label.setText( t("Downloaded, version {version}.", 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 ------------------------------------------------------- def _fill_repos(self, current): def work(): - self._listed.emit([("repos", ggml.llm_repos())], "") + self._listed.emit([("repos", ggml.llm_repos(), "")], "") 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): + 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: self._fetch_models(self.repository()) def _fetch_models(self, repo=""): + self._answered = False self.status.setText(t("Fetching the model list…")) def work(): try: 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: - self._listed.emit([], str(exc)) + self._listed.emit([("models", [], repo)], str(exc)) threading.Thread(target=work, daemon=True).start() 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: - 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.status.setText(error) return - kind, found = payload[0] if kind == "repos": - current = self.repo.currentText() - self.repo.blockSignals(True) - self.repo.clear() - self.repo.addItems(found) - self.repo.setCurrentText(current) - self.repo.blockSignals(False) - self._fit_popup(self.repo) + self._fill_repos_box(found) return + self._answered = True 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): - """One row per model, saying what it weighs and whether it is here.""" - wanted = self._wanted or self.selected() - here = [name for name in (self._model_path(i.name).name for i in items)] + """One row per model, grouped, saying what it weighs and where it is.""" + # The selection is only worth carrying over within the publisher it was + # 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.clear() - for item, name in zip(items, here): - mark = (t("downloaded") if ggml.have_model(self._model_path(item.name)) - else ggml.human_size(item.size)) - self.model.addItem(f"{name} ({mark})", name) - self.model.setItemData(self.model.count() - 1, item, Qt.ItemDataRole.UserRole + 1) + listed = set() + for heading, group in self._sections(items, best): + if heading: + self._add_heading(heading) + 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 - # still on this disk and still works, so it stays on offer. - for name in self._on_disk(): - if self.model.findData(name) < 0: - self.model.addItem(f"{name} ({t('downloaded')})", name) - # And one that is chosen but not here, because the file was deleted from - # underneath or the settings came from another machine, stays chosen: - # Save reads this box, and a row missing here would quietly empty the - # setting rather than showing that the model needs downloading again. - if wanted and self.model.findData(wanted) < 0: - self.model.addItem(f"{wanted} ({t('not downloaded')})", wanted) + # still on this disk and still works, so it stays on offer. So does one + # that is chosen but not here: Save reads this box, and a row missing + # here would quietly empty the setting rather than showing that the + # model needs downloading again. + extras = [(t("Already on this machine"), + [name for name in self._on_disk() if name not in listed])] + if wanted and wanted not in listed \ + and not ggml.have_model(self._model_path(wanted)): + extras.append((t("Chosen, but 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) - self.model.setCurrentIndex(max(index, 0)) + self.model.setCurrentIndex(index if index >= 0 else self._first_model()) self.model.blockSignals(False) self._fit_popup(self.model) self._wanted = "" + self._chosen_in = self.repository() self._model_changed() def _on_disk(self): @@ -459,6 +708,9 @@ class LocalModelBox(QGroupBox): self._show_program() if 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() def _current_item(self): @@ -467,12 +719,21 @@ class LocalModelBox(QGroupBox): def _download(self): if self._downloading: 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 item = self._current_item() if item is None: return self._downloading, self._stop = True, False 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(): try: @@ -518,10 +779,17 @@ class LocalModelBox(QGroupBox): def _fill_models_from_current(self): """Redraw the rows without asking anybody anything again.""" - items = [self.model.itemData(i, Qt.ItemDataRole.UserRole + 1) - for i in range(self.model.count())] + # By name, because the recommended model has a row of its own at the + # 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._fill_models([i for i in items if i is not None]) + self._fill_models(items) def _delete(self): name = self.selected() @@ -545,15 +813,41 @@ class LocalModelBox(QGroupBox): def _refresh_buttons(self): name = self.selected() 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.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: 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.")) + 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: 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: self.status.setText(t("{name} has not been downloaded yet.", name=name)) @@ -716,20 +1010,11 @@ class SettingsWindow(QDialog): return t("Loaded on the graphics card ({detail}).", detail=detail) if not entry.get("gpu_wanted"): return t("Loaded on the processor ({detail}).", detail=detail) - if not ggml.cpu_only_build(entry): + if not ggml.cpu_only_loaded(entry): return t("Loaded on the processor: the graphics card is switched " - "on, but none was found.") - # The one case with a fix worth naming: whisper.cpp publishes no build - # that can reach a card on most systems, and Dikte runs a system copy - # ahead of its own, so installing one is the whole remedy. - if entry.get("downloaded"): - return t("Loaded on the processor: the build Dikte downloaded " - "carries no graphics backend. A {binary} from your own " - "system is used ahead of it, so installing one is what " - "reaches the card.", binary=program.binary) - return t("Loaded on the processor: this {binary} carries no graphics " - "backend, so the box above cannot change that.", - binary=program.binary) + "on, but could not be used.") + return t("Loaded on the processor: only the CPU backend was loaded. " + "Check the server log for graphics backend or driver errors.") def _scrolled(self, page): """A tab that scrolls instead of growing the window to fit.""" @@ -770,6 +1055,8 @@ class SettingsWindow(QDialog): def _general_tab(self): page = QWidget() form = QFormLayout(page) + form.setFieldGrowthPolicy( + QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) self.ui_language = QComboBox() for label, code in UI_LANGUAGES: @@ -861,7 +1148,11 @@ class SettingsWindow(QDialog): form = QFormLayout(page) 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(): # The native resolution, so that a scaled 4K screen reads # 3840 × 2160 and not the 1920 × 1080 Qt sees through the scale. @@ -875,12 +1166,26 @@ class SettingsWindow(QDialog): ) 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() for value in CORNERS: self.corner.addItem(t(value), value) form.addRow(t("Indicator corner"), self.corner) return page + def _sync_follow_pointer(self): + self.follow_pointer.setEnabled(not self.indicator_screen.currentData()) + def _api_tab(self): page = QWidget() outer = QVBoxLayout(page) @@ -908,6 +1213,8 @@ class SettingsWindow(QDialog): stt = QGroupBox(t("Speech to text")) stt_form = QFormLayout(stt) + stt_form.setFieldGrowthPolicy( + QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) self.transcribe_provider = QComboBox() for label, value in TRANSCRIBE_PROVIDERS: self.transcribe_provider.addItem(t(label), value) @@ -924,6 +1231,15 @@ class SettingsWindow(QDialog): self.transcribe_model_row = self._row(self.transcribe_model, self.refresh_transcribe_models) stt_form.addRow(t("Model"), self.transcribe_model_row) + # OpenRouter only: which of its models a timestamped run asks for. + self.file_model = QComboBox() + self.file_model.setEditable(True) + self.file_model.lineEdit().setPlaceholderText(api.OPENROUTER_FILE_MODEL) + self.file_model.setToolTip( + t("The model a timestamped audio file (subtitles) is sent to. Not every model " + "on OpenRouter returns segment times; empty means openai/whisper-1.")) + self.file_model_row = self._row(self.file_model) + stt_form.addRow(t("Audio file model"), self.file_model_row) # A spanning row: in the narrow field column a wrapped label gets a # height that fits one line, and the rest of the text is cut off. self.transcribe_status = QLabel("") @@ -946,13 +1262,13 @@ class SettingsWindow(QDialog): "spends that once instead of on the first dictation, at the cost of " "the memory it sits in.")) self.local_threads = QSpinBox() - self.local_threads.setRange(0, 64) + max_threads = max(1, os.cpu_count() or 1) + self.local_threads.setRange(0, max_threads) self.local_threads.setSpecialValueText(t("Automatic")) - # A spin box asks for room for its numbers, and 64 is two characters: - # the word standing in for zero is what actually has to fit, and on - # macOS, where the stepper sits inside the frame, it does not. Widened - # to the word rather than to a number picked by eye, so that it still - # fits once the word is "Otomatik". + # A spin box asks for room for its numbers, and the word standing in for + # zero is what actually has to fit, and on macOS, where the stepper sits + # inside the frame, it does not. Widened to the word rather than to a + # number picked by eye, so that it still fits once the word is "Otomatik". self.local_threads.setMinimumWidth( self.local_threads.fontMetrics() .horizontalAdvance(t("Automatic")) + 56) @@ -975,6 +1291,8 @@ class SettingsWindow(QDialog): orr = QGroupBox(t("Transcript cleanup")) orr_form = self.cleanup_form = QFormLayout(orr) + orr_form.setFieldGrowthPolicy( + QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) self.cleanup_enabled = QCheckBox(t("Clean the transcript with a model")) orr_form.addRow("", self.cleanup_enabled) @@ -1074,9 +1392,47 @@ class SettingsWindow(QDialog): orr_form.addRow(self.local_llm_state) 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) 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): page = QWidget() layout = QVBoxLayout(page) @@ -1172,6 +1528,8 @@ class SettingsWindow(QDialog): # be worse than none. self.claude_box = QGroupBox(t("Claude Code")) claude_form = QFormLayout(self.claude_box) + claude_form.setFieldGrowthPolicy( + QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) self.assistant_model = QComboBox() self.assistant_model.setEditable(True) self.assistant_model.addItems(ASSISTANT_MODELS) @@ -1189,6 +1547,8 @@ class SettingsWindow(QDialog): self.codex_box = QGroupBox(t("Codex")) codex_form = QFormLayout(self.codex_box) + codex_form.setFieldGrowthPolicy( + QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) self.assistant_codex_model = QComboBox() self.assistant_codex_model.setEditable(True) self.assistant_codex_model.addItem(t("Codex's own default"), "") @@ -1204,6 +1564,8 @@ class SettingsWindow(QDialog): self.openrouter_box = QGroupBox("OpenRouter") or_form = QFormLayout(self.openrouter_box) + or_form.setFieldGrowthPolicy( + QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) self.assistant_openrouter_model = QComboBox() self.assistant_openrouter_model.setEditable(True) self.assistant_openrouter_model.addItems(ASSISTANT_OR_MODELS) @@ -1221,6 +1583,8 @@ class SettingsWindow(QDialog): self.agy_box = QGroupBox(t("Antigravity")) agy_form = QFormLayout(self.agy_box) + agy_form.setFieldGrowthPolicy( + QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) self.assistant_agy_model = QComboBox() self.assistant_agy_model.setEditable(True) self.assistant_agy_model.addItem(t("Antigravity's own default"), "") @@ -1239,6 +1603,8 @@ class SettingsWindow(QDialog): self.opencode_box = QGroupBox("OpenCode Go") og_form = QFormLayout(self.opencode_box) + og_form.setFieldGrowthPolicy( + QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) self.assistant_opencode_model = QComboBox() self.assistant_opencode_model.setEditable(True) self.assistant_opencode_model.addItems(OPENCODE_MODELS) @@ -1378,6 +1744,8 @@ class SettingsWindow(QDialog): models = QGroupBox(t("Minutes")) models_form = QFormLayout(models) + models_form.setFieldGrowthPolicy( + QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) self.meeting_model = QComboBox() self.meeting_model.setEditable(True) self.meeting_model.addItems(MEETING_MODELS) @@ -1560,6 +1928,8 @@ class SettingsWindow(QDialog): # and two combination boxes starting at different places read as two # unrelated settings rather than the pair they are. form = QFormLayout() + form.setFieldGrowthPolicy( + QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) self._shortcut_row( form, "toggle", t("Start and stop"), t("No global shortcut installed."), placeholder="Ctrl+Space", @@ -1798,6 +2168,8 @@ class SettingsWindow(QDialog): 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._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.max_seconds.setValue(conf["max_seconds"]) self.skip_silent.setChecked(conf["skip_silent"]) @@ -1815,6 +2187,7 @@ class SettingsWindow(QDialog): self._shown_provider = "" self._select_data(self.transcribe_provider, conf["transcribe_provider"]) self._provider_changed() # selecting index 0 fires no signal + self.file_model.setCurrentText(conf["openrouter_file_model"]) self.local_gpu.setChecked(conf["local_gpu"]) self.local_preload.setChecked(conf["local_preload"]) self.local_threads.setValue(int(conf["local_threads"])) @@ -1840,6 +2213,9 @@ class SettingsWindow(QDialog): self.local_llm_preload.setChecked(conf["local_llm_preload"]) 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_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 # language switch the boxes still hold the old language's default, and # comparing against the new one would store that text as a custom @@ -1917,6 +2293,9 @@ class SettingsWindow(QDialog): conf["paste_shortcut"] = self.paste_shortcut.currentText().strip() conf["restore_clipboard"] = self.restore_clipboard.isChecked() 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["max_seconds"] = self.max_seconds.value() conf["skip_silent"] = self.skip_silent.isChecked() @@ -1932,6 +2311,7 @@ class SettingsWindow(QDialog): for name, who in cfg.TRANSCRIBERS.items(): conf[who.key] = self._key_fields[name].text().strip() conf[who.model] = self._models[name].strip() or cfg.DEFAULTS[who.model] + conf["openrouter_file_model"] = self.file_model.currentText().strip() conf["gemini_api_key"] = self.gemini_key.text().strip() conf["opencode_api_key"] = self.opencode_key.text().strip() conf["local_model"] = self.local_whisper.selected() @@ -1967,6 +2347,8 @@ class SettingsWindow(QDialog): conf["local_llm_gpu"] = self.local_llm_gpu.isChecked() conf["local_llm_preload"] = self.local_llm_preload.isChecked() 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 # loaded with, or today's (a Reset click in a session that switched @@ -2105,17 +2487,27 @@ class SettingsWindow(QDialog): self._shown_provider = provider local = provider == "local" self.stt_form.setRowVisible(self.transcribe_model_row, not local) + self.stt_form.setRowVisible(self.file_model_row, provider == "openrouter") self.stt_form.setRowVisible(self.transcribe_status, not local) self.stt_form.setRowVisible(self.local_whisper, local) self.stt_form.setRowVisible(self.local_options, local) self.stt_form.setRowVisible(self.local_state, local) + self._refresh_local_box() if local: return self.transcribe_model.clear() self.transcribe_model.addItems(TRANSCRIBE_MODELS[provider]) self.transcribe_model.setCurrentText(self._models[provider]) + if provider == "openrouter": + self._fill_file_models(TRANSCRIBE_MODELS[provider]) self.transcribe_status.setText("") + def _fill_file_models(self, models): + current = self.file_model.currentText() + self.file_model.clear() + self.file_model.addItems(models) + self.file_model.setCurrentText(current) + def _load_transcribe_models(self): """The model list of whichever provider is selected.""" provider = self.transcribe_provider.currentData() or "openai" @@ -2144,6 +2536,8 @@ class SettingsWindow(QDialog): self.transcribe_model.clear() self.transcribe_model.addItems(models) self.transcribe_model.setCurrentText(current) + if self._shown_provider == "openrouter": + self._fill_file_models(models) self.transcribe_status.setText(t("{count} models loaded.", count=len(models))) def _load_models(self): @@ -2608,6 +3002,7 @@ class SettingsWindow(QDialog): 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_state, provider == "local") + self._refresh_local_box() binary = cleanup.executable(provider) found = shutil.which(binary) if binary else "" if provider == "local": diff --git a/dikte/worker.py b/dikte/worker.py index e4fcca8..48fb82b 100644 --- a/dikte/worker.py +++ b/dikte/worker.py @@ -37,7 +37,7 @@ _paste_lock = threading.Lock() class Pipeline(QObject): stage = pyqtSignal(str) # human-readable progress line - finished = pyqtSignal(str, str, str) # raw transcript, final text, warning + finished = pyqtSignal(str, str, str, str) # raw, final text, warning, language failed = pyqtSignal(str) cancelled = pyqtSignal() @@ -120,12 +120,24 @@ class Pipeline(QObject): try: self.stage.emit(t("Transcribing…")) target = conf.transcribe_target() - raw = api.transcribe( - target, - wav_path, - language=conf["language"], - prompt=conf["transcribe_prompt"], - ) + # The spoken language is only knowable after the fact, and only the + # local server says what it heard: auto mode asks it there, and + # every other run (a fixed language, or a hosted provider that + # detects but stays silent) transcribes as before. + auto = conf["language"] == "auto" + if auto: + raw, detected = api.transcribe_detected( + target, wav_path, language=conf["language"], + prompt=conf["transcribe_prompt"], + ) + else: + raw = api.transcribe( + target, + wav_path, + language=conf["language"], + prompt=conf["transcribe_prompt"], + ) + detected = "" if conf["filter_hallucinations"] and vad.looks_like_hallucination(raw, duration): self._discard(wav_path) @@ -134,6 +146,10 @@ class Pipeline(QObject): text = raw warning = "" + # The language the run actually spoke, reported to the window, the + # clipboard path and the history alike: the detected code, or the + # configured one when nothing was detected to replace it. + speech_language = detected or conf["language"] # Remembered rather than re-derived at the history write below: the # ask path runs cleanup under a different setting, and the record # should say what happened, not what one of the two gates implies. @@ -145,7 +161,7 @@ class Pipeline(QObject): self.stage.emit(t("Cleaning up…")) cleaned = True try: - text = cleanup.run(raw, conf, conf.cleanup_prompt()) + text = cleanup.run(raw, conf, conf.cleanup_prompt(speech=detected)) except api.ApiError as exc: # Keep the transcript, but never let the failure pass unseen: # a rejected key would otherwise look like working dictation. @@ -183,6 +199,7 @@ class Pipeline(QObject): "question": question, "assistant": assistant.provider(conf) if ask else "", "assistant_model": assistant.model(conf) if ask else "", + "speech_language": speech_language, "raw": raw, "text": text, } @@ -221,7 +238,7 @@ class Pipeline(QObject): time.sleep(0.35) paste.copy_bytes(previous) - self.finished.emit(raw, text, warning) + self.finished.emit(raw, text, warning, speech_language) except assistant.Cancelled: self.cancelled.emit() diff --git a/packaging/whisper-vulkan/Dockerfile.build b/packaging/whisper-vulkan/Dockerfile.build new file mode 100644 index 0000000..fae985b --- /dev/null +++ b/packaging/whisper-vulkan/Dockerfile.build @@ -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 diff --git a/packaging/whisper-vulkan/Dockerfile.runtime-cpu b/packaging/whisper-vulkan/Dockerfile.runtime-cpu new file mode 100644 index 0000000..89ac704 --- /dev/null +++ b/packaging/whisper-vulkan/Dockerfile.runtime-cpu @@ -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 diff --git a/packaging/whisper-vulkan/Dockerfile.runtime-noicd b/packaging/whisper-vulkan/Dockerfile.runtime-noicd new file mode 100644 index 0000000..bda8b71 --- /dev/null +++ b/packaging/whisper-vulkan/Dockerfile.runtime-noicd @@ -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 diff --git a/packaging/whisper-vulkan/Dockerfile.runtime-vulkan b/packaging/whisper-vulkan/Dockerfile.runtime-vulkan new file mode 100644 index 0000000..3a16288 --- /dev/null +++ b/packaging/whisper-vulkan/Dockerfile.runtime-vulkan @@ -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 diff --git a/packaging/whisper-vulkan/README.md b/packaging/whisper-vulkan/README.md new file mode 100644 index 0000000..371b7c9 --- /dev/null +++ b/packaging/whisper-vulkan/README.md @@ -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`, 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. diff --git a/packaging/whisper-vulkan/build-package.sh b/packaging/whisper-vulkan/build-package.sh new file mode 100755 index 0000000..f4e2215 --- /dev/null +++ b/packaging/whisper-vulkan/build-package.sh @@ -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" < "$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" diff --git a/packaging/whisper-vulkan/licenses/cpp-httplib-MIT.txt b/packaging/whisper-vulkan/licenses/cpp-httplib-MIT.txt new file mode 100644 index 0000000..47c418e --- /dev/null +++ b/packaging/whisper-vulkan/licenses/cpp-httplib-MIT.txt @@ -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. diff --git a/packaging/whisper-vulkan/licenses/nlohmann-json-MIT.txt b/packaging/whisper-vulkan/licenses/nlohmann-json-MIT.txt new file mode 100644 index 0000000..70c6af6 --- /dev/null +++ b/packaging/whisper-vulkan/licenses/nlohmann-json-MIT.txt @@ -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. diff --git a/packaging/whisper-vulkan/lunarg-signing-key-pub.asc b/packaging/whisper-vulkan/lunarg-signing-key-pub.asc new file mode 100644 index 0000000..59b5d72 --- /dev/null +++ b/packaging/whisper-vulkan/lunarg-signing-key-pub.asc @@ -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----- diff --git a/packaging/whisper-vulkan/make-sbom.py b/packaging/whisper-vulkan/make-sbom.py new file mode 100755 index 0000000..1dcf99c --- /dev/null +++ b/packaging/whisper-vulkan/make-sbom.py @@ -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/ggml@0.20.2" +httplib_ref = "pkg:github/yhirose/cpp-httplib@0.20.0" +json_ref = "pkg:github/nlohmann/json@3.11.2" + +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() diff --git a/packaging/whisper-vulkan/smoke-runtime.sh b/packaging/whisper-vulkan/smoke-runtime.sh new file mode 100755 index 0000000..ac71319 --- /dev/null +++ b/packaging/whisper-vulkan/smoke-runtime.sh @@ -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[@]}" diff --git a/packaging/whisper-vulkan/validate-package.sh b/packaging/whisper-vulkan/validate-package.sh new file mode 100755 index 0000000..d21a083 --- /dev/null +++ b/packaging/whisper-vulkan/validate-package.sh @@ -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" diff --git a/tests/support.py b/tests/support.py index 5ee5f62..072e0f0 100644 --- a/tests/support.py +++ b/tests/support.py @@ -112,6 +112,10 @@ class DikteTest(unittest.TestCase): 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 # instance is running. A test that reaches it would take the whole run # with it and hang, so it fails loudly here instead. diff --git a/tests/test_api.py b/tests/test_api.py index c914017..75da6da 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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. """ +import contextlib import http.server import json import os @@ -53,6 +54,16 @@ class TimestampModel(unittest.TestCase): self.assertEqual(api.timestamp_model("openai", "gpt-4o-transcribe"), "whisper-1") + def test_openrouter_takes_the_file_model_that_was_set(self): + self.assertEqual( + api.timestamp_model("openrouter", "openai/gpt-4o-transcribe", + "openai/whisper-large-v3"), + "openai/whisper-large-v3") + + def test_openrouter_with_no_file_model_falls_back_to_whisper(self): + self.assertEqual(api.timestamp_model("openrouter", "openai/gpt-4o-transcribe", ""), + "openai/whisper-1") + class Explain(DikteTest): def error(self, status): @@ -311,13 +322,25 @@ class TranscribeSegments(DikteTest): fields = multipart_fields(calls[0]) self.assertEqual(fields["model"], "whisper-1") self.assertEqual(fields["response_format"], "verbose_json") - self.assertEqual(fields["timestamp_granularities[]"], "segment") + # Both are asked for: whisper answers with segments, and a model that + # does not mark them still answers with word times. + body = calls[0].data.decode("utf-8", "replace") + for level in ("segment", "word"): + self.assertIn( + f'name="timestamp_granularities[]"\r\n\r\n{level}\r\n', body) def test_openrouter_uses_the_namespaced_id(self): with fake_urlopen(self.reply([{"start": 0, "end": 1, "text": "hi"}])) as calls: api.transcribe_segments(OPENROUTER, self.wav) self.assertEqual(multipart_fields(calls[0])["model"], "openai/whisper-1") + def test_openrouter_asks_for_the_file_model_when_one_is_set(self): + target = OPENROUTER._replace(file_model="mistralai/voxtral-mini-transcribe") + with fake_urlopen(self.reply([{"start": 0, "end": 1, "text": "hi"}])) as calls: + api.transcribe_segments(target, self.wav) + self.assertEqual(multipart_fields(calls[0])["model"], + "mistralai/voxtral-mini-transcribe") + def test_groq_stays_on_the_model_it_was_given(self): target = GROQ._replace(model="whisper-large-v3") with fake_urlopen(self.reply([{"start": 0, "end": 1, "text": "hi"}])) as calls: @@ -345,6 +368,74 @@ class TranscribeSegments(DikteTest): self.assertEqual(api.transcribe_segments(OPENAI, self.wav), [(5.0, 5.0, "hi")]) + def test_a_long_sentence_is_broken_where_it_gets_too_long_to_read(self): + words = [{"word": "word", "start": i * 0.2, "end": i * 0.2 + 0.2} + for i in range(60)] + cues = api.cues_from_words(words) + self.assertGreater(len(cues), 1) + for start, end, text in cues: + self.assertLessEqual(len(text), api.MAX_CUE_CHARS) + self.assertLessEqual(end - start, api.MAX_CUE_SECONDS + 0.2) + + def test_a_pause_between_short_sentences_does_not_join_them(self): + cues = api.cues_from_words([ + {"word": "Yes.", "start": 0.0, "end": 0.3}, + {"word": "No.", "start": 9.0, "end": 9.3}, + ]) + self.assertEqual([(start, text) for start, _, text in cues], + [(0.0, "Yes."), (9.0, "No.")]) + + def test_a_cue_too_short_to_read_is_held_until_the_next_one(self): + cues = api.cues_from_words([ + {"word": "Yes.", "start": 0.0, "end": 0.3}, + {"word": "No.", "start": 9.0, "end": 9.3}, + ]) + # The first has the room for it, the last has nothing after it to wait for. + self.assertEqual(cues[0][1], api.MIN_CUE_SECONDS) + self.assertEqual(cues[1][1], 9.0 + api.MIN_CUE_SECONDS) + + def test_a_list_marker_does_not_end_a_cue_on_its_own(self): + cues = api.cues_from_words([ + {"word": "1.", "start": 0.0, "end": 0.2}, + {"word": "Antivirus.", "start": 0.4, "end": 1.6}, + ]) + self.assertEqual([text for _, _, text in cues], ["1. Antivirus."]) + + def test_a_sentence_ending_inside_a_quote_still_ends_the_cue(self): + cues = api.cues_from_words([ + {"word": '"Stop', "start": 0.0, "end": 1.0}, + {"word": 'there."', "start": 1.1, "end": 2.0}, + {"word": "Then", "start": 2.2, "end": 2.6}, + ]) + self.assertEqual([text for _, _, text in cues], + ['"Stop there."', "Then"]) + + def test_word_times_take_over_from_segments_too_long_to_read(self): + # What a model that does not mark segments answers with: one entry for + # the whole file, and the real timing in the words beside it. + reply = { + "text": "One. Two.", + "segments": [{"start": 0, "end": 60, "text": "One. Two."}], + "words": [ + {"word": "One.", "start": 0.1, "end": 1.5}, + {"word": "Two.", "start": 1.7, "end": 3.0}, + ], + } + with fake_urlopen(reply): + self.assertEqual(api.transcribe_segments(OPENAI, self.wav), + [(0.1, 1.5, "One."), (1.7, 3.0, "Two.")]) + + def test_whisper_segments_are_left_alone_when_words_come_too(self): + reply = { + "text": "hi there", + "segments": [{"start": 0, "end": 2, "text": "hi there"}], + "words": [{"word": "hi", "start": 0.0, "end": 0.5}, + {"word": "there", "start": 0.5, "end": 2.0}], + } + with fake_urlopen(reply): + self.assertEqual(api.transcribe_segments(OPENAI, self.wav), + [(0.0, 2.0, "hi there")]) + def test_a_model_that_returned_no_segments_still_gives_its_text(self): with fake_urlopen(self.reply([], text="the whole thing")): self.assertEqual(api.transcribe_segments(OPENAI, self.wav), @@ -446,6 +537,29 @@ class Cleanup(DikteTest): with fake_urlopen(chat_reply(" ")), self.assertRaises(api.ApiError): 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): with fake_urlopen(http_error(429)), \ self.assertRaises(api.ApiError) as caught: @@ -454,6 +568,14 @@ class Cleanup(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): history = [{"role": "user", "content": "book it"}, {"role": "assistant", "content": "done"}] @@ -599,11 +721,14 @@ if __name__ == "__main__": class FakeServer: """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.fails = fails self.log = log self.starts = 0 + self.held = 0 + self.context = context def serve(self): self.starts += 1 @@ -611,9 +736,20 @@ class FakeServer: raise ggml.LocalError(self.fails) return self.url + @contextlib.contextmanager + def busy(self): + self.held += 1 + try: + yield + finally: + self.held -= 1 + def error(self): return self.log + def settings(self): + return {"context": self.context} + LOCAL = api.Target("local", "Local whisper", "", "", "ggml-base.bin") @@ -691,6 +827,40 @@ class TranscribeHere(DikteTest): api.transcribe_segments(LOCAL, self.wav) self.assertEqual(multipart_fields(calls[0])["model"], "ggml-base.bin") + # ---- the detected language -------------------------------------------- + + def test_auto_mode_asks_whisper_for_the_detected_language(self): + # The -nlp the server was started with is switched back on for this one + # request, so whisper's verbose_json reports what it heard. + reply = {"text": " Merhaba dünya. ", "detected_language": "turkish"} + with fake_urlopen(reply) as calls: + text, code = api.transcribe_detected(LOCAL, self.wav, language="auto") + fields = multipart_fields(calls[0]) + self.assertEqual(fields["response_format"], "verbose_json") + self.assertEqual(fields["no_language_probabilities"], "false") + self.assertNotIn("language", fields) + self.assertEqual(text, "Merhaba dünya.") + self.assertEqual(code, "tr") + + def test_a_fixed_language_reports_no_detection(self): + with fake_urlopen({"text": "hello"}) as calls: + text, code = api.transcribe_detected(LOCAL, self.wav, language="tr") + self.assertNotIn("no_language_probabilities", multipart_fields(calls[0])) + self.assertEqual(text, "hello") + self.assertEqual(code, "") + + def test_a_detected_language_without_a_code_stays_unknown(self): + with fake_urlopen({"text": "hello", "detected_language": "somali"}): + _text, code = api.transcribe_detected(LOCAL, self.wav, language="auto") + self.assertEqual(code, "") + + def test_a_hosted_auto_run_transcribes_without_detection(self): + with fake_urlopen({"text": "hi"}) as calls: + text, code = api.transcribe_detected(OPENAI, self.wav, language="auto") + self.assertNotIn("no_language_probabilities", multipart_fields(calls[0])) + self.assertEqual(text, "hi") + self.assertEqual(code, "") + class Stopping(unittest.TestCase): """The Stop button, from the far end: a request already blocked on a reply. diff --git a/tests/test_cleanup.py b/tests/test_cleanup.py index 9de3dc3..245053f 100644 --- a/tests/test_cleanup.py +++ b/tests/test_cleanup.py @@ -411,6 +411,52 @@ class Here(DikteTest): cleanup.run("uh, done", self.conf, "the rules") 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): reply = {"choices": [{"message": {"content": "", "reasoning": "hmm"}}]} with fake_urlopen(reply), self.assertRaises(api.ApiError) as caught: diff --git a/tests/test_cli.py b/tests/test_cli.py index 5c9e70e..92246ea 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -9,6 +9,7 @@ socket is faked, and everything that runs locally runs for real. import contextlib import io import json +import sys import unittest import webbrowser from typing import ClassVar @@ -668,7 +669,11 @@ class WithoutAnInstance(DikteTest): def run_verb(self, argv): # launch_gui replaces this process with the application, so it never # 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), \ + mock.patch.object(sys, "stdin", io.StringIO()), \ mock.patch.object(cli, "launch_gui") as launch, \ captured() as (out, err): code = cli.run(argv) @@ -741,6 +746,13 @@ class Replies(DikteTest): self.assertEqual(code, 0) self.assertEqual(out.strip(), "Book it for Thursday.") + def test_the_json_answer_carries_the_detected_language(self): + code, out, _ = self.run_verb( + ["--json", "record"], + {"ok": True, "text": "Selam", "speech_language": "tr"}) + self.assertEqual(code, 0) + self.assertEqual(json.loads(out)["speech_language"], "tr") + def test_a_dictation_that_failed(self): code, out, err = self.run_verb(["stop", "--wait"], {"ok": False, "error": "No speech detected"}) @@ -804,18 +816,19 @@ class LocalModels(DikteTest): line = self.status({"whisper": self.entry( backend="CPU", device="CPU", available=["CPU"])}) self.assertIn("loaded on the processor", line) - self.assertIn("this build carries none", line) + self.assertIn("only the CPU backend was loaded", line) - def test_the_downloaded_build_is_told_where_a_working_one_comes_from(self): + def test_a_download_is_not_assumed_to_lack_gpu_support(self): line = self.status({"whisper": self.entry( backend="CPU", device="CPU", available=["CPU"], downloaded=True)}) - self.assertIn("downloaded build has no GPU backend", line) - self.assertIn("whisper-server on your system", line) + self.assertIn("only the CPU backend was loaded", line) + self.assertIn("driver errors", line) + self.assertNotIn("has no GPU backend", line) def test_a_card_the_build_could_have_used_says_something_else(self): line = self.status({"whisper": self.entry( backend="CPU", device="CPU", available=["CUDA", "CPU"])}) - self.assertIn("none was found", line) + self.assertIn("could not be used", line) self.assertNotIn("carries none", line) def test_a_card_nobody_asked_for_is_not_a_complaint(self): @@ -861,7 +874,31 @@ class LocalModels(DikteTest): "whisper_backend_init_gpu: no GPU found\n") line = self.doctor(transcribe_provider="local", local_gpu=True) self.assertIn("last run on the processor", line) - self.assertIn("this build carries none", line) + self.assertNotIn("this build carries none", line) + self.assertNotIn("check the server log", line) + + def test_old_cpu_log_does_not_diagnose_a_new_system_binary(self): + self.log("load_backend: loaded CPU backend from /old-download.so\n" + "whisper_backend_init_gpu: no GPU found\n") + with mock.patch.object(ggml, "program_path", + return_value="/usr/bin/whisper-server"): + line = self.doctor(transcribe_provider="local", local_gpu=True) + data = self.doctor(as_json=True, transcribe_provider="local", + local_gpu=True) + self.assertIn("last run on the processor", line) + self.assertNotIn("carries none", line) + self.assertNotIn("gpu_wanted", data["local"]["whisper"]) + self.assertNotIn("downloaded", data["local"]["whisper"]) + + def test_enabling_gpu_does_not_reinterpret_a_past_cpu_run(self): + self.log("load_backend: loaded Vulkan backend from /gpu.so\n" + "load_backend: loaded CPU backend from /cpu.so\n" + "whisper_init_with_params_no_state: use gpu = 0\n" + "whisper_backend_init_gpu: no GPU found\n") + line = self.doctor(transcribe_provider="local", local_gpu=True) + self.assertIn("last run on the processor", line) + self.assertNotIn("none was found", line) + self.assertNotIn("could not be used", line) def test_a_run_that_named_no_backend_is_not_read_as_no_run_at_all(self): # A log with nothing recognisable in it still says a server started diff --git a/tests/test_config.py b/tests/test_config.py index 2c6535f..243c6b2 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -220,6 +220,19 @@ class TranscribeTarget(DikteTest): self.assertEqual(target.service, "OpenRouter") self.assertEqual(target.api_key, "sk-or-test") self.assertEqual(target.model, "openai/whisper-1") + self.assertEqual(target.file_model, "") + + def test_openrouter_carries_its_file_model(self): + conf = self.config(transcribe_provider="openrouter", + openrouter_api_key="sk-or-test", + openrouter_file_model=" openai/whisper-large-v3 ") + self.assertEqual(conf.transcribe_target().file_model, + "openai/whisper-large-v3") + + def test_only_openrouter_has_a_file_model(self): + conf = self.config(transcribe_provider="openai", openai_api_key="sk-test", + openrouter_file_model="openai/whisper-large-v3") + self.assertEqual(conf.transcribe_target().file_model, "") def test_groq_when_it_is_picked(self): conf = self.config(transcribe_provider="groq", groq_api_key="gsk-test", @@ -261,6 +274,18 @@ class CleanupPrompt(DikteTest): def test_no_glossary_means_no_rule_about_one(self): self.assertEqual(cfg.Config().cleanup_prompt(), cfg.CLEANUP_PROMPT_EN) + def test_a_detected_turkish_recording_gets_the_turkish_prompt(self): + """Auto mode learns what was heard, and that decides the prompt rather + than the interface language.""" + self.write_config({"ui_language": "en", "transcribe_prompt": "Paraşüt"}) + conf = cfg.Config() + prompt = conf.cleanup_prompt(speech="tr") + self.assertEqual(prompt, cfg.CLEANUP_PROMPT_TR + + cfg.GLOSSARY_RULE_TR.format(glossary="Paraşüt")) + self.assertIn("KONUŞMACININ KULLANDIĞI İSİM VE TERİMLER", prompt) + self.assertIn("NAMES AND TERMS THE SPEAKER USES", + conf.cleanup_prompt(speech="de")) + def test_subtitles_use_their_own_prompt(self): conf = cfg.Config() self.assertNotEqual(conf.cleanup_prompt(subtitles=True), conf.cleanup_prompt()) @@ -676,3 +701,24 @@ class ReadyToRun(DikteTest): self.assertEqual(ggml.whisper.settings()["threads"], 4) self.assertFalse(ggml.whisper.settings()["gpu"]) 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) diff --git a/tests/test_ggml.py b/tests/test_ggml.py index 5f7b777..a183e85 100644 --- a/tests/test_ggml.py +++ b/tests/test_ggml.py @@ -49,6 +49,11 @@ def item(name, data, url="https://example.invalid/f", sha=True): 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 def serving(release, archive): """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 # on every host, including the Mac that checks the macOS backend. self.patch_attr(sys, "platform", "linux") + self.patch_attr(ggml.platform, "machine", lambda: "x86_64") # Built once, because the release listing has to publish its checksum # and a tarball is not the same bytes twice. self.archive = tarball({ @@ -221,6 +227,7 @@ class InstallProgram(Local): def install(self, *names, archive=None): self.patch_attr(ggml, "_arch", lambda: "x64") + self.patch_attr(ggml, "_has_vulkan", lambda: False) blob = self.archive if archive is None else archive with serving(self.release(*names, archive=blob), blob) as calls: path = ggml.install_program(ggml.WHISPER) @@ -238,6 +245,162 @@ class InstallProgram(Local): "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): self.patch_attr(ggml, "_arch", lambda: "x64") with fake_urlopen(self.release("whisper-bin-Win32.zip")): @@ -506,6 +669,53 @@ class Catalogue(Local): with self.assertRaises(ggml.LocalError): 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): self.assertEqual(ggml.installed_whisper_models(), []) path = ggml.whisper_model_path("ggml-base.bin") @@ -558,6 +768,7 @@ load_backend: loaded CPU backend from /usr/lib/ggml/libggml-cpu-haswell.so whisper_model_load: CPU total size = 189.49 MB whisper_backend_init_gpu: device 0: Vulkan0 (type: 1) whisper_backend_init_gpu: found GPU device 0: Vulkan0 (type: 1, cnt: 0) +whisper_backend_init_gpu: using Vulkan0 backend whisper_backend_init_gpu: failed to initialize Vulkan0 backend """ @@ -599,6 +810,7 @@ load_backend: loaded Vulkan backend from /opt/llama/libggml-vulkan.so load_backend: loaded CPU backend from /opt/llama/libggml-cpu.so load_tensors: offloading 28 repeating layers to GPU load_tensors: offloaded 29/29 layers to GPU +load_tensors: Vulkan0 model buffer size = 2048.00 MiB """ LLAMA_CPU = """\ @@ -655,9 +867,9 @@ class WhatItRunsOn(Local): self.assertEqual(accel.backend, "CPU") self.assertEqual(ggml.accel_kind(accel), "cpu") - def test_a_build_with_nothing_but_a_processor_backend_says_so(self): - self.assertTrue(ggml.cpu_only_build(self.read(ggml.WHISPER, WHISPER_CPU))) - self.assertFalse(ggml.cpu_only_build(self.read(ggml.WHISPER, WHISPER_CUDA))) + def test_only_loaded_backends_are_reported(self): + self.assertTrue(ggml.cpu_only_loaded(self.read(ggml.WHISPER, WHISPER_CPU))) + self.assertFalse(ggml.cpu_only_loaded(self.read(ggml.WHISPER, WHISPER_CUDA))) def test_a_card_that_was_found_is_named(self): accel = self.read(ggml.WHISPER, WHISPER_CUDA) @@ -697,6 +909,15 @@ class WhatItRunsOn(Local): self.assertEqual(accel.backend, "CPU") self.assertEqual(ggml.accel_kind(accel), "cpu") + def test_failed_initialisation_overrides_weights_on_the_card(self): + log = WHISPER_GPU_FAILED.replace("CPU total size", "Vulkan0 total size") + self.assertEqual(self.read(ggml.WHISPER, log).backend, "CPU") + + def test_a_later_successful_attempt_replaces_the_failed_one(self): + log = WHISPER_GPU_FAILED + ( + "whisper_backend_init_gpu: using Vulkan0 backend\n") + self.assertEqual(self.read(ggml.WHISPER, log).backend, "Vulkan") + def test_the_card_named_is_the_one_that_ran(self): accel = self.read(ggml.WHISPER, WHISPER_MIXED) self.assertEqual(accel.backend, "CUDA") @@ -723,12 +944,46 @@ class WhatItRunsOn(Local): self.assertEqual(accel.layers, "29/29") self.assertEqual(ggml.accel_detail(accel), "Vulkan, 29/29 layers") + def test_llama_names_the_allocated_device_not_the_first_loaded_backend(self): + log = ( + "load_backend: loaded CUDA backend from /x.so\n" + " Device 0: NVIDIA RTX 4070, compute capability 8.9, VMM: yes\n" + "ggml_vulkan: 0 = Intel UHD Graphics | uma: 1\n" + "ggml_vulkan: 1 = AMD Radeon RX 6600 | uma: 0\n" + "load_tensors: CUDA_Host model buffer size = 32.00 MiB\n" + "load_tensors: Vulkan0 model buffer size = 0.00 MiB\n" + + LLAMA_GPU.replace("Vulkan0 model", "Vulkan1 model")) + accel = self.read(ggml.LLAMA, log) + self.assertEqual(accel.backend, "Vulkan") + self.assertEqual(accel.device, "AMD Radeon RX 6600") + + def test_llama_without_buffer_evidence_does_not_guess_the_backend(self): + log = LLAMA_GPU.replace( + "load_tensors: Vulkan0 model buffer size = 2048.00 MiB\n", "") + accel = self.read(ggml.LLAMA, log) + self.assertEqual((accel.backend, accel.device), ("GPU", "")) + self.assertEqual(accel.layers, "29/29") + + def test_llama_split_across_cards_does_not_name_only_one(self): + log = LLAMA_GPU + ( + "load_tensors: Vulkan1 model buffer size = 1024.00 MiB\n") + accel = self.read(ggml.LLAMA, log) + self.assertEqual((accel.backend, accel.device), ("GPU", "")) + + def test_llama_static_metal_build_can_be_identified_by_its_buffer(self): + log = ( + "ggml_metal_init: picking default device: Apple M2\n" + "load_tensors: offloaded 29/29 layers to GPU\n" + "load_tensors: Metal model buffer size = 2048.00 MiB\n") + accel = self.read(ggml.LLAMA, log) + self.assertEqual((accel.backend, accel.device), ("Metal", "Apple M2")) + def test_a_llama_that_offloaded_nothing_is_on_the_processor(self): accel = self.read(ggml.LLAMA, LLAMA_CPU) self.assertEqual(accel.backend, "CPU") self.assertEqual(ggml.accel_kind(accel), "cpu") # The build could have used the card; this run did not. - self.assertFalse(ggml.cpu_only_build(accel)) + self.assertFalse(ggml.cpu_only_loaded(accel)) def test_the_processor_is_not_named_twice(self): # whisper prints CPU as the backend and as the device, and saying it @@ -776,7 +1031,7 @@ STAND_IN = textwrap.dedent(""" flush=True) print("whisper_backend_init_gpu: device 0: Test Card (type: 1)", flush=True) - # The line that says one of them worked, which is the one read back. + # The attempt is followed by no failure in this stand-in. print("whisper_backend_init_gpu: using " + opt("--backend") + "0 backend", flush=True) @@ -809,7 +1064,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): super().setUp() self.path("data").mkdir(parents=True, exist_ok=True) @@ -832,6 +1089,8 @@ class Servers(Local): self.addCleanup(made.stop) return made + +class Servers(ServerCase): def test_a_started_server_hands_back_its_address(self): server = self.server() url = server.serve() @@ -1107,6 +1366,122 @@ class Servers(Local): 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): """What the two command lines say, since neither program is here to say it.""" @@ -1305,3 +1680,205 @@ class WindowsOwnership(Local): # from here", and only one of those makes the pid file safe to drop. self.image("") 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"]) diff --git a/tests/test_packaging.py b/tests/test_packaging.py new file mode 100644 index 0000000..dde74b6 --- /dev/null +++ b/tests/test_packaging.py @@ -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() diff --git a/tests/test_paths.py b/tests/test_paths.py index df84a09..af54bde 100644 --- a/tests/test_paths.py +++ b/tests/test_paths.py @@ -42,9 +42,12 @@ class Directories(unittest.TestCase): 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.""" - 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") - 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): """Settings roam with the account; several gigabytes must not.""" diff --git a/tests/test_ui.py b/tests/test_ui.py index 350c1d1..dd0513e 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -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. """ +import json import os import sys +import time import unittest from typing import ClassVar from unittest import mock -from PyQt6.QtCore import QPoint, QPointF, Qt +from PyQt6.QtCore import QPoint, QPointF, QRect, Qt from PyQt6.QtGui import QHideEvent, QShowEvent, QWheelEvent from PyQt6.QtWidgets import QApplication, QMessageBox @@ -21,11 +23,13 @@ from dikte import cleanup from dikte import config as cfg from dikte import ggml from dikte import hotkey +from dikte import hub from dikte import ipc from dikte import overlay as overlay_module from dikte import paste from dikte import settings_ui from dikte import update +from dikte.i18n import t from tests.support import DikteTest, only_these_tools # The harness below replaces this method on the class so that opening a window @@ -48,6 +52,7 @@ CHANGED = { "restore_clipboard": True, "overlay_corner": "top-right", "overlay_screen": "DP-1", + "overlay_follows_pointer": True, "max_seconds": 120, "skip_silent": False, "silence_db": -42.0, @@ -72,12 +77,14 @@ CHANGED = { "local_model": "ggml-small.bin", "local_gpu": False, "local_preload": False, - "local_threads": 6, + "local_threads": 1, "local_llm_model": "gemma-3-4b-it-Q4_K_M.gguf", "local_llm_repo": "ggml-org/gemma-4-E2B-it-GGUF", "local_llm_gpu": False, "local_llm_preload": True, "local_llm_reasoning": "low", + "local_idle_unload": False, + "local_idle_minutes": 45, "cleanup_prompt": "Only fix the punctuation.", "file_cleanup_prompt": "Keep the stamps where they are.", "transcribe_prompt": "Paraşüt, OpenFrame", @@ -239,6 +246,39 @@ class Settings(DikteTest): label.resize(2000, 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): """Every widget has to load what is stored, or Save writes its default over it. This says so for the whole table at once.""" @@ -286,6 +326,56 @@ class Settings(DikteTest): self.assertEqual(shown, [provider]) self.assertFalse(box.isHidden()) + def test_editable_boxes_live_in_forms_that_grow_the_field_column(self): + window = self.window(cfg.Config()) + + def contains(layout, target): + for index in range(layout.count()): + item = layout.itemAt(index) + widget = item.widget() + if widget is target or (widget is not None and + widget.isAncestorOf(target)): + return True + child = item.layout() + if child is not None and contains(child, target): + return True + return False + + forms = window.findChildren(settings_ui.QFormLayout) + boxes = [ + window.paste_shortcut, + window.transcribe_model, + window.file_model, + window.cleanup_model, + window.cleanup_gemini_model, + window.cleanup_opencode_model, + window.cleanup_agy_model, + window.cleanup_claude_model, + window.cleanup_codex_model, + window.assistant_model, + window.assistant_agy_model, + window.assistant_opencode_model, + window.assistant_codex_model, + window.assistant_openrouter_model, + window.meeting_model, + *(box for box, _status, _missing in + window._shortcut_rows.values()), + ] + for box in boxes: + form = next((candidate for candidate in forms + if contains(candidate, box)), None) + with self.subTest(box=box.objectName() or box.currentText()): + self.assertIsNotNone(form) + self.assertEqual( + form.fieldGrowthPolicy(), + settings_ui.QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow, + ) + + self.assertEqual( + window.local_llm.layout().fieldGrowthPolicy(), + settings_ui.QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow, + ) + def test_codex_answering_refills_both_of_its_boxes(self): """The list Codex gave replaces the built-in one, in both places, and neither loses what was already picked.""" @@ -504,6 +594,20 @@ class Settings(DikteTest): self.assertEqual(conf["transcribe_model"], "gpt-4o-transcribe") self.assertEqual(conf["groq_transcribe_model"], "whisper-large-v3") + def test_the_file_model_is_saved_and_only_shown_for_openrouter(self): + self.write_config({"transcribe_provider": "openrouter", + "openrouter_file_model": "openai/whisper-large-v3"}) + conf = cfg.Config() + window = self.window(conf) + self.assertEqual(window.file_model.currentText(), "openai/whisper-large-v3") + self.assertTrue(window.stt_form.isRowVisible(window.file_model_row)) + window.file_model.setCurrentText(" deepgram/nova-3 ") + window._save() + self.assertEqual(conf["openrouter_file_model"], "deepgram/nova-3") + window.transcribe_provider.setCurrentIndex( + window.transcribe_provider.findData("openai")) + self.assertFalse(window.stt_form.isRowVisible(window.file_model_row)) + def test_the_provider_box_offers_every_provider_config_knows(self): window = self.window(cfg.Config()) offered = [window.transcribe_provider.itemData(i) @@ -1051,6 +1155,121 @@ class Overlay(DikteTest): screen_at.assert_not_called() 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): widget = self.overlay() widget.show_warning("cleanup failed") @@ -1203,26 +1422,25 @@ class LocalModels(DikteTest): def test_a_card_asked_for_and_missing_is_not_left_to_be_guessed_at(self): whisper, _ = self.shown(backend="CPU", device="CPU", available=["CPU"]) self.assertIn("processor", whisper) - self.assertIn("no graphics backend", whisper) + self.assertIn("only the CPU backend was loaded", whisper) - def test_the_downloaded_build_is_told_what_to_install(self): - # whisper.cpp publishes nothing that reaches a card on this system, and - # a copy on the PATH is used ahead of Dikte's own, so that is the fix. + def test_a_download_is_not_assumed_to_lack_gpu_support(self): whisper, _ = self.shown(backend="CPU", device="CPU", available=["CPU"], downloaded=True) - self.assertIn("Dikte downloaded", whisper) - self.assertIn("whisper-server", whisper) + self.assertIn("only the CPU backend was loaded", whisper) + self.assertIn("driver errors", whisper) + self.assertNotIn("installing one", whisper) def test_a_system_build_is_not_told_to_install_itself(self): whisper, _ = self.shown(backend="CPU", device="CPU", available=["CPU"], downloaded=False) - self.assertIn("no graphics backend", whisper) + self.assertIn("only the CPU backend was loaded", whisper) self.assertNotIn("Dikte downloaded", whisper) def test_a_build_that_could_have_used_one_says_the_other_thing(self): whisper, _ = self.shown(backend="CPU", device="CPU", available=["CUDA", "CPU"]) - self.assertIn("none was found", whisper) + self.assertIn("could not be used", whisper) self.assertNotIn("no graphics backend", whisper) def test_a_processor_nobody_argued_about_is_stated_plainly(self): @@ -1270,6 +1488,28 @@ class LocalModels(DikteTest): self.assertIn("10", box.program_label.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): # 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". @@ -1282,6 +1522,319 @@ class LocalModels(DikteTest): for row in range(box.repo.count())) 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): window = self.window(self.config(transcribe_provider="openai")) self.assertTrue(window.stt_form.isRowVisible(window.transcribe_model_row)) @@ -1300,6 +1853,26 @@ class LocalModels(DikteTest): # Its own thinking box, because the two default to opposite things. 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): window = self.window(cfg.Config()) rows = {"openrouter": window.cleanup_model_row, @@ -1325,3 +1898,15 @@ class LocalModels(DikteTest): # isHidden rather than isVisible: the window itself is never # shown in a test, so nothing in it is ever visible. self.assertEqual(other.isHidden(), name != chosen) + + def test_local_threads_range_is_bounded_by_cpu_count(self): + with mock.patch("os.cpu_count", return_value=8): + window = self.window(cfg.Config()) + self.assertEqual(window.local_threads.minimum(), 0) + self.assertEqual(window.local_threads.maximum(), 8) + + def test_local_threads_range_has_safe_minimum_when_cpu_count_is_none(self): + with mock.patch("os.cpu_count", return_value=None): + window = self.window(cfg.Config()) + self.assertEqual(window.local_threads.minimum(), 0) + self.assertEqual(window.local_threads.maximum(), 1) diff --git a/tests/test_worker.py b/tests/test_worker.py index 725ae91..b69f630 100644 --- a/tests/test_worker.py +++ b/tests/test_worker.py @@ -35,7 +35,7 @@ class Chain(DikteTest): cleaned="Book it for Thursday.", cleanup_error=None, answer=("Booked.", ""), rms=None, clipboard=b"what was there before", paste_error=None, - focus=None): + detected="en", focus=None): pipeline = worker.Pipeline(self.conf) done, failures, stages, cancels = [], [], [], [] pipeline.finished.connect(lambda *args: done.append(args)) @@ -45,14 +45,19 @@ class Chain(DikteTest): cleanup = (mock.Mock(side_effect=cleanup_error) if cleanup_error else mock.Mock(return_value=cleaned)) + # Auto mode takes the detection path; a fixed language the plain one. + # Both are mocked so the chain runs either way without a server. + behavior = {"side_effect": transcribe_error} if transcribe_error \ + else {"return_value": transcript} + detect_behavior = {"side_effect": transcribe_error} if transcribe_error \ + else {"return_value": (transcript, detected)} calls = {} # The chain reports its own failures on stderr, which a test run has no # use for. with contextlib.redirect_stderr(io.StringIO()), \ - mock.patch.object( - api, "transcribe", - **({"side_effect": transcribe_error} if transcribe_error - else {"return_value": transcript})) as tr, \ + mock.patch.object(api, "transcribe", **behavior) as tr, \ + mock.patch.object(api, "transcribe_detected", + **detect_behavior) as tdet, \ mock.patch.object(api, "cleanup", cleanup), \ mock.patch.object(assistant, "ask", return_value=answer) as ask_call, \ mock.patch.object(paste, "copy") as copy, \ @@ -62,7 +67,8 @@ class Chain(DikteTest): return_value=clipboard) as read_clipboard, \ mock.patch.object(worker.time, "sleep", lambda seconds: None): press.side_effect = paste_error - calls = {"transcribe": tr, "cleanup": cleanup, "ask": ask_call, + calls = {"transcribe": tr, "transcribe_detected": tdet, + "cleanup": cleanup, "ask": ask_call, "copy": copy, "copy_bytes": copy_bytes, "press": press, "read_clipboard": read_clipboard} pipeline._work(self.wav, duration, @@ -77,7 +83,8 @@ class Chain(DikteTest): run = self.run_chain() self.assertEqual(run["failures"], []) self.assertEqual(run["done"][0], - ("uh, book it for Thursday", "Book it for Thursday.", "")) + ("uh, book it for Thursday", "Book it for Thursday.", + "", "en")) run["copy"].assert_called_once_with("Book it for Thursday.") run["press"].assert_called_once_with(self.conf["paste_shortcut"], focus=None) @@ -129,7 +136,7 @@ class Chain(DikteTest): self.conf["restore_clipboard"] = True run = self.run_chain(paste_error=paste.PasteError("not trusted")) self.assertEqual(run["failures"], []) - raw, text, warning = run["done"][0] + raw, text, warning, _lang = run["done"][0] self.assertIn("not trusted", warning) run["copy_bytes"].assert_not_called() @@ -182,17 +189,41 @@ class Chain(DikteTest): self.assertEqual(run["transcribe"].call_args.kwargs["language"], "tr") self.assertEqual(run["transcribe"].call_args.kwargs["prompt"], "Paraşüt") + def test_auto_mode_asks_for_the_detected_language_and_records_it(self): + run = self.run_chain(detected="tr") + told = run["transcribe_detected"].call_args.kwargs + self.assertEqual(told["language"], "auto") + self.assertEqual(cfg.read_history()[0]["speech_language"], "tr") + self.assertEqual(run["done"][0][3], "tr") + run["transcribe"].assert_not_called() + + def test_the_detected_language_is_told_to_the_cleanup_prompt(self): + # The mock stands in for api.cleanup, which the cleanup module calls + # with (text, key, model, system_prompt, …); the prompt is the fourth. + self.conf["transcribe_prompt"] = "Paraşüt" + run = self.run_chain(detected="tr") + prompt = run["cleanup"].call_args.args[3] + # Turkish was detected, so the Turkish glossary rule is appended. + self.assertIn("KONUŞMACININ KULLANDIĞI İSİM VE TERİMLER", prompt) + + def test_a_fixed_language_needs_no_detection(self): + self.conf["language"] = "en" + run = self.run_chain() + run["transcribe"].assert_called_once() + run["transcribe_detected"].assert_not_called() + self.assertEqual(cfg.read_history()[0]["speech_language"], "en") + # ---- silence and stock phrases ---------------------------------------- def test_room_tone_costs_no_api_call(self): run = self.run_chain(rms=[0.00001] * 60) - run["transcribe"].assert_not_called() + run["transcribe_detected"].assert_not_called() self.assertIn("No speech", run["failures"][0]) def test_the_silence_check_can_be_switched_off(self): self.conf["skip_silent"] = False run = self.run_chain(rms=[0.00001] * 60) - run["transcribe"].assert_called_once() + run["transcribe_detected"].assert_called_once() def test_a_stock_phrase_from_a_short_clip_is_thrown_away(self): run = self.run_chain(duration=2.0, transcript="Altyazı M.K.") @@ -208,7 +239,7 @@ class Chain(DikteTest): def test_a_failed_cleanup_still_pastes_the_transcript(self): run = self.run_chain(cleanup_error=api.ApiError("rate limited")) - _raw, text, warning = run["done"][0] + _raw, text, warning, _lang = run["done"][0] self.assertEqual(text, "uh, book it for Thursday") self.assertIn("rate limited", warning) run["copy"].assert_called_once_with("uh, book it for Thursday") @@ -220,6 +251,9 @@ class Chain(DikteTest): self.assertEqual(cfg.read_history()[0]["cleanup_error"], "bad key") def test_a_failed_transcription_ends_the_run(self): + # This path mocks api.transcribe, so it wants + # the plain (fixed-language) transcription. + self.conf["language"] = "tr" pipeline = worker.Pipeline(self.conf) failures = [] pipeline.failed.connect(failures.append) @@ -231,6 +265,9 @@ class Chain(DikteTest): copy.assert_not_called() def test_a_clipboard_that_will_not_take_it(self): + # This path mocks api.transcribe, so it wants + # the plain (fixed-language) transcription. + self.conf["language"] = "tr" pipeline = worker.Pipeline(self.conf) failures = [] pipeline.failed.connect(failures.append) @@ -243,6 +280,9 @@ class Chain(DikteTest): self.assertIn("wl-copy", failures[0]) def test_an_unexpected_error_is_reported_rather_than_swallowed(self): + # This path mocks api.transcribe, so it wants + # the plain (fixed-language) transcription. + self.conf["language"] = "tr" pipeline = worker.Pipeline(self.conf) failures = [] pipeline.failed.connect(failures.append) @@ -280,6 +320,9 @@ class Chain(DikteTest): run["press"].assert_not_called() def test_a_command_that_was_cancelled(self): + # This path mocks api.transcribe, so it wants + # the plain (fixed-language) transcription. + self.conf["language"] = "tr" pipeline = worker.Pipeline(self.conf) cancels = [] pipeline.cancelled.connect(lambda: cancels.append(True)) @@ -289,6 +332,9 @@ class Chain(DikteTest): self.assertEqual(cancels, [True]) def test_an_agent_that_is_not_installed(self): + # This path mocks api.transcribe, so it wants + # the plain (fixed-language) transcription. + self.conf["language"] = "tr" pipeline = worker.Pipeline(self.conf) failures = [] pipeline.failed.connect(failures.append)