From 4044d7b644ef591f8f1d239709aeb02e0eec44cf Mon Sep 17 00:00:00 2001 From: div0-space Date: Tue, 18 Aug 2026 11:40:53 +0200 Subject: [PATCH 01/35] [grok/vc-justdo] collapse Whisper embed ifs; Menu label before primaryAction - Clippy -D collapsible_if was failing pre-push on core/build.rs - Overlay Retranscribe Menu: label must precede primaryAction or xcodebuild dies Authored-By: grok --- core/build.rs | 16 +++++++--------- .../Screens/Overlay/DictationOverlayView.swift | 4 ++-- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/core/build.rs b/core/build.rs index ec730687..21d1c0da 100644 --- a/core/build.rs +++ b/core/build.rs @@ -474,18 +474,16 @@ fn resolve_whisper_embed_model_path( } } } - if embed_model.contains('/') { - if let Some(snapshot) = find_hf_snapshot(embed_model) { - if whisper_dir_complete(&snapshot) { - return snapshot; - } - } + if embed_model.contains('/') + && let Some(snapshot) = find_hf_snapshot(embed_model) + && whisper_dir_complete(&snapshot) + { + return snapshot; } else if embed_model == DEFAULT_MODEL_NAME && let Some(snapshot) = find_hf_snapshot(default_repo) + && whisper_dir_complete(&snapshot) { - if whisper_dir_complete(&snapshot) { - return snapshot; - } + return snapshot; } resolve_embed_model_path(manifest_dir, embed_model) } diff --git a/macos/Codescribe/Screens/Overlay/DictationOverlayView.swift b/macos/Codescribe/Screens/Overlay/DictationOverlayView.swift index e51c7c4e..a2aec3fc 100644 --- a/macos/Codescribe/Screens/Overlay/DictationOverlayView.swift +++ b/macos/Codescribe/Screens/Overlay/DictationOverlayView.swift @@ -574,8 +574,6 @@ struct DictationOverlayView: View { state.retranscribe(pass: pass) } } - } primaryAction: { - state.retranscribe(pass: .fullHq) } label: { actionButtonLabel( title: state.isRetranscribing @@ -584,6 +582,8 @@ struct DictationOverlayView: View { tone: .neutral, iconOnly: iconOnly ) + } primaryAction: { + state.retranscribe(pass: .fullHq) } .menuStyle(.button) .csFocusRing(cornerRadius: 8) From 1b6f27735958eb4aae2923aa7072c433b8d25d83 Mon Sep 17 00:00:00 2001 From: div0-space Date: Tue, 18 Aug 2026 11:57:23 +0200 Subject: [PATCH 02/35] [grok/vc-justdo] handshake reads meta content; :8444 live maps to :8446 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - validate_quality_html requires engine-contract and quality-report-surface on the same meta tag, not prose - loopback file worker :8444 becomes Voice Lab live :8446 - make site-dev is npm run dev in site/ — Make has no site:dev Authored-By: grok --- Makefile | 8 +++++- core/asr_session/bootstrap.rs | 13 +++++++++ core/quality/engine_contract.rs | 51 ++++++++++++++++++++++++++++++--- docs/STT_CONTRACT.md | 4 ++- 4 files changed, 70 insertions(+), 6 deletions(-) diff --git a/Makefile b/Makefile index 86ce34e8..5e61c5ef 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,7 @@ demo demo-raw demo-assistive check verify semgrep fix clean help corpus-census test-corpus-parity \ dist-preflight dist-preflight-signed verify-canaries smoke-canaries \ dmg dmg-signed release-standard release-full release-dmgs release-stable install-app-release notarize verify-dmg download-model download-e5 download-embedder ensure-models \ - hooks + hooks site-dev SHELL := /bin/bash VERSION_FILE := Cargo.toml @@ -179,6 +179,11 @@ config: @$(EDITOR) ~/.codescribe/.env +# Colon form `make site:dev` is not a target — Make treats `:` as a rule +# separator. The website lives in site/; this is `npm run dev` from there. +site-dev: + cd site && npm run dev + install-app: @echo "Building $(CODESCRIBE_APP_NAME).app (SwiftUI, optimized local profile) via scripts/build-app.sh ..." @echo "Local install uses the development license verifier; CODESCRIBE_LICENSE_PUBLIC_KEY_HEX is reserved for distribution builds." @@ -1188,6 +1193,7 @@ help: @printf '%s\n' ' make release-codescribe-embedded Fat dylib with Whisper baked in (not daily)' @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'config' 'Edit ~/.codescribe/.env' @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'install-app' 'Local-release install to /Applications (may re-sign; Lab if keys resolve)' + @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'site-dev' 'Astro site at site/ (http://localhost:4321) — not make site:dev' @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'release-stable' 'Everyday: notarize slim DMG + install that stapled .app' @printf '\n' @printf ' $(HELP_C_YELLOW)%s$(HELP_C_RESET)\n' 'RELEASE & DISTRIBUTION' diff --git a/core/asr_session/bootstrap.rs b/core/asr_session/bootstrap.rs index 3aef9bff..447447d3 100644 --- a/core/asr_session/bootstrap.rs +++ b/core/asr_session/bootstrap.rs @@ -76,6 +76,11 @@ fn live_websocket_endpoint(endpoint: &str) -> Option { let path = url.path().trim_end_matches("transcriptions").to_string() + "transcribe"; url.set_path(&path); } + // Inverse of file_probe_endpoint: Voice Lab file worker :8444 is not a + // live socket. The live socket is :8446. + if url.port() == Some(8444) { + url.set_port(Some(8446)).ok()?; + } Some(url.to_string()) } @@ -226,6 +231,14 @@ mod tests { live_websocket_endpoint("ws://127.0.0.1:8446/v1/audio/transcribe").as_deref(), Some("ws://127.0.0.1:8446/v1/audio/transcribe") ); + assert_eq!( + live_websocket_endpoint("http://127.0.0.1:8444/v1/audio/transcriptions").as_deref(), + Some("ws://127.0.0.1:8446/v1/audio/transcribe") + ); + assert_eq!( + live_websocket_endpoint("http://localhost:8444/v1/audio/transcriptions").as_deref(), + Some("ws://localhost:8446/v1/audio/transcribe") + ); } #[test] diff --git a/core/quality/engine_contract.rs b/core/quality/engine_contract.rs index 80901318..fa8c2273 100644 --- a/core/quality/engine_contract.rs +++ b/core/quality/engine_contract.rs @@ -20,16 +20,34 @@ pub const ENGINE_CONTRACT_DOC: &str = "docs/THE_ENGINE_CONTRACT.md"; /// Path of the HTML-surface contract, relative to the repo root. pub const QUALITY_HTML_CONTRACT_DOC: &str = "docs/quality-reports/CONTRACT.md"; +/// True when a `` tag carries both `name` and `content` on the same tag. +/// Prose elsewhere in the document does not satisfy the handshake. +fn meta_content_is(html: &str, name: &str, content: &str) -> bool { + let name_attr = format!(r#"name="{name}""#); + let content_attr = format!(r#"content="{content}""#); + let mut rest = html; + while let Some(name_at) = rest.find(&name_attr) { + let before = &rest[..name_at]; + let tag_start = before.rfind('<').unwrap_or(0); + let after = &rest[name_at..]; + let tag_end = after.find('>').unwrap_or(after.len()); + let tag = &rest[tag_start..name_at + tag_end]; + if tag.contains(&content_attr) { + return true; + } + rest = &rest[name_at + name_attr.len()..]; + } + false +} + /// Failures if this string is not a Seal Atlas quality report. pub fn validate_quality_html(html: &str) -> Vec { let lowered = html.to_ascii_lowercase(); let mut failures = Vec::new(); - if !html.contains(r#"name="engine-contract""#) || !html.contains(ENGINE_CONTRACT_ID) { + if !meta_content_is(html, "engine-contract", ENGINE_CONTRACT_ID) { failures.push("missing meta engine-contract=the-engine/v1".into()); } - if !html.contains(r#"name="quality-report-surface""#) - || !lowered.contains("seal-atlas") && !lowered.contains("seal atlas") - { + if !meta_content_is(html, "quality-report-surface", QUALITY_REPORT_SURFACE) { failures.push("missing meta quality-report-surface=seal-atlas".into()); } if !lowered.contains("seal atlas") && !lowered.contains("seal-atlas") { @@ -510,6 +528,31 @@ mod tests { assert!(failures.len() >= 3, "{failures:?}"); } + #[test] + fn handshake_rejects_prose_that_is_not_the_meta_content() { + let fake = r#" + + +mentions the-engine/v1 and seal-atlas in prose + + +

the-engine/v1 seal-atlas

+
1word-grain
+

utterance-grain clock-lie SealedSpan.words whisper

+"#; + let failures = validate_quality_html(fake); + assert!( + failures.iter().any(|f| f.contains("engine-contract")), + "{failures:?}" + ); + assert!( + failures + .iter() + .any(|f| f.contains("quality-report-surface")), + "{failures:?}" + ); + } + #[test] fn gold_atlas_html_is_a_pcm_instrument_not_a_wer_table() { let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); diff --git a/docs/STT_CONTRACT.md b/docs/STT_CONTRACT.md index ee2e218a..3da7833f 100644 --- a/docs/STT_CONTRACT.md +++ b/docs/STT_CONTRACT.md @@ -70,7 +70,9 @@ resolver, so Settings cannot disagree with delivery. Settings → Test is the multipart file probe (`/v1/audio/transcriptions`) for every OpenAI-compatible host. A stored `wss`/`ws` `…/transcribe` URL is remapped to that file path first; loopback Voice Lab `:8446` becomes `:8444`. It is not a WebSocket -handshake. +handshake. The inverse is also explicit: a loopback file URL on `:8444` +(`http(s)://…/v1/audio/transcriptions`) becomes the live socket on `:8446`. +A generic loopback file URL on another port keeps that port. Transport ownership is equally explicit. Live capture uses a stored Voice Lab WebSocket (`config` → bounded PCM `chunk` → periodic `flush` → `end`) and From fba61d08b4e51b7818d80590c9cbb1de0a8e9bda Mon Sep 17 00:00:00 2001 From: div0-space Date: Tue, 18 Aug 2026 12:08:50 +0200 Subject: [PATCH 03/35] [grok/vc-justdo] install-app fetches org Voice Lab or fails - make install-app fail-closes without vetcoders/voice-lab access - Monika pack writes Sparkle/Ed public keys, then CSDeveloperSurface arms - External contributors stay on make app; toolbox stays org-closed Authored-By: grok --- CHANGELOG.md | 7 ++ Makefile | 20 +++- README.md | 4 +- docs/ENV_REGISTRY.toml | 21 +++++ docs/INSTALLATION.md | 12 ++- scripts/install-voice-lab.sh | 118 ++++++++++++++++++++++++ scripts/tests/install-voice-lab-test.sh | 79 ++++++++++++++++ 7 files changed, 249 insertions(+), 12 deletions(-) create mode 100755 scripts/install-voice-lab.sh create mode 100755 scripts/tests/install-voice-lab-test.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 31a2ac22..312c64c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **`make install-app` is org-only.** It fetches `vetcoders/voice-lab` + (fail-closed if this machine cannot read that repo), installs the Voice + Lab toolbox + Monika public Sparkle/Ed keys, then arms `CSDeveloperSurface`. + External contributors use `make app`. Production DMGs stay Lab-off. + ## [0.14.1] - 2026-08-18 > Patch: everyday-stable 0.14.x. Same slim public SKU as 0.14.0, plus the two diff --git a/Makefile b/Makefile index 5e61c5ef..a7e71467 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ # The user-facing app is built by `make app` (xcodebuild); the Rust side no # longer ships a standalone `codescribe` tray binary. -.PHONY: all build release release-codescribe release-codescribe-embedded release-qube app app-bindings install install-no-embed config install-app \ +.PHONY: all build release release-codescribe release-codescribe-embedded release-qube app app-bindings install install-no-embed config install-app install-voice-lab \ start stop restart status logs logs-follow \ bump bump-patch bump-minor bump-major version \ lint format test test-quick test-e2e test-e2e-real test-sse test-sse-release test-responses-live test-sse-heavy test-formatting test-all \ @@ -184,13 +184,22 @@ config: site-dev: cd site && npm run dev -install-app: +install-voice-lab: + @./scripts/install-voice-lab.sh + +install-app: install-voice-lab @echo "Building $(CODESCRIBE_APP_NAME).app (SwiftUI, optimized local profile) via scripts/build-app.sh ..." @echo "Local install uses the development license verifier; CODESCRIBE_LICENSE_PUBLIC_KEY_HEX is reserved for distribution builds." @BIT=$$(./scripts/developer-surface-gate.sh); \ - echo "Developer surface: $$BIT (1 needs legit Sparkle + license public keys on this machine)."; \ + if [ "$$BIT" != "1" ]; then \ + echo "Developer surface stayed off after the Voice Lab pack — Sparkle/Ed public keys did not resolve."; \ + exit 1; \ + fi; \ + echo "Developer surface: 1 (Sparkle + license public keys from the org Voice Lab pack)."; \ + SPARKLE=$$(tr -d '[:space:]' < "$(CODESCRIBE_SPARKLE_PUBLIC_KEY_FILE)" 2>/dev/null || true); \ env -u CODESCRIBE_LICENSE_PUBLIC_KEY_HEX \ - CODESCRIBE_DEVELOPER_SURFACE=$$BIT \ + CODESCRIBE_DEVELOPER_SURFACE=1 \ + SPARKLE_ED_PUBLIC_KEY="$$SPARKLE" \ $(MAKE) --no-print-directory app PROFILE=local-release @APP_SRC="macos/build/Build/Products/Release/Codescribe.app"; \ if [ ! -d "$$APP_SRC" ]; then \ @@ -1192,7 +1201,8 @@ help: @printf '%s\n' ' make install-no-embed DEV/RECOVERY: no optional embeds (runtime paths only)' @printf '%s\n' ' make release-codescribe-embedded Fat dylib with Whisper baked in (not daily)' @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'config' 'Edit ~/.codescribe/.env' - @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'install-app' 'Local-release install to /Applications (may re-sign; Lab if keys resolve)' + @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'install-app' 'Org-only: fetch Voice Lab, arm Lab from Sparkle/Ed keys, install /Applications' + @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'install-voice-lab' 'Fail-closed clone/update of vetcoders/voice-lab → ~/.codescribe/voice-lab' @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'site-dev' 'Astro site at site/ (http://localhost:4321) — not make site:dev' @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'release-stable' 'Everyday: notarize slim DMG + install that stapled .app' @printf '\n' diff --git a/README.md b/README.md index e7dda202..1eac0912 100644 --- a/README.md +++ b/README.md @@ -176,7 +176,7 @@ Tagged builds publish DMGs through GitHub Releases: ```bash make app # Debug SwiftUI app build make app PROFILE=local-release # Optimized local SwiftUI app build -make install-app # Build + install macOS .app into /Applications +make install-app # Org-only: Voice Lab + Lab surface + /Applications make release-qube # Build qube CLI tools make install # Install qube CLI tools + repo-local git hooks ``` @@ -405,7 +405,7 @@ make format # cargo fmt ``` make app # Debug SwiftUI app build make app PROFILE=local-release # Optimized local SwiftUI app build -make install-app # Local-release install (may re-sign; Lab only if keys resolve) +make install-app # Org-only: fetch Voice Lab, arm Lab from Sparkle/Ed keys, install /Applications make release-stable # Everyday: notarize slim DMG + install that stapled .app make release-qube # Build qube CLI tools make install # Install qube CLI tools + repo-local hooks diff --git a/docs/ENV_REGISTRY.toml b/docs/ENV_REGISTRY.toml index de6dfa87..bb25eca6 100644 --- a/docs/ENV_REGISTRY.toml +++ b/docs/ENV_REGISTRY.toml @@ -1752,3 +1752,24 @@ type = "usize" reload = "restart" category = "assistive" description = "Max characters captured from recent selection for voice chat" + +[vars.VOICE_LAB_REPO_URL] +default = "git@github.com:vetcoders/voice-lab.git" +type = "string" +reload = "rebuild" +category = "install" +description = "Org-only Voice Lab git URL for make install-app. Clone fails closed if this machine cannot read the repo." + +[vars.CODESCRIBE_VOICE_LAB_SRC] +default = "" +type = "string" +reload = "rebuild" +category = "install" +description = "Existing Voice Lab checkout used by make install-app instead of cloning (~/.codescribe/src/voice-lab)" + +[vars.VOICE_LAB_INSTALL_SETTINGS] +default = "0" +type = "bool" +reload = "rebuild" +category = "install" +description = "When 1, Voice Lab setup also copies examples/monika/settings.json over Application Support (opt-in)" diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 8a13696a..e8cfadb6 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -43,11 +43,13 @@ checked-in development license verifier. Production DMGs use the distinct is the real 32-byte Ed25519 public key paired with the production signer. A UUID is not a license public key. -`make install-app` bakes Lab (`CSDeveloperSurface=1`) only when both the -Sparkle public key and the production-license public key resolve from -`~/.vibecrafted/secrets/codescribe/` (the same files a real release uses). -A public clone without those files still installs the daily app; Lab stays -off. Production DMGs refuse the bit. +`make install-app` is the **org hot path**. It fail-closes unless this +machine can read `vetcoders/voice-lab` (sibling checkout or `git clone`), +runs the Monika pack so Sparkle Ed + license public keys land in +`~/.vibecrafted/secrets/codescribe/`, then bakes `CSDeveloperSurface=1` and +installs the Voice Lab runtime to `~/.codescribe/voice-lab`. External +contributors stay on `make app` — they do not get the toolbox. Production +DMGs still refuse the Lab bit. ### Method 3: DMG Distribution (For End Users) diff --git a/scripts/install-voice-lab.sh b/scripts/install-voice-lab.sh new file mode 100755 index 00000000..5ab33787 --- /dev/null +++ b/scripts/install-voice-lab.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# Fail-closed Voice Lab toolbox install for `make install-app`. +# +# Org-only: the operator must be able to read vetcoders/voice-lab (sibling +# checkout or git clone). External contributors cannot walk this path. +# Public Sparkle Ed + license verify keys come from the Monika pack so +# CSDeveloperSurface and agent Lab extras stay armed on this hot path. +# +# Env: +# VOICE_LAB_REPO_URL default git@github.com:vetcoders/voice-lab.git +# CODESCRIBE_VOICE_LAB_SRC existing checkout (skips clone) +# VOICE_LAB_INSTALL_SETTINGS 1 = also copy examples/monika/settings.json +# HOME runtime dest ~/.codescribe/voice-lab +# +# 𝚅𝚒𝚋𝚎𝚌𝚛𝚊𝚏𝚝𝚎𝚍. with AI Agents by Vetcoders (c)2024-2026 LibraxisAI +set -euo pipefail + +REPO_URL="${VOICE_LAB_REPO_URL:-git@github.com:vetcoders/voice-lab.git}" +CACHE="${HOME}/.codescribe/src/voice-lab" +RUNTIME="${HOME}/.codescribe/voice-lab" +LAUNCHER="${HOME}/.codescribe/bin/voice-lab" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +CODESCRIBE_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +SIBLING="$(cd "${CODESCRIBE_ROOT}/.." && pwd)/voice-lab" + +fail() { + echo "install-voice-lab: $*" >&2 + exit 1 +} + +looks_like_voice_lab() { + local root="$1" + [[ -f "${root}/server.py" && -f "${root}/setup.sh" && -d "${root}/examples/monika/keys" ]] +} + +remote_is_voice_lab() { + local url="$1" + [[ "$url" == *voice-lab* ]] +} + +resolve_src() { + if [[ -n "${CODESCRIBE_VOICE_LAB_SRC:-}" ]]; then + echo "${CODESCRIBE_VOICE_LAB_SRC}" + return + fi + if [[ -d "${SIBLING}/.git" ]] && looks_like_voice_lab "$SIBLING"; then + echo "$SIBLING" + return + fi + echo "$CACHE" +} + +need_git() { + command -v git >/dev/null 2>&1 || fail "git is required to fetch the org Voice Lab repo" +} + +ensure_checkout() { + local src="$1" + remote_is_voice_lab "$REPO_URL" || fail "VOICE_LAB_REPO_URL must point at the org voice-lab repo (got ${REPO_URL})" + + if looks_like_voice_lab "$src"; then + if [[ -d "${src}/.git" && "$src" == "$CACHE" ]]; then + echo "==> updating ${src}" + git -C "$src" remote get-url origin >/dev/null 2>&1 || fail "${src} has no origin" + git -C "$src" fetch --tags origin + git -C "$src" checkout --quiet main + git -C "$src" merge --ff-only origin/main + else + echo "==> using existing checkout ${src}" + fi + return + fi + + if [[ -e "$src" ]]; then + fail "${src} exists but is not a Voice Lab checkout" + fi + + need_git + echo "==> probing ${REPO_URL}" + if ! git ls-remote "$REPO_URL" HEAD >/dev/null 2>&1; then + fail "no access to ${REPO_URL}. Voice Lab is org-closed. Ask for vetcoders/voice-lab, or use make app without install-app." + fi + mkdir -p "$(dirname "$src")" + echo "==> cloning ${REPO_URL} → ${src}" + git clone --branch main --single-branch "$REPO_URL" "$src" + looks_like_voice_lab "$src" || fail "clone succeeded but ${src} is missing server.py / Monika pack" +} + +run_setup() { + local src="$1" + [[ -x "${src}/setup.sh" ]] || fail "missing ${src}/setup.sh" + echo "==> setup.sh → ${RUNTIME}" + INSTALL_PUBLIC_KEYS=1 \ + INSTALL_SETTINGS="${VOICE_LAB_INSTALL_SETTINGS:-0}" \ + SKIP_CODESCRIBE_CLONE=1 \ + "${src}/setup.sh" +} + +verify_runtime() { + [[ -f "${RUNTIME}/server.py" ]] || fail "runtime missing ${RUNTIME}/server.py" + [[ -x "$LAUNCHER" ]] || fail "launcher missing ${LAUNCHER}" + [[ -f "${HOME}/.vibecrafted/secrets/codescribe/sparkle-public.b64" ]] \ + || fail "Sparkle public key missing after Monika pack" + [[ -f "${HOME}/.vibecrafted/secrets/codescribe/license-public.hex" ]] \ + || fail "license public key missing after Monika pack" + echo "==> Voice Lab runtime ${RUNTIME}" + echo "==> launcher ${LAUNCHER}" +} + +main() { + local src + src="$(resolve_src)" + ensure_checkout "$src" + run_setup "$src" + verify_runtime +} + +main "$@" diff --git a/scripts/tests/install-voice-lab-test.sh b/scripts/tests/install-voice-lab-test.sh new file mode 100755 index 00000000..b49056b7 --- /dev/null +++ b/scripts/tests/install-voice-lab-test.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +INSTALL="$ROOT/scripts/install-voice-lab.sh" +WORKDIR="$(mktemp -d)" +trap 'rm -rf "$WORKDIR"' EXIT + +export HOME="$WORKDIR/home" +mkdir -p "$HOME" + +fake_git="$WORKDIR/bin" +mkdir -p "$fake_git" +cat >"$fake_git/git" <<'EOF' +#!/bin/sh +echo "unexpected git $*" >&2 +exit 99 +EOF +chmod +x "$fake_git/git" +export PATH="$fake_git:$PATH" + +ABSENT="$WORKDIR/absent" + +# No checkout, ls-remote fails → fail-closed. Force a clone path so a +# sibling voice-lab checkout on the operator machine cannot satisfy this. +cat >"$fake_git/git" <<'EOF' +#!/bin/sh +if [ "$1" = "ls-remote" ]; then + echo "Permission denied" >&2 + exit 128 +fi +echo "unexpected git $*" >&2 +exit 99 +EOF +set +e +out="$( + CODESCRIBE_VOICE_LAB_SRC="$ABSENT" \ + VOICE_LAB_REPO_URL="git@github.com:vetcoders/voice-lab.git" \ + "$INSTALL" 2>&1 +)" +status=$? +set -e +[[ "$status" -ne 0 ]] || { echo "expected fail without repo access, got: $out" >&2; exit 1; } +[[ "$out" == *org-closed* ]] || { echo "expected org-closed message, got: $out" >&2; exit 1; } + +# Wrong repo URL → fail before clone. +set +e +out="$( + CODESCRIBE_VOICE_LAB_SRC="$ABSENT" \ + VOICE_LAB_REPO_URL="https://github.com/octocat/Hello-World.git" \ + "$INSTALL" 2>&1 +)" +status=$? +set -e +[[ "$status" -ne 0 ]] || { echo "expected fail on non-voice-lab URL, got: $out" >&2; exit 1; } +[[ "$out" == *"voice-lab repo"* ]] || { echo "expected repo-name check, got: $out" >&2; exit 1; } + +# Sibling-shaped checkout via CODESCRIBE_VOICE_LAB_SRC runs setup.sh. +pack="$WORKDIR/pack" +mkdir -p "$pack/examples/monika/keys" +printf 'x' >"$pack/server.py" +cat >"$pack/setup.sh" < "\$HOME/.codescribe/voice-lab/server.py" +echo '#!/bin/sh' > "\$HOME/.codescribe/bin/voice-lab" +chmod 755 "\$HOME/.codescribe/bin/voice-lab" +echo sparkle > "\$HOME/.vibecrafted/secrets/codescribe/sparkle-public.b64" +echo license > "\$HOME/.vibecrafted/secrets/codescribe/license-public.hex" +echo ran-setup +EOF +chmod +x "$pack/setup.sh" + +out="$(CODESCRIBE_VOICE_LAB_SRC="$pack" "$INSTALL" 2>&1)" +[[ -f "$HOME/.codescribe/voice-lab/server.py" ]] || { echo "runtime not installed" >&2; exit 1; } +[[ -x "$HOME/.codescribe/bin/voice-lab" ]] || { echo "launcher missing" >&2; exit 1; } +[[ "$out" == *ran-setup* ]] || { echo "setup.sh did not run: $out" >&2; exit 1; } + +echo "install-voice-lab: ok" From 4070b736e7da1ee62d53f3076d7dcc02fcd6dc04 Mon Sep 17 00:00:00 2001 From: div0-space Date: Tue, 18 Aug 2026 12:10:48 +0200 Subject: [PATCH 04/35] [grok/vc-decorate] tag keyed install-app as dev power mode - Small terracotta mono caption in the bottom-right of overlay, chat, Settings - Hidden unless CSDeveloperSurface is baked; production DMGs stay unmarked Authored-By: grok --- CHANGELOG.md | 3 +++ macos/Codescribe/Core/DeveloperSurface.swift | 3 +++ .../DesignSystem/DeveloperPowerMark.swift | 27 +++++++++++++++++++ .../Screens/AgentChat/AgentChatView.swift | 1 + .../Overlay/DictationOverlayView.swift | 1 + .../Screens/Settings/SettingsView.swift | 1 + .../DeveloperSurfaceTests.swift | 4 +++ 7 files changed, 40 insertions(+) create mode 100644 macos/Codescribe/DesignSystem/DeveloperPowerMark.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 312c64c2..8c25d03b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 (fail-closed if this machine cannot read that repo), installs the Voice Lab toolbox + Monika public Sparkle/Ed keys, then arms `CSDeveloperSurface`. External contributors use `make app`. Production DMGs stay Lab-off. +- **Dev-power corner mark.** A keyed install paints a small + “You use dev power mode” caption in the bottom-right of overlay, Agent + chat, and Settings. Production DMGs stay unmarked. ## [0.14.1] - 2026-08-18 diff --git a/macos/Codescribe/Core/DeveloperSurface.swift b/macos/Codescribe/Core/DeveloperSurface.swift index 901d40ab..81e8aa40 100644 --- a/macos/Codescribe/Core/DeveloperSurface.swift +++ b/macos/Codescribe/Core/DeveloperSurface.swift @@ -3,6 +3,9 @@ import OSLog /// Lab extras baked only by keyed `make install-app`. enum DeveloperSurface { + /// Corner caption on overlay, chat, and Settings for an org `install-app` bake. + static let powerModeCaption = "You use dev power mode" + static func parse(_ raw: Any?) -> Bool { if let flag = raw as? Bool { return flag } if let number = raw as? NSNumber { return number.boolValue } diff --git a/macos/Codescribe/DesignSystem/DeveloperPowerMark.swift b/macos/Codescribe/DesignSystem/DeveloperPowerMark.swift new file mode 100644 index 00000000..445daa40 --- /dev/null +++ b/macos/Codescribe/DesignSystem/DeveloperPowerMark.swift @@ -0,0 +1,27 @@ +import SwiftUI + +/// Quiet corner mark for an org `make install-app` bake. +/// Hidden on production DMGs (`CSDeveloperSurface` off). +struct DeveloperPowerMark: View { + var body: some View { + if DeveloperSurface.isEnabled() { + Text(DeveloperSurface.powerModeCaption) + .font(CSFont.mono(10, .medium)) + .tracking(0.2) + .foregroundStyle(CSColor.terracottaLight) + .opacity(0.72) + .allowsHitTesting(false) + .accessibilityIdentifier("developer-power-mark") + } + } +} + +extension View { + func developerPowerCorner(padding: CGFloat = 10) -> some View { + overlay(alignment: .bottomTrailing) { + DeveloperPowerMark() + .padding(.trailing, padding) + .padding(.bottom, padding) + } + } +} diff --git a/macos/Codescribe/Screens/AgentChat/AgentChatView.swift b/macos/Codescribe/Screens/AgentChat/AgentChatView.swift index 22907476..49e6e543 100644 --- a/macos/Codescribe/Screens/AgentChat/AgentChatView.swift +++ b/macos/Codescribe/Screens/AgentChat/AgentChatView.swift @@ -49,6 +49,7 @@ struct AgentChatView: View { } .navigationSplitViewStyle(.balanced) .csFocusPolicy() + .developerPowerCorner(padding: 12) .background(CSColor.glassBase) .background(AgentWindowCapabilities(isPinned: isPinned)) .frame(minWidth: 760, idealWidth: 960, minHeight: 560, idealHeight: 600) diff --git a/macos/Codescribe/Screens/Overlay/DictationOverlayView.swift b/macos/Codescribe/Screens/Overlay/DictationOverlayView.swift index a2aec3fc..62b23183 100644 --- a/macos/Codescribe/Screens/Overlay/DictationOverlayView.swift +++ b/macos/Codescribe/Screens/Overlay/DictationOverlayView.swift @@ -80,6 +80,7 @@ struct DictationOverlayView: View { // already falls outside the borderless window (never rendered), so this // clip costs nothing visually. .clipShape(RoundedRectangle(cornerRadius: CSRadius.window, style: .continuous)) + .developerPowerCorner(padding: 10) .overlay(alignment: .bottom) { if let toast = state.toast { ToastPill(text: toast) diff --git a/macos/Codescribe/Screens/Settings/SettingsView.swift b/macos/Codescribe/Screens/Settings/SettingsView.swift index 839c5931..73f6ba22 100644 --- a/macos/Codescribe/Screens/Settings/SettingsView.swift +++ b/macos/Codescribe/Screens/Settings/SettingsView.swift @@ -47,6 +47,7 @@ struct SettingsView: View { } } .csFocusPolicy() + .developerPowerCorner(padding: 12) .frame(minWidth: 880, maxWidth: .infinity, minHeight: 620, maxHeight: .infinity) .background(SettingsWindowCapabilities()) // The panels still paint hand-picked dark tokens, so the window stays diff --git a/macos/CodescribeTests/DeveloperSurfaceTests.swift b/macos/CodescribeTests/DeveloperSurfaceTests.swift index 81f921ee..469beee2 100644 --- a/macos/CodescribeTests/DeveloperSurfaceTests.swift +++ b/macos/CodescribeTests/DeveloperSurfaceTests.swift @@ -15,6 +15,10 @@ final class DeveloperSurfaceTests: XCTestCase { XCTAssertTrue(DeveloperSurface.parse(NSNumber(value: 1))) } + func testPowerModeCaptionIsTheVisibleInstallTag() { + XCTAssertEqual(DeveloperSurface.powerModeCaption, "You use dev power mode") + } + func testLabSectionIsHiddenOnProductionBundle() { XCTAssertEqual(SettingsSection.lab.availability, .hidden) XCTAssertFalse(SettingsSection.matching(query: "").contains(.lab)) From 86045d1b377bbf3da5439d66b069cb780a023e0f Mon Sep 17 00:00:00 2001 From: div0-space Date: Tue, 18 Aug 2026 15:01:55 +0200 Subject: [PATCH 05/35] [grok/vc-justdo] resolve Sparkle/Ed from ~/.codescribe/config/dev - developer-surface-gate and install-app key files prefer the laptop pack - fall back to ~/.vibecrafted/secrets/codescribe - install-voice-lab accepts either public-key location Authored-By: grok --- Makefile | 4 ++-- scripts/developer-surface-gate.sh | 20 ++++++++++++++++++-- scripts/install-voice-lab.sh | 18 ++++++++++++++---- 3 files changed, 34 insertions(+), 8 deletions(-) diff --git a/Makefile b/Makefile index a7e71467..40632b57 100644 --- a/Makefile +++ b/Makefile @@ -49,7 +49,7 @@ CODESCRIBE_DIST_CODESIGN_IDENTITY ?= $(if $(strip $(CODESCRIBE_DEVELOPER_ID_IDEN # `env -u CODESCRIBE_LICENSE_PUBLIC_KEY_HEX`, and that must keep working — a # plain `?=` fallback here would silently re-arm the production key in a local # build that deliberately wants the development verifier. -CODESCRIBE_LICENSE_PUBLIC_KEY_FILE ?= $(HOME)/.vibecrafted/secrets/codescribe/license-public.hex +CODESCRIBE_LICENSE_PUBLIC_KEY_FILE ?= $(firstword $(wildcard $(HOME)/.codescribe/config/dev/keys/license-public.hex $(HOME)/.vibecrafted/secrets/codescribe/license-public.hex)) CODESCRIBE_DIST_LICENSE_KEY = $(if $(CODESCRIBE_LICENSE_PUBLIC_KEY_HEX),$(CODESCRIBE_LICENSE_PUBLIC_KEY_HEX),$(shell cat $(CODESCRIBE_LICENSE_PUBLIC_KEY_FILE) 2>/dev/null | tr -d '[:space:]')) # Sparkle's update-verification public key has the same missing-local-source # problem: release.yml supplies SPARKLE_ED_PUBLIC_KEY as a repository variable, @@ -58,7 +58,7 @@ CODESCRIBE_DIST_LICENSE_KEY = $(if $(CODESCRIBE_LICENSE_PUBLIC_KEY_HEX),$(CODESC # would reject every update"). A local `make release-standard` had no way to # supply it, so a locally cut release failed the gate at the very last check — # after codesigning, notarisation and stapling had already been paid for. -CODESCRIBE_SPARKLE_PUBLIC_KEY_FILE ?= $(HOME)/.vibecrafted/secrets/codescribe/sparkle-public.b64 +CODESCRIBE_SPARKLE_PUBLIC_KEY_FILE ?= $(firstword $(wildcard $(HOME)/.codescribe/config/dev/keys/sparkle-public.b64 $(HOME)/.vibecrafted/secrets/codescribe/sparkle-public.b64)) CODESCRIBE_DIST_SPARKLE_KEY = $(if $(SPARKLE_ED_PUBLIC_KEY),$(SPARKLE_ED_PUBLIC_KEY),$(shell cat $(CODESCRIBE_SPARKLE_PUBLIC_KEY_FILE) 2>/dev/null | tr -d '[:space:]')) CODESCRIBE_APP_NAME ?= Codescribe CODESCRIBE_DISPLAY_NAME ?= Codescribe diff --git a/scripts/developer-surface-gate.sh b/scripts/developer-surface-gate.sh index 1b72f0ed..1fb12463 100755 --- a/scripts/developer-surface-gate.sh +++ b/scripts/developer-surface-gate.sh @@ -10,8 +10,24 @@ # 𝚅𝚒𝚋𝚎𝚌𝚛𝚊𝚏𝚝𝚎𝚍. with AI Agents by Vetcoders (c)2024-2026 LibraxisAI set -euo pipefail -SPARKLE_FILE="${CODESCRIBE_SPARKLE_PUBLIC_KEY_FILE:-$HOME/.vibecrafted/secrets/codescribe/sparkle-public.b64}" -LICENSE_FILE="${CODESCRIBE_LICENSE_PUBLIC_KEY_FILE:-$HOME/.vibecrafted/secrets/codescribe/license-public.hex}" +DEV_PACK="${HOME}/.codescribe/config/dev/keys" +VIBE_SECRETS="${HOME}/.vibecrafted/secrets/codescribe" +if [[ -z "${CODESCRIBE_SPARKLE_PUBLIC_KEY_FILE:-}" ]]; then + if [[ -f "${DEV_PACK}/sparkle-public.b64" ]]; then + CODESCRIBE_SPARKLE_PUBLIC_KEY_FILE="${DEV_PACK}/sparkle-public.b64" + else + CODESCRIBE_SPARKLE_PUBLIC_KEY_FILE="${VIBE_SECRETS}/sparkle-public.b64" + fi +fi +if [[ -z "${CODESCRIBE_LICENSE_PUBLIC_KEY_FILE:-}" ]]; then + if [[ -f "${DEV_PACK}/license-public.hex" ]]; then + CODESCRIBE_LICENSE_PUBLIC_KEY_FILE="${DEV_PACK}/license-public.hex" + else + CODESCRIBE_LICENSE_PUBLIC_KEY_FILE="${VIBE_SECRETS}/license-public.hex" + fi +fi +SPARKLE_FILE="$CODESCRIBE_SPARKLE_PUBLIC_KEY_FILE" +LICENSE_FILE="$CODESCRIBE_LICENSE_PUBLIC_KEY_FILE" read_trimmed() { local path="$1" diff --git a/scripts/install-voice-lab.sh b/scripts/install-voice-lab.sh index 5ab33787..17989702 100755 --- a/scripts/install-voice-lab.sh +++ b/scripts/install-voice-lab.sh @@ -99,10 +99,20 @@ run_setup() { verify_runtime() { [[ -f "${RUNTIME}/server.py" ]] || fail "runtime missing ${RUNTIME}/server.py" [[ -x "$LAUNCHER" ]] || fail "launcher missing ${LAUNCHER}" - [[ -f "${HOME}/.vibecrafted/secrets/codescribe/sparkle-public.b64" ]] \ - || fail "Sparkle public key missing after Monika pack" - [[ -f "${HOME}/.vibecrafted/secrets/codescribe/license-public.hex" ]] \ - || fail "license public key missing after Monika pack" + if [[ -f "${HOME}/.codescribe/config/dev/keys/sparkle-public.b64" ]]; then + : + elif [[ -f "${HOME}/.vibecrafted/secrets/codescribe/sparkle-public.b64" ]]; then + : + else + fail "Sparkle public key missing (~/.codescribe/config/dev/keys or Monika pack)" + fi + if [[ -f "${HOME}/.codescribe/config/dev/keys/license-public.hex" ]]; then + : + elif [[ -f "${HOME}/.vibecrafted/secrets/codescribe/license-public.hex" ]]; then + : + else + fail "license public key missing (~/.codescribe/config/dev/keys or Monika pack)" + fi echo "==> Voice Lab runtime ${RUNTIME}" echo "==> launcher ${LAUNCHER}" } From f2ebb57a255ce94b03a535af21d18be589a6b926 Mon Sep 17 00:00:00 2001 From: div0-space Date: Tue, 18 Aug 2026 17:02:34 +0200 Subject: [PATCH 06/35] [grok/vc-justdo] seed Monika settings on install-app, keep existing endpoints - Missing Application Support settings.json is copied from voice-lab examples/monika - Empty asr_mode / cloud_transcription_endpoint are filled; a set URL is left alone - Prints the resolved mode and endpoint so the org host can see what landed Authored-By: grok --- CHANGELOG.md | 2 + docs/ENV_REGISTRY.toml | 6 +- docs/INSTALLATION.md | 9 ++- scripts/install-voice-lab.sh | 95 ++++++++++++++++++++++++- scripts/tests/install-voice-lab-test.sh | 45 ++++++++++++ 5 files changed, 149 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c25d03b..02ddfe05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`make install-app` is org-only.** It fetches `vetcoders/voice-lab` (fail-closed if this machine cannot read that repo), installs the Voice Lab toolbox + Monika public Sparkle/Ed keys, then arms `CSDeveloperSurface`. + Missing app `settings.json` is seeded from the Monika pack (Libraxis live + URL + `local_power`); an existing endpoint is never overwritten. External contributors use `make app`. Production DMGs stay Lab-off. - **Dev-power corner mark.** A keyed install paints a small “You use dev power mode” caption in the bottom-right of overlay, Agent diff --git a/docs/ENV_REGISTRY.toml b/docs/ENV_REGISTRY.toml index bb25eca6..906e9e4a 100644 --- a/docs/ENV_REGISTRY.toml +++ b/docs/ENV_REGISTRY.toml @@ -1768,8 +1768,8 @@ category = "install" description = "Existing Voice Lab checkout used by make install-app instead of cloning (~/.codescribe/src/voice-lab)" [vars.VOICE_LAB_INSTALL_SETTINGS] -default = "0" -type = "bool" +default = "" +type = "string" reload = "rebuild" category = "install" -description = "When 1, Voice Lab setup also copies examples/monika/settings.json over Application Support (opt-in)" +description = "Voice Lab settings seed: empty=copy pack if missing and fill empty engine keys; 1=overwrite settings.json; 0=never touch Application Support" diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index e8cfadb6..d6d1a826 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -47,9 +47,12 @@ is not a license public key. machine can read `vetcoders/voice-lab` (sibling checkout or `git clone`), runs the Monika pack so Sparkle Ed + license public keys land in `~/.vibecrafted/secrets/codescribe/`, then bakes `CSDeveloperSurface=1` and -installs the Voice Lab runtime to `~/.codescribe/voice-lab`. External -contributors stay on `make app` — they do not get the toolbox. Production -DMGs still refuse the Lab bit. +installs the Voice Lab runtime to `~/.codescribe/voice-lab`. Missing +Codescribe `settings.json` is seeded from `examples/monika/settings.json` +(Libraxis `wss` + `asr_mode=local_power`). A file that already has an +endpoint — including this studio's loopback `:8446` — is left alone. +External contributors stay on `make app`. Production DMGs still refuse +the Lab bit. ### Method 3: DMG Distribution (For End Users) diff --git a/scripts/install-voice-lab.sh b/scripts/install-voice-lab.sh index 17989702..d0fb2348 100755 --- a/scripts/install-voice-lab.sh +++ b/scripts/install-voice-lab.sh @@ -9,7 +9,10 @@ # Env: # VOICE_LAB_REPO_URL default git@github.com:vetcoders/voice-lab.git # CODESCRIBE_VOICE_LAB_SRC existing checkout (skips clone) -# VOICE_LAB_INSTALL_SETTINGS 1 = also copy examples/monika/settings.json +# VOICE_LAB_INSTALL_SETTINGS unset = seed missing app settings + empty +# engine keys from examples/monika/settings.json +# 1 = overwrite settings (setup.sh backup) +# 0 = never touch Application Support settings # HOME runtime dest ~/.codescribe/voice-lab # # 𝚅𝚒𝚋𝚎𝚌𝚛𝚊𝚏𝚝𝚎𝚍. with AI Agents by Vetcoders (c)2024-2026 LibraxisAI @@ -86,12 +89,98 @@ ensure_checkout() { looks_like_voice_lab "$src" || fail "clone succeeded but ${src} is missing server.py / Monika pack" } +app_settings_path() { + echo "${HOME}/Library/Application Support/Codescribe/settings.json" +} + +# Codescribe does not invent the org cloud URL on first launch. The Monika +# pack in voice-lab does. Seed it on this hot path: missing file → copy; +# existing file → fill empty asr_mode / cloud_transcription_endpoint only. +# A host that already pointed STT at loopback or Libraxis is left alone. +seed_app_settings() { + local src="$1" + local pack="${src}/examples/monika/settings.json" + local dest + dest="$(app_settings_path)" + local mode="${VOICE_LAB_INSTALL_SETTINGS:-auto}" + + if [[ "$mode" == "0" ]]; then + echo "==> app settings skipped (VOICE_LAB_INSTALL_SETTINGS=0)" + return + fi + [[ -f "$pack" ]] || fail "Monika settings pack missing: ${pack}" + + if [[ "$mode" == "1" ]]; then + return + fi + + command -v python3 >/dev/null 2>&1 || fail "python3 is required to seed Codescribe settings" + python3 - "$pack" "$dest" <<'PY' +import json +import sys +from pathlib import Path + +pack = Path(sys.argv[1]) +dest = Path(sys.argv[2]) +wanted = json.loads(pack.read_text()) +engine = (wanted.get("speech") or {}).get("engine") or {} +want_endpoint = (engine.get("cloud_transcription_endpoint") or "").strip() +want_mode = (engine.get("asr_mode") or "").strip() + +if not dest.is_file(): + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(pack.read_text()) + print(f"==> seeded app settings from Monika pack → {dest}") + raise SystemExit(0) + +data = json.loads(dest.read_text()) +speech = data.setdefault("speech", {}) +cur = speech.setdefault("engine", {}) +changed = [] +if not str(cur.get("cloud_transcription_endpoint") or "").strip() and want_endpoint: + cur["cloud_transcription_endpoint"] = want_endpoint + changed.append("cloud_transcription_endpoint") +if not str(cur.get("asr_mode") or "").strip() and want_mode: + cur["asr_mode"] = want_mode + changed.append("asr_mode") +if changed: + dest.write_text(json.dumps(data, indent=2) + "\n") + print("==> filled empty engine keys:", ", ".join(changed)) +else: + print("==> app settings kept (endpoint/mode already set)") +PY +} + +print_settings_guarantee() { + local dest + dest="$(app_settings_path)" + if [[ ! -f "$dest" ]]; then + echo "==> app settings: none at ${dest}" + return + fi + command -v python3 >/dev/null 2>&1 || return + python3 - "$dest" <<'PY' +import json, sys +from pathlib import Path +data = json.loads(Path(sys.argv[1]).read_text()) +engine = (data.get("speech") or {}).get("engine") or {} +mode = engine.get("asr_mode") or "(unset)" +endpoint = engine.get("cloud_transcription_endpoint") or "(unset)" +print(f"==> app settings guarantee asr_mode={mode}") +print(f"==> app settings guarantee endpoint={endpoint}") +PY +} + run_setup() { local src="$1" [[ -x "${src}/setup.sh" ]] || fail "missing ${src}/setup.sh" + local settings_flag="${VOICE_LAB_INSTALL_SETTINGS:-0}" + if [[ -z "${VOICE_LAB_INSTALL_SETTINGS:-}" ]]; then + settings_flag=0 + fi echo "==> setup.sh → ${RUNTIME}" INSTALL_PUBLIC_KEYS=1 \ - INSTALL_SETTINGS="${VOICE_LAB_INSTALL_SETTINGS:-0}" \ + INSTALL_SETTINGS="$settings_flag" \ SKIP_CODESCRIBE_CLONE=1 \ "${src}/setup.sh" } @@ -122,7 +211,9 @@ main() { src="$(resolve_src)" ensure_checkout "$src" run_setup "$src" + seed_app_settings "$src" verify_runtime + print_settings_guarantee } main "$@" diff --git a/scripts/tests/install-voice-lab-test.sh b/scripts/tests/install-voice-lab-test.sh index b49056b7..ba686eed 100755 --- a/scripts/tests/install-voice-lab-test.sh +++ b/scripts/tests/install-voice-lab-test.sh @@ -58,6 +58,17 @@ set -e pack="$WORKDIR/pack" mkdir -p "$pack/examples/monika/keys" printf 'x' >"$pack/server.py" +cat >"$pack/examples/monika/settings.json" <<'JSON' +{ + "schema_version": 3, + "speech": { + "engine": { + "cloud_transcription_endpoint": "wss://api.libraxis.cloud/v1/audio/transcribe", + "asr_mode": "local_power" + } + } +} +JSON cat >"$pack/setup.sh" <&1)" [[ -f "$HOME/.codescribe/voice-lab/server.py" ]] || { echo "runtime not installed" >&2; exit 1; } [[ -x "$HOME/.codescribe/bin/voice-lab" ]] || { echo "launcher missing" >&2; exit 1; } [[ "$out" == *ran-setup* ]] || { echo "setup.sh did not run: $out" >&2; exit 1; } +[[ "$out" == *seeded\ app\ settings* ]] || { echo "expected seed on missing settings: $out" >&2; exit 1; } +[[ "$out" == *endpoint=wss://api.libraxis.cloud/v1/audio/transcribe* ]] || { + echo "expected libraxis guarantee, got: $out" >&2 + exit 1 +} + +# Existing loopback endpoint must not be overwritten. +export HOME="$WORKDIR/home-keep" +mkdir -p "$HOME/Library/Application Support/Codescribe" +cat >"$HOME/Library/Application Support/Codescribe/settings.json" <<'JSON' +{ + "speech": { + "engine": { + "cloud_transcription_endpoint": "ws://127.0.0.1:8446/v1/audio/transcribe", + "asr_mode": "local_power" + } + } +} +JSON +out="$(CODESCRIBE_VOICE_LAB_SRC="$pack" "$INSTALL" 2>&1)" +[[ "$out" == *app\ settings\ kept* ]] || { echo "expected keep existing endpoint: $out" >&2; exit 1; } +[[ "$out" == *endpoint=ws://127.0.0.1:8446/v1/audio/transcribe* ]] || { + echo "loopback must survive seed, got: $out" >&2 + exit 1 +} + +# Empty engine keys get the pack values without replacing the file wholesale. +export HOME="$WORKDIR/home-fill" +mkdir -p "$HOME/Library/Application Support/Codescribe" +printf '%s\n' '{"schema_version":3,"speech":{"engine":{}}}' \ + >"$HOME/Library/Application Support/Codescribe/settings.json" +out="$(CODESCRIBE_VOICE_LAB_SRC="$pack" "$INSTALL" 2>&1)" +[[ "$out" == *filled\ empty\ engine\ keys* ]] || { echo "expected fill: $out" >&2; exit 1; } +[[ "$out" == *asr_mode=local_power* ]] || { echo "expected mode fill: $out" >&2; exit 1; } echo "install-voice-lab: ok" From 42f52dd13873ad36c6d1f63973bcc7858145d90b Mon Sep 17 00:00:00 2001 From: div0-space Date: Tue, 18 Aug 2026 17:04:13 +0200 Subject: [PATCH 07/35] [grok/vc-justdo] keep org install internals out of the public changelog - Unreleased no longer names the private console, pack, keys, or live URL - README, install help, and INSTALLATION.md match that surface Authored-By: grok --- CHANGELOG.md | 8 +------- Makefile | 4 ++-- README.md | 4 ++-- docs/INSTALLATION.md | 15 +++++---------- 4 files changed, 10 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02ddfe05..b01ad02f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,13 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **`make install-app` is org-only.** It fetches `vetcoders/voice-lab` - (fail-closed if this machine cannot read that repo), installs the Voice - Lab toolbox + Monika public Sparkle/Ed keys, then arms `CSDeveloperSurface`. - Missing app `settings.json` is seeded from the Monika pack (Libraxis live - URL + `local_power`); an existing endpoint is never overwritten. - External contributors use `make app`. Production DMGs stay Lab-off. -- **Dev-power corner mark.** A keyed install paints a small +- **Dev-power corner mark.** A keyed local install paints a small “You use dev power mode” caption in the bottom-right of overlay, Agent chat, and Settings. Production DMGs stay unmarked. diff --git a/Makefile b/Makefile index 40632b57..1f3fe958 100644 --- a/Makefile +++ b/Makefile @@ -1201,8 +1201,8 @@ help: @printf '%s\n' ' make install-no-embed DEV/RECOVERY: no optional embeds (runtime paths only)' @printf '%s\n' ' make release-codescribe-embedded Fat dylib with Whisper baked in (not daily)' @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'config' 'Edit ~/.codescribe/.env' - @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'install-app' 'Org-only: fetch Voice Lab, arm Lab from Sparkle/Ed keys, install /Applications' - @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'install-voice-lab' 'Fail-closed clone/update of vetcoders/voice-lab → ~/.codescribe/voice-lab' + @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'install-app' 'Local-release install to /Applications' + @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'install-voice-lab' 'Install the private developer console when this machine can reach it' @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'site-dev' 'Astro site at site/ (http://localhost:4321) — not make site:dev' @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'release-stable' 'Everyday: notarize slim DMG + install that stapled .app' @printf '\n' diff --git a/README.md b/README.md index 1eac0912..b02c90ee 100644 --- a/README.md +++ b/README.md @@ -176,7 +176,7 @@ Tagged builds publish DMGs through GitHub Releases: ```bash make app # Debug SwiftUI app build make app PROFILE=local-release # Optimized local SwiftUI app build -make install-app # Org-only: Voice Lab + Lab surface + /Applications +make install-app # Build + install macOS .app into /Applications make release-qube # Build qube CLI tools make install # Install qube CLI tools + repo-local git hooks ``` @@ -405,7 +405,7 @@ make format # cargo fmt ``` make app # Debug SwiftUI app build make app PROFILE=local-release # Optimized local SwiftUI app build -make install-app # Org-only: fetch Voice Lab, arm Lab from Sparkle/Ed keys, install /Applications +make install-app # Local-release install to /Applications make release-stable # Everyday: notarize slim DMG + install that stapled .app make release-qube # Build qube CLI tools make install # Install qube CLI tools + repo-local hooks diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index d6d1a826..c523af23 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -43,16 +43,11 @@ checked-in development license verifier. Production DMGs use the distinct is the real 32-byte Ed25519 public key paired with the production signer. A UUID is not a license public key. -`make install-app` is the **org hot path**. It fail-closes unless this -machine can read `vetcoders/voice-lab` (sibling checkout or `git clone`), -runs the Monika pack so Sparkle Ed + license public keys land in -`~/.vibecrafted/secrets/codescribe/`, then bakes `CSDeveloperSurface=1` and -installs the Voice Lab runtime to `~/.codescribe/voice-lab`. Missing -Codescribe `settings.json` is seeded from `examples/monika/settings.json` -(Libraxis `wss` + `asr_mode=local_power`). A file that already has an -endpoint — including this studio's loopback `:8446` — is left alone. -External contributors stay on `make app`. Production DMGs still refuse -the Lab bit. +`make install-app` builds the local-release app and copies it to +`/Applications`. Extra developer-console pieces are resolved from a +private sibling checkout when present; they are not part of the public +source path. A machine that already has `settings.json` keeps it. +Production DMGs do not bake the developer surface. ### Method 3: DMG Distribution (For End Users) From 1dc6089053730018153d900523e972ca8f54a8bc Mon Sep 17 00:00:00 2001 From: div0-space Date: Tue, 18 Aug 2026 17:05:41 +0200 Subject: [PATCH 08/35] [grok/vc-justdo] strip remaining Lab/key internals from shipped changelog - 0.14.0/0.14.1 no longer name the keyed bake, Sparkle, or live socket ports Authored-By: grok --- CHANGELOG.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b01ad02f..9d344cbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,8 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - **STT Test is a file probe.** Settings Test no longer POSTs to a live - Voice Lab WebSocket (`STT_ENDPOINT`). Known live sockets map to - `/v1/audio/transcriptions`; loopback `:8446` → `:8444`. + socket. Known live sockets map to `/v1/audio/transcriptions`. - **ChatGPT sign-in no longer requires Responses write.** OAuth persists identity after exchange. `api.responses.write` stays a lane Test, so Codex public tokens can sign in. @@ -38,7 +37,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`make release-stable`** is the everyday cut: slim sign + notarize + `verify-dmg`, then install that stapled Developer ID `.app` to `/Applications` without re-signing. `make install-app` remains the - local-release / Lab path. + local-release path. - **`make release-full` is fail-closed.** Whisper embed no longer falls back to a slim dylib when the HF snapshot is weights-only. It uses the composed `~/.codescribe/models` tree from `make download-model`. @@ -51,9 +50,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **Developer Lab on keyed `make install-app`.** `CSDeveloperSurface` bakes - only when Sparkle + production license public keys resolve on the machine. - Public `git clone && make` stays Lab-off. Production DMG refuses the bit. +- **Developer Lab on a keyed local install.** A public `git clone && make` + stays Lab-off. Production DMG refuses the bit. - **Lab mode overlay-off.** Developer veto hides the daily HUD without flipping the tray "Transcription Overlay" toggle. Leftover UserDefaults cannot hide overlay on a production bundle. From 9ab6f3da7537116185b47e044e0d6bd0ad478ccb Mon Sep 17 00:00:00 2001 From: div0-space Date: Tue, 18 Aug 2026 19:41:59 +0200 Subject: [PATCH 09/35] [grok/vc-justdo] send programming vocabulary on Codescribe STT takes - Loopback and Libraxis file/live requests name vocabulary=programming - Official OpenAI file audio omits the field - Client never classifies audio to pick a domain token - Voice Lab quality bench stays unbiased by sending nothing Authored-By: grok --- CHANGELOG.md | 4 ++ core/asr_session/cloud.rs | 4 ++ core/llm/client.rs | 25 +++++++++++- core/llm/key_liveness.rs | 10 ++++- core/stt/mod.rs | 2 + core/stt/request_vocabulary.rs | 70 ++++++++++++++++++++++++++++++++++ core/stt/tail_provider.rs | 23 ++++++++++- docs/STT_CONTRACT.md | 8 ++++ 8 files changed, 142 insertions(+), 4 deletions(-) create mode 100644 core/stt/request_vocabulary.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d344cbc..0a2778ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Cloud STT names the programming domain.** Loopback and Libraxis file + and live requests send `vocabulary=programming`. Official OpenAI file + audio omits the field. The client does not classify audio to pick a + dictionary. - **Dev-power corner mark.** A keyed local install paints a small “You use dev power mode” caption in the bottom-right of overlay, Agent chat, and Settings. Production DMGs stay unmarked. diff --git a/core/asr_session/cloud.rs b/core/asr_session/cloud.rs index db30bf88..5662ae48 100644 --- a/core/asr_session/cloud.rs +++ b/core/asr_session/cloud.rs @@ -160,6 +160,8 @@ pub struct GatewaySessionConfig { protocol_version: u16, session_id: String, locale: Option, + /// Codescribe domain token. The gateway must not classify audio to pick one. + vocabulary: &'static str, audio: GatewayAudioConfig, } @@ -170,6 +172,7 @@ impl GatewaySessionConfig { protocol_version: 1, session_id: input.session_id.as_str().to_string(), locale: input.locale.clone(), + vocabulary: crate::stt::request_vocabulary::CODESCRIBE_STT_VOCABULARY, audio: GatewayAudioConfig { encoding: "pcm_s16le", sample_rate_hz: input.sample_rate, @@ -1409,6 +1412,7 @@ mod tests { let start_json = serde_json::to_value(&transport.started[0]).expect("serialize start"); assert_eq!(start_json["type"], "session.start"); assert_eq!(start_json["protocol_version"], 1); + assert_eq!(start_json["vocabulary"], "programming"); assert_eq!(start_json["audio"]["encoding"], "pcm_s16le"); assert_eq!(start_json["audio"]["channels"], 1); assert!(start_json.get("provider").is_none()); diff --git a/core/llm/client.rs b/core/llm/client.rs index e03733f1..e156d213 100644 --- a/core/llm/client.rs +++ b/core/llm/client.rs @@ -113,6 +113,8 @@ struct WsConfig { msg_type: &'static str, language: String, api_key: String, + /// Codescribe domain token. Hosts that do not accept it are not this path. + vocabulary: &'static str, } /// WebSocket end signal (sent after audio) @@ -429,11 +431,12 @@ async fn transcribe_websocket( response.status() ); - // 1. Send config + // 1. Send config. Topic is the product domain, never classified from audio. let config = WsConfig { msg_type: "config", language: language.to_string(), api_key: api_key.to_string(), + vocabulary: crate::stt::request_vocabulary::CODESCRIBE_STT_VOCABULARY, }; ws.send(Message::Text(serde_json::to_string(&config)?.into())) .await @@ -602,6 +605,7 @@ async fn transcribe_ndjson( "sample_rate": sample_rate, "encoding": "pcm16", "language": language, + "request_vocabulary": crate::stt::request_vocabulary::CODESCRIBE_STT_VOCABULARY, "last": true }); @@ -751,10 +755,13 @@ async fn transcribe_multipart( let whisper_model = std::env::var("WHISPER_MODEL") .unwrap_or_else(|_| "mlx-community/whisper-large-v3-mlx".to_string()); - let form = Form::new() + let mut form = Form::new() .part("file", file_part) .text("model", whisper_model.clone()) .text("language", language.to_string()); + if let Some(vocabulary) = crate::stt::request_vocabulary::codescribe_stt_vocabulary(url) { + form = form.text("vocabulary", vocabulary.to_string()); + } debug!( "[Multipart STT] attempt {}/{} for {}", @@ -861,6 +868,20 @@ mod tests { format!("{}{}", WS_SCHEME_PREFIX, authority) } + #[test] + fn ws_config_names_programming_domain() { + let encoded = serde_json::to_value(&WsConfig { + msg_type: "config", + language: "pl".to_string(), + api_key: "unused".to_string(), + vocabulary: crate::stt::request_vocabulary::CODESCRIBE_STT_VOCABULARY, + }) + .expect("serialize ws config"); + assert_eq!(encoded["type"], "config"); + assert_eq!(encoded["vocabulary"], "programming"); + assert_ne!(encoded["vocabulary"], "veterinary"); + } + /// Plain WebSocket only on loopback; non-loopback rejected; the secure scheme always ok. #[test] fn ws_plain_rejected_for_non_loopback() { diff --git a/core/llm/key_liveness.rs b/core/llm/key_liveness.rs index 4c12f39b..9b6d729c 100644 --- a/core/llm/key_liveness.rs +++ b/core/llm/key_liveness.rs @@ -212,11 +212,14 @@ fn probe_stt_key( .with_probed_endpoint(endpoint); } }; - let form = Form::new() + let mut form = Form::new() .part("file", file) .text("model", "whisper-1") .text("language", "pl") .text("response_format", "json"); + if let Some(vocabulary) = crate::stt::request_vocabulary::codescribe_stt_vocabulary(&endpoint) { + form = form.text("vocabulary", vocabulary.to_string()); + } let request = client.post(&endpoint); let auth_mode = crate::stt::tail_provider::stt_auth_mode(&endpoint); let request = match auth_mode { @@ -517,6 +520,11 @@ mod tests { assert!(!request_lower.contains("x-api-key:")); assert!(!request_lower.contains("authorization:")); assert!(request.contains("codescribe-key-probe.wav")); + assert!( + request.contains("name=\"vocabulary\""), + "loopback Codescribe probe must name the programming domain" + ); + assert!(request.contains("programming")); assert_eq!( result.message, "local STT endpoint accepts unauthenticated requests" diff --git a/core/stt/mod.rs b/core/stt/mod.rs index cacfdcb3..e54250dc 100644 --- a/core/stt/mod.rs +++ b/core/stt/mod.rs @@ -32,6 +32,8 @@ pub mod apple_stt; pub mod onnx_adapter; /// Whisper sentence shape onto committed Apple words — punctuation only. pub mod punctuation_transplant; +/// Explicit cloud/loopback STT topic token. Client-owned; never from audio. +pub mod request_vocabulary; /// Serialized STT request scheduler: live, commit, and refine lanes with /// supersede semantics for stale requests and thermal-pressure backoff. pub mod scheduler; diff --git a/core/stt/request_vocabulary.rs b/core/stt/request_vocabulary.rs new file mode 100644 index 00000000..3dba74f0 --- /dev/null +++ b/core/stt/request_vocabulary.rs @@ -0,0 +1,70 @@ +//! Explicit STT topic token. The client names the domain; audio never does. +//! +//! Codescribe takes send `programming`. Official OpenAI file audio does not +//! accept this field, so that host stays omitted. Missing field means no +//! dictionary bias. The client does not classify audio to pick a token. + +use reqwest::Url; + +/// Codescribe product domain for hosts that accept a topic token. +pub const CODESCRIBE_STT_VOCABULARY: &str = "programming"; + +/// Topic token to send on one outbound STT URL, if that host accepts one. +/// +/// `None` for official OpenAI (unknown field) and unparseable URLs. Every +/// other Codescribe take is `programming`. Never inferred from audio. +pub fn codescribe_stt_vocabulary(endpoint: &str) -> Option<&'static str> { + let host = Url::parse(endpoint).ok().and_then(|url| { + url.host_str() + .map(|host| host.trim_matches(['[', ']']).to_owned()) + })?; + if host.eq_ignore_ascii_case("api.openai.com") { + return None; + } + Some(CODESCRIBE_STT_VOCABULARY) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn codescribe_sends_programming_except_official_openai() { + assert_eq!( + codescribe_stt_vocabulary("http://127.0.0.1:8444/v1/audio/transcriptions"), + Some("programming") + ); + assert_eq!( + codescribe_stt_vocabulary("http://127.0.0.1:8088/v1/audio/transcriptions"), + Some("programming") + ); + assert_eq!( + codescribe_stt_vocabulary("ws://127.0.0.1:8446/v1/audio/transcribe"), + Some("programming") + ); + assert_eq!( + codescribe_stt_vocabulary("https://api.libraxis.cloud/v1/audio/transcriptions"), + Some("programming") + ); + assert_eq!( + codescribe_stt_vocabulary("wss://api.libraxis.cloud/v1/audio/transcribe"), + Some("programming") + ); + assert_eq!( + codescribe_stt_vocabulary("https://stt.example.test/v1/audio/transcriptions"), + Some("programming") + ); + assert_eq!( + codescribe_stt_vocabulary("https://api.openai.com/v1/audio/transcriptions"), + None + ); + assert_eq!(codescribe_stt_vocabulary("not a url"), None); + } + + #[test] + fn token_is_not_chosen_from_audio() { + assert_eq!(CODESCRIBE_STT_VOCABULARY, "programming"); + assert_ne!(CODESCRIBE_STT_VOCABULARY, "veterinary"); + assert_ne!(CODESCRIBE_STT_VOCABULARY, "off"); + } +} diff --git a/core/stt/tail_provider.rs b/core/stt/tail_provider.rs index 82bed5e8..d3e20565 100644 --- a/core/stt/tail_provider.rs +++ b/core/stt/tail_provider.rs @@ -1088,11 +1088,16 @@ impl TailProvider for RemoteTailProvider { let file = Part::bytes(wav) .file_name("tail-window.wav") .mime_str("audio/wav")?; - let form = Form::new() + let mut form = Form::new() .part("file", file) .text("model", model.clone()) .text("language", language.to_string()) .text("response_format", "verbose_json"); + if let Some(vocabulary) = + crate::stt::request_vocabulary::codescribe_stt_vocabulary(&self.endpoint) + { + form = form.text("vocabulary", vocabulary.to_string()); + } let http_request = Client::builder() .timeout(REMOTE_REQUEST_TIMEOUT) .connect_timeout(SIDECAR_CONNECT_TIMEOUT) @@ -1357,6 +1362,22 @@ mod tests { ); } + #[test] + fn remote_tail_topic_follows_codescribe_product_not_audio() { + assert_eq!( + crate::stt::request_vocabulary::codescribe_stt_vocabulary( + "http://127.0.0.1:8444/v1/audio/transcriptions" + ), + Some("programming") + ); + assert_eq!( + crate::stt::request_vocabulary::codescribe_stt_vocabulary( + "https://api.openai.com/v1/audio/transcriptions" + ), + None + ); + } + #[test] fn file_probe_endpoint_inverts_known_live_sockets() { assert_eq!( diff --git a/docs/STT_CONTRACT.md b/docs/STT_CONTRACT.md index 3da7833f..1df2f1f1 100644 --- a/docs/STT_CONTRACT.md +++ b/docs/STT_CONTRACT.md @@ -81,6 +81,14 @@ streams its normalized events into `PresentationEmitter`. A public HTTPS socket. A complete audio-file multipart request is allowed for Settings → Test and for an explicit retranscribe action (Overlay, Dictionary, or Teacher). +**Domain token (client-owned, 2026-08-18).** Codescribe names the take +`vocabulary=programming` on loopback and Libraxis file/live requests +(multipart field `vocabulary`; JSON alias `request_vocabulary`; live +`session.start` / WS `config`). Official OpenAI file audio omits the field. +Absence means no dictionary bias. The client never classifies audio to pick +`programming` vs another domain. A quality bench that must stay unbiased +omits the field or sends `off`. + **Dictionary helper (everyone, 2026-08-17):** Settings → Dictionary Retranscribe is an explicit file surface on the row's archived `_raw.{m4a,wav,flac}`. Helper engine follows `speech.engine.asr_mode`: `local_power` → `hq:` (same From b29c7ac560528dba2b2a24e84568ae4c091011f1 Mon Sep 17 00:00:00 2001 From: div0-space Date: Tue, 18 Aug 2026 21:14:48 +0200 Subject: [PATCH 10/35] [grok/vc-justdo] apply aligned Layer-1 sentence rewrites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bypass the 0.50 change cap when Apple and Whisper share ≥60% of tokens and every edit is a substitution of similar length - Inserts stay on the under-commit path so pause tails do not dump - Document the live Layer 1 rule in STT_CONTRACT Authored-By: grok --- CHANGELOG.md | 4 +++ core/stt/tail_patcher/mod.rs | 56 ++++++++++++++++++++++++++++++++++-- docs/STT_CONTRACT.md | 4 +-- 3 files changed, 60 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a2778ff..41568441 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Layer 1 applies aligned same-utterance wording.** When live Apple and + the Whisper window share most words, Layer 1 now substitutes those + spans instead of discarding the repair at the 50% change cap. Unrelated + dumps and pause-tail inserts still skip. - **Cloud STT names the programming domain.** Loopback and Libraxis file and live requests send `vocabulary=programming`. Official OpenAI file audio omits the field. The client does not classify audio to pick a diff --git a/core/stt/tail_patcher/mod.rs b/core/stt/tail_patcher/mod.rs index 8b2c9c22..03c64170 100644 --- a/core/stt/tail_patcher/mod.rs +++ b/core/stt/tail_patcher/mod.rs @@ -52,7 +52,12 @@ //! - **Conservative by default.** If the diff distance exceeds //! [`TailPatchConfig::max_change_ratio`], the whole patch is dropped //! ([`TailPatchOutcome::Skipped`]) and Layer 0 output stands unchanged — -//! "don't patch if uncertain". +//! "don't patch if uncertain". Three measured same-utterance repairs +//! bypass that cap without lifting it: a substitution inside +//! [`TailPatchConfig::small_edit_token_floor`], a high-coverage digit-run +//! / trailing-fill expansion, and an aligned sentence rewrite (coverage +//! ≥ 0.60, similar length, substitutions only). Inserts stay on the +//! under-commit path. A 0-match rewrite is still wholesale divergence. //! //! # Scope of this cut (v1) //! @@ -949,8 +954,32 @@ pub fn compute_tail_patch_with_context( g.committed.len() == 1 && token_is_digit_run(&c_tokens[g.committed.start]) }); let high_coverage_expansion = almost_covered && modest_extra && expansion_groups_ok; + // Same utterance, fuller wording. Operator 2026-08-18: Whisper is already + // fast enough to swap those sentences in the background. The 0.50 cap was + // treating "we still share most tokens" as a dump. Wholesale divergence + // (few or no anchors, or a 3× longer decode) still skips below. + let coverage = matches.len() as f64 / c_tokens.len() as f64; + let similar_length = r_tokens.len() >= c_tokens.len().saturating_sub(2) + && r_tokens.len() <= c_tokens.len().saturating_add(c_tokens.len() / 2 + 3); + let rewrite_groups_ok = groups.iter().all(|g| { + if g.retranscribed.is_empty() { + return true; + } + // Inserts stay on under-commit. This bypass only rewrites spans + // already on the canvas, and never turns one token into a dump. + !g.committed.is_empty() && g.retranscribed.len() <= g.committed.len().saturating_add(2) + }); + let aligned_sentence_rewrite = shares_anchor + && c_tokens.len() >= 4 + && coverage >= 0.60 + && similar_length + && rewrite_groups_ok; let ratio = changed as f64 / c_tokens.len() as f64; - if ratio > cfg.max_change_ratio && !small_substitution_fix && !high_coverage_expansion { + if ratio > cfg.max_change_ratio + && !small_substitution_fix + && !high_coverage_expansion + && !aligned_sentence_rewrite + { // Before the cap discards this: is the canvas starved rather than // wrong? The bounded diff was never an instrument for measuring lost // speech, and using it as one is what threw the recovered 104 s / 107 s @@ -1662,6 +1691,29 @@ mod tests { assert_eq!(outcome, TailPatchOutcome::NoChange); } + /// High-overlap rewrite of the same utterance applies even when more than + /// half the tokens move. Chopped Apple + faster Whisper is the live job. + #[test] + fn aligned_sentence_rewrite_applies_when_most_words_still_match() { + let cfg = TailPatchConfig::default(); + let committed = "ala ma czarnego kota i białego psa dzisiaj w domu"; + let retranscribed = "ala ma dużego rudego kota oraz małego psa dzisiaj u siebie domu"; + let outcome = compute_tail_patch(committed, retranscribed, 31, &cfg); + let applied = apply_all(committed, &outcome); + assert!( + !matches!(outcome, TailPatchOutcome::Skipped { .. }), + "aligned rewrite must not hit the 0.50 cap, got {outcome:?}" + ); + assert!( + applied.contains("dużego") || applied.contains("rudego") || applied.contains("oraz"), + "Whisper wording must land, got {applied:?}" + ); + assert!( + applied.contains("ala") && applied.contains("domu"), + "anchors must stay, got {applied:?}" + ); + } + /// The floor is a small-edit budget, not a hole in the divergence guard: a /// wholesale rewrite of a short utterance still skips. #[test] diff --git a/docs/STT_CONTRACT.md b/docs/STT_CONTRACT.md index 1df2f1f1..fa95fb39 100644 --- a/docs/STT_CONTRACT.md +++ b/docs/STT_CONTRACT.md @@ -214,8 +214,8 @@ file-pass belongs only to explicit retranscribe surfaces. | Call site | When | Function / transport | Engine rule | | --------------------- | ---------------- | ---------------------------------------------- | -------------------------------------------------------- | | Live Layer 0 | during recording | Apple progressive | committed canvas floor | -| Live Layer 1 local | during recording | bounded Whisper windows | gap/tail fill only | -| Live Layer 1 cloud | during recording | Voice Lab WSS | normalized gap/tail fill only | +| Live Layer 1 local | during recording | bounded Whisper windows | gap/tail fill; aligned same-utterance substitutions | +| Live Layer 1 cloud | during recording | Voice Lab WSS | normalized gap/tail fill; same substitution rule | | Explicit Retranscribe | operator action | local completed-file decode or cloud multipart | may replace the selected artifact, never the live canvas | **This split is the MacGyver fracture:** UI can show Whisper readiness while live is Apple-only and fails closed. From 00e2e23a9f7964848337f1b682a559ae1f45542d Mon Sep 17 00:00:00 2001 From: div0-space Date: Tue, 18 Aug 2026 21:30:12 +0200 Subject: [PATCH 11/35] [grok/vc-justdo] coalesce Layer-1 Whisper onto ~5 Apple segments - Hold short Apple seals and decode one joined window (5 segments, 16s, or a pause) - Remap concat ReplaceRange events back onto utterance-local offsets - Flush leftover fragments on hands-free silence and session end - Keep pause-tail inserts on the under-commit path Authored-By: grok --- CHANGELOG.md | 4 + core/pipeline/streaming/apple_live_session.rs | 340 ++++++++----- core/pipeline/streaming/layer1_window.rs | 469 ++++++++++++++++++ core/pipeline/streaming/mod.rs | 2 + docs/STT_CONTRACT.md | 2 +- site/src/pages/voice/lab.astro | 381 +++++++------- site/src/styles/global.css | 40 +- site/src/styles/lab-shell.css | 216 ++++++++ site/src/styles/tokens.css | 40 ++ 9 files changed, 1154 insertions(+), 340 deletions(-) create mode 100644 core/pipeline/streaming/layer1_window.rs create mode 100644 site/src/styles/lab-shell.css create mode 100644 site/src/styles/tokens.css diff --git a/CHANGELOG.md b/CHANGELOG.md index 41568441..0a6d239a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 the Whisper window share most words, Layer 1 now substitutes those spans instead of discarding the repair at the 50% change cap. Unrelated dumps and pause-tail inserts still skip. +- **Layer 1 Whisper windows join about five Apple segments.** Short + fragments wait for a sentence-sized window (or a pause) before the + background swap, instead of each breath becoming its own failed + repair. - **Cloud STT names the programming domain.** Loopback and Libraxis file and live requests send `vocabulary=programming`. Official OpenAI file audio omits the field. The client does not classify audio to pick a diff --git a/core/pipeline/streaming/apple_live_session.rs b/core/pipeline/streaming/apple_live_session.rs index f49c9dee..02bc471a 100644 --- a/core/pipeline/streaming/apple_live_session.rs +++ b/core/pipeline/streaming/apple_live_session.rs @@ -59,6 +59,9 @@ use crate::stt::tail_provider::{ TailProviderRequest, TailRequestIdentity, TailSampleRange, TailTimingQuality, TimedTailSegment, }; +use super::layer1_window::{ + CoalesceFlush, CoalescedPiece, ConcatSpan, Layer1Coalesce, split_outcome_for_members, +}; use super::live_audio_buffer::{DEFAULT_RETENTION_SECS, LiveAudioBuffer, ResolvedAudioWindow}; use super::progressive_seal::{ AppleCommit, ProgressiveSealMachine, SealTick, SealedSpan, seal_span_text, @@ -122,6 +125,10 @@ struct TailPatchRequest { /// Exact capture range behind `audio`; this is the window-start authority. provider_request: TailProviderRequest, covered_through_secs: f32, + /// Concat-space map when this job covers more than one Apple seal. + span_map: Vec, + /// Every sealed utterance this job must close (id, covered_through_secs). + member_ids: Vec<(u64, f32)>, } /// Whisper closure returned to the worker that owns Apple + seal state. @@ -130,6 +137,16 @@ struct TailPatchCompletion { covered_through_secs: f32, outcome: TailPatchOutcome, payload: Option, + span_map: Vec, + member_ids: Vec<(u64, f32)>, +} + +/// In-flight Layer 1 job identity, including the coalesce map. +struct TailPatchInFlight { + utterance_id: u64, + covered_through_secs: f32, + span_map: Vec, + member_ids: Vec<(u64, f32)>, } /// Async Layer 1 lane for the Apple progressive path. @@ -213,29 +230,41 @@ impl AppleTailPatchLane { /// after `UtteranceFinal`, preserving event order. fn finish_for_worker( &mut self, - request_id: u64, - req_end_secs: f32, + inflight: Option, result: Result, ) -> TailPatchCompletion { + let (fallback_id, fallback_end, span_map, member_ids) = match inflight { + Some(job) => ( + job.utterance_id, + job.covered_through_secs, + job.span_map, + job.member_ids, + ), + None => (0, 0.0, Vec::new(), Vec::new()), + }; match result { Ok(job) => { let utterance_id = job.utterance_id; let outcome = job.outcome; TailPatchCompletion { utterance_id, - covered_through_secs: req_end_secs, + covered_through_secs: fallback_end, outcome, payload: Some(job.payload), + span_map, + member_ids, } } Err(error) => TailPatchCompletion { - utterance_id: request_id, - covered_through_secs: req_end_secs, + utterance_id: fallback_id, + covered_through_secs: fallback_end, outcome: TailPatchOutcome::skipped( crate::stt::tail_patcher::SkipReasonCode::ProviderError, format!("tail patch failed: {error}"), ), payload: None, + span_map, + member_ids, }, } } @@ -385,7 +414,7 @@ pub(crate) async fn apple_stream_transcription_session( // At-most-one-in-flight gate (F1), tracked outside the lane so the admit // branch's guard does not borrow what the collect branch holds mutably. let mut tail_patch_in_flight = false; - let mut tail_patch_lane_in_flight: Option<(u64, f32)> = None; + let mut tail_patch_lane_in_flight: Option = None; // Bounded: the worker `try_send`s from the PCM-forwarding thread. let (tp_tx, mut tp_rx) = mpsc::channel::(TAIL_PATCH_QUEUE_CAP); let (tp_done_tx, tp_done_rx) = std_mpsc::channel::(); @@ -476,21 +505,26 @@ pub(crate) async fn apple_stream_transcription_session( // only ever schedules and collects — inference never sits on the // event-drain path (F1). Some(req) = tp_rx.recv(), if !tail_patch_in_flight => { - let utterance_id = req.utterance_id; - let covered_through_secs = req.covered_through_secs; + let inflight = TailPatchInFlight { + utterance_id: req.utterance_id, + covered_through_secs: req.covered_through_secs, + span_map: req.span_map.clone(), + member_ids: req.member_ids.clone(), + }; tail_patch_lane.push_request(req); tail_patch_in_flight = true; - // One job is in flight, so one end boundary is enough. - // FuturesOrdered preserves the same request/result order. - tail_patch_lane_in_flight = Some((utterance_id, covered_through_secs)); + // One job is in flight; the coalesce map rides alongside so + // the completion can close every member seal. + tail_patch_lane_in_flight = Some(inflight); } Some(result) = tail_patch_lane.next() => { tail_patch_in_flight = false; - let (id, end) = tail_patch_lane_in_flight.take().unwrap_or_default(); - let completion = tail_patch_lane.finish_for_worker(id, end, result); + let inflight = tail_patch_lane_in_flight.take(); + let completion = tail_patch_lane.finish_for_worker(inflight, result); + let rejected_id = completion.utterance_id; if !tail_patch_lane.forward_completion_to_worker(&tp_done_tx, completion) { warn!( - utterance_id = id, + utterance_id = rejected_id, "Layer 1 completion rejected — Apple seal worker already closed" ); } @@ -656,6 +690,8 @@ struct AppleSealState { under_commit_escalations: u64, /// Layer 1 hand-off, present only when layered transcription is armed. tail_patch: Option>, + /// Sealed fragments waiting to share one Whisper window (~5 segments). + layer1_coalesce: Layer1Coalesce, /// Seals whose tail-patch request found the queue full (F1 backpressure). tail_patch_backpressure_drops: u64, /// Requests accepted by the Layer 1 queue that have not reported back yet. @@ -709,6 +745,7 @@ impl AppleSealState { unresolved_windows: 0, under_commit_escalations: 0, tail_patch: None, + layer1_coalesce: Layer1Coalesce::default(), tail_patch_backpressure_drops: 0, tail_patch_awaiting_completion: 0, sealed_prefix: String::new(), @@ -732,6 +769,77 @@ impl AppleSealState { } } + fn enqueue_layer1_piece(&mut self, piece: CoalescedPiece) -> bool { + if self.tail_patch.is_none() { + return false; + } + if self.layer1_coalesce.is_empty() { + self.layer1_coalesce + .set_neighbour(self.sealed_prefix.clone()); + } + let flushes = self.layer1_coalesce.push(piece, self.sample_rate); + if flushes.is_empty() { + // Held for a larger window. Still counts as queued so the + // no-Whisper fallback does not seal the fragment raw. + return true; + } + let mut sent = false; + for flush in flushes { + sent |= self.queue_layer1_flush(flush); + } + sent + } + + fn flush_layer1_coalesce(&mut self) -> bool { + self.layer1_coalesce + .force_flush() + .is_some_and(|flush| self.queue_layer1_flush(flush)) + } + + fn queue_layer1_flush(&mut self, flush: CoalesceFlush) -> bool { + let Some(tx) = self.tail_patch.as_ref() else { + return false; + }; + let provider_request = TailProviderRequest { + identity: TailRequestIdentity { + request_id: flush.primary_utterance_id, + range: TailSampleRange { + session: self.session_id.clone(), + capture_epoch: self.capture_epoch, + sample_start: flush.sample_start, + sample_end: flush.sample_end, + }, + }, + sample_rate: self.sample_rate, + language: None, + }; + match tx.try_send(TailPatchRequest { + utterance_id: flush.primary_utterance_id, + committed_text: flush.committed_text, + neighbour_context: flush.neighbour_context, + audio: flush.audio, + provider_request, + covered_through_secs: flush.covered_through_secs, + span_map: flush.spans, + member_ids: flush.member_ids, + }) { + Ok(()) => { + self.tail_patch_awaiting_completion = + self.tail_patch_awaiting_completion.saturating_add(1); + true + } + Err(error) => { + self.tail_patch_backpressure_drops = + self.tail_patch_backpressure_drops.saturating_add(1); + warn!( + utterance_id = flush.primary_utterance_id, + "Layer 1 tail-patch request dropped — queue full or lane gone: {error}" + ); + false + } + } + } + fn new_with_tail_patch_for_session( sample_rate: u32, session_id: String, @@ -762,14 +870,26 @@ impl AppleSealState { } else { completion.outcome }; - self.tail_patch_outcomes.insert(utterance_id, outcome); - self.progressive - .note_whisper_window_elapsed_with_provenance( - utterance_id, - completion.covered_through_secs, - evidence, - words, - ); + let member_ids = if completion.member_ids.is_empty() { + vec![(utterance_id, completion.covered_through_secs)] + } else { + completion.member_ids + }; + let split = split_outcome_for_members(outcome, &completion.span_map, &member_ids); + for (index, (id, end, member_outcome)) in split.into_iter().enumerate() { + self.tail_patch_outcomes.insert(id, member_outcome); + if index == 0 { + self.progressive + .note_whisper_window_elapsed_with_provenance( + id, + end, + evidence.clone(), + words.clone(), + ); + } else { + self.progressive.note_whisper_window_elapsed(id, end); + } + } self.emit_ready_progressive_seals(ev_tx, now_secs); // A window that finishes AFTER its span sealed had no reader: the only // drain of `tail_patch_outcomes` runs inside the seal tick, so a patch @@ -780,7 +900,9 @@ impl AppleSealState { // recovered speech was computed, stored, and never delivered. Ordering // is unchanged for the normal case (still emitted after `UtteranceFinal`, // which the seal already sent). - self.deliver_sealed_tail_patch(ev_tx, utterance_id); + for (id, _) in &member_ids { + self.deliver_sealed_tail_patch(ev_tx, *id); + } } /// Deliver a tail-patch outcome whose span is already sealed and emitted. @@ -1522,42 +1644,21 @@ fn seal_sliced_by_silero( let window = state .audio .window_by_samples(request_range.sample_start, request_range.sample_end); - let queued = if let (Some(window), Some(tx)) = (window, state.tail_patch.as_ref()) { - let committed_text = seal_span_text(&text, &state.sealed_prefix, false); - match tx.try_send(TailPatchRequest { - utterance_id, - committed_text, - neighbour_context: state.sealed_prefix.clone(), - audio: window.samples, - provider_request: TailProviderRequest { - identity: TailRequestIdentity { - request_id: utterance_id, - range: TailSampleRange { - session: state.session_id.clone(), - capture_epoch: state.capture_epoch, - sample_start: window.sample_start, - sample_end: window.sample_end, - }, - }, - sample_rate: state.sample_rate, - language: None, - }, - covered_through_secs: span_end, - }) { - Ok(()) => { - state.tail_patch_awaiting_completion = - state.tail_patch_awaiting_completion.saturating_add(1); - true - } - Err(error) => { - state.tail_patch_backpressure_drops = - state.tail_patch_backpressure_drops.saturating_add(1); - warn!( - utterance_id, - "Layer 1 tail-patch request dropped — queue full or lane gone: {error}" - ); - false - } + let queued = if let Some(window) = window { + if state.tail_patch.is_some() { + let committed_text = seal_span_text(&text, &state.sealed_prefix, false); + state.enqueue_layer1_piece(CoalescedPiece { + utterance_id, + committed_text, + audio: window.samples, + sample_start: window.sample_start, + sample_end: window.sample_end, + start_ts: span_start, + covered_through_secs: span_end, + segment_count: disjoint.len().max(1), + }) + } else { + false } } else { false @@ -1814,6 +1915,7 @@ fn seal_utterance_final( }) { return false; } + let segment_count = disjoint.len().max(1); state.pending_events.insert( utterance_id, PendingAppleSeal { @@ -1826,42 +1928,20 @@ fn seal_utterance_final( let window = resolve_sealed_audio_window(state, end_ts); let committed_text = seal_span_text(&after_lexicon, &state.sealed_prefix, false); - let queued = if let (Some(window), Some(tx)) = (window, state.tail_patch.as_ref()) { - let provider_request = TailProviderRequest { - identity: TailRequestIdentity { - request_id: utterance_id, - range: TailSampleRange { - session: state.session_id.clone(), - capture_epoch: state.capture_epoch, - sample_start: window.sample_start, - sample_end: window.sample_end, - }, - }, - sample_rate: state.sample_rate, - language: None, - }; - match tx.try_send(TailPatchRequest { - utterance_id, - committed_text, - neighbour_context: state.sealed_prefix.clone(), - audio: window.samples, - provider_request, - covered_through_secs: end_ts, - }) { - Ok(()) => { - state.tail_patch_awaiting_completion = - state.tail_patch_awaiting_completion.saturating_add(1); - true - } - Err(e) => { - state.tail_patch_backpressure_drops = - state.tail_patch_backpressure_drops.saturating_add(1); - warn!( - utterance_id, - "Layer 1 tail-patch request dropped — queue full or lane gone: {e}" - ); - false - } + let queued = if let Some(window) = window { + if state.tail_patch.is_some() { + state.enqueue_layer1_piece(CoalescedPiece { + utterance_id, + committed_text, + audio: window.samples, + sample_start: window.sample_start, + sample_end: window.sample_end, + start_ts, + covered_through_secs: end_ts, + segment_count, + }) + } else { + false } } else { false @@ -2258,6 +2338,7 @@ fn apple_stream_worker( // left open is sealed here, because no later // callback from this epoch can arrive. seal_open_partial(&mut state, &ev_tx, audio_secs); + let _ = state.flush_layer1_coalesce(); info!( audio_secs, silence_secs, @@ -2301,8 +2382,9 @@ fn apple_stream_worker( // Same seal-time correction as the phrase path — a stop mid-utterance must // not be the one route that commits uncorrected text. seal_open_partial(&mut state, &ev_tx, audio_secs); + let _ = state.flush_layer1_coalesce(); - // Every accepted Layer 1 request must close (success, no-change, or + // Every accepted Layer 1 request must close (success, no-change, or) // explicit skip) before the session task returns. This is bounded by the // queue cap and happens while the async side is still draining jobs. // @@ -2734,6 +2816,8 @@ mod tests { covered_through_secs: 2.0, outcome: TailPatchOutcome::NoChange, payload: Some(synthetic_tail_payload(1, whisper_range, vec![whisper_word])), + span_map: Vec::new(), + member_ids: Vec::new(), }, 5.0, ); @@ -2813,6 +2897,8 @@ mod tests { covered_through_secs: 2.0, outcome, payload: None, + span_map: Vec::new(), + member_ids: Vec::new(), }, 5.0, ); @@ -3163,6 +3249,7 @@ mod tests { &mut state, 3.0, ); + assert!(state.flush_layer1_coalesce()); let initial = tp_rx .try_recv() .expect("the real captured span must reach Layer 1"); @@ -3730,7 +3817,15 @@ mod tests { 1, &TailPatchConfig::default(), ); - let completion = lane.finish_for_worker(1, 2.0, Ok(synthetic_tail_job(1, outcome))); + let completion = lane.finish_for_worker( + Some(TailPatchInFlight { + utterance_id: 1, + covered_through_secs: 2.0, + span_map: Vec::new(), + member_ids: Vec::new(), + }), + Ok(synthetic_tail_job(1, outcome)), + ); assert!( completion .outcome @@ -3757,7 +3852,15 @@ mod tests { 2, &TailPatchConfig::default(), ); - let rejected = lane.finish_for_worker(2, 3.0, Ok(synthetic_tail_job(2, rejected_outcome))); + let rejected = lane.finish_for_worker( + Some(TailPatchInFlight { + utterance_id: 2, + covered_through_secs: 3.0, + span_map: Vec::new(), + member_ids: Vec::new(), + }), + Ok(synthetic_tail_job(2, rejected_outcome)), + ); assert!(!lane.forward_completion_to_worker(&done_tx, rejected)); assert_eq!( lane.replacements(), @@ -3786,6 +3889,10 @@ mod tests { 6.0, ); + assert!( + state.flush_layer1_coalesce(), + "one-seal tests flush the held window so the request is observable" + ); let req = tp_rx .try_recv() .expect("sealed utterance must enqueue a tail-patch request"); @@ -3894,6 +4001,7 @@ mod tests { &mut state, 6.0, ); + assert!(state.flush_layer1_coalesce()); let req = tp_rx .try_recv() .expect("sealed utterance enqueues a request"); @@ -3911,6 +4019,8 @@ mod tests { "no change", ), payload: None, + span_map: req.span_map, + member_ids: req.member_ids, }, 2.1, ); @@ -3989,24 +4099,24 @@ mod tests { let mut state = AppleSealState::new_with_tail_patch(TEST_SAMPLE_RATE, tp_tx); push_capture(&mut state, 10.0); - emit_stream_events( - vec![ - LiveStreamEvent::PhraseFinal { - text: "pierwsze zdanie".into(), - segments: vec![segment("pierwsze zdanie", 0.5, 2.0)], - }, - LiveStreamEvent::PhraseFinal { - text: "drugie zdanie".into(), - segments: vec![segment("drugie zdanie", 2.5, 4.0)], - }, - ], - &tx, - &mut state, - 10.0, - ); + let mut events = Vec::new(); + for i in 0..10 { + let start = i as f32 * 0.5; + events.push(LiveStreamEvent::PhraseFinal { + text: format!("segment {i}"), + segments: vec![segment(&format!("segment {i}"), start, start + 0.4)], + }); + } + emit_stream_events(events, &tx, &mut state, 10.0); - assert_eq!(state.sealed_count, 2, "seals never wait on the patch queue"); - assert_eq!(state.tail_patch_backpressure_drops, 1); + assert!( + state.tail_patch_backpressure_drops >= 1, + "a second 5-segment flush must drop when the queue already holds one job" + ); + assert!( + state.sealed_count >= 1, + "a dropped flush still seals Apple instead of stalling capture" + ); } /// Acceptance arm: an induced gap (Layer 0 committed a shorter span than diff --git a/core/pipeline/streaming/layer1_window.rs b/core/pipeline/streaming/layer1_window.rs new file mode 100644 index 00000000..74c420ca --- /dev/null +++ b/core/pipeline/streaming/layer1_window.rs @@ -0,0 +1,469 @@ +//! Layer 1 window: coalesce ~5 Apple segments into one Whisper job. +//! +//! Apple seals short fragments. Diffing each fragment against its own Whisper +//! window hits the change-ratio cap and leaves the chopped canvas standing. +//! This module joins a handful of those fragments — text, PCM, and char +//! offsets — so one decode can rewrite the sentence, then maps +//! `ReplaceRange` events back onto the original utterance ids. + +use crate::pipeline::contracts::{EngineEvent, LayerSource}; +use crate::stt::tail_patcher::{TailPatchOutcome, UnderCommit}; + +/// One utterance's slice inside a concatenated Layer 1 window. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConcatSpan { + pub utterance_id: u64, + /// Inclusive char start in the concatenated committed string. + pub start: usize, + /// Exclusive char end in the concatenated committed string. + pub end: usize, +} + +/// One sealed Apple fragment waiting to share a Whisper window. +#[derive(Debug, Clone)] +pub struct CoalescedPiece { + pub utterance_id: u64, + pub committed_text: String, + pub audio: Vec, + pub sample_start: u64, + pub sample_end: u64, + pub start_ts: f32, + pub covered_through_secs: f32, + pub segment_count: usize, +} + +/// Ready-to-send Layer 1 job built from one or more coalesced pieces. +#[derive(Debug, Clone)] +pub struct CoalesceFlush { + pub committed_text: String, + pub audio: Vec, + pub spans: Vec, + pub member_ids: Vec<(u64, f32)>, + pub neighbour_context: String, + pub covered_through_secs: f32, + pub sample_start: u64, + pub sample_end: u64, + pub primary_utterance_id: u64, +} + +/// Rolling buffer of sealed Apple fragments for one Layer 1 decode. +#[derive(Debug, Default)] +pub struct Layer1Coalesce { + pieces: Vec, + neighbour_before: String, + segments: usize, +} + +impl Layer1Coalesce { + /// Darek's live window: swap after about five Apple segments. + pub const TARGET_SEGMENTS: usize = 5; + /// Hard cap so a long run-on still gets a decode. + pub const MAX_AUDIO_SECS: f32 = 16.0; + /// A pause this long is a sentence boundary — flush what we have. + pub const PAUSE_SECS: f32 = 1.2; + + pub fn is_empty(&self) -> bool { + self.pieces.is_empty() + } + + /// Remember the canvas already sealed before the next piece. + pub fn set_neighbour(&mut self, neighbour: impl Into) { + if self.pieces.is_empty() { + self.neighbour_before = neighbour.into(); + } + } + + /// Push a sealed fragment. Returns a flush when the window is full, or + /// when `piece` starts after a sentence pause (the previous window first). + pub fn push(&mut self, piece: CoalescedPiece, sample_rate: u32) -> Vec { + let mut out = Vec::new(); + if let Some(last) = self.pieces.last() { + let gap = piece.start_ts - last.covered_through_secs; + if gap >= Self::PAUSE_SECS { + if let Some(flush) = self.take_flush() { + out.push(flush); + } + } + } + if self.pieces.is_empty() && self.neighbour_before.is_empty() { + // Neighbour is set by the caller before the first push of a window. + } + self.segments = self.segments.saturating_add(piece.segment_count.max(1)); + self.pieces.push(piece); + if self.should_flush(sample_rate) { + if let Some(flush) = self.take_flush() { + out.push(flush); + } + } + out + } + + /// Drain whatever is held — session end, epoch sleep, or test. + pub fn force_flush(&mut self) -> Option { + self.take_flush() + } + + fn should_flush(&self, sample_rate: u32) -> bool { + if self.pieces.is_empty() { + return false; + } + if self.segments >= Self::TARGET_SEGMENTS { + return true; + } + let samples: u64 = self + .pieces + .iter() + .map(|p| p.sample_end.saturating_sub(p.sample_start)) + .sum(); + let rate = sample_rate.max(1) as f32; + (samples as f32 / rate) >= Self::MAX_AUDIO_SECS + } + + fn take_flush(&mut self) -> Option { + if self.pieces.is_empty() { + return None; + } + let pieces = std::mem::take(&mut self.pieces); + self.segments = 0; + let neighbour_context = std::mem::take(&mut self.neighbour_before); + Some(build_flush(pieces, neighbour_context)) + } +} + +fn build_flush(pieces: Vec, neighbour_context: String) -> CoalesceFlush { + let mut committed_text = String::new(); + let mut audio = Vec::new(); + let mut spans = Vec::with_capacity(pieces.len()); + let mut member_ids = Vec::with_capacity(pieces.len()); + let mut offset = 0usize; + let sample_start = pieces.first().map_or(0, |p| p.sample_start); + let sample_end = pieces.last().map_or(0, |p| p.sample_end); + let covered_through_secs = pieces.last().map_or(0.0, |p| p.covered_through_secs); + let primary_utterance_id = pieces.last().map_or(0, |p| p.utterance_id); + for (i, piece) in pieces.into_iter().enumerate() { + if i > 0 { + committed_text.push(' '); + offset += 1; + } + let start = offset; + committed_text.push_str(&piece.committed_text); + offset += piece.committed_text.chars().count(); + spans.push(ConcatSpan { + utterance_id: piece.utterance_id, + start, + end: offset, + }); + member_ids.push((piece.utterance_id, piece.covered_through_secs)); + audio.extend_from_slice(&piece.audio); + } + CoalesceFlush { + committed_text, + audio, + spans, + member_ids, + neighbour_context, + covered_through_secs, + sample_start, + sample_end, + primary_utterance_id, + } +} + +/// Map concat-space `ReplaceRange` events onto utterance-local offsets. +/// +/// A patch that stays inside one span is remapped 1:1. A patch that crosses +/// a join lands on the first overlapped utterance from the local start to +/// that utterance's end — later fragments in the same cross are left intact +/// so we never wipe a committed span we cannot address cleanly. +pub fn remap_concat_events(events: Vec, spans: &[ConcatSpan]) -> Vec { + if spans.is_empty() { + return events; + } + if spans.len() == 1 { + return events + .into_iter() + .map(|event| remap_single(event, spans[0].utterance_id)) + .collect(); + } + let mut out = Vec::with_capacity(events.len()); + for event in events { + match event { + EngineEvent::ReplaceRange { + start, + end, + text, + source, + .. + } => { + if let Some(mapped) = remap_range(start, end, text, source, spans) { + out.push(mapped); + } + } + other => out.push(other), + } + } + out +} + +fn remap_single(event: EngineEvent, utterance_id: u64) -> EngineEvent { + match event { + EngineEvent::ReplaceRange { + start, + end, + text, + source, + .. + } => EngineEvent::ReplaceRange { + utterance_id, + start, + end, + text, + source, + }, + other => other, + } +} + +fn remap_range( + start: usize, + end: usize, + text: String, + source: LayerSource, + spans: &[ConcatSpan], +) -> Option { + let first = span_owning(start, spans)?; + let last_pos = end.saturating_sub(1).max(start); + let last = span_owning(last_pos, spans).unwrap_or(first); + let local_start = start.saturating_sub(first.start); + let local_end = if first.utterance_id == last.utterance_id { + end.saturating_sub(first.start).min(first.end - first.start) + } else { + first.end - first.start + }; + Some(EngineEvent::ReplaceRange { + utterance_id: first.utterance_id, + start: local_start, + end: local_end, + text, + source, + }) +} + +fn span_owning(pos: usize, spans: &[ConcatSpan]) -> Option<&ConcatSpan> { + spans + .iter() + .find(|span| pos >= span.start && pos < span.end) + .or_else(|| { + // Zero-width insert exactly on a join belongs to the previous span. + spans.iter().rev().find(|span| pos == span.end) + }) +} + +/// Split a remapped outcome so each member utterance can seal independently. +pub fn split_outcome_for_members( + outcome: TailPatchOutcome, + spans: &[ConcatSpan], + member_ids: &[(u64, f32)], +) -> Vec<(u64, f32, TailPatchOutcome)> { + if member_ids.is_empty() { + return Vec::new(); + } + if spans.len() <= 1 { + let (id, end) = member_ids[0]; + return vec![(id, end, outcome)]; + } + match outcome { + TailPatchOutcome::NoChange => member_ids + .iter() + .map(|&(id, end)| (id, end, TailPatchOutcome::NoChange)) + .collect(), + TailPatchOutcome::Skipped { code, reason } => { + let mut out = Vec::with_capacity(member_ids.len()); + out.push(( + member_ids[0].0, + member_ids[0].1, + TailPatchOutcome::Skipped { code, reason }, + )); + for &(id, end) in &member_ids[1..] { + out.push((id, end, TailPatchOutcome::NoChange)); + } + out + } + TailPatchOutcome::Patches(events) => { + let remapped = remap_concat_events(events, spans); + group_events(remapped, member_ids) + } + TailPatchOutcome::UnderCommit(under) => { + let residual = under.residual_required; + let remapped = remap_concat_events(under.appends.clone(), spans); + group_events(remapped, member_ids) + .into_iter() + .map(|(id, end, oc)| { + let appends = oc.into_events(); + ( + id, + end, + TailPatchOutcome::UnderCommit(UnderCommit { + appends, + residual_required: residual && id == member_ids[0].0, + committed_tokens: under.committed_tokens, + retranscribed_tokens: under.retranscribed_tokens, + committed_chars: under.committed_chars, + retranscribed_chars: under.retranscribed_chars, + commit_ratio: under.commit_ratio, + }), + ) + }) + .collect() + } + } +} + +fn group_events( + events: Vec, + member_ids: &[(u64, f32)], +) -> Vec<(u64, f32, TailPatchOutcome)> { + let mut out = Vec::with_capacity(member_ids.len()); + for &(id, end) in member_ids { + let evs: Vec = events + .iter() + .filter(|event| match event { + EngineEvent::ReplaceRange { utterance_id, .. } => *utterance_id == id, + _ => false, + }) + .cloned() + .collect(); + let oc = if evs.is_empty() { + TailPatchOutcome::NoChange + } else { + TailPatchOutcome::Patches(evs) + }; + out.push((id, end, oc)); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn piece(id: u64, text: &str, start_ts: f32, end_ts: f32, segs: usize) -> CoalescedPiece { + let rate = 16_000u64; + CoalescedPiece { + utterance_id: id, + committed_text: text.to_string(), + audio: vec![0.0; ((end_ts - start_ts) * rate as f32) as usize], + sample_start: (start_ts * rate as f32) as u64, + sample_end: (end_ts * rate as f32) as u64, + start_ts, + covered_through_secs: end_ts, + segment_count: segs, + } + } + + #[test] + fn flushes_after_five_segments() { + let mut buf = Layer1Coalesce::default(); + buf.set_neighbour("already sealed"); + let mut flushes = Vec::new(); + for i in 0..5 { + flushes.extend(buf.push(piece(i + 1, "słowo", i as f32, i as f32 + 0.4, 1), 16_000)); + } + assert_eq!(flushes.len(), 1); + assert_eq!(flushes[0].spans.len(), 5); + assert_eq!(flushes[0].committed_text, "słowo słowo słowo słowo słowo"); + assert_eq!(flushes[0].neighbour_context, "already sealed"); + assert_eq!(flushes[0].primary_utterance_id, 5); + assert!(buf.is_empty()); + } + + #[test] + fn pause_flushes_the_previous_window() { + let mut buf = Layer1Coalesce::default(); + assert!(buf.push(piece(1, "raz", 0.0, 0.5, 1), 16_000).is_empty()); + let flushes = buf.push(piece(2, "dwa", 3.0, 3.4, 1), 16_000); + assert_eq!(flushes.len(), 1); + assert_eq!(flushes[0].spans.len(), 1); + assert_eq!(flushes[0].committed_text, "raz"); + assert!(!buf.is_empty()); + } + + #[test] + fn remap_stays_inside_the_owning_utterance() { + let spans = vec![ + ConcatSpan { + utterance_id: 1, + start: 0, + end: 4, + }, + ConcatSpan { + utterance_id: 2, + start: 5, + end: 9, + }, + ]; + // "ala ma" — replace "ma" (chars 5..7) on utterance 2. + let events = vec![EngineEvent::ReplaceRange { + utterance_id: 99, + start: 5, + end: 7, + text: "psa".into(), + source: LayerSource::TailPatch, + }]; + let remapped = remap_concat_events(events, &spans); + match &remapped[0] { + EngineEvent::ReplaceRange { + utterance_id, + start, + end, + text, + .. + } => { + assert_eq!(*utterance_id, 2); + assert_eq!(*start, 0); + assert_eq!(*end, 2); + assert_eq!(text, "psa"); + } + other => panic!("expected remap, got {other:?}"), + } + } + + #[test] + fn crossing_patch_lands_on_the_first_span() { + let spans = vec![ + ConcatSpan { + utterance_id: 1, + start: 0, + end: 4, + }, + ConcatSpan { + utterance_id: 2, + start: 5, + end: 9, + }, + ]; + let events = vec![EngineEvent::ReplaceRange { + utterance_id: 99, + start: 2, + end: 8, + text: "pełne zdanie".into(), + source: LayerSource::TailPatch, + }]; + let remapped = remap_concat_events(events, &spans); + match &remapped[0] { + EngineEvent::ReplaceRange { + utterance_id, + start, + end, + text, + .. + } => { + assert_eq!(*utterance_id, 1); + assert_eq!(*start, 2); + assert_eq!(*end, 4); + assert_eq!(text, "pełne zdanie"); + } + other => panic!("expected first-span landing, got {other:?}"), + } + } +} diff --git a/core/pipeline/streaming/mod.rs b/core/pipeline/streaming/mod.rs index cb078180..25b463c0 100644 --- a/core/pipeline/streaming/mod.rs +++ b/core/pipeline/streaming/mod.rs @@ -13,6 +13,8 @@ pub(crate) mod apple_live_session; pub(crate) mod correction; /// Buffered "typing" emission of transcript deltas. pub(crate) mod emitter; +/// Coalesce ~5 Apple segments into one Layer 1 Whisper window. +pub(crate) mod layer1_window; /// Assembly of the live transcript from engine events. pub mod live_assembly; /// Bounded per-session PCM retention, so a sealed utterance can be re-read for tail-patch. diff --git a/docs/STT_CONTRACT.md b/docs/STT_CONTRACT.md index fa95fb39..6bd69d94 100644 --- a/docs/STT_CONTRACT.md +++ b/docs/STT_CONTRACT.md @@ -214,7 +214,7 @@ file-pass belongs only to explicit retranscribe surfaces. | Call site | When | Function / transport | Engine rule | | --------------------- | ---------------- | ---------------------------------------------- | -------------------------------------------------------- | | Live Layer 0 | during recording | Apple progressive | committed canvas floor | -| Live Layer 1 local | during recording | bounded Whisper windows | gap/tail fill; aligned same-utterance substitutions | +| Live Layer 1 local | during recording | Whisper on ~5 Apple segments | aligned sentence swap on the joined window | | Live Layer 1 cloud | during recording | Voice Lab WSS | normalized gap/tail fill; same substitution rule | | Explicit Retranscribe | operator action | local completed-file decode or cloud multipart | may replace the selected artifact, never the live canvas | diff --git a/site/src/pages/voice/lab.astro b/site/src/pages/voice/lab.astro index 078fd77b..43ce0b37 100644 --- a/site/src/pages/voice/lab.astro +++ b/site/src/pages/voice/lab.astro @@ -1,8 +1,8 @@ --- import Layout from '../../layouts/Layout.astro'; import Nav from '../../components/Nav.astro'; -import Footer from '../../components/Footer.astro'; import { page } from '../../lib/asset'; +import '../../styles/lab-shell.css'; --- -