Compare commits

..
Author SHA1 Message Date
mergetestandClaude Fable 5 dc984ac195 ci(test-install): round 2 — CPU-force macOS smoke (runner MPS pool broken), graceful skip for Intel-mac torch gap
Two first-run smoke fixes from run 28576798706:
- macOS Apple Silicon: GH runners advertise torch MPS but the virtualized
  Metal shared pool cannot allocate (256-byte alloc OOMs). Hide MPS via a
  CI-only sitecustomize on PYTHONPATH so device auto-detect picks CPU.
- macOS Intel: uv sync --frozen fails because torch 2.8.0 ships no macOS
  x86_64 wheels (PyTorch dropped Intel macs after 2.2.x) — a real end-user
  first launch fails identically. Surface as a loud warning + skip instead
  of a permanently red leg; product-level dependency gap reported separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 14:20:54 +05:30
mergetestandClaude Fable 5 15e5efed5f ci: add test-install workflow — cross-platform installer + first-run-model smoke (no release)
New workflow (dispatch + push on ci/test-install only) that mirrors
release.yml's bundle steps (uv/ffmpeg sidecars, same tauri build targets)
but unsigned, read-only token, artifacts-only — no tag, no release, no
updater manifest, no version stamping. A parallel per-OS job replicates the
app's first launch headlessly (uv sync --frozen --no-dev), boots the
backend, waits for the required default model download, and runs one real
/generate synthesis.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 14:06:02 +05:30
62035435e8 fix(desktop): always open maximized (not fullscreen) — stop window-state restoring stale geometry (#881)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 14:03:23 +05:30
4eed552153 fix(engines): Confucius4-TTS validated E2E — clone sys.path import, 22.05 kHz, real install docs (#590) (#872)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 04:49:50 +05:30
ae516cae63 fix(asr): un-gate Parakeet TDT from CUDA-only — measured ~10× realtime on CPU (#871)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 04:49:42 +05:30
85fd9ca799 fix(asr): VRAM preflight before whisperx load — no more native OOM abort on 8 GB cards (#723) (#870)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 04:48:51 +05:30
Palash Debnathandmergetest 86c701bff9 fix(translate): bound the whole cinematic/autofit pass so a slow LLM can't hang "Translating…" (#868)
The per-segment call already has a 45s timeout and concurrency is capped, but a
slow or rate-limited provider on a large dub (hundreds of segments) can still keep
the "Translating…" spinner spinning for minutes as segments queue through the
bounded pool. There was no ceiling on the *whole* pass.

Add an overall wall-clock budget, OMNIVOICE_CINEMATIC_BUDGET_S (default 180s,
<=0 disables). Segments that finish in time keep their cinematic refine; any still
in-flight when the budget hits is cancelled and degrades to its literal (Fast)
translation with error="cinematic-budget", so the translate ALWAYS returns instead
of hanging. Order and length of the result are preserved. Abandoned executor
threads follow the same fire-and-forget pattern as the GPU-pool wedge guard (#730).

Regression tests: a 3s-per-segment refine under a 0.3s budget returns in <2s with
literal fallbacks; budget<=0 runs every segment to completion.

Co-authored-by: mergetest <test@local>
2026-07-02 00:39:36 +05:30
Palash Debnathandmergetest f4e318f9f2 fix(translate+dub): wire Cinematic/Autofit to the LLM Providers registry; retry a wedged transcribe chunk instead of dropping it (#867)
Two bugs from real reports:

1. LLM not wired — translator._llm_client()/_llm_model() read TRANSLATE_*/OPENAI_*
   directly, bypassing the LLM Providers registry (#854). So a provider set up
   in Settings → LLM Providers never powered Cinematic/Autofit. Now resolves the
   ACTIVE provider (base_url/key/model) via llm_providers; the 'custom' provider
   still maps TRANSLATE_* so legacy env setups keep working.

2. Transcription 'missing the beginning' — the chunked dub transcribe dropped a
   whole chunk's window on failure/timeout (returned empty segments, no retry).
   A transient wedge on the FIRST chunk (whisperx cold-loads its model there, the
   #730 hang) therefore lost the start and left only middle+end. Now retries a
   failed/timed-out chunk once on a fresh pool (OMNIVOICE_TRANSCRIBE_CHUNK_ATTEMPTS,
   default 2) so the recovered chunk fills the hole.

Imports + dub_transcribe/translator/llm_providers tests green.

Co-authored-by: mergetest <test@local>
2026-07-02 00:18:35 +05:30
287a6cb3a2 refactor(gallery): cleaner, elegant voice cards — tokens over hardcoded surfaces, borderless state (#866)
Redesign ArchetypeCard for a calmer visual hierarchy and design-token
surfaces, no behavior change (all props/handlers/loading states identical).

- Replace hardcoded surfaces with tokens: chips/wand/preview bg → tokens
  (bg-white/[0.05] → --color-bg-elev-2, bg-white/[0.03] hover → --chrome-hover-bg),
  text → --color-fg / --color-fg-muted / --color-fg-subtle, and the literal
  #1d2021 hover text on Use voice → --color-fg-inverse.
- Borderless by direction: drop the hover/state border classes on the action
  buttons and the card; convey hover via background tint + text color and the
  playing state via an accent box-shadow ring (no literal/token borders).
- Hierarchy: name is the focal point (semibold, --color-fg); metadata line is
  smaller/muted (--color-fg-muted) so it recedes.
- Chip row renders only when there are chips (no empty min-h reserve); the grid
  stretches rows so mt-auto still bottom-aligns actions.
- Accent used tastefully: tinted Use voice → solid accent on hover/focus with
  inverse text; favorite star stays subtle until hover/active. Focus-visible
  rings intact.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 23:47:52 +05:30
Palash Debnathandmergetest c29c276dc1 fix(dub): compact + responsive header — tighter stepper/title/actions, drop hardcoded borders (#865)
- Stepper (inline): smaller step gap/font (0.66rem), 19px icons, 10px connectors
  → the 6 stages take far less width so title + actions fit before wrapping.
- Title: lighter weight (medium/0.78rem), normal-case, min-w-0 truncation; meta
  0.68rem; project name truncates too. Tighter header padding + gaps.
- Removed the hardcoded header border + border-left divider (borderless) and the
  rgba bg → token --color-bg-elev-1.

Co-authored-by: mergetest <test@local>
2026-07-01 23:46:58 +05:30
3d0705fdb7 feat(engines): Confucius4-TTS — finalized (API-validated + unit-tested; opt-in, GPU run pending) (#590) (#637)
* feat(engines): Confucius4-TTS scaffold (opt-in, needs hardware validation) (#590)

Plumbing for netease-youdao's Confucius4-TTS — LLM-based 14-language
cross-lingual zero-shot voice cloning, Apache-2.0 — mirroring the opt-in
subprocess-venv pattern of dots.tts / MOSS-TTS-v1.5:

- engines/confucius4/__init__.py: Confucius4Backend(SubprocessBackend), CUDA-only
  (gpu_compat=("cuda",)), language passthrough, ref_audio→prompt_wav. is_available
  reports a clear reason and stays unavailable without a clone.
- bootstrap.py: dedicated Python 3.10 venv resolution (user clone-level venv →
  package venv → uv bootstrap), import-probed on `confuciustts`.
- main.py: sidecar speaking the same length-prefixed JSON-over-stdio protocol as
  the other engines, calling ConfuciusTTS(config_path, device).generate(text,
  lang, prompt_wav).
- Registered lazily in _LAZY_REGISTRY; docs/engines/confucius4-tts.md.

Gated behind OMNIVOICE_CONFUCIUS4_TTS_DIR — inert on every default install, never
imports the upstream package unless opted in. The sidecar's synthesis API is
derived from the upstream README and is NOT yet validated on a CUDA box; the
module, docs, and CHANGELOG all flag this. 4 tests pin registration +
inert-by-default. No version bump.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(#590): register Confucius4 in install-hints + docs inventory (CI gates)

Registering the engine tripped two completeness gates: every backend needs an
install_hint (test_issue_fixes) and every registry engine must appear in the
tts_engines docs inventory + README (check-docs-drift). Add the install_hint,
the docs/features.yaml entry, and the README engine-table row (with the scaffold
caveat). Docs-drift clean; gates pass. No version bump.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(confucius4): finalize — validate API vs upstream, add 22 sidecar unit tests, document external deps (Amphion/w2v-bert/weights)

The synthesis API (ConfuciusTTS(config_path, device) → generate(text, lang,
prompt_wav) → tensor, model.sample_rate) is confirmed against the
netease-youdao/Confucius4-TTS repo. Added runnable unit tests for the sidecar's
pure logic (language norm, tensor→PCM mono/stereo/clip, config resolution, wire
framing, synthesize dispatch with the model mocked) — 22 cases, all green.
Docs now list the external deps (Amphion/MaskGCT codec, facebook/w2v-bert-2.0,
~2-4GB HF checkpoint) and CUDA 12.6. Softened the scaffold warnings to reflect
API-validated + unit-tested status; a one-time CUDA GPU run is still needed to
confirm live inference + true sample rate.

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 23:37:40 +05:30
8f7b242610 fix(theme): remove stray token-border frames + make accent family theme-track (#864)
Task 1 — physically remove the token-based structural border utilities that
kept rendering stray frames (history panels, cards, rows, settings) whenever a
`--*-border` token didn't resolve transparent (theme re-declare, or bare
`border` = currentColor under Tailwind v4). Converted every
`border[-trbl]-[var(--chrome-border…)]` / `[var(--color-border…)]` (83
occurrences across 32 components/pages) to `border-transparent` — keeps the 1px
box (no layout shift, matches the badge.tsx convention), drops the frame, and
active/selected state stays visible via the existing bg-tint/text cues. Also
converted button.tsx's `border-border`/`border-input` variants and Panel's
header divider. Kept: focus-visible rings, aria-invalid, dashed drop-zones, and
the waveform/segment editor. Strengthened tests/test_no_literal_borders.py with
`test_no_token_border_utilities_in_jsx` so a reintroduced token border fails CI
(allowlists the editor + shadcn form-control primitives).

Task 2 — aliased the accent family in the base :root to the themed brand token
(`--chrome-accent: var(--color-brand)`, `-bg`/`-border` via color-mix), so
donate/support/commercial CTAs, active tabs, .btn-primary, status pills and
GoalBar/Pip track the active theme instead of the fixed pink. Replaced the
hardcoded `#d3869b`/`#f3a5b6`/`rgba(243,165,182,…)` pinks and the DONATE_HUE
constant in SupportPage.jsx with `var(--color-brand)` tints.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 22:06:56 +05:30
Palash Debnathandmergetest d0aa2fbd52 fix(ui): remove the SECOND history aside's border (missed by #860's replace_all — different indentation) + filter-chip borders (#862)
Co-authored-by: mergetest <test@local>
2026-07-01 21:39:34 +05:30
df845f45a0 fix(theme): theme-aware native selects — color-scheme per theme + token-driven caret/options/focus ring (#861)
Native <select> chrome (option popups, scrollbars, form UA elements) rendered
in the OS light scheme on dark themes because `color-scheme` was never set as a
property (only a `prefers-color-scheme: light` media query existed, which is
not the same thing). The dropdown caret was also a hardcoded gray SVG that
ignored theme + accent. Owner report: gallery/install/language selects looked
wrong for accent + dark/light.

- Declare `color-scheme: dark` on :root (default Gruvbox Dark) and re-assert it
  on every [data-theme] block. All six shipped themes are dark (verified by
  their real --color-bg lightness: midnight #0f172a, nord #2e3440, solarized
  #002b36, rose-pine #191724, catppuccin #1e1e2e), so all get `dark`. The empty
  auto/light scaffold is left at dark (no light theme ships yet; a light value
  there would mismatch the still-dark surface) with a note for when one lands.
- Replace the hardcoded %23a1a1aa caret in select.input-base and .ui-select with
  a single --select-caret token, overridden per theme to that theme's muted
  foreground (a background-image SVG can't read a CSS var, so the color is baked
  per theme). Both selects consume the one token (DRY).
- Paint <option>/<optgroup> from --color-bg-elev-1 / --chrome-fg so Chromium
  (Windows/Linux) popups match; macOS WebKit popups follow color-scheme.
- Give selects a themed focus-visible ring (--color-ring → --color-brand) to
  match the buttons/checkboxes tokenized last phase, instead of the
  non-theme-tracking --chrome-accent.

Borderless guardrail and :focus-visible rings intact. Covers every named
native select (DubTab/AudiobookTab language, DubLeftColumn engine, gallery,
ui/Input.jsx Select, VoicePreview, StoriesEditor, ExportModal, DubSegmentRow)
via the shared input-base/ui-select rules — no per-call-site edits needed.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 21:28:05 +05:30
Palash Debnathandmergetest d55976eff0 fix(ui): physically remove the panel-frame border utilities on history + active-voice panels (#860)
#857 zeroed the border TOKENS but left token-based border utilities
(border-t-[var(--chrome-border-strong,…)], border-b-[var(--chrome-border)]) in
the JSX — a fragile indirection that still renders a line if the token doesn't
resolve transparent (stale HMR / the pre-zero rgba base value). Per 'no borders
whatsoever', remove the utilities outright from the WorkspaceHistory (dub +
regular history) and WorkspaceVoices (active-voice) panel frames; the
active-voice card keeps its background tint as the selection cue.

Co-authored-by: mergetest <test@local>
2026-07-01 21:21:37 +05:30
b2e578b21d style(controls): unify buttons/inputs/selects/checkboxes/toggles onto design tokens (Phase 2) (#859)
* style(controls): Phase 2 — tokenize + unify buttons/inputs/checkboxes/toggles onto design tokens

Phase 2 of the borderless styling pass. Converts interactive controls to
design tokens with a cohesive, theme-tracking active/checked affordance,
building on Phase 1's borderless base. No behavior changes — visual/token only.

Shared primitives (highest leverage):
- ui/button.tsx: replace literal `hover:bg-white/[0.04]` (subtle/softGhost/
  chip/preset/iconBtn) with `hover:bg-[var(--chrome-hover-bg)]`.
- ui/toggle.tsx (seg): drop hardcoded `text-[#fff9ef]` active text and hover
  white literal for `text-fg` + `--chrome-hover-bg`.
- ui/Segmented.jsx: recessed track `bg-black/[0.28]` -> `bg-bg-elev-2`.
- index.css: native checkbox `accent-color` and range-input thumb/track/active
  moved off non-themed `--chrome-accent` / legacy `--text-primary`/`--primary`/
  raw rgba onto themed `--color-brand` / `--color-fg` / `--color-bg-elev-2` +
  radius/shadow/duration tokens. Checked state is now brand-tinted and recolors
  per [data-theme], matching sliders/segmented/primary buttons.
- SettingsToggle: on-state -> `--color-brand`, focus ring -> `--color-ring`,
  knob shadow -> `--shadow-sm`, radius -> `--radius-pill`.

Control call sites (exact-token swaps, remove hardcoded hex/rgba):
- Unified every checkbox `accent`/`accentColor` override onto `--color-brand`
  (DubbingDemo, DubRightColumn, DubLeftColumn, IdleSkeleton, DubFooter,
  DubSegmentRow, AppearancePanel range).
- FooterBtn blue/orange tones -> --color-info/--color-warn.
- MicButton danger tint/neutral fill -> tokens.
- DubLeftColumn install CTAs + engine chip -> brand tokens + --radius-pill.
- NetworkToggle: neutral fg/hover tokens; removed stray `#504945` fallback border.

Focus rings and the borderless guardrail (tests/test_no_literal_borders.py)
intact. build + format:check + lint (0 errors) + guardrail all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(dub): assert the tokenized brand-accent install button (bg-[var(--color-brand)]) after Phase 2

Phase 2 tokenized the highlighted Install CTA from the hardcoded #d3869b to
var(--color-brand); update the two assertions to match.

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 20:50:04 +05:30
Palash Debnathandmergetest f7b7e2c13a chore(version): pin main to 0.3.8 (revert the post-release auto-bump) (#858)
* Revert "chore(version): main -> 0.3.9 after v0.3.8 release"

This reverts commit 7489bef085.

* chore(release): gate the post-release version-bump behind AUTO_VERSION_BUMP (owner controls bumps)

Owner decision (2026-07-01): keep main pinned to the released version and bump
only on explicit request. The version-bump job now runs only when the repo
variable AUTO_VERSION_BUMP == 'true' (default off), so releasing no longer
auto-rolls main to +1. Documented the override in CLAUDE.md's versioning rule.

---------

Co-authored-by: mergetest <test@local>
2026-07-01 20:18:15 +05:30
1550ce2976 feat(ui): app-wide decorative border/divider removal (keep focus rings, bg cues) (#857)
Remove decorative borders, hairlines, dividers, and panel frames across the
frontend for a flat, frameless look. Selection/active state and input fields
stay perceivable via background/elevation cues; keyboard :focus-visible focus
rings are preserved.

index.css:
- Append a final `:root, [data-theme]` block zeroing every border token
  (--color-border[-strong|-warm], --chrome-border[-strong], --chrome-accent-
  border, --glass-border) → transparent. Kept last so it wins over the default
  root and all [data-theme] overrides. --color-ring / --focus-ring untouched.
- .glass-panel::before decorative top-highlight → display:none.
- Zero 22 neutral (white/black rgba) literal hairline borders (history divider,
  segment table, override toggles, etc.).
- Selection cue: .project-active border → transparent, stronger bg tint.
- Inputs: .input-base / textarea.input-base get a recessed --color-bg-elev-2
  fill (was --chrome-hover-bg / --chrome-bg which equalled the panel bg) so
  fields stay visible without a border; subtle elevation shift on focus.
- .history-kind--audio colored pill border → bg tint.

JSX/TSX:
- 79 literal-color border utilities (border-white/black, border-[#|rgba|
  color-mix]) → border-transparent (width kept: app ships without Preflight,
  so a bare button keeps a UA border).
- badge.tsx / button.tsx colored tone/active variants → border-transparent
  (bg fill + text color carry the tone); outline badge gains a bg.
- Bare `border` on shadcn card/dialog/select/dropdown content + ui/Tabs →
  border-transparent (no-Preflight currentColor line).
- Active/selected chips (StoriesEditor track, HfTokenCard, FirstRunSetup
  option, WorkspaceHistory/Sidebar/WorkspaceVoices kind pills) → background
  tint instead of accent border.
- 9 inline style borderColor → removed or swapped to a background tint
  (selection/error stay perceivable).

Kept intentionally: :focus-visible / border-ring focus rings, aria-invalid
error borders, the waveform segment editor (SegmentTrack) functional
boundaries/handles/selection, drag-active dropzone accent, and severity-token
state cues — these are functional affordances, not decorative chrome.

Guardrail: tests/test_no_literal_borders.py fails if the regression class
reappears (neutral literal borders in index.css, literal-color border
utilities / inline borderColor in jsx/tsx) and asserts focus tokens survive.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 20:02:18 +05:30
github-actions[bot] 7489bef085 chore(version): main -> 0.3.9 after v0.3.8 release 2026-07-01 13:57:41 +00:00
91 changed files with 2516 additions and 371 deletions
+11 -6
View File
@@ -770,13 +770,18 @@ jobs:
print(f"preview manifest OK: {v} platforms={sorted(pk)}")
PY
# ── Post-release version bump (versioning hard rule, owner-set 2026-06-11) ──
# main is always last-release + 1 patch. The moment a stable v* tag is
# released, bump the three version sources on main to the next patch so every
# PR and preview build identifies as the next version. Pushes directly to
# main with the workflow token (a metadata-only commit; CI runs on PRs).
# ── Post-release version bump (OWNER-GATED as of 2026-07-01) ──────────────
# Previously auto-ran after every stable v* tag to keep main = release + 1.
# The owner now controls bumps manually ("keep 0.3.8; I say when to bump"), so
# this job is OPT-IN: it runs ONLY when the repo variable AUTO_VERSION_BUMP is
# set to 'true' (Settings → Secrets and variables → Actions → Variables).
# Unset/anything-else → main stays at whatever it is after release. Re-enable
# by setting the variable; disable again by unsetting it.
version-bump:
if: github.event_name == 'push' && github.ref_type == 'tag' && !contains(github.ref, '-')
if: >-
github.event_name == 'push' && github.ref_type == 'tag'
&& !contains(github.ref, '-')
&& vars.AUTO_VERSION_BUMP == 'true'
runs-on: ubuntu-22.04
permissions:
contents: write
+570
View File
@@ -0,0 +1,570 @@
# Test-install pipeline — CI-only verification that the app INSTALLS and
# FIRST-RUNS (including the required default TTS model) on every supported
# platform, producing throwaway installer artifacts.
#
# This workflow NEVER releases anything:
# - no tag, no GitHub Release, no updater manifest, no publishing
# - unsigned builds (updater artifacts disabled via a --config overlay, so
# no TAURI_SIGNING_PRIVATE_KEY / APPLE_* secrets are needed or read)
# - no version stamping/bumping — bundles carry whatever version is in git
# - installers land as short-lived workflow ARTIFACTS (retention: 7 days)
#
# Two independent matrices per platform:
# build — mirrors release.yml's bundle steps (uv + ffmpeg sidecars,
# same `tauri build --target --bundles` invocation) minus
# every tag/sign/publish part, then re-runs release.yml's
# installer structural smoke (DMG mount / MSI quiet install /
# AppImage extract) and uploads the installers.
# first-run-smoke— sets up the backend venv exactly like the app's own first
# launch (`uv sync --frozen --no-dev`, the command
# lib.rs::ensure_venv_ready runs), boots the backend
# headless, waits for the REQUIRED default model
# (k2-fsa/OmniVoice, ~2.4 GB) to download + load, then runs
# one real POST /generate synthesis and validates the WAV.
#
# Triggers: manual dispatch, or a push to the ci/test-install working branch
# (so the run starts straight from the branch without merging to main).
name: Test Install (no release)
on:
workflow_dispatch:
push:
branches: ["ci/test-install"]
# Read-only token — this workflow must be structurally incapable of creating
# tags/releases or pushing version bumps.
permissions:
contents: read
concurrency:
group: test-install-${{ github.ref }}
cancel-in-progress: true
env:
# Run all JavaScript actions on Node 24 (mirrors ci.yml / release.yml).
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
# ── Installer builds (unsigned, artifacts only) ──────────────────────────
build:
strategy:
fail-fast: false
matrix:
include:
# Same platform set as release.yml. Note: the Intel-mac leg uses
# macos-15-intel — macos-13 retired in Dec 2025; macos-15-intel is
# GitHub's designated x86_64 migration target (see release.yml).
- os: macos-14
slug: macos-arm64
label: "macOS Apple Silicon"
rust_target: aarch64-apple-darwin
bundles: "app,dmg"
- os: macos-15-intel
slug: macos-x64
label: "macOS Intel"
rust_target: x86_64-apple-darwin
bundles: "app,dmg"
# Windows: MSI only — NSIS fails at makensis near its ~2 GB stub
# limit (see release.yml).
- os: windows-2022
slug: windows-x64
label: "Windows x64"
rust_target: x86_64-pc-windows-msvc
bundles: "msi"
# Linux: AppImage only — tauri-bundler's .deb target currently fails
# with "Failed to create control scripts" (see release.yml).
- os: ubuntu-22.04
slug: linux-x64
label: "Linux x64"
rust_target: x86_64-unknown-linux-gnu
bundles: "appimage"
runs-on: ${{ matrix.os }}
name: Build (${{ matrix.label }})
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
# ── Language runtimes (mirrors release.yml) ────────────────────────
- name: Setup Rust (stable)
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.rust_target }}
- name: Rust cache
uses: Swatinem/rust-cache@v2
with:
workspaces: frontend/src-tauri -> target
key: ${{ matrix.rust_target }}-testinstall
- name: Setup Bun
uses: oven-sh/setup-bun@v1
# ── Platform deps (Tauri host requirements only — no Python here) ─
- name: macOS system deps
if: runner.os == 'macOS'
run: |
brew install ffmpeg || true
- name: Linux system deps
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
build-essential curl wget file libxdo-dev libssl-dev \
libayatana-appindicator3-dev librsvg2-dev \
libasound2-dev ffmpeg
# ── Frontend build ─────────────────────────────────────────────────
- name: Cache bun deps
uses: actions/cache@v4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('frontend/bun.lock', 'bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install frontend deps
working-directory: frontend
run: bun install
# ── Sidecars (verbatim from release.yml) ───────────────────────────
# Pinned uv version mirrors the `UV_VERSION` constant in lib.rs; bump
# both together when refreshing.
- name: Bundle uv (${{ matrix.rust_target }})
shell: bash
env:
UV_VERSION: "0.11.7"
TRIPLE: ${{ matrix.rust_target }}
run: |
set -euo pipefail
mkdir -p frontend/src-tauri/binaries
case "$TRIPLE" in
aarch64-apple-darwin|x86_64-apple-darwin|x86_64-unknown-linux-gnu)
ARCHIVE="tar.gz"
;;
x86_64-pc-windows-msvc)
ARCHIVE="zip"
;;
*)
echo "Unsupported target for uv bundling: $TRIPLE"
exit 1
;;
esac
URL="https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-${TRIPLE}.${ARCHIVE}"
echo "Fetching $URL"
WORK=$(mktemp -d)
if [ "$ARCHIVE" = "zip" ]; then
curl -fsSL "$URL" -o "$WORK/uv.zip"
unzip -j -o "$WORK/uv.zip" -d "$WORK"
mv "$WORK/uv.exe" "frontend/src-tauri/binaries/uv-${TRIPLE}.exe"
else
curl -fsSL "$URL" | tar -xz -C "$WORK"
mv "$WORK/uv-${TRIPLE}/uv" "frontend/src-tauri/binaries/uv-${TRIPLE}"
chmod +x "frontend/src-tauri/binaries/uv-${TRIPLE}"
fi
ls -la "frontend/src-tauri/binaries/"
# Same constant lives in frontend/src-tauri/src/tools.rs:
# FFMPEG_BTBN_VERSION — bump together.
- name: Bundle ffmpeg + ffprobe (${{ matrix.rust_target }})
shell: bash
env:
TRIPLE: ${{ matrix.rust_target }}
FFMPEG_BTBN_VERSION: "latest"
run: |
set -euo pipefail
BINDIR="frontend/src-tauri/binaries"
mkdir -p "$BINDIR"
WORK=$(mktemp -d)
case "$TRIPLE" in
aarch64-apple-darwin|x86_64-apple-darwin)
for TOOL in ffmpeg ffprobe; do
if [ "$TOOL" = "ffmpeg" ]; then
URL="https://evermeet.cx/ffmpeg/getrelease/zip"
else
URL="https://evermeet.cx/ffmpeg/getrelease/${TOOL}/zip"
fi
echo "Fetching $TOOL from evermeet.cx"
curl -fsSL "$URL" -o "$WORK/${TOOL}.zip"
unzip -o -j "$WORK/${TOOL}.zip" -d "$WORK"
mv "$WORK/${TOOL}" "$BINDIR/${TOOL}-${TRIPLE}"
chmod +x "$BINDIR/${TOOL}-${TRIPLE}"
done
;;
x86_64-unknown-linux-gnu)
URL="https://github.com/BtbN/FFmpeg-Builds/releases/download/${FFMPEG_BTBN_VERSION}/ffmpeg-master-${FFMPEG_BTBN_VERSION}-linux64-gpl.tar.xz"
echo "Fetching ffmpeg from BtbN (linux64) — version=${FFMPEG_BTBN_VERSION}"
curl -fsSL "$URL" -o "$WORK/ffmpeg.tar.xz"
tar -xJf "$WORK/ffmpeg.tar.xz" -C "$WORK"
EXTRACTED=$(find "$WORK" -type d -name "bin" | head -1)
mv "$EXTRACTED/ffmpeg" "$BINDIR/ffmpeg-${TRIPLE}"
mv "$EXTRACTED/ffprobe" "$BINDIR/ffprobe-${TRIPLE}"
chmod +x "$BINDIR/ffmpeg-${TRIPLE}" "$BINDIR/ffprobe-${TRIPLE}"
;;
x86_64-pc-windows-msvc)
URL="https://github.com/BtbN/FFmpeg-Builds/releases/download/${FFMPEG_BTBN_VERSION}/ffmpeg-master-${FFMPEG_BTBN_VERSION}-win64-gpl.zip"
echo "Fetching ffmpeg from BtbN (win64) — version=${FFMPEG_BTBN_VERSION}"
curl -fsSL "$URL" -o "$WORK/ffmpeg.zip"
unzip -o "$WORK/ffmpeg.zip" -d "$WORK"
EXTRACTED=$(find "$WORK" -type f -name "ffmpeg.exe" | head -1)
EXTRACTED_DIR=$(dirname "$EXTRACTED")
mv "$EXTRACTED_DIR/ffmpeg.exe" "$BINDIR/ffmpeg-${TRIPLE}.exe"
mv "$EXTRACTED_DIR/ffprobe.exe" "$BINDIR/ffprobe-${TRIPLE}.exe"
;;
*)
echo "⚠ No ffmpeg bundling for target: $TRIPLE (will download at first run)"
;;
esac
ls -la "$BINDIR/"
# ── Tauri build — UNSIGNED, NO PUBLISH ─────────────────────────────
# Invokes the tauri CLI directly (not tauri-action) so there is no
# release codepath at all. A --config overlay turns off
# createUpdaterArtifacts (tauri.conf.json has it on for release.yml),
# because updater payload signing requires TAURI_SIGNING_PRIVATE_KEY —
# deliberately absent here. macOS bundles still get the valid ad-hoc
# seal from tauri.conf.json (bundle.macOS.signingIdentity = "-").
- name: Tauri build (unsigned)
working-directory: frontend
shell: bash
env:
# GH runners have no FUSE; linuxdeploy must extract-and-run.
APPIMAGE_EXTRACT_AND_RUN: 1
run: |
set -euo pipefail
printf '%s\n' '{"bundle": {"createUpdaterArtifacts": false}}' > test-install-overlay.json
bunx tauri build --target ${{ matrix.rust_target }} --bundles ${{ matrix.bundles }} --config test-install-overlay.json
# ── Installer smoke (mirrors release.yml's structural checks) ──────
- name: Installer smoke (macOS)
if: runner.os == 'macOS'
timeout-minutes: 5
shell: bash
run: |
set -euo pipefail
DMG=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/dmg -name "*.dmg" | head -1)
echo "Smoke-testing DMG: $DMG"
MOUNT=$(hdiutil attach -nobrowse -readonly "$DMG" | tail -1 | grep -oE '/Volumes/.*$')
APP=$(find "$MOUNT" -maxdepth 2 -name "*.app" | head -1)
fail() { echo "FAIL — $1"; find "$APP/Contents" -maxdepth 4 -type f 2>/dev/null | head -40; hdiutil detach "$MOUNT" || true; exit 1; }
[ -n "$APP" ] || { echo "FAIL — no .app inside DMG"; hdiutil detach "$MOUNT" || true; exit 1; }
ls "$APP/Contents/MacOS"/* >/dev/null 2>&1 || fail "no shell binary in Contents/MacOS"
find "$APP/Contents" -type f -name 'uv' | grep -q . || fail "bundled uv sidecar missing"
find "$APP/Contents" -type f -name 'pyproject.toml' | grep -q . || fail "backend resource pyproject.toml missing"
find "$APP/Contents" -type f -path '*/backend/main.py' | grep -q . || fail "backend source backend/main.py missing"
echo "OK — bundle has shell + uv + backend resources"
hdiutil detach "$MOUNT" || true
# Report-only signing verification (same script release.yml runs on
# unsigned/preview paths) — asserts the ad-hoc seal is valid.
- name: Verify macOS signing (report-only)
if: runner.os == 'macOS'
shell: bash
run: |
set -uo pipefail
APP=$(find "frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/macos" -maxdepth 1 -name '*.app' | head -1)
[ -n "$APP" ] || { echo "FAIL — no .app found to verify"; exit 1; }
echo "Unsigned test build → report-only verification."
bash scripts/verify-macos-signing.sh "$APP"
- name: Installer smoke (Windows)
if: runner.os == 'Windows'
timeout-minutes: 5
shell: bash
run: |
set -euo pipefail
MSI=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/msi -name "*.msi" | head -1)
echo "Smoke-testing MSI: $MSI"
msiexec.exe //i "$(cygpath -w "$MSI")" //quiet //norestart
INSTALL="/c/Program Files/OmniVoice Studio"
fail() { echo "FAIL — $1. Contents:"; find "$INSTALL" -maxdepth 4 -type f 2>/dev/null | head -40; exit 1; }
test -f "$INSTALL/omnivoice-studio.exe" || fail "shell exe missing"
test -f "$INSTALL/uv.exe" || fail "bundled uv missing"
find "$INSTALL" -type f -name 'pyproject.toml' | grep -q . || fail "backend resource pyproject.toml missing"
find "$INSTALL" -type f -path '*backend*main.py' | grep -q . || fail "backend source main.py missing"
echo "OK — MSI installed shell + uv + backend resources"
- name: Installer smoke (Linux)
if: runner.os == 'Linux'
timeout-minutes: 5
shell: bash
run: |
set -euo pipefail
APPIMAGE=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/appimage -name "*.AppImage" | head -1)
APPIMAGE=$(realpath "$APPIMAGE")
echo "Smoke-testing AppImage: $APPIMAGE"
chmod +x "$APPIMAGE"
EXTRACT_DIR="$(mktemp -d)"
cd "$EXTRACT_DIR"
"$APPIMAGE" --appimage-extract >/dev/null
ROOT="$EXTRACT_DIR/squashfs-root"
fail() { echo "FAIL — $1"; find "$ROOT" -maxdepth 5 -type f 2>/dev/null | head -40; exit 1; }
{ [ -f "$ROOT/AppRun" ] || find "$ROOT" -type f \( -name "OmniVoice Studio" -o -name "omnivoice-studio" \) | grep -q .; } || fail "shell binary / AppRun missing"
find "$ROOT" -type f -name 'uv' | grep -q . || fail "bundled uv sidecar missing"
find "$ROOT" -type f -name 'pyproject.toml' | grep -q . || fail "backend resource pyproject.toml missing"
find "$ROOT" -type f -path '*/backend/main.py' | grep -q . || fail "backend source backend/main.py missing"
echo "OK — AppImage has shell + uv + backend resources"
# ── Collect + upload installers as short-lived artifacts ───────────
- name: Collect installers
shell: bash
run: |
set -euo pipefail
BUNDLE_DIR="frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle"
mkdir -p test-install-artifacts
find "$BUNDLE_DIR" -type f \
\( -name "*.dmg" -o -name "*.msi" -o -name "*.AppImage" -o -name "*.deb" \) \
-exec cp {} test-install-artifacts/ \;
echo "Installers built:"
ls -la test-install-artifacts/
- name: Upload installers (artifact only — NOT a release)
uses: actions/upload-artifact@v4
with:
name: test-install-${{ matrix.slug }}
path: test-install-artifacts/*
retention-days: 7
if-no-files-found: error
# ── First-run with required models (headless backend, per OS) ───────────
# Replicates what the installed app does on first launch, without the GUI:
# the same venv sync the Tauri shell runs, then backend boot → default
# model download (k2-fsa/OmniVoice, ~2.4 GB) → one real synthesis.
# CPU-only runners: device auto-detect resolves to cpu (or mps on the M1
# runner) exactly as it would on a user's machine.
first-run-smoke:
strategy:
fail-fast: false
matrix:
include:
- os: macos-14
slug: macos-arm64
label: "macOS Apple Silicon"
- os: macos-15-intel
slug: macos-x64
label: "macOS Intel"
- os: windows-2022
slug: windows-x64
label: "Windows x64"
- os: ubuntu-22.04
slug: linux-x64
label: "Linux x64"
runs-on: ${{ matrix.os }}
name: First-run smoke (${{ matrix.label }})
timeout-minutes: 75
env:
# Restricted-network resilience (mirrors ci.yml smoke-matrix).
UV_HTTP_TIMEOUT: "120"
UV_HTTP_RETRIES: "5"
steps:
- uses: actions/checkout@v4
# The Linux venv pulls CUDA-enabled torch (+ nvidia libs); reclaim the
# runner space the preinstalled toolchains occupy so venv + ~2.4 GB
# model fit comfortably.
- name: Free disk space (Linux)
if: runner.os == 'Linux'
run: |
sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc /usr/local/share/boost || true
df -h /
# Graceful skip when a runner genuinely lacks disk: log clearly what
# was (not) covered instead of failing the whole run on ENOSPC.
- name: Disk space gate
id: disk
shell: bash
run: |
set -euo pipefail
df -Pk . "$HOME" || true
FREE_WS=$(df -Pk . | awk 'NR==2 {print int($4/1048576)}')
FREE_HOME=$(df -Pk "$HOME" | awk 'NR==2 {print int($4/1048576)}')
FREE=$(( FREE_WS < FREE_HOME ? FREE_WS : FREE_HOME ))
echo "Free disk: workspace=${FREE_WS}G home=${FREE_HOME}G -> min=${FREE}G"
if [ "$FREE" -lt 12 ]; then
echo "::warning::First-run model smoke SKIPPED on ${{ matrix.label }} — only ${FREE} GB free (< 12 GB needed for venv + ~2.4 GB default model). Installer build coverage is unaffected."
echo "proceed=false" >> "$GITHUB_OUTPUT"
else
echo "proceed=true" >> "$GITHUB_OUTPUT"
fi
- name: Setup Python 3.11
if: steps.disk.outputs.proceed == 'true'
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install uv
if: steps.disk.outputs.proceed == 'true'
uses: astral-sh/setup-uv@v3
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
- name: System deps (macOS)
if: steps.disk.outputs.proceed == 'true' && runner.os == 'macOS'
run: brew install ffmpeg libsndfile || true
- name: System deps (Windows)
if: steps.disk.outputs.proceed == 'true' && runner.os == 'Windows'
shell: bash
run: |
choco install ffmpeg -y --no-progress
ffmpeg -version
- name: System deps (Linux)
if: steps.disk.outputs.proceed == 'true' && runner.os == 'Linux'
uses: awalsh128/cache-apt-pkgs-action@latest
with:
packages: ffmpeg libsndfile1
version: 1.0
# Exactly the command the installed app's first launch runs
# (lib.rs::ensure_venv_ready → `uv sync --frozen --no-dev`).
#
# Known platform gap surfaced by this smoke (2026-07-02): torch is locked
# to 2.8.0, and PyTorch ships no macOS x86_64 wheels past 2.2.x — so the
# locked dependency set cannot install on Intel Macs AT ALL. A real
# Intel-Mac user's first launch hits the exact same wall. That is a
# product bug, not a harness bug: surface it as a loud warning and skip
# the rest of the smoke instead of failing a leg that can never pass
# until the dependency gap is fixed.
- name: Install backend venv (first-launch parity)
if: steps.disk.outputs.proceed == 'true'
id: venv
shell: bash
run: |
set -uo pipefail
if uv sync --frozen --no-dev 2>&1 | tee uv-sync.log; then
echo "proceed=true" >> "$GITHUB_OUTPUT"
elif grep -q "doesn't have a source distribution or wheel for the current platform" uv-sync.log; then
echo "::warning::First-run smoke SKIPPED on ${{ matrix.label }} — the LOCKED dependency set cannot install on this platform (e.g. torch 2.8.0 has no macOS x86_64 wheels; PyTorch dropped Intel-mac support after 2.2.x). An end-user first launch on this platform fails the same way — this is a product-level dependency gap, not a CI harness issue."
echo "proceed=false" >> "$GITHUB_OUTPUT"
else
exit 1
fi
- name: First-run smoke — backend boot, required-model download, real synthesis
if: steps.disk.outputs.proceed == 'true' && steps.venv.outputs.proceed == 'true'
shell: bash
timeout-minutes: 60
env:
# Generous cold-load budget for CPU runners on a fresh HF cache.
OMNIVOICE_MODEL_LOAD_TIMEOUT: "1800"
run: |
set -uo pipefail
BASE="http://127.0.0.1:3900"
# GH macOS Apple Silicon runners ADVERTISE torch MPS, but the
# virtualized Metal shared pool cannot actually allocate (even a
# 256-byte alloc fails with "MPS backend out of memory") — a runner
# limitation, not a product bug; real M1 machines run MPS fine.
# Hide MPS via a CI-only sitecustomize so device auto-detect
# resolves to CPU, keeping the smoke CPU-only as on the other legs.
if [ "${RUNNER_OS:-}" = "macOS" ]; then
mkdir -p ci-sitecustomize
cat > ci-sitecustomize/sitecustomize.py <<'PY'
# CI-only shim (lives ONLY inside the test-install workflow job):
# GitHub's Apple Silicon runners expose torch.backends.mps as
# available, but Metal allocations fail in the VM. Report MPS as
# unavailable so the backend's device auto-detect picks CPU.
try:
import torch
torch.backends.mps.is_available = lambda: False # type: ignore[assignment]
except Exception:
pass
PY
export PYTHONPATH="$PWD/ci-sitecustomize${PYTHONPATH:+:$PYTHONPATH}"
echo "MPS hidden for this smoke (CI runner limitation) — forcing CPU."
fi
uv run --no-sync python backend/main.py > backend.log 2>&1 &
SERVER_PID=$!
trap 'kill $SERVER_PID 2>/dev/null || true' EXIT
echo "backend pid: $SERVER_PID"
fail() {
echo "::error::${{ matrix.label }}: $1"
echo "── backend.log (last 120 lines) ──"
tail -120 backend.log || true
exit 1
}
# Phase 1: liveness — /health (torch import makes cold boot slow).
UP=0
for i in $(seq 1 60); do
if curl -sf "$BASE/health" >/dev/null 2>&1; then
echo "Phase 1 OK — /health up after ~$((i*5))s"
UP=1
break
fi
kill -0 $SERVER_PID 2>/dev/null || fail "backend process died during boot"
sleep 5
done
[ "$UP" = "1" ] || fail "backend /health not responding after 300s"
curl -sf "$BASE/system/info" | python -c "import sys,json; d=json.load(sys.stdin); print('device:', d.get('device'), '| platform:', d.get('platform'), '| python:', d.get('python'), '| version:', d.get('version'))" || true
# Phase 2: required-model bootstrap — the lifespan preload downloads
# and loads the default checkpoint (k2-fsa/OmniVoice) on first run.
echo "Phase 2 — waiting for required-model download + load (fresh HF cache)…"
ELAPSED=0
READY=0
LAST=""
while [ $ELAPSED -lt 1800 ]; do
LAST=$(curl -sf "$BASE/model/status" 2>/dev/null || echo '{}')
STATUS=$(printf '%s' "$LAST" | python -c "import sys,json; d=json.load(sys.stdin); print(d.get('status','?'))" 2>/dev/null || echo '?')
DETAIL=$(printf '%s' "$LAST" | python -c "import sys,json; d=json.load(sys.stdin); print(d.get('sub_stage',''), d.get('progress',''), d.get('error',''))" 2>/dev/null || echo '')
echo " [${ELAPSED}s] model status: $STATUS $DETAIL"
if [ "$STATUS" = "ready" ]; then READY=1; break; fi
kill -0 $SERVER_PID 2>/dev/null || fail "backend died during model load"
sleep 15
ELAPSED=$((ELAPSED+15))
done
[ "$READY" = "1" ] || fail "required model not ready after 1800s (last status: $LAST)"
echo "Phase 2 OK — required model downloaded + loaded in ~${ELAPSED}s"
# Phase 3: one REAL synthesis through the default engine — the
# end-to-end proof that a fresh install can produce audio.
echo "Phase 3 — POST /generate (real synthesis)…"
HTTP_CODE=$(curl -sS -o smoke_out.wav -w "%{http_code}" --max-time 1200 \
-F "text=OmniVoice Studio first run smoke test. This sentence validates a fresh installation with the required model." \
-F "num_step=4" \
"$BASE/generate") || fail "generate request failed (curl transport error)"
if [ "$HTTP_CODE" != "200" ]; then
echo "response body (first 2000 bytes):"; head -c 2000 smoke_out.wav || true; echo
fail "generate returned HTTP $HTTP_CODE"
fi
SIZE=$(wc -c < smoke_out.wav | tr -d ' ')
HEAD4=$(head -c 4 smoke_out.wav)
[ "$HEAD4" = "RIFF" ] || fail "output is not a RIFF/WAV file (got: $HEAD4)"
[ "$SIZE" -gt 40000 ] || fail "output WAV suspiciously small (${SIZE} bytes)"
echo "Phase 3 OK — real synthesis produced a ${SIZE}-byte WAV on a fresh install"
# Phase 4: report what was downloaded (model cache inventory).
echo "Phase 4 — downloaded model inventory:"
for C in "$HOME/.cache/huggingface" "${LOCALAPPDATA:-}/OmniVoice/hf_cache" "${HF_HOME:-}"; do
if [ -n "$C" ] && [ -d "$C" ]; then
du -sh "$C" 2>/dev/null || true
find "$C" -maxdepth 3 -type d -name "models--*" 2>/dev/null | sed 's/^/ /' || true
fi
done
echo "FIRST-RUN SMOKE PASSED on ${{ matrix.label }}"
- name: Upload smoke evidence (log + WAV)
if: always() && steps.disk.outputs.proceed == 'true'
uses: actions/upload-artifact@v4
with:
name: first-run-smoke-${{ matrix.slug }}
path: |
backend.log
smoke_out.wav
retention-days: 7
if-no-files-found: ignore
+41
View File
@@ -6,6 +6,47 @@ The format is loosely based on [Keep a Changelog](https://keepachangelog.com/).
Versions track the desktop app (`tauri.conf.json` + `frontend/src-tauri/Cargo.toml`).
The bundled TTS model package (`pyproject.toml`) is versioned independently.
## [Unreleased]
### Changed
- **The app now always opens maximized (not fullscreen).** Window size and
position are no longer carried over from the previous session — one manual
resize used to make every later launch reopen at that smaller size,
overriding the intended maximized default. Same behavior on macOS
(zoomed window, not a fullscreen Space), Windows, and Linux.
### Fixed
- **Confucius4-TTS is now validated end-to-end — and actually loads.** The
opt-in engine's first live run (Apple Silicon, CPU) caught three
scaffold-era faults: the sidecar could never import `confuciustts` (upstream
ships no packaging, so the documented `pip install -e` fails — the sidecar
and bootstrap probe now put the clone on `sys.path`, like upstream's own
example), the assumed 24 kHz sample rate was wrong (confirmed **22 050 Hz**,
now regression-tested), and the docs demanded an Amphion/MaskGCT install
that doesn't exist (all weights auto-download from HuggingFace). CPU is
~17× realtime, so CUDA stays the recommended path; `gpu_compat` now
advertises `("cuda", "cpu")`. (#590)
- **Parakeet TDT transcription now works without an NVIDIA GPU.** The
`nemo-parakeet` ASR engine (parakeet-tdt-0.6b-v3, 25 languages, word
timestamps) was hard-gated behind CUDA — but a live measurement on an Apple
Silicon M2 shows it transcribing at ~10× realtime *on CPU*, roughly 20×
faster than the default whisper-large-v3 on the same machine at equal
accuracy. The false GPU gate is removed, so Mac and CPU-only users can now
pick the dramatically faster engine in Settings → Engines.
- **8 GB GPUs: voice-clone/dub transcription no longer kills the backend.**
On cards where the TTS model already held most of the VRAM (e.g. RTX
4060 Ti 8 GB), loading whisper `large-v3` in float16 for a reference-clip
or dub transcription died as a *native* CUDA out-of-memory abort — the
whole backend process vanished with no error logged, and the app showed
"Can't reach the local OmniVoice backend." A new VRAM preflight re-checks
free GPU memory right before the ASR load and steps down float16 →
int8 → CPU instead of attempting a load that can't fit (opt-out:
`OMNIVOICE_ASR_VRAM_PREFLIGHT=0`). (#723)
## [0.3.8] — 2026-07-01
A stability-focused release that makes first-run and Windows "just work," ships
+1 -1
View File
@@ -192,7 +192,7 @@ Everything else (new engines, fancy features) is downstream of "the thing instal
**Versioning (hard rule, owner-set 2026-06-11; single-source 2026-06-16):** main is always **latest release + 1 patch**. **`frontend/package.json` is the SINGLE SOURCE OF TRUTH for the app version** — vite injects `__APP_VERSION__` from it (first-run footer + every auto bug report), and `frontend/src-tauri/tauri.conf.json` reads its bundle version from it (`"version": "../package.json"`, so the MSI/dmg/updater version can't drift from the UI). Three toolchain-required **mirrors** are kept equal to it and bumped in lockstep — `frontend/src-tauri/Cargo.toml` + `pyproject.toml` (cargo/uv need a literal) and `backend/core/version.py`'s `_FALLBACK_VERSION` (the frozen-backend last resort; at runtime the backend reads its version from package metadata via `importlib.metadata`, which `backend.spec`'s `copy_metadata('omnivoice')` makes work in the frozen build too). Never hand-edit any mirror or re-hardcode a literal in `tauri.conf.json`. Guarded by `tests/test_app_version.py` (`test_all_version_files_in_lockstep` + `test_tauri_version_derives_from_package_json`). The moment `vX.Y.Z` is released, bump `package.json` (+ the mirrors) to `X.Y.(Z+1)`. Consequences:
- Every PR and preview build identifies as the **next** version. Preview builds stamp `X.Y.(Z+1)-N` (run number), which semver-sorts **above** the last stable `X.Y.Z` — the updater ordering is natural, no comparator tricks needed.
- Releasing = tag `vX.Y.(Z+1)` from main (version files already match), then immediately bump main to `X.Y.(Z+2)`. The post-release bump is automated by the `version-bump` job in release.yml; if it fails, do it manually in the same day.
- Releasing = tag `vX.Y.(Z+1)` from main (version files already match), then immediately bump main to `X.Y.(Z+2)`. **Owner override (2026-07-01): the post-release bump is now MANUAL — the `version-bump` job in release.yml is opt-in behind the `AUTO_VERSION_BUMP` repo variable (default off), so `main` stays at the released version until the owner explicitly asks to bump.** (Historically the bump auto-ran; re-enable that by setting `AUTO_VERSION_BUMP=true`.) When pinned, `main` == the released tag; preview-build ordering and "release + 1" only resume once a bump is requested.
- Docker: `ghcr.io/debpalash/omnivoice-studio:latest` = **main** (rolling preview); `:X.Y.Z` + `:X.Y` + `:stable` = tagged releases. `:latest` is the preview channel by design — stable users pin `:stable` or a version tag.
- Do not bump minor/major or invent RCs/codenames without the owner asking. No "defer to next version" labels — scope is absorbed or declined, never re-versioned.
+3 -2
View File
@@ -292,10 +292,11 @@ OmniVoice ships a multi-engine TTS backend. The default engine (OmniVoice) is al
| **Supertonic 3** ⚡ | 31 | — | — | ✅ CPU | ✅ CPU | ✅ CPU | OpenRAIL-M |
| **MOSS-TTS-v1.5** ⚡ (8B) | 31 | ✅ | — | ✅ CUDA/CPU | ✅ CPU | ✅ CUDA/CPU | Apache-2.0 |
| **dots.tts** ⚡ (2B) | 24 | ✅ | — | ✅ CUDA/CPU | ✅ CPU | ❌ | Apache-2.0 |
| **Confucius4-TTS** ⚡ | 14 | ✅ | — | ✅ CUDA/CPU | ✅ CPU | ✅ CUDA/CPU | Apache-2.0 |
> **CUDA** = GPU-accelerated · **MPS** = Apple Silicon Metal · **CPU** = runs everywhere, slower for large models · KittenTTS and MOSS-TTS-Nano run realtime on CPU · MLX-Audio is Apple Silicon only · ⚡ = lazy-registered (installed on first use)
>
> **MOSS-TTS-v1.5** (8B, ~16 GB weights) and **dots.tts** (2B, ~9 GB weights) are heavyweight opt-in engines that run in their own isolated venv from a local clone — see [MOSS-TTS-v1.5](docs/engines/moss-tts-v15.md) and [dots.tts](docs/engines/dots-tts.md). Neither claims Apple-Silicon **MPS** (upstream is CUDA/CPU only; on a Mac they run on CPU). dots.tts upstream is Linux/macOS only — no Windows path.
> **MOSS-TTS-v1.5** (8B, ~16 GB weights) and **dots.tts** (2B, ~9 GB weights) are heavyweight opt-in engines that run in their own isolated venv from a local clone — see [MOSS-TTS-v1.5](docs/engines/moss-tts-v15.md) and [dots.tts](docs/engines/dots-tts.md). Neither claims Apple-Silicon **MPS** (upstream is CUDA/CPU only; on a Mac they run on CPU). dots.tts upstream is Linux/macOS only — no Windows path. **Confucius4-TTS** (14-language cross-lingual zero-shot cloning) is similar — its own Python 3.10 venv from a clone; CUDA recommended, CPU validated end-to-end (slow, ~17× realtime; no MPS — tested slower than CPU); see [Confucius4-TTS](docs/engines/confucius4-tts.md).
### ASR Engines
@@ -308,7 +309,7 @@ OmniVoice ships a multi-engine ASR (speech-to-text) backend that powers dictatio
| **Faster-Whisper (isolated)** | `faster-whisper-isolated` | ~100 | Same as Faster-Whisper but crash-isolated in a subprocess — an ASR crash won't take down the app |
| **MLX Whisper** | `mlx-whisper` | ~100 | Native Apple Silicon speed (Apple MLX / Metal) |
| **PyTorch Whisper** | `pytorch-whisper` | ~100 | CUDA / CPU fallback via 🤗 Transformers (no cuDNN 8 needed) |
| **Parakeet TDT** | `nemo-parakeet` | English + 25 EU | SOTA English accuracy, auto language detection (NVIDIA NeMo, GPU only) |
| **Parakeet TDT** | `nemo-parakeet` | English + 25 EU | SOTA accuracy at ~10× realtime even on CPU, auto language detection (NVIDIA NeMo, CUDA/CPU) |
| **Moonshine** | `moonshine` | English | Edge / low-latency, ONNX |
| **FunASR** | `funasr` | 50+ | All-in-one multilingual — built-in VAD + inline speaker diarization (SenseVoice) |
| **sherpa-onnx** (live dictation) | `sherpa-onnx-asr` | 25 EU + 90+ | Live, faster-than-real-time dictation — small streaming/offline ONNX models (Parakeet TDT v3/v2, streaming Zipformer & Paraformer, Whisper Tiny), CPU, identical on macOS / Windows / Linux. Picked per-model in **Settings → Voice**. |
+53 -30
View File
@@ -382,6 +382,11 @@ async def dub_ingest_url(req: DubIngestUrlRequest):
TRANSCRIBE_CHUNK_S = float(os.environ.get("OMNIVOICE_TRANSCRIBE_CHUNK_S", "30.0"))
TRANSCRIBE_CHUNK_TIMEOUT_S = float(os.environ.get("OMNIVOICE_TRANSCRIBE_CHUNK_TIMEOUT_S", "120.0"))
#: How many times to attempt each transcribe chunk before giving up on it. A
#: transient wedge (esp. the first chunk, where whisperx cold-loads its model)
#: shouldn't silently drop that whole window — retry once on a fresh pool so the
#: transcript doesn't come back "missing the beginning".
_CHUNK_TRANSCRIBE_ATTEMPTS = max(1, int(os.environ.get("OMNIVOICE_TRANSCRIBE_CHUNK_ATTEMPTS", "2")))
_sse_event = dub_pipeline.sse_event
@@ -567,36 +572,54 @@ async def dub_transcribe_stream(
logger.exception("chunk transcribe failed (backend=%s)", _asr_backend.id)
return {"chunks": [], "language": None, "error": str(e)}
try:
# wait_for in a loop to yield pings so the EventSource connection doesn't drop
fut = loop.run_in_executor(_gpu_pool, _transcribe_chunk)
waited = 0.0
part = None
while True:
done, pending = await asyncio.wait([fut], timeout=5.0)
if done:
part = done.pop().result()
break
yield _sse_event("ping", {})
waited += 5.0
if waited >= TRANSCRIBE_CHUNK_TIMEOUT_S:
# Re-raise TimeoutError if we exceed the overall limit
raise asyncio.TimeoutError()
except asyncio.TimeoutError:
logger.error(
"Transcribe chunk %d/%d timed out after %.0fs (job=%s)",
i + 1, chunks_n, TRANSCRIBE_CHUNK_TIMEOUT_S, job_id,
)
# #730: the wedged chunk thread keeps holding its GPU-pool worker.
# Abandon the poisoned pool so the next chunk (and any TTS work)
# gets a fresh worker instead of queueing behind the stuck one —
# same recovery the whole-file paths get via run_transcribe_guarded.
_reset_pool_on_wedge(_gpu_pool)
part = {
"chunks": [], "language": None,
"error": f"Chunk {i+1} timed out after {TRANSCRIBE_CHUNK_TIMEOUT_S:.0f}s — "
f"ASR backend may be stuck. Try restarting the server.",
}
# Retry a failed/timed-out chunk once on a fresh pool before giving
# up. Otherwise a transient wedge on the FIRST chunk (whisperx often
# cold-loads its model there, the #730 hang) drops that whole window
# and the transcript is "missing the beginning, only middle+end".
# The retry reuses the same audio window, so a recovered chunk fills
# the hole instead of leaving silent gaps.
part = None
for _attempt in range(1, _CHUNK_TRANSCRIBE_ATTEMPTS + 1):
try:
# wait_for in a loop to yield pings so the EventSource connection doesn't drop
fut = loop.run_in_executor(_gpu_pool, _transcribe_chunk)
waited = 0.0
while True:
done, pending = await asyncio.wait([fut], timeout=5.0)
if done:
part = done.pop().result()
break
yield _sse_event("ping", {})
waited += 5.0
if waited >= TRANSCRIBE_CHUNK_TIMEOUT_S:
# Re-raise TimeoutError if we exceed the overall limit
raise asyncio.TimeoutError()
except asyncio.TimeoutError:
logger.error(
"Transcribe chunk %d/%d timed out after %.0fs (attempt %d/%d, job=%s)",
i + 1, chunks_n, TRANSCRIBE_CHUNK_TIMEOUT_S, _attempt,
_CHUNK_TRANSCRIBE_ATTEMPTS, job_id,
)
# #730: the wedged chunk thread keeps holding its GPU-pool
# worker. Abandon the poisoned pool so the retry (and any TTS
# work) gets a fresh worker instead of queueing behind it.
_reset_pool_on_wedge(_gpu_pool)
part = {
"chunks": [], "language": None,
"error": f"Chunk {i+1} timed out after {TRANSCRIBE_CHUNK_TIMEOUT_S:.0f}s — "
f"ASR backend may be stuck. Try restarting the server.",
}
# Success → keep it. Failure/timeout → retry once on a fresh
# worker (the internal _transcribe_chunk except returns an
# error-part; the timeout path already reset the pool).
if part is not None and not part.get("error"):
break
if _attempt < _CHUNK_TRANSCRIBE_ATTEMPTS:
logger.warning(
"Retrying transcribe chunk %d/%d after failure/timeout (next attempt %d/%d, job=%s)",
i + 1, chunks_n, _attempt + 1, _CHUNK_TRANSCRIBE_ATTEMPTS, job_id,
)
_reset_pool_on_wedge(_gpu_pool)
if part.get("error"):
chunk_errors.append(part["error"])
logger.warning("Chunk %d/%d error: %s", i + 1, chunks_n, part["error"])
+134
View File
@@ -0,0 +1,134 @@
"""Confucius4-TTS sidecar package (issue #590).
Confucius4-TTS (netease-youdao) is an LLM-based multilingual / cross-lingual
zero-shot voice-cloning TTS: 14 languages, **no reference transcript required**,
cross-lingual voice transfer, Apache-2.0 (https://github.com/netease-youdao/Confucius4-TTS).
Like IndexTTS / MOSS-TTS-v1.5 / dots.tts it runs in its **own subprocess venv**
(upstream: Python 3.10 + CUDA 12.6 + its own deps), isolated from the OmniVoice
parent. It is **opt-in** selected in the engine picker and enabled only when
the user points ``OMNIVOICE_CONFUCIUS4_TTS_DIR`` at a clone so it can never
become a broken default on any platform (the strict default-parity rule).
Status (#590): **validated end-to-end** (2026-07-02, Apple Silicon, CPU) — the
synthesis API (``confuciustts.cli.inference.ConfuciusTTS``
``.generate(text, lang, prompt_wav)`` tensor, ``model.sample_rate``) produced
audible speech at 22 050 Hz; the sidecar's pure logic is unit-tested
(``tests/test_confucius4_sidecar.py``). CPU inference is slow (~17× realtime),
so CUDA is the recommended path. Gated off by default, so this affects no one
until they opt in.
Three entry points: ``Confucius4Backend`` (this module), ``main.py`` (the sidecar,
runs under the Confucius4 venv never imported by the parent), and
``bootstrap.py`` (venv probe + lazy bootstrap).
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from services.subprocess_backend import SubprocessBackend
if TYPE_CHECKING:
import torch # noqa: F401
logger = logging.getLogger("omnivoice.confucius4")
class Confucius4Backend(SubprocessBackend):
"""Confucius4-TTS (netease-youdao) — LLM-based, 14 langs, zero-shot clone.
Runs in a long-lived sidecar over length-prefixed JSON-over-stdio in a
dedicated venv. First synthesize cold-loads the checkpoint; subsequent calls
reuse the process.
Installation::
git clone https://github.com/netease-youdao/Confucius4-TTS.git
cd Confucius4-TTS
uv venv --python 3.10 && uv pip install -r requirements.txt
(Upstream ships no pyproject.toml/setup.py, so there is nothing to
``pip install -e`` the sidecar sys.path-inserts the clone instead.)
Then set ``OMNIVOICE_CONFUCIUS4_TTS_DIR`` to the clone root and restart.
License: Apache-2.0. CUDA recommended; CPU validated but ~17× realtime.
"""
id = "confucius4-tts"
display_name = (
"Confucius4-TTS (LLM, 14 langs, cross-lingual zero-shot clone, CUDA/CPU, Apache-2.0)"
)
supports_voice_design = False # timbre comes from a reference clip
# Upstream vocoder rate (config target_sample_rate) — confirmed 22 050 Hz by
# a live run (2026-07-02); still re-read from the sidecar's ready/audio frames.
_DEFAULT_SAMPLE_RATE = 22050
# CUDA fast path + CPU fallback, both exercised (CPU end-to-end validated).
# No MPS claim — upstream has no Metal path.
gpu_compat = ("cuda", "cpu")
@classmethod
def is_available(cls) -> tuple[bool, str]:
# Verify the venv on disk only — do NOT import the engine here (separate
# interpreter). A real health-check runs on the user's "Test engine"
# action in Settings.
from engines.confucius4.bootstrap import (
CONFUCIUS4_SIDECAR_SCRIPT,
is_confucius4_installed,
)
if not is_confucius4_installed():
return False, (
"Confucius4-TTS venv not found. Set OMNIVOICE_CONFUCIUS4_TTS_DIR "
"to your Confucius4-TTS clone (the directory containing "
"requirements.txt) and restart OmniVoice. CUDA GPU recommended "
"(CPU works but is slow). See docs/engines/confucius4-tts.md."
)
if not CONFUCIUS4_SIDECAR_SCRIPT.exists():
return False, (
"Confucius4-TTS sidecar script missing at "
f"{CONFUCIUS4_SIDECAR_SCRIPT} — reinstall OmniVoice."
)
return True, "ok"
@classmethod
def venv_python(cls):
from engines.confucius4.bootstrap import resolve_confucius4_venv
return resolve_confucius4_venv()
@classmethod
def sidecar_script(cls):
from engines.confucius4.bootstrap import CONFUCIUS4_SIDECAR_SCRIPT
return CONFUCIUS4_SIDECAR_SCRIPT
@property
def sample_rate(self) -> int:
return self._DEFAULT_SAMPLE_RATE
@property
def supported_languages(self) -> list[str]:
# 14 languages with the caller's language passed through at synthesize
# time; "multi" on the protocol surface.
return ["multi"]
def generate(self, text: str, **kw) -> "torch.Tensor":
"""Synthesize one utterance through the Confucius4 sidecar.
kwargs honored:
* ``ref_audio`` reference clip path ``prompt_wav`` (zero-shot
cloning). Optional but recommended for a specific voice.
* ``language`` ISO code / name ``lang`` (cross-lingual transfer).
* ``ref_text`` is intentionally ignored Confucius4 is unconstrained
cloning (no reference transcript needed).
Returns a tensor of shape (1, n_samples) at :attr:`sample_rate`.
"""
forwarded: dict = {}
ref_audio = kw.get("ref_audio")
if ref_audio:
forwarded["ref_audio"] = ref_audio
language = kw.get("language")
if language:
forwarded["language"] = str(language)
return super().generate(text, **forwarded)
__all__ = ["Confucius4Backend"]
+217
View File
@@ -0,0 +1,217 @@
"""Confucius4-TTS venv probe + lazy bootstrap (issue #590).
Confucius4-TTS (netease-youdao) is an LLM-based multilingual zero-shot cloning
TTS 14 languages, no reference transcript required, Apache-2.0. Like the other
heavyweight opt-in engines (IndexTTS / MOSS-TTS-v1.5 / dots.tts) it runs in its
**own subprocess venv**: upstream targets Python 3.10 + CUDA 12.6 with its own
dependency set, which we keep off the parent interpreter.
Probe order (existing power-user installs win zero migration):
1. ``${OMNIVOICE_CONFUCIUS4_TTS_DIR}/.venv/`` the user's clone-level venv.
2. ``backend/engines/confucius4/.venv/`` this package's own venv.
3. Bootstrap: ``uv venv`` then ``uv pip install -r <clone>/requirements.txt``
(+ ``uv pip install -e <clone>`` only if upstream ever ships packaging).
Validated end-to-end 2026-07-02 (Apple Silicon, CPU): upstream ships **no
pyproject.toml/setup.py**, so ``confuciustts`` is importable only with the
clone root on ``sys.path`` the import probe and the sidecar both handle
that. The engine is opt-in (env-dir gated) and never touched unless
``OMNIVOICE_CONFUCIUS4_TTS_DIR`` is set, so this can't affect the default
install on any platform.
"""
from __future__ import annotations
import logging
import os
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Optional
logger = logging.getLogger("omnivoice.confucius4.bootstrap")
#: Absolute path to the sidecar entrypoint.
CONFUCIUS4_SIDECAR_SCRIPT: Path = Path(__file__).parent / "main.py"
#: This package's owned venv (Probe 2).
_ENGINES_VENV_DIR: Path = Path(__file__).parent / ".venv"
#: Env var pointing at the user's Confucius4-TTS clone root.
_CLONE_DIR_ENV: str = "OMNIVOICE_CONFUCIUS4_TTS_DIR"
#: The package importable from the clone (verify against upstream).
_IMPORT_PROBE = "confuciustts"
_resolved_python: Optional[Path] = None
_IMPORT_PROBE_TIMEOUT_S = 15
_UV_VENV_TIMEOUT_S = 120
_UV_PIP_INSTALL_TIMEOUT_S = 1800
def invalidate() -> None:
"""Clear the resolved-python cache. Tests call this between scenarios."""
global _resolved_python
_resolved_python = None
def is_confucius4_installed() -> bool:
"""Cheap file-existence check for a usable venv (no subprocess spawn)."""
return any(cand.is_file() for cand in _probe_paths())
def resolve_confucius4_venv() -> Path:
"""Resolve the sidecar's Python interpreter (probe order in the docstring).
Memoised. Raises :exc:`RuntimeError` if none can be located and bootstrap
is unavailable."""
global _resolved_python
if _resolved_python is not None:
return _resolved_python
clone_dir = os.environ.get(_CLONE_DIR_ENV)
if clone_dir:
cand = _venv_python_path(Path(clone_dir) / ".venv")
if cand.is_file() and _venv_can_import(cand):
logger.info("Confucius4 venv resolved from %s: %s", _CLONE_DIR_ENV, cand)
_resolved_python = cand
return cand
cand = _venv_python_path(_ENGINES_VENV_DIR)
if cand.is_file() and _venv_can_import(cand):
logger.info("Confucius4 venv resolved from engines path: %s", cand)
_resolved_python = cand
return cand
if not clone_dir:
raise RuntimeError(
"Confucius4-TTS is not installed. Set the "
f"{_CLONE_DIR_ENV} environment variable to your Confucius4-TTS clone "
"(the directory that contains requirements.txt), then restart "
"OmniVoice. See docs/engines/confucius4-tts.md."
)
cand = _bootstrap_engines_venv(Path(clone_dir))
_resolved_python = cand
return cand
def _venv_python_path(venv_dir: Path) -> Path:
if sys.platform == "win32":
return venv_dir / "Scripts" / "python.exe"
return venv_dir / "bin" / "python"
def _probe_paths() -> list[Path]:
out: list[Path] = []
clone_dir = os.environ.get(_CLONE_DIR_ENV)
if clone_dir:
out.append(_venv_python_path(Path(clone_dir) / ".venv"))
out.append(_venv_python_path(_ENGINES_VENV_DIR))
return out
def _import_probe_code() -> str:
"""Probe snippet mirroring the sidecar's import semantics: upstream is not
pip-installable, so ``confuciustts`` resolves via the clone on sys.path."""
clone = os.environ.get(_CLONE_DIR_ENV, "")
if clone:
return f"import sys; sys.path.insert(0, {clone!r}); import {_IMPORT_PROBE}"
return f"import {_IMPORT_PROBE}"
def _venv_can_import(python_path: Path) -> bool:
"""Spawn the candidate python and verify ``import confuciustts`` works."""
try:
proc = subprocess.run(
[str(python_path), "-c", _import_probe_code()],
capture_output=True, timeout=_IMPORT_PROBE_TIMEOUT_S,
)
except (subprocess.TimeoutExpired, OSError) as exc:
logger.debug("Confucius4 import probe failed for %s: %s", python_path, exc)
return False
if proc.returncode != 0:
logger.debug(
"Confucius4 import probe non-zero for %s: %s",
python_path, proc.stderr.decode("utf-8", errors="replace")[:200],
)
return False
return True
def _locate_uv() -> Optional[str]:
bundled = os.environ.get("OMNIVOICE_BUNDLED_UV")
if bundled and Path(bundled).is_file():
return bundled
return shutil.which("uv")
def _bootstrap_engines_venv(clone_dir: Path) -> Path:
"""Create engines/confucius4/.venv and install the user's clone."""
uv = _locate_uv()
if not uv:
raise RuntimeError(
"uv is required to bootstrap the Confucius4-TTS venv but was not "
"found on PATH (and OMNIVOICE_BUNDLED_UV was not set). Install uv "
"from https://docs.astral.sh/uv/ and re-launch OmniVoice."
)
logger.info(
"Bootstrapping Confucius4 venv at %s from %s (several minutes on first "
"launch)", _ENGINES_VENV_DIR, clone_dir,
)
try:
subprocess.run(
[uv, "venv", "--python", "3.10", str(_ENGINES_VENV_DIR)],
check=True, timeout=_UV_VENV_TIMEOUT_S, capture_output=True,
)
except subprocess.CalledProcessError as exc:
raise RuntimeError(
f"uv venv failed for Confucius4 bootstrap at {_ENGINES_VENV_DIR}: "
f"{exc.stderr.decode('utf-8', errors='replace') if exc.stderr else exc}"
) from exc
python_path = _venv_python_path(_ENGINES_VENV_DIR)
requirements = clone_dir / "requirements.txt"
try:
if requirements.is_file():
subprocess.run(
[uv, "pip", "install", "--python", str(python_path),
"-r", str(requirements)],
check=True, timeout=_UV_PIP_INSTALL_TIMEOUT_S, capture_output=True,
)
# Editable install only if upstream ever ships packaging metadata —
# as of 2026-07 there is none, and `uv pip install -e` on a bare clone
# fails outright. Import resolution is handled via sys.path instead.
if (clone_dir / "pyproject.toml").is_file() or (clone_dir / "setup.py").is_file():
subprocess.run(
[uv, "pip", "install", "--python", str(python_path), "-e", str(clone_dir)],
check=True, timeout=_UV_PIP_INSTALL_TIMEOUT_S, capture_output=True,
)
except subprocess.CalledProcessError as exc:
raise RuntimeError(
"uv pip install failed during Confucius4 bootstrap "
f"({clone_dir}): "
f"{exc.stderr.decode('utf-8', errors='replace') if exc.stderr else exc}. "
"See docs/engines/confucius4-tts.md."
) from exc
if not _venv_can_import(python_path):
raise RuntimeError(
f"Confucius4 bootstrap completed but `import {_IMPORT_PROBE}` still "
f"fails from {python_path}. Verify {clone_dir} is a valid clone. "
"See docs/engines/confucius4-tts.md."
)
logger.info("Confucius4 venv bootstrap successful: %s", python_path)
return python_path
__all__ = [
"CONFUCIUS4_SIDECAR_SCRIPT",
"invalidate",
"is_confucius4_installed",
"resolve_confucius4_venv",
]
+216
View File
@@ -0,0 +1,216 @@
"""Confucius4-TTS sidecar entry point (issue #590).
Runs inside ``engines/confucius4/.venv`` (or the user's
``${OMNIVOICE_CONFUCIUS4_TTS_DIR}/.venv``), isolated from the OmniVoice parent.
Same isolation rationale as the IndexTTS / MOSS-TTS-v1.5 / dots.tts sidecars.
Stdlib-only at import time; ``confuciustts`` + torch are imported lazily on the
first synthesize op so the ``ready`` frame fits inside the parent's 30 s spawn
handshake.
Wire protocol length-prefixed JSON over stdin/stdout, byte-identical to
``backend/services/subprocess_backend.py``::
[ 4-byte big-endian uint32 length ][ N bytes UTF-8 JSON ]
Op flow: ready ping/pong synthesize ( progress, audio) shutdown.
Status (#590): the model API below
(``confuciustts.cli.inference.ConfuciusTTS(config_path=, device=)`` and
``model.generate(text=, lang=, prompt_wav=)`` audio tensor, ``model.sample_rate``)
is **validated end-to-end** (2026-07-02, Apple Silicon, CPU): live generate()
produced audible speech at 22 050 Hz. This sidecar's pure logic is unit-tested
in ``tests/test_confucius4_sidecar.py``. Opt-in, so it affects no one until
enabled.
Restrictions: NO imports from OmniVoice parent code. NO logging of os.environ.
"""
from __future__ import annotations
import base64
import json
import os
import struct
import sys
import traceback
MAX_FRAME_BYTES = 64 * 1024 * 1024
#: Upstream BigVGAN vocoder rate — ``target_sample_rate: 22050`` in
#: ``config/inference_config.yaml``, confirmed by a live end-to-end run
#: (2026-07-02). The real value is still re-read from ``model.sample_rate``
#: on each generate() so a future upstream change can't corrupt audio.
CONFUCIUS_SAMPLE_RATE = 22050
def _send(stream, obj: dict) -> None:
body = json.dumps(obj, separators=(",", ":")).encode("utf-8")
stream.write(struct.pack("!I", len(body)))
stream.write(body)
stream.flush()
def _recv(stream):
header = stream.read(4)
if len(header) < 4:
return None # EOF
(n,) = struct.unpack("!I", header)
if n > MAX_FRAME_BYTES:
raise IOError(f"frame too large: {n}")
body = bytearray()
while len(body) < n:
chunk = stream.read(n - len(body))
if not chunk:
raise IOError("short read")
body.extend(chunk)
return json.loads(bytes(body).decode("utf-8"))
def _measure_vram_mb() -> float:
try:
import torch
if torch.cuda.is_available():
return round(torch.cuda.memory_allocated() / (1024 ** 2), 1)
except Exception:
pass
return 0.0
_model = None
def _config_path() -> str:
"""Locate Confucius4's inference config (``config/inference_config.yaml``)
under the clone, or an explicit override."""
explicit = os.environ.get("OMNIVOICE_CONFUCIUS4_CONFIG")
if explicit:
return explicit
clone = os.environ.get("OMNIVOICE_CONFUCIUS4_TTS_DIR", "")
return os.path.join(clone, "config", "inference_config.yaml")
def _ensure_clone_on_sys_path() -> None:
"""Make ``import confuciustts`` resolve from the user's clone.
Upstream Confucius4-TTS is **not pip-installable** (no pyproject.toml /
setup.py as of 2026-07); its own ``example.py`` sys.path-inserts the repo
root instead. Mirror that here so the sidecar works from a plain
``uv pip install -r requirements.txt`` venv. Inserted at position 0 so the
clone the user pointed at always wins over any stale installed copy.
"""
clone = os.environ.get("OMNIVOICE_CONFUCIUS4_TTS_DIR", "")
if clone and clone not in sys.path:
sys.path.insert(0, clone)
def _load_model(stdout):
"""Cold-construct the Confucius4 model (CUDA, else CPU — both validated)."""
global _model
if _model is not None:
return _model
_send(stdout, {"op": "progress", "stage": "loading_model", "percent": 0})
_ensure_clone_on_sys_path()
import torch
from confuciustts.cli.inference import ConfuciusTTS # type: ignore[import-not-found]
device = "cuda" if torch.cuda.is_available() else "cpu"
_send(stdout, {"op": "progress", "stage": "loading_model", "percent": 50})
_model = ConfuciusTTS(config_path=_config_path(), device=device)
_send(stdout, {"op": "progress", "stage": "loading_model", "percent": 100})
return _model
def _tensor_to_pcm_b64(audio, sample_rate: int) -> tuple[str, int, int]:
import numpy as np
arr = audio.detach().to("cpu").float().numpy() if hasattr(audio, "detach") else np.asarray(audio)
arr = np.asarray(arr, dtype=np.float32).squeeze()
if arr.ndim > 1:
arr = arr.mean(axis=0)
arr = np.clip(arr, -1.0, 1.0)
pcm = (arr * 32767.0).astype(np.int16).tobytes()
return base64.b64encode(pcm).decode("ascii"), int(sample_rate), int(arr.shape[0])
def _normalize_language(raw):
"""Confucius4 expects an ISO-ish language code (e.g. 'en', 'zh'). Empty /
'auto' 'en' as a safe default (the API requires a lang)."""
if not raw or not isinstance(raw, str):
return "en"
s = raw.strip().lower()
if not s or s == "auto":
return "en"
return s[:2] if (len(s) >= 2 and s[:2].isalpha()) else s
def _handle_synthesize(msg: dict, stdout) -> None:
text = msg.get("text")
if not text or not isinstance(text, str):
raise ValueError("synthesize: missing or non-string 'text'")
model = _load_model(stdout)
gen_kwargs: dict = {"text": text, "lang": _normalize_language(msg.get("language"))}
ref_audio = msg.get("ref_audio")
if ref_audio:
gen_kwargs["prompt_wav"] = ref_audio
audio = model.generate(**gen_kwargs)
sample_rate = int(getattr(model, "sample_rate", CONFUCIUS_SAMPLE_RATE))
pcm_b64, sr, n_samples = _tensor_to_pcm_b64(audio, sample_rate)
_send(stdout, {
"op": "audio",
"audio_pcm_b64": pcm_b64,
"sample_rate": sr,
"n_samples": n_samples,
})
def main() -> int:
stdin = sys.stdin.buffer
stdout = sys.stdout.buffer
_send(stdout, {
"op": "ready",
"engine": "confucius4-tts",
"sample_rate": CONFUCIUS_SAMPLE_RATE,
})
while True:
try:
msg = _recv(stdin)
except Exception as exc:
_send(stdout, {
"op": "error", "stage": "recv",
"message": f"{type(exc).__name__}: {exc}",
"traceback": traceback.format_exc(),
})
return 1
if msg is None:
return 0
op = msg.get("op") if isinstance(msg, dict) else None
try:
if op == "ping":
_send(stdout, {"op": "pong", "vram_mb": _measure_vram_mb()})
elif op == "synthesize":
_handle_synthesize(msg, stdout)
elif op == "shutdown":
return 0
else:
_send(stdout, {"op": "error", "stage": "dispatch",
"message": f"unknown op: {op!r}"})
except Exception as exc:
_send(stdout, {
"op": "error", "stage": op or "unknown",
"message": f"{type(exc).__name__}: {exc}",
"traceback": traceback.format_exc(),
})
if __name__ == "__main__":
sys.exit(main())
+86 -12
View File
@@ -316,6 +316,74 @@ class WhisperXBackend(ASRBackend):
pass
return "cpu", "int8"
# Peak VRAM (GB) to load *and transcribe* whisper large-v3 per CTranslate2
# compute type (weights + encoder/decoder workspace, with headroom). #723:
# on an 8 GB card with the TTS model resident, loading fp16 large-v3 dies
# as a *native* CUDA OOM abort — the process is killed, no Python
# exception ever fires, and the UI reports "Can't reach the local
# backend". The only defense is to never start that load, so the device
# pick is re-checked against actually-free VRAM right before loading.
_CUDA_VRAM_BUDGET_GB = {"float16": 5.0, "int8_float16": 3.5, "int8": 3.0}
#: Budget multiplier by model size (budgets above are for large-v3).
_MODEL_VRAM_SCALE = (
("large", 1.0), ("turbo", 0.55), ("medium", 0.5),
("small", 0.25), ("base", 0.15), ("tiny", 0.1),
)
@staticmethod
def _free_vram_gb():
"""Device-wide free VRAM in GB (counts other processes), or None."""
try:
import torch
if torch.cuda.is_available():
free, _total = torch.cuda.mem_get_info()
return free / 1024**3
except Exception: # noqa: BLE001 — preflight must never block ASR
pass
return None
@classmethod
def _model_scale(cls, model_name: str) -> float:
name = (model_name or "").lower()
for key, scale in cls._MODEL_VRAM_SCALE:
if key in name:
return scale
return 1.0 # unknown → assume large
def _degrade_for_vram(self, device: str, compute_type: str) -> tuple[str, str]:
"""Downgrade the CUDA compute type (or fall to CPU) if free VRAM can't
hold the model preventing the un-catchable native OOM abort (#723).
Opt-out: OMNIVOICE_ASR_VRAM_PREFLIGHT=0."""
if device != "cuda" or os.environ.get(
"OMNIVOICE_ASR_VRAM_PREFLIGHT", "1"
).strip().lower() in ("0", "false", "no"):
return device, compute_type
free = self._free_vram_gb()
if free is None:
return device, compute_type
scale = self._model_scale(self._model_name)
candidates = list(self._CUDA_VRAM_BUDGET_GB)
start = candidates.index(compute_type) if compute_type in candidates else 0
for ct in candidates[start:]:
if free >= self._CUDA_VRAM_BUDGET_GB[ct] * scale:
if ct != compute_type:
logger.warning(
"whisperx VRAM preflight: %.1f GB free < %.1f GB needed "
"for %s %s — degrading to %s (#723)",
free, self._CUDA_VRAM_BUDGET_GB[compute_type] * scale,
self._model_name, compute_type, ct,
)
return device, ct
logger.warning(
"whisperx VRAM preflight: %.1f GB free is too little for %s on CUDA "
"(needs ≥%.1f GB even at int8) — using CPU int8 instead. Free VRAM "
"(flush the TTS model, or close other GPU apps) for GPU-speed ASR. (#723)",
free, self._model_name,
self._CUDA_VRAM_BUDGET_GB["int8"] * scale,
)
return "cpu", "int8"
@classmethod
def is_available(cls) -> tuple[bool, str]:
try:
@@ -346,6 +414,13 @@ class WhisperXBackend(ASRBackend):
# (#630/#611/#647). No-op on macOS/Linux and when speechbrain is absent.
_harden_speechbrain_lazy_imports()
import whisperx
# #723: re-check the CUDA pick against *currently free* VRAM — the TTS
# model may have claimed the card since __init__. A too-big load dies
# as a native abort (whole process, no exception), so it must be
# avoided up front rather than caught below.
self._device, self._compute_type = self._degrade_for_vram(
self._device, self._compute_type
)
logger.info(
"whisperx loading ASR %s on %s (%s)",
self._model_name, self._device, self._compute_type,
@@ -997,7 +1072,7 @@ class PyTorchWhisperBackend(ASRBackend):
return result if isinstance(result, dict) else {"chunks": [], "raw": result}
# ── NeMo Parakeet TDT (NVIDIA — English SOTA from ASR Leaderboard) ────────
# ── NeMo Parakeet TDT (NVIDIA — Open ASR Leaderboard SOTA, 25 langs) ────────
class NeMoASRBackend(ASRBackend):
@@ -1005,16 +1080,14 @@ class NeMoASRBackend(ASRBackend):
FastConformer encoder + Token-and-Duration Transducer decoder.
Beats Whisper large-v3 on English benchmarks (~6% WER).
Supports 25+ European languages with auto language detection.
Requires NVIDIA GPU.
Supports 25 (mostly European) languages with auto language detection.
CUDA or CPU parakeet-tdt-0.6b-v3 measured RTF 0.080.23 on an Apple
Silicon M2 *CPU* (2026-07-02), ~20× faster than faster-whisper large-v3
int8 on the same host, so the old hard CUDA gate was a false claim.
"""
id = "nemo-parakeet"
# CUDA-only: is_available() hard-fails without a GPU ("Parakeet TDT requires
# NVIDIA GPU (CUDA)"), so declaring a CPU path would be a false claim. On a
# CPU host this correctly resolves to routing_status="unavailable", matching
# is_available()=False (the matrix suppresses the routing badge there).
gpu_compat = ("cuda",)
display_name = "Parakeet TDT (NVIDIA NeMo — English SOTA)"
gpu_compat = ("cuda", "cpu")
display_name = "Parakeet TDT (NVIDIA NeMo — 25 langs, CUDA/CPU)"
def __init__(self):
self._model_name = os.environ.get(
@@ -1024,10 +1097,11 @@ class NeMoASRBackend(ASRBackend):
@classmethod
def is_available(cls) -> tuple[bool, str]:
# No CUDA gate: the 0.6B TDT model is comfortably faster than realtime
# on CPU (see class docstring), so availability is a pure dependency
# check and engine_routing picks the effective device from gpu_compat.
try:
import torch
if not torch.cuda.is_available():
return False, "Parakeet TDT requires NVIDIA GPU (CUDA)"
import torch # noqa: F401
except ImportError:
return False, "PyTorch not installed"
try:
+61 -8
View File
@@ -94,18 +94,26 @@ def _looks_like_target_script(text: str, code: str, threshold: float = 0.5) -> b
def _llm_client():
"""Lazy-build the OpenAI-compatible client. Returns None if no key + no local base_url."""
"""Lazy-build the OpenAI-compatible client for the ACTIVE LLM provider.
Resolves through the LLM Providers registry (Settings LLM Providers) so a
provider configured there actually powers Cinematic/Autofit previously this
only read ``TRANSLATE_*``/``OPENAI_*`` directly, so the registry-configured
provider was ignored (the "LLM not wired" bug). The registry's ``custom``
provider still maps ``TRANSLATE_BASE_URL``/``TRANSLATE_API_KEY``, so legacy
env setups keep working. Returns None if no provider is configured.
"""
try:
from openai import OpenAI
except ImportError:
logger.warning("openai package not installed — cinematic mode unavailable.")
return None
base_url = os.environ.get("TRANSLATE_BASE_URL")
api_key = (
os.environ.get("TRANSLATE_API_KEY")
or os.environ.get("OPENAI_API_KEY")
or ("local" if base_url else None) # local providers often accept any key
)
from services import llm_providers
p = llm_providers.active_provider()
if p is None:
return None
base_url = llm_providers.resolve_base_url(p)
api_key = llm_providers.resolve_api_key(p)
if not api_key:
return None
kw = {"api_key": api_key}
@@ -115,6 +123,10 @@ def _llm_client():
def _llm_model() -> str:
from services import llm_providers
p = llm_providers.active_provider()
if p is not None:
return llm_providers.resolve_model(p)
return os.environ.get("TRANSLATE_MODEL", "gpt-4o-mini")
@@ -125,6 +137,16 @@ def _llm_timeout() -> float:
return 45.0
def _cinematic_budget() -> float:
"""Overall wall-clock cap for a whole cinematic/autofit refine pass (seconds).
Unfinished segments degrade to their literal (Fast) translation once hit, so
a slow provider can't hang the translate. Default 180s; <=0 disables."""
try:
return float(os.environ.get("OMNIVOICE_CINEMATIC_BUDGET_S", "180"))
except ValueError:
return 180.0
def _glossary_text(glossary: Iterable[dict] | None) -> str:
"""Format the project glossary as a preamble for the LLM prompts.
@@ -320,4 +342,35 @@ async def cinematic_refine_many(
)
return {"id": seg_id, **res}
return await asyncio.gather(*(_one(sid, src, lit) for sid, src, lit in pairs))
# Overall wall-clock budget for the whole pass. Per-call timeout + bounded
# concurrency already cap it, but a slow/rate-limited provider on a large dub
# can still stall the "Translating…" spinner for minutes. Bound it: segments
# that finish in time keep their cinematic refine; any still-running segment
# degrades to its literal (Fast) translation so the translate ALWAYS returns
# within the budget instead of hanging. 0/negative disables the bound.
budget = _cinematic_budget()
tasks = [asyncio.ensure_future(_one(sid, src, lit)) for sid, src, lit in pairs]
if budget <= 0:
return await asyncio.gather(*tasks)
done, pending = await asyncio.wait(tasks, timeout=budget)
if pending:
logger.warning(
"Cinematic pass hit its %.0fs budget with %d/%d segment(s) unfinished "
"— falling back to the literal translation for those (slow LLM "
"provider?). Raise OMNIVOICE_CINEMATIC_BUDGET_S or pick a faster "
"provider.", budget, len(pending), len(tasks),
)
out: list[dict] = []
for task, (sid, _src, lit) in zip(tasks, pairs):
if task in done and not task.cancelled():
try:
out.append(task.result())
continue
except Exception as e: # noqa: BLE001 — never let one seg sink the pass
logger.warning("cinematic segment %s failed: %s", sid, e)
else:
task.cancel() # stop awaiting; the executor thread is abandoned (#730 pattern)
out.append({"id": sid, "text": lit, "literal": lit, "critique": "",
"error": "cinematic-budget"})
return out
+7
View File
@@ -1158,6 +1158,12 @@ _LAZY_REGISTRY: dict[str, tuple[str, str]] = {
# IndexTTS2. Lazy for the same import-cycle reason as the entries above.
"moss-tts-v15": ("engines.moss_tts_v15", "MossTTSV15Backend"),
"dots-tts": ("engines.dots_tts", "DotsTTSBackend"),
# Issue #590: Confucius4-TTS (netease-youdao) — LLM-based, 14-language
# cross-lingual zero-shot cloning, Apache-2.0. Opt-in + subprocess-isolated
# (own Python 3.10 venv) like the entries above. Validated end-to-end
# 2026-07-02 (CPU, Apple Silicon; 22.05 kHz output). Gated behind
# OMNIVOICE_CONFUCIUS4_TTS_DIR so it's inert until enabled.
"confucius4-tts": ("engines.confucius4", "Confucius4Backend"),
}
@@ -1253,6 +1259,7 @@ _INSTALL_HINTS: dict[str, str] = {
"supertonic3": "uv sync --extra supertonic (CPU-only ONNX, 31 langs, ~400 MB model on first use; OpenRAIL-M model license)",
"moss-tts-v15": "git clone OpenMOSS/MOSS-TTS + set OMNIVOICE_MOSS_TTS_V15_DIR (own venv, transformers==5.0; 8B, ~16 GB weights; CUDA/CPU, no MPS; Apache-2.0)",
"dots-tts": "git clone rednote-hilab/dots.tts + set OMNIVOICE_DOTS_TTS_DIR (own venv, transformers==4.57; 2B, ~9 GB weights; CUDA/CPU, Linux/macOS only — no Windows; Apache-2.0)",
"confucius4-tts":"git clone netease-youdao/Confucius4-TTS + set OMNIVOICE_CONFUCIUS4_TTS_DIR (own Python 3.10 venv; 14-lang cross-lingual zero-shot clone; ~5 GB weights auto-download; CUDA/CPU, no MPS; Apache-2.0)",
}
+90
View File
@@ -0,0 +1,90 @@
# Confucius4-TTS (opt-in engine)
> **Status: validated end-to-end (2026-07-02).** The integration (engine
> registration, dedicated-venv bootstrap, sidecar wire protocol, opt-in gating)
> is done, the sidecar's pure logic is unit-tested
> (`tests/test_confucius4_sidecar.py`), and a live synthesis run on Apple
> Silicon (CPU) produced audible cloned speech — confirming the model API and
> the true output sample rate of **22 050 Hz**. CUDA is the recommended
> hardware; CPU works but is slow (~17× realtime — roughly 100 s for 6 s of
> audio). MPS also runs but is *slower* than CPU (~64× realtime), so the
> sidecar deliberately never selects it. The engine is gated behind
> `OMNIVOICE_CONFUCIUS4_TTS_DIR`, so it's completely inert until you opt in —
> it can't affect the default install on any platform.
[Confucius4-TTS](https://github.com/netease-youdao/Confucius4-TTS) (netease-youdao)
is an LLM-based multilingual / cross-lingual zero-shot voice-cloning TTS.
- **14 languages**: Chinese, English, Japanese, Korean, German, French, Spanish,
Indonesian, Italian, Thai, Portuguese, Russian, Malay, Vietnamese.
- **Unconstrained cloning** — no reference transcript required.
- **Cross-lingual voice transfer** — keep one voice across languages.
- **License:** Apache-2.0. **Hardware:** NVIDIA GPU (CUDA 12.6) recommended;
CPU validated on Apple Silicon but ~17× realtime. Output: 22 050 Hz mono.
Like IndexTTS-2 / MOSS-TTS-v1.5 / dots.tts, it runs in its **own subprocess venv**
so its dependency stack never touches the default OmniVoice interpreter.
## Install
```bash
git clone https://github.com/netease-youdao/Confucius4-TTS.git
cd Confucius4-TTS
uv venv --python 3.10
uv pip install -r requirements.txt
```
> Upstream ships **no `pyproject.toml`/`setup.py`**, so there is nothing to
> `pip install -e` — don't try; it fails. The OmniVoice sidecar puts the clone
> on `sys.path` itself (the same thing upstream's `example.py` does).
**Model weights — all fetched automatically from HuggingFace on first
synthesis (~5 GB total, cached in `$HF_HUB_CACHE`):**
- `netease-youdao/Confucius4-TTS``t2s_model.safetensors` + `s2a_model.pt`
(the tokenizer + `wav2vec2bert_stats.pt` already ship in the clone's
`checkpoints/`).
- `facebook/w2v-bert-2.0` — semantic feature extractor (~2.3 GB).
- `funasr/campplus` — speaker-style encoder (small).
- `nvidia/bigvgan_v2_22khz_80band_256x` — vocoder (BigVGAN and CAMPPlus
*code* is vendored in the clone's `external/`; no Amphion install needed).
Set your `HF_TOKEN` (Settings → Credentials) if you hit rate limits.
Then point OmniVoice at the clone and restart:
- **macOS/Linux:** `export OMNIVOICE_CONFUCIUS4_TTS_DIR=/path/to/Confucius4-TTS`
- **Windows (PowerShell):** `[Environment]::SetEnvironmentVariable("OMNIVOICE_CONFUCIUS4_TTS_DIR","C:\path\to\Confucius4-TTS","User")`
Select **Confucius4-TTS** in Settings → Engines. The first synthesize triggers
the weight downloads above, then generates.
### Optional overrides
- `OMNIVOICE_CONFUCIUS4_CONFIG` — path to `inference_config.yaml` if it isn't at
`<clone>/config/inference_config.yaml`.
## Validation record (2026-07-02, Apple Silicon M-series, CPU)
The sidecar (`backend/engines/confucius4/main.py`) uses:
```python
from confuciustts.cli.inference import ConfuciusTTS
model = ConfuciusTTS(config_path=..., device="cuda") # or "cpu"
audio = model.generate(text=..., lang="en", prompt_wav="ref.wav") # → tensor
sr = model.sample_rate # 22050
```
- ✅ **Live end-to-end run**: English zero-shot clone from a 9.5 s reference —
6.06 s of audible speech (peak 0.85) in 102 s on CPU. `model.sample_rate`
returned **22 050**, matching `target_sample_rate` in
`config/inference_config.yaml`; `CONFUCIUS_SAMPLE_RATE` /
`_DEFAULT_SAMPLE_RATE` are pinned to it (regression-tested).
- ✅ **Not pip-installable upstream** — discovered live; the bootstrap now skips
the editable install unless upstream ships packaging, and both the import
probe and the sidecar resolve `confuciustts` via the clone on `sys.path`.
- ✅ **MPS probed and rejected**: runs, but ~4× slower than CPU (Metal op
fallbacks) — the sidecar selects CUDA when available, else CPU, never MPS.
- ✅ **Sidecar logic unit-tested** (`tests/test_confucius4_sidecar.py`):
language normalization, tensor→PCM (mono/stereo/clip), config-path
resolution, clone sys.path injection, wire framing, synthesize dispatch.
+3
View File
@@ -52,6 +52,9 @@ tts_engines:
- id: dots-tts
readme: "**dots.tts**"
doc: docs/engines/dots-tts.md
- id: confucius4-tts
readme: "**Confucius4-TTS**"
doc: docs/engines/confucius4-tts.md
# Same contract against backend/services/asr_backend.py _REGISTRY.
asr_engines:
+6 -2
View File
@@ -274,10 +274,14 @@ pub fn run() {
// launch if the user happened to be dictating when they quit,
// overriding the WebviewWindowBuilder `.visible(false)` below.
// Symptom: pill appears on app load with no shortcut press.
// The main window is fine to persist (size/position are useful).
// "main" is denylisted too (owner decision, 2026-07-02): the app
// must ALWAYS open maximized — not fullscreen — per
// tauri.conf.json (`maximized: true`, `fullscreen: false`).
// Persisting geometry meant one manual resize made every later
// launch reopen at that smaller size, overriding the config.
app.handle().plugin(
tauri_plugin_window_state::Builder::default()
.with_denylist(&["widget"])
.with_denylist(&["widget", "main"])
.build(),
)?;
app.handle().plugin(
+2 -2
View File
@@ -767,14 +767,14 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
{/* Ruler */}
<canvas
ref={rulerRef}
className="w-full h-[18px] bg-[#141414] rounded-md border-b border-solid border-b-[rgba(255,255,255,0.05)]"
className="w-full h-[18px] bg-[#141414] rounded-md border-b border-solid border-b-transparent"
/>
{/* Waveform */}
<canvas
ref={waveRef}
onMouseDown={onCanvasDown}
className="w-full h-[160px] bg-[#0f1112] rounded-lg border border-solid border-[rgba(255,255,255,0.05)] cursor-crosshair touch-none"
className="w-full h-[160px] bg-[#0f1112] rounded-lg border border-solid border-transparent cursor-crosshair touch-none"
/>
{/* Numeric fields */}
+1 -1
View File
@@ -91,7 +91,7 @@ export default function BatchAddDialog({
className={`flex cursor-pointer flex-col items-center justify-center gap-[6px] rounded-[10px] border-2 border-dashed px-4 py-7 text-[0.82rem] transition-all hover:border-[var(--chrome-accent)] hover:bg-white/[0.02] hover:text-[var(--chrome-fg)] ${
dragOver
? 'border-[var(--chrome-accent)] bg-white/[0.02] text-[var(--chrome-fg)]'
: 'border-[var(--chrome-border)] text-[var(--chrome-fg-muted)]'
: 'border-transparent text-[var(--chrome-fg-muted)]'
}`}
onDragOver={(e) => {
e.preventDefault();
+1 -1
View File
@@ -128,7 +128,7 @@ export default function CompareModal({
</span>
<button
type="button"
className="ml-auto inline-flex h-[var(--chrome-icon-btn,22px)] w-[var(--chrome-icon-btn,22px)] cursor-pointer items-center justify-center rounded-[var(--chrome-radius-pill)] bg-transparent text-[var(--chrome-fg-muted)] [border:1px_solid_transparent] transition-[background,color,border-color] duration-[var(--dur-fast)] hover:border-[var(--chrome-border-strong)] hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--chrome-fg)]"
className="ml-auto inline-flex h-[var(--chrome-icon-btn,22px)] w-[var(--chrome-icon-btn,22px)] cursor-pointer items-center justify-center rounded-[var(--chrome-radius-pill)] bg-transparent text-[var(--chrome-fg-muted)] [border:1px_solid_transparent] transition-[background,color,border-color] duration-[var(--dur-fast)] hover:border-transparent hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--chrome-fg)]"
onClick={onClose}
aria-label={t('compare.close')}
>
+3 -3
View File
@@ -87,7 +87,7 @@ export default function DemoPresetGrid({ presets, onUse }) {
return (
<div
key={p.id}
className="flex flex-col gap-[6px] p-[12px] rounded-xl border border-border bg-[rgba(255,255,255,0.02)] [transition:border-color_120ms_ease,background_120ms_ease] hover:border-[rgba(243,165,182,0.35)] hover:bg-[rgba(255,255,255,0.04)]"
className="flex flex-col gap-[6px] p-[12px] rounded-xl border border-border bg-[rgba(255,255,255,0.02)] [transition:border-color_120ms_ease,background_120ms_ease] hover:border-transparent hover:bg-[rgba(255,255,255,0.04)]"
>
<div className="inline-flex items-center gap-[6px]">
<span className="text-[16px] leading-none" aria-hidden>
@@ -102,7 +102,7 @@ export default function DemoPresetGrid({ presets, onUse }) {
<div className="flex gap-[6px] mt-auto pt-[4px]">
<button
type="button"
className="demo-preset-card__preview flex-1 inline-flex items-center justify-center gap-[4px] px-[8px] py-[5px] text-[11px] font-semibold rounded-lg border border-border bg-transparent text-fg cursor-pointer [transition:background_100ms_ease,border-color_100ms_ease] hover:bg-[rgba(255,255,255,0.05)] hover:border-[rgba(255,255,255,0.2)]"
className="demo-preset-card__preview flex-1 inline-flex items-center justify-center gap-[4px] px-[8px] py-[5px] text-[11px] font-semibold rounded-lg border border-border bg-transparent text-fg cursor-pointer [transition:background_100ms_ease,border-color_100ms_ease] hover:bg-[rgba(255,255,255,0.05)] hover:border-transparent"
onClick={() => handlePreview(p)}
aria-label={isPlaying ? `Pause ${p.name}` : `Preview ${p.name}`}
aria-pressed={isPlaying}
@@ -112,7 +112,7 @@ export default function DemoPresetGrid({ presets, onUse }) {
</button>
<button
type="button"
className="flex-1 inline-flex items-center justify-center gap-[4px] px-[8px] py-[5px] text-[11px] font-semibold rounded-lg border border-[rgba(243,165,182,0.3)] bg-[rgba(243,165,182,0.12)] text-fg cursor-pointer [transition:background_100ms_ease,border-color_100ms_ease] hover:border-[rgba(255,255,255,0.2)] hover:bg-[rgba(243,165,182,0.22)]"
className="flex-1 inline-flex items-center justify-center gap-[4px] px-[8px] py-[5px] text-[11px] font-semibold rounded-lg border border-transparent bg-[rgba(243,165,182,0.12)] text-fg cursor-pointer [transition:background_100ms_ease,border-color_100ms_ease] hover:border-transparent hover:bg-[rgba(243,165,182,0.22)]"
onClick={() => onUse(p)}
aria-label={`Use ${p.name} design`}
>
+6 -6
View File
@@ -192,7 +192,7 @@ export default function DictationDemo({ embedded = false }) {
case 'verified':
return (
<span
className={`${STATUS_BASE} border-[rgba(152,151,26,0.35)] bg-[rgba(152,151,26,0.12)] text-[#b8bb26]`}
className={`${STATUS_BASE} border-transparent bg-[rgba(152,151,26,0.12)] text-[#b8bb26]`}
>
<CheckCircle2 size={12} /> {t('demo.dictation_status_ok')}
</span>
@@ -200,7 +200,7 @@ export default function DictationDemo({ embedded = false }) {
case 'registered':
return (
<span
className={`${STATUS_BASE} border-[rgba(215,153,33,0.30)] bg-[rgba(215,153,33,0.10)] text-[#fabd2f]`}
className={`${STATUS_BASE} border-transparent bg-[rgba(215,153,33,0.10)] text-[#fabd2f]`}
>
<Keyboard size={12} /> {t('demo.dictation_status_pending')}{' '}
<code className="font-mono text-[10px] px-[4px] py-[1px] bg-[rgba(0,0,0,0.3)] rounded-[3px]">
@@ -211,7 +211,7 @@ export default function DictationDemo({ embedded = false }) {
default:
return (
<span
className={`${STATUS_BASE} border-[rgba(204,36,29,0.30)] bg-[rgba(204,36,29,0.10)] text-[#fb4934]`}
className={`${STATUS_BASE} border-transparent bg-[rgba(204,36,29,0.10)] text-[#fb4934]`}
>
<AlertTriangle size={12} /> {t('demo.dictation_status_warn')}
</span>
@@ -270,7 +270,7 @@ export default function DictationDemo({ embedded = false }) {
{t(s.labelKey)}
</span>
</div>
<blockquote className="m-0 px-[8px] py-[6px] text-[11.5px] leading-[1.45] border-l-2 border-l-[rgba(243,165,182,0.4)] bg-[rgba(255,255,255,0.02)] text-fg italic">
<blockquote className="m-0 px-[8px] py-[6px] text-[11.5px] leading-[1.45] border-l-2 border-l-transparent bg-[rgba(255,255,255,0.02)] text-fg italic">
{s.text}
</blockquote>
<div className="flex gap-[6px] mt-[2px]">
@@ -301,12 +301,12 @@ export default function DictationDemo({ embedded = false }) {
</Button>
</div>
{tx.state === 'ok' && (
<div className="flex items-start gap-[6px] text-[11px] px-[8px] py-[6px] rounded-lg leading-[1.4] text-[#b8bb26] bg-[rgba(152,151,26,0.08)] border border-[rgba(152,151,26,0.25)]">
<div className="flex items-start gap-[6px] text-[11px] px-[8px] py-[6px] rounded-lg leading-[1.4] text-[#b8bb26] bg-[rgba(152,151,26,0.08)] border border-transparent">
<CheckCircle2 size={11} /> <em className="not-italic">{tx.text}</em>
</div>
)}
{tx.state === 'fail' && (
<div className="flex items-start gap-[6px] text-[11px] px-[8px] py-[6px] rounded-lg leading-[1.4] text-[#fb4934] bg-[rgba(204,36,29,0.08)] border border-[rgba(204,36,29,0.25)]">
<div className="flex items-start gap-[6px] text-[11px] px-[8px] py-[6px] rounded-lg leading-[1.4] text-[#fb4934] bg-[rgba(204,36,29,0.08)] border border-transparent">
<AlertTriangle size={11} /> {tx.error}
</div>
)}
+7 -4
View File
@@ -194,7 +194,6 @@ function DubSegmentRow({
onChange={(e) => onSelect(seg.id, idx, e.nativeEvent.shiftKey)}
onClick={(e) => onSelect(seg.id, idx, e.shiftKey)}
disabled={disabled}
style={{ accentColor: '#d3869b' }}
className="cursor-pointer justify-self-center"
title={t('segment.select_title')}
/>
@@ -325,9 +324,9 @@ function DubSegmentRow({
}
style={
overBudget
? { borderColor: 'rgba(250,189,47,0.6)', background: 'rgba(250,189,47,0.06)' }
? { background: 'rgba(250,189,47,0.10)' }
: seg.translate_error
? { borderColor: 'rgba(251,73,52,0.5)' }
? { background: 'rgba(251,73,52,0.10)' }
: undefined
}
/>
@@ -423,7 +422,11 @@ function DubSegmentRow({
className="seg-gain-slider"
style={{
accentColor:
(seg.gain ?? 1.0) > 1.2 ? '#fb4934' : (seg.gain ?? 1.0) < 0.5 ? '#83a598' : '#a89984',
(seg.gain ?? 1.0) > 1.2
? 'var(--color-danger)'
: (seg.gain ?? 1.0) < 0.5
? 'var(--color-info)'
: 'var(--color-fg-muted)',
}}
/>
+3 -3
View File
@@ -116,7 +116,7 @@ export default function DubbingDemo({ onDismiss }) {
type="checkbox"
checked={syncPlay}
onChange={(e) => setSyncPlay(e.target.checked)}
className="accent-[#f3a5b6]"
className="accent-[var(--color-brand)]"
/>
{t('demo.dubbing_sync')}
</label>
@@ -186,7 +186,7 @@ export default function DubbingDemo({ onDismiss }) {
<button
key={d.code}
type="button"
className={`text-[11px] px-[10px] py-[3px] rounded-[999px] border border-border bg-transparent text-fg-muted cursor-pointer [transition:background_100ms_ease,border-color_100ms_ease,color_100ms_ease] hover:bg-[rgba(255,255,255,0.04)] hover:text-fg ${pickedCode === d.code ? 'bg-[rgba(243,165,182,0.18)] border-[rgba(243,165,182,0.45)] text-[#fff9ef]' : ''}`}
className={`text-[11px] px-[10px] py-[3px] rounded-[999px] border border-border bg-transparent text-fg-muted cursor-pointer [transition:background_100ms_ease,border-color_100ms_ease,color_100ms_ease] hover:bg-[rgba(255,255,255,0.04)] hover:text-fg ${pickedCode === d.code ? 'bg-[rgba(243,165,182,0.18)] border-transparent text-[#fff9ef]' : ''}`}
onClick={() => setPickedCode(d.code)}
>
{d.label}
@@ -197,7 +197,7 @@ export default function DubbingDemo({ onDismiss }) {
{onDismiss && (
<button
type="button"
className="self-end inline-flex items-center gap-[6px] px-[12px] py-[6px] text-[11px] font-semibold rounded-lg border border-[rgba(243,165,182,0.4)] bg-[rgba(243,165,182,0.12)] text-fg cursor-pointer hover:bg-[rgba(243,165,182,0.22)]"
className="self-end inline-flex items-center gap-[6px] px-[12px] py-[6px] text-[11px] font-semibold rounded-lg border border-transparent bg-[rgba(243,165,182,0.12)] text-fg cursor-pointer hover:bg-[rgba(243,165,182,0.22)]"
onClick={onDismiss}
>
<Play size={12} /> {t('demo.dubbing_cta')}
+1 -1
View File
@@ -76,7 +76,7 @@ export default class ErrorBoundary extends React.Component {
<p className="m-0 mb-3 text-[0.82rem] leading-[1.5] text-[var(--chrome-fg-muted)]">
{i18next.t('errors.desc')}
</p>
<pre className="m-0 mb-3.5 max-h-[140px] overflow-auto rounded-[var(--chrome-radius-pill)] border border-[var(--chrome-border)] bg-[var(--chrome-hover-bg)] px-2.5 py-2 text-left font-mono text-[0.72rem] text-[var(--chrome-severity-err)]">
<pre className="m-0 mb-3.5 max-h-[140px] overflow-auto rounded-[var(--chrome-radius-pill)] border border-transparent bg-[var(--chrome-hover-bg)] px-2.5 py-2 text-left font-mono text-[0.72rem] text-[var(--chrome-severity-err)]">
{msg}
</pre>
<div className="flex flex-wrap justify-center gap-2">
+5 -5
View File
@@ -26,8 +26,8 @@ const trackCls = (on, kind) => {
if (on && kind === 'dub')
return `${TRACK_BASE} border-[var(--chrome-accent-border)] bg-[var(--chrome-accent-bg)] text-[var(--chrome-accent)]`;
if (on)
return `${TRACK_BASE} border-[var(--chrome-border-strong)] bg-[var(--chrome-hover-bg)] text-[var(--chrome-fg)]`;
return `${TRACK_BASE} border-[var(--chrome-border)] text-[var(--chrome-fg-muted)]`;
return `${TRACK_BASE} border-transparent bg-[var(--chrome-hover-bg)] text-[var(--chrome-fg)]`;
return `${TRACK_BASE} border-transparent text-[var(--chrome-fg-muted)]`;
};
const TAB_BASE =
'inline-flex items-center gap-[6px] px-[12px] py-[6px] bg-transparent border-0 border-b-2 cursor-pointer text-[length:var(--text-sm)] transition-[color,border-color] duration-[var(--dur-fast)]';
@@ -288,7 +288,7 @@ export default function ExportModal({
aria-label={t('exportModal.export_options')}
>
<div
className="pointer-events-auto flex w-[min(880px,calc(100vw-24px))] max-h-[min(70vh,560px)] flex-col overflow-hidden rounded-t-lg border border-b-0 border-[var(--chrome-border-strong)] bg-[var(--chrome-bg)] shadow-[0_-8px_24px_rgba(0,0,0,0.45),0_-1px_0_var(--chrome-border)_inset] animate-in fade-in slide-in-from-bottom-full duration-200"
className="pointer-events-auto flex w-[min(880px,calc(100vw-24px))] max-h-[min(70vh,560px)] flex-col overflow-hidden rounded-t-lg border border-b-0 border-transparent bg-[var(--chrome-bg)] shadow-[0_-8px_24px_rgba(0,0,0,0.45),0_-1px_0_var(--chrome-border)_inset] animate-in fade-in slide-in-from-bottom-full duration-200"
ref={drawerRef}
>
<header className="relative flex items-center gap-[var(--space-3)] p-[6px_var(--space-4)_10px] [border-bottom:1px_solid_var(--chrome-border)] [background:linear-gradient(180deg,rgba(255,255,255,0.02),transparent)]">
@@ -306,7 +306,7 @@ export default function ExportModal({
</span>
<button
type="button"
className="ml-auto inline-flex h-[var(--chrome-icon-btn,22px)] w-[var(--chrome-icon-btn,22px)] cursor-pointer items-center justify-center rounded-[var(--chrome-radius-pill)] bg-transparent text-[var(--chrome-fg-muted)] [border:1px_solid_transparent] transition-[background,color,border-color] duration-[var(--dur-fast)] hover:border-[var(--chrome-border-strong)] hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--chrome-fg)]"
className="ml-auto inline-flex h-[var(--chrome-icon-btn,22px)] w-[var(--chrome-icon-btn,22px)] cursor-pointer items-center justify-center rounded-[var(--chrome-radius-pill)] bg-transparent text-[var(--chrome-fg-muted)] [border:1px_solid_transparent] transition-[background,color,border-color] duration-[var(--dur-fast)] hover:border-transparent hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--chrome-fg)]"
onClick={onClose}
aria-label={t('exportModal.close_drawer')}
>
@@ -323,7 +323,7 @@ export default function ExportModal({
<button
key={k}
type="button"
className="inline-flex cursor-pointer items-center gap-[4px] rounded-[var(--chrome-radius-pill)] bg-transparent px-[8px] py-[3px] font-sans text-[length:var(--text-xs)] text-[var(--chrome-fg-muted)] [border:1px_solid_var(--chrome-border)] transition-[background,color,border-color] duration-[var(--dur-fast)] hover:border-[var(--chrome-border-strong)] hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--chrome-fg)]"
className="inline-flex cursor-pointer items-center gap-[4px] rounded-[var(--chrome-radius-pill)] bg-transparent px-[8px] py-[3px] font-sans text-[length:var(--text-xs)] text-[var(--chrome-fg-muted)] [border:1px_solid_var(--chrome-border)] transition-[background,color,border-color] duration-[var(--dur-fast)] hover:border-transparent hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--chrome-fg)]"
onClick={() => applyPreset(k)}
title={t('exportModal.preset_title', { tab: v.tab, label: t(v.labelKey) })}
>
+3 -1
View File
@@ -211,7 +211,9 @@ function OptionCard({ active, disabled, onSelect, name, desc, badge }) {
className={cn(
'flex flex-col gap-1 rounded-md border px-3 py-2.5 text-left transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
active ? 'border-primary bg-primary/10' : 'border-border bg-bg-elev-2 hover:bg-bg-elev-1',
active
? 'border-transparent bg-primary/10'
: 'border-border bg-bg-elev-2 hover:bg-bg-elev-1',
disabled && 'cursor-not-allowed opacity-40 hover:bg-bg-elev-2',
)}
>
+2 -2
View File
@@ -189,7 +189,7 @@ export default function GlossaryPanel({
</div>
) : (
<>
<table className="w-full border-collapse text-[length:var(--text-sm)] [&_td]:border-b [&_td]:border-b-white/[0.04] [&_td]:px-[6px] [&_td]:py-[3px] [&_td]:text-left [&_td]:align-middle [&_th]:border-b [&_th]:border-b-[var(--color-border)] [&_th]:px-[6px] [&_th]:py-[3px] [&_th]:text-left [&_th]:align-middle [&_th]:text-[length:var(--text-xs)] [&_th]:font-semibold [&_th]:uppercase [&_th]:tracking-[0.04em] [&_th]:text-[var(--color-fg-subtle)]">
<table className="w-full border-collapse text-[length:var(--text-sm)] [&_td]:border-b [&_td]:border-b-transparent [&_td]:px-[6px] [&_td]:py-[3px] [&_td]:text-left [&_td]:align-middle [&_th]:border-b [&_th]:border-b-transparent [&_th]:px-[6px] [&_th]:py-[3px] [&_th]:text-left [&_th]:align-middle [&_th]:text-[length:var(--text-xs)] [&_th]:font-semibold [&_th]:uppercase [&_th]:tracking-[0.04em] [&_th]:text-[var(--color-fg-subtle)]">
<thead>
<tr>
<th>{t('glossary.source')}</th>
@@ -228,7 +228,7 @@ export default function GlossaryPanel({
onDelete={() => onDelete(term.id)}
/>
))}
<tr className="border-t border-dashed border-[var(--color-border)] [&>td]:py-[4px]">
<tr className="border-t border-dashed border-transparent [&>td]:py-[4px]">
<td>
<Input
size="sm"
+2 -2
View File
@@ -42,7 +42,7 @@ export default function HfTokenCard({ className = '' }) {
return (
<div
className={cn(
'flex flex-wrap items-center gap-2 rounded-md border border-success/45 bg-success/[0.09] px-3 py-2 text-sm',
'flex flex-wrap items-center gap-2 rounded-md border border-transparent bg-success/[0.09] px-3 py-2 text-sm',
className,
)}
>
@@ -57,7 +57,7 @@ export default function HfTokenCard({ className = '' }) {
return (
<div
className={cn(
'flex flex-wrap items-center gap-2 rounded-md border border-primary/30 bg-primary/[0.07] px-3 py-2 text-sm',
'flex flex-wrap items-center gap-2 rounded-md border border-transparent bg-primary/[0.07] px-3 py-2 text-sm',
className,
)}
>
@@ -5,7 +5,7 @@ import { Dialog } from '../ui';
function Kbd({ children }) {
return (
<span className="inline-flex h-[22px] min-w-[28px] items-center justify-center gap-[2px] rounded-[var(--chrome-radius-pill)] border border-[var(--chrome-border-strong)] bg-[var(--chrome-hover-bg)] px-2 py-[2px] font-mono text-[0.7rem] font-medium text-[var(--chrome-fg)]">
<span className="inline-flex h-[22px] min-w-[28px] items-center justify-center gap-[2px] rounded-[var(--chrome-radius-pill)] border border-transparent bg-[var(--chrome-hover-bg)] px-2 py-[2px] font-mono text-[0.7rem] font-medium text-[var(--chrome-fg)]">
{children}
</span>
);
@@ -78,7 +78,7 @@ export default function KeyboardCheatsheet({ open, onClose }) {
<div className="grid grid-cols-[repeat(auto-fit,minmax(260px,1fr))] gap-[18px]">
{SECTIONS.map((sec) => (
<div key={sec.title}>
<div className="mb-[10px] border-b border-[var(--chrome-border)] pb-[6px] font-mono text-[length:var(--chrome-label-size)] font-semibold uppercase tracking-[var(--chrome-label-track)] text-[var(--chrome-fg-muted)]">
<div className="mb-[10px] border-b border-transparent pb-[6px] font-mono text-[length:var(--chrome-label-size)] font-semibold uppercase tracking-[var(--chrome-label-track)] text-[var(--chrome-fg-muted)]">
{sec.title}
</div>
<div className="flex flex-col gap-[6px]">
+3 -3
View File
@@ -76,7 +76,7 @@ export default function MultiLangPicker({
{selected.map((s) => (
<span
key={s.code}
className="inline-flex items-center gap-[4px] px-[8px] py-[2px] bg-[var(--chrome-hover-bg)] border border-solid border-[var(--chrome-border)] rounded-full [font-family:var(--font-mono)] text-[0.68rem] font-medium text-[color:var(--chrome-fg)] uppercase"
className="inline-flex items-center gap-[4px] px-[8px] py-[2px] bg-[var(--chrome-hover-bg)] border border-solid border-transparent rounded-full [font-family:var(--font-mono)] text-[0.68rem] font-medium text-[color:var(--chrome-fg)] uppercase"
>
<Globe size={9} />
<span>{s.code}</span>
@@ -95,7 +95,7 @@ export default function MultiLangPicker({
{!disabled && (
<button
type="button"
className="flex items-center justify-center w-[24px] h-[24px] rounded-full border border-dashed border-[var(--chrome-border)] bg-transparent text-[color:var(--chrome-fg-muted)] cursor-pointer [transition:all_0.15s] hover:bg-[var(--chrome-hover-bg)] hover:text-[color:var(--chrome-fg)] hover:border-solid"
className="flex items-center justify-center w-[24px] h-[24px] rounded-full border border-dashed border-transparent bg-transparent text-[color:var(--chrome-fg-muted)] cursor-pointer [transition:all_0.15s] hover:bg-[var(--chrome-hover-bg)] hover:text-[color:var(--chrome-fg)] hover:border-solid"
onClick={() => setDropOpen(!dropOpen)}
title={t('dub.add_language')}
>
@@ -112,7 +112,7 @@ export default function MultiLangPicker({
{dropOpen && (
<div className="multi-lang__drop">
<div className="flex items-center gap-[6px] px-[10px] py-[8px] border-b border-solid border-b-[var(--chrome-border)] text-[color:var(--chrome-fg-muted)]">
<div className="flex items-center gap-[6px] px-[10px] py-[8px] border-b border-solid border-b-transparent text-[color:var(--chrome-fg-muted)]">
<Search size={10} />
<input
ref={inputRef}
+7 -7
View File
@@ -80,7 +80,7 @@ export default function NetworkToggle() {
return (
<div className="relative inline-flex items-center flex-shrink-0">
<button
className={`inline-flex items-center gap-[5px] py-[2px] px-[8px] h-[20px] rounded-sm font-medium text-[11px] [font-family:inherit] cursor-pointer [transition:all_0.1s] border border-solid disabled:opacity-50 disabled:cursor-not-allowed ${st.enabled ? 'bg-[rgba(184,187,38,0.12)] border-[rgba(184,187,38,0.4)] text-[#b8bb26] hover:bg-[rgba(184,187,38,0.18)]' : 'bg-transparent border-transparent text-[#a89984] hover:bg-[rgba(255,255,255,0.04)] hover:text-fg'}`}
className={`inline-flex items-center gap-[5px] py-[2px] px-[8px] h-[20px] rounded-sm font-medium text-[11px] [font-family:inherit] cursor-pointer [transition:all_0.1s] border border-solid disabled:opacity-50 disabled:cursor-not-allowed ${st.enabled ? 'bg-[rgba(184,187,38,0.12)] border-transparent text-[#b8bb26] hover:bg-[rgba(184,187,38,0.18)]' : 'bg-transparent border-transparent text-[var(--color-fg-muted)] hover:bg-[var(--chrome-hover-bg)] hover:text-fg'}`}
onClick={st.enabled ? () => setOpen((o) => !o) : () => setConfirming((c) => !c)}
disabled={busy}
title={st.enabled ? t('network.sharing_on_title') : t('network.share_on_network')}
@@ -92,7 +92,7 @@ export default function NetworkToggle() {
</button>
{!st.enabled && confirming && (
<div className="absolute bottom-[calc(100%+8px)] right-0 z-[60] w-[248px] flex flex-col gap-[8px] p-[12px] bg-[var(--chrome-bg,#1d2021)] border border-solid border-[rgba(184,187,38,0.35)] rounded-[8px] shadow-[0_8px_24px_rgba(0,0,0,0.45)] text-fg">
<div className="absolute bottom-[calc(100%+8px)] right-0 z-[60] w-[248px] flex flex-col gap-[8px] p-[12px] bg-[var(--chrome-bg,#1d2021)] border border-solid border-transparent rounded-[8px] shadow-[0_8px_24px_rgba(0,0,0,0.45)] text-fg">
<div className="text-[11px] font-semibold uppercase [letter-spacing:0.06em] text-[#b8bb26]">
{t('network.share_confirm_title')}
</div>
@@ -102,7 +102,7 @@ export default function NetworkToggle() {
<div className="flex gap-[6px] mt-[8px]">
<button
type="button"
className="bg-transparent border border-solid border-[var(--border,#504945)] [color:inherit] text-[11px] [font-family:inherit] py-[5px] px-[12px] rounded-md cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
className="bg-transparent border border-solid border-transparent [color:inherit] text-[11px] [font-family:inherit] py-[5px] px-[12px] rounded-md cursor-pointer hover:bg-[var(--chrome-hover-bg)] disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => setConfirming(false)}
disabled={busy}
>
@@ -110,7 +110,7 @@ export default function NetworkToggle() {
</button>
<button
type="button"
className="bg-[rgba(184,187,38,0.15)] border border-solid border-[rgba(184,187,38,0.4)] text-[#b8bb26] text-[11px] font-semibold [font-family:inherit] py-[5px] px-[12px] rounded-md cursor-pointer hover:bg-[rgba(184,187,38,0.25)] disabled:opacity-50 disabled:cursor-not-allowed"
className="bg-[rgba(184,187,38,0.15)] border border-solid border-transparent text-[#b8bb26] text-[11px] font-semibold [font-family:inherit] py-[5px] px-[12px] rounded-md cursor-pointer hover:bg-[rgba(184,187,38,0.25)] disabled:opacity-50 disabled:cursor-not-allowed"
onClick={enable}
disabled={busy}
>
@@ -121,7 +121,7 @@ export default function NetworkToggle() {
)}
{st.enabled && open && (
<div className="absolute bottom-[calc(100%+8px)] right-0 z-[60] w-[248px] flex flex-col gap-[8px] p-[12px] bg-[var(--chrome-bg,#1d2021)] border border-solid border-[rgba(184,187,38,0.35)] rounded-[8px] shadow-[0_8px_24px_rgba(0,0,0,0.45)] text-fg">
<div className="absolute bottom-[calc(100%+8px)] right-0 z-[60] w-[248px] flex flex-col gap-[8px] p-[12px] bg-[var(--chrome-bg,#1d2021)] border border-solid border-transparent rounded-[8px] shadow-[0_8px_24px_rgba(0,0,0,0.45)] text-fg">
<div className="text-[11px] font-semibold uppercase [letter-spacing:0.06em] text-[#b8bb26]">
{t('network.shared_title')}
</div>
@@ -135,7 +135,7 @@ export default function NetworkToggle() {
return (
<div
key={ip}
className="flex flex-col items-center gap-[8px] p-[8px] rounded-lg bg-[rgba(255,255,255,0.03)] border border-solid border-[rgba(255,255,255,0.05)]"
className="flex flex-col items-center gap-[8px] p-[8px] rounded-lg bg-[rgba(255,255,255,0.03)] border border-solid border-transparent"
>
<div className="flex items-center justify-between gap-[8px] w-full">
<code className="[font-family:var(--chrome-font-mono,var(--font-mono,monospace))] text-[11.5px] text-fg break-all">
@@ -182,7 +182,7 @@ export default function NetworkToggle() {
</div>
<button
type="button"
className="bg-[rgba(251,73,52,0.12)] border border-solid border-[rgba(251,73,52,0.35)] text-danger text-[11px] font-semibold [font-family:inherit] py-[5px] px-[10px] rounded-md cursor-pointer [transition:all_0.1s] hover:bg-[rgba(251,73,52,0.2)] disabled:opacity-50 disabled:cursor-not-allowed"
className="bg-[rgba(251,73,52,0.12)] border border-solid border-transparent text-danger text-[11px] font-semibold [font-family:inherit] py-[5px] px-[10px] rounded-md cursor-pointer [transition:all_0.1s] hover:bg-[rgba(251,73,52,0.2)] disabled:opacity-50 disabled:cursor-not-allowed"
onClick={disable}
disabled={busy}
>
@@ -125,7 +125,7 @@ export default function ReadinessChecklist({ compact = false, showWhenAllPass =
const issues = checks.filter((c) => c.status !== 'pass');
if (issues.length === 0) {
return (
<div className="flex items-center gap-[var(--space-3)] py-[var(--space-3)] px-[var(--space-4)] bg-[rgba(142,192,124,0.08)] border border-solid border-[rgba(142,192,124,0.15)] rounded-md text-success font-medium [font-size:var(--text-sm)]">
<div className="flex items-center gap-[var(--space-3)] py-[var(--space-3)] px-[var(--space-4)] bg-[rgba(142,192,124,0.08)] border border-solid border-transparent rounded-md text-success font-medium [font-size:var(--text-sm)]">
<CheckCircle size={14} />
{t('readiness.all_ready')}
</div>
+2 -2
View File
@@ -555,7 +555,7 @@ export default function SegmentTrack({
<span className="inline-flex gap-[2px] mr-[8px] z-[3]">
<button
type="button"
className="inline-flex items-center justify-center w-[16px] h-[16px] p-0 border border-[rgba(168,153,132,0.4)] rounded-sm bg-[rgba(40,40,40,0.85)] text-[#ebdbb2] cursor-pointer hover:border-[#d3869b] hover:text-[#d3869b]"
className="inline-flex items-center justify-center w-[16px] h-[16px] p-0 border border-transparent rounded-sm bg-[rgba(40,40,40,0.85)] text-[#ebdbb2] cursor-pointer hover:border-transparent hover:text-[#d3869b]"
aria-label={t('timeline.play_slot')}
title={t('timeline.play_slot')}
onPointerDown={(ev) => ev.stopPropagation()}
@@ -569,7 +569,7 @@ export default function SegmentTrack({
{onPreviewSegment && (
<button
type="button"
className="inline-flex items-center justify-center w-[16px] h-[16px] p-0 border border-[rgba(168,153,132,0.4)] rounded-sm bg-[rgba(40,40,40,0.85)] text-[#ebdbb2] cursor-pointer hover:border-[#d3869b] hover:text-[#d3869b]"
className="inline-flex items-center justify-center w-[16px] h-[16px] p-0 border border-transparent rounded-sm bg-[rgba(40,40,40,0.85)] text-[#ebdbb2] cursor-pointer hover:border-transparent hover:text-[#d3869b]"
aria-label={t('timeline.preview_dub')}
title={t('timeline.preview_dub')}
onPointerDown={(ev) => ev.stopPropagation()}
+3 -3
View File
@@ -395,7 +395,7 @@ export default function Sidebar(props) {
<div className="flex items-center justify-between gap-2 min-w-0">
<span
className="history-kind"
style={{ color: accent, borderColor: `${accent}40` }}
style={{ color: accent, background: `${accent}22` }}
>
<KindIcon size={9} />{' '}
{proj.is_locked
@@ -610,7 +610,7 @@ export default function Sidebar(props) {
<div className="flex items-center justify-between gap-2 min-w-0">
<span
className="history-kind"
style={{ color: accent, borderColor: `${accent}40` }}
style={{ color: accent, background: `${accent}22` }}
>
<KindIcon size={9} /> {item.mode || 'synth'}
</span>
@@ -768,7 +768,7 @@ export default function Sidebar(props) {
<div className="flex items-center justify-between gap-2 min-w-0">
<span
className="history-kind"
style={{ color: accent, borderColor: `${accent}40` }}
style={{ color: accent, background: `${accent}22` }}
>
<KindIcon size={9} /> {item.mode}
</span>
+3 -5
View File
@@ -64,7 +64,7 @@ import { effectiveProfile, effectiveSpeed, castMember, nextCastColor } from '../
// Shared class strings (replacing the old stories-* BEM chrome)
const ADD_BTN =
'inline-flex items-center gap-[4px] bg-transparent border border-border text-fg [font-size:var(--text-xs)] px-[8px] py-[3px] rounded-sm cursor-pointer hover:border-accent hover:text-accent';
'inline-flex items-center gap-[4px] bg-transparent border border-border text-fg [font-size:var(--text-xs)] px-[8px] py-[3px] rounded-sm cursor-pointer hover:text-accent';
const NAME_INPUT =
'bg-bg-elev-2 border border-border rounded-sm text-fg [font-size:var(--text-xs)] px-[8px] py-[4px]';
const SELECT_CHROME =
@@ -1035,9 +1035,7 @@ export default function StoriesEditor({ profiles = [] }) {
role="listitem"
className={[
'group grid [grid-template-columns:32px_1fr_160px_100px_44px] gap-[8px] items-center px-[10px] py-[8px] bg-bg-elev-1 border border-border rounded-lg [transition:border-color_0.15s,box-shadow_0.15s] cursor-grab flex-wrap hover:border-border-strong hover:[box-shadow:var(--shadow-sm)]',
activeTrack === track.id
? 'border-brand [box-shadow:0_0_0_1px_var(--color-brand-glow)]'
: '',
activeTrack === track.id ? 'bg-primary/[0.12]' : '',
track.character === 'narrator'
? '[border-left:3px_solid_var(--color-accent)]'
: '',
@@ -1192,7 +1190,7 @@ export default function StoriesEditor({ profiles = [] }) {
<button
key={tn.tag}
type="button"
className="inline-flex items-center gap-[4px] bg-bg-elev-2 border border-border rounded-full text-fg [font-size:var(--text-xs)] px-[9px] py-[3px] cursor-pointer hover:border-accent hover:text-accent"
className="inline-flex items-center gap-[4px] bg-bg-elev-2 border border-border rounded-full text-fg [font-size:var(--text-xs)] px-[9px] py-[3px] cursor-pointer hover:text-accent"
onClick={() => insertTokenInto(track.id, tn.tag)}
title={tn.tag}
>
@@ -41,7 +41,7 @@ const LICENSE_URLS = {
const LINK_CLS =
'text-[0.83rem] text-[color:var(--accent,#8ab4f8)] no-underline hover:underline focus-visible:underline';
const SECTION_CLS = 'rounded-lg border border-white/10 bg-white/[0.04] px-[0.9rem] py-3';
const SECTION_CLS = 'rounded-lg border border-transparent bg-white/[0.04] px-[0.9rem] py-3';
const SECTION_H_CLS =
'm-0 mb-[0.3rem] text-[0.85rem] font-semibold uppercase tracking-[0.02em] opacity-85';
const SECTION_P_CLS = 'm-0 mb-2 text-[0.85rem] leading-[1.5] opacity-90';
+3 -3
View File
@@ -97,8 +97,8 @@ export default function VoicePreview({
if (!open) return null;
return (
<div className="fixed bottom-[calc(var(--logs-footer-height,28px)+16px)] right-[16px] z-[900] w-[320px] bg-[var(--chrome-bg)] border border-solid border-[var(--chrome-border-strong)] rounded-[12px] [box-shadow:0_8px_32px_rgba(0,0,0,0.4)] flex flex-col overflow-hidden animate-[voice-preview-in_0.2s_ease-out]">
<div className="flex items-center justify-between py-[10px] px-[14px] border-b border-solid border-b-[var(--chrome-border)]">
<div className="fixed bottom-[calc(var(--logs-footer-height,28px)+16px)] right-[16px] z-[900] w-[320px] bg-[var(--chrome-bg)] border border-solid border-transparent rounded-[12px] [box-shadow:0_8px_32px_rgba(0,0,0,0.4)] flex flex-col overflow-hidden animate-[voice-preview-in_0.2s_ease-out]">
<div className="flex items-center justify-between py-[10px] px-[14px] border-b border-solid border-b-transparent">
<span className="flex items-center gap-[6px] [font-family:var(--font-mono)] text-[0.72rem] font-semibold uppercase [letter-spacing:0.04em] text-[color:var(--chrome-fg)]">
<Volume2 size={13} /> {t('voicePreview.title')}
</span>
@@ -171,7 +171,7 @@ export default function VoicePreview({
)}
</div>
<div className="flex items-center justify-between pt-[8px] px-[14px] pb-[10px] border-t border-solid border-t-[var(--chrome-border)]">
<div className="flex items-center justify-between pt-[8px] px-[14px] pb-[10px] border-t border-solid border-t-transparent">
{loading ? (
<Button variant="ghost" size="sm" onClick={handleStop} leading={<Square size={10} />}>
{t('voicePreview.stop')}
+2 -2
View File
@@ -276,7 +276,7 @@ export default function WaveformPlayer({
if (missing) {
return (
<div
className={`flex items-center justify-center w-full min-w-0 box-border opacity-55 border border-solid border-[rgba(168,153,132,0.18)] bg-[rgba(168,153,132,0.08)] ${
className={`flex items-center justify-center w-full min-w-0 box-border opacity-55 border border-solid border-transparent bg-[rgba(168,153,132,0.08)] ${
compact
? 'gap-[8px] py-[4px] px-[8px] rounded-[8px]'
: 'gap-[10px] py-[6px] px-[10px] rounded-[10px]'
@@ -328,7 +328,7 @@ export default function WaveformPlayer({
return (
<div
className={`flex items-center w-full min-w-0 box-border border border-solid border-[rgba(168,153,132,0.18)] bg-[rgba(168,153,132,0.08)] ${
className={`flex items-center w-full min-w-0 box-border border border-solid border-transparent bg-[rgba(168,153,132,0.08)] ${
compact
? 'gap-[8px] py-[4px] px-[8px] rounded-[8px]'
: 'gap-[10px] py-[6px] px-[10px] rounded-[10px]'
+8 -8
View File
@@ -92,8 +92,8 @@ export default function WorkspaceHistory({
// Dub variant: a flat list of dub jobs, no clone/design filter.
if (variant === 'dub') {
return (
<aside className="flex-[1_1_0] flex flex-col min-h-0 overflow-hidden border-t border-solid border-t-[var(--chrome-border-strong,var(--chrome-border))]">
<div className="flex-[0_0_auto] flex flex-col gap-[8px] py-[10px] px-[12px] border-b border-solid border-b-[var(--chrome-border)]">
<aside className="flex-[1_1_0] flex flex-col min-h-0 overflow-hidden">
<div className="flex-[0_0_auto] flex flex-col gap-[8px] py-[10px] px-[12px]">
<span className="inline-flex items-center gap-[6px] [font-family:var(--chrome-font-mono,var(--font-mono))] text-[0.72rem] font-semibold [letter-spacing:0.04em] uppercase text-[color:var(--chrome-fg-muted)]">
<History size={13} /> {t('history.dub_title', { defaultValue: 'Dub history' })}
</span>
@@ -154,8 +154,8 @@ export default function WorkspaceHistory({
}
return (
<aside className="flex-[1_1_0] flex flex-col min-h-0 overflow-hidden border-t border-solid border-t-[var(--chrome-border-strong,var(--chrome-border))]">
<div className="flex-[0_0_auto] flex flex-col gap-[8px] py-[10px] px-[12px] border-b border-solid border-b-[var(--chrome-border)]">
<aside className="flex-[1_1_0] flex flex-col min-h-0 overflow-hidden">
<div className="flex-[0_0_auto] flex flex-col gap-[8px] py-[10px] px-[12px]">
<span className="inline-flex items-center gap-[6px] [font-family:var(--chrome-font-mono,var(--font-mono))] text-[0.72rem] font-semibold [letter-spacing:0.04em] uppercase text-[color:var(--chrome-fg-muted)]">
<History size={13} /> {t('history.title', { defaultValue: 'History' })}
</span>
@@ -164,10 +164,10 @@ export default function WorkspaceHistory({
<button
key={f.id}
type="button"
className={`flex-[0_0_auto] py-[2px] px-[10px] text-[0.68rem] font-medium border border-solid rounded-[var(--chrome-radius-pill,999px)] cursor-pointer [transition:background_0.15s_ease,color_0.15s_ease,border-color_0.15s_ease] ${
className={`flex-[0_0_auto] py-[2px] px-[10px] text-[0.68rem] font-medium rounded-[var(--chrome-radius-pill,999px)] cursor-pointer [transition:background_0.15s_ease,color_0.15s_ease] ${
filter === f.id
? 'text-[color:var(--color-brand,#d3869b)] bg-[color-mix(in_srgb,var(--color-brand,#d3869b)_12%,transparent)] border-[color-mix(in_srgb,var(--color-brand,#d3869b)_35%,transparent)]'
: 'bg-transparent text-[color:var(--chrome-fg-muted)] border-[var(--chrome-border-strong)] hover:bg-[var(--chrome-hover-bg)] hover:text-[color:var(--chrome-fg)]'
? 'text-[color:var(--color-brand,#d3869b)] bg-[color-mix(in_srgb,var(--color-brand,#d3869b)_12%,transparent)]'
: 'bg-transparent text-[color:var(--chrome-fg-muted)] hover:bg-[var(--chrome-hover-bg)] hover:text-[color:var(--chrome-fg)]'
}`}
onClick={() => setFilter(f.id)}
>
@@ -193,7 +193,7 @@ export default function WorkspaceHistory({
<div className="flex items-center justify-between gap-2 min-w-0">
<span
className="history-kind"
style={{ color: accent, borderColor: `${accent}40` }}
style={{ color: accent, background: `${accent}22` }}
>
<KindIcon size={9} /> {item.mode || 'synth'}
</span>
+6 -6
View File
@@ -69,12 +69,12 @@ export default function WorkspaceVoices({
return (
<section className={`wv ${items.length === 0 ? 'wv--collapsed' : ''}`}>
{/* ── ACTIVE VOICE ─────────────────────────────────────────────── */}
<div className="flex-[0_0_auto] py-[10px] px-[12px] border-b border-solid border-b-[var(--chrome-border)]">
<div className="flex-[0_0_auto] py-[10px] px-[12px]">
<div className="[font-family:var(--chrome-font-mono,var(--font-mono))] text-[0.62rem] uppercase [letter-spacing:0.06em] text-[color:var(--chrome-fg-muted,#a89984)] mb-[6px]">
{t('voices.active', { defaultValue: 'Active voice' })}
</div>
{active ? (
<div className="flex flex-col gap-[6px] py-[8px] px-[10px] border border-solid border-[var(--chrome-accent-border,rgba(211,134,155,0.35))] bg-[var(--chrome-accent-bg,rgba(211,134,155,0.08))] rounded-[10px]">
<div className="flex flex-col gap-[6px] py-[8px] px-[10px] bg-[var(--chrome-accent-bg,rgba(211,134,155,0.08))] rounded-[10px]">
<div className="flex items-center gap-[8px] justify-between">
<span className="text-[0.8rem] font-semibold text-[color:var(--chrome-fg)] min-w-0 overflow-hidden text-ellipsis whitespace-nowrap">
{active.name}
@@ -83,7 +83,7 @@ export default function WorkspaceVoices({
className="history-kind"
style={{
color: active.instruct ? '#8ec07c' : '#d3869b',
borderColor: active.instruct ? '#8ec07c40' : '#d3869b40',
background: active.instruct ? '#8ec07c22' : '#d3869b22',
}}
>
{active.instruct ? t('sidebar.design_label') : t('sidebar.clone_label')}
@@ -114,7 +114,7 @@ export default function WorkspaceVoices({
</div>
</div>
) : (
<div className="text-[0.7rem] [line-height:1.5] text-[color:var(--chrome-fg-muted)] py-[8px] px-[10px] border border-dashed border-[var(--chrome-border)] rounded-[10px]">
<div className="text-[0.7rem] [line-height:1.5] text-[color:var(--chrome-fg-muted)] py-[8px] px-[10px] border border-dashed border-transparent rounded-[10px]">
{t('voices.none_selected', {
defaultValue: 'No voice selected — describe one, drop audio, or pick below.',
})}
@@ -144,7 +144,7 @@ export default function WorkspaceVoices({
{/* Empty states carry verbs (10x §2). */}
<button
type="button"
className="block mt-[8px] mx-auto py-[4px] px-[10px] text-[0.66rem] text-[color:var(--chrome-fg-muted)] bg-transparent border border-dashed border-[var(--chrome-border)] rounded-[var(--chrome-radius-pill,999px)] cursor-default"
className="block mt-[8px] mx-auto py-[4px] px-[10px] text-[0.66rem] text-[color:var(--chrome-fg-muted)] bg-transparent border border-dashed border-transparent rounded-[var(--chrome-radius-pill,999px)] cursor-default"
onClick={() => setDefineMethod(defineMethod === 'audio' ? 'audio' : 'design')}
>
{defineMethod === 'audio'
@@ -170,7 +170,7 @@ export default function WorkspaceVoices({
<div className="flex items-center justify-between gap-2 min-w-0">
<span
className="history-kind"
style={{ color: accent, borderColor: `${accent}40` }}
style={{ color: accent, background: `${accent}22` }}
>
<KindIcon size={9} />{' '}
{proj.is_locked
+1 -1
View File
@@ -216,7 +216,7 @@ export default function ActionBar({
</label>
<button
type="button"
className="inline-flex items-center gap-[4px] px-[10px] py-[4px] text-[0.7rem] text-[var(--chrome-fg-muted)] bg-transparent border border-[var(--chrome-border)] rounded-[var(--chrome-radius-pill)] cursor-pointer whitespace-nowrap flex-none transition-[color,border-color] duration-[var(--dur-fast)] hover:text-[var(--chrome-fg)] hover:border-[var(--chrome-border-strong)] focus-visible:[outline:2px_solid_var(--chrome-accent)] focus-visible:[outline-offset:1px]"
className="inline-flex items-center gap-[4px] px-[10px] py-[4px] text-[0.7rem] text-[var(--chrome-fg-muted)] bg-transparent border border-transparent rounded-[var(--chrome-radius-pill)] cursor-pointer whitespace-nowrap flex-none transition-[color,border-color] duration-[var(--dur-fast)] hover:text-[var(--chrome-fg)] hover:border-transparent focus-visible:[outline:2px_solid_var(--chrome-accent)] focus-visible:[outline-offset:1px]"
onClick={() => setShowOverrides(!showOverrides)}
aria-expanded={showOverrides}
>
@@ -21,12 +21,12 @@ const CHIP_FOCUS =
'focus-visible:[outline:2px_solid_var(--chrome-accent)] focus-visible:[outline-offset:1px]';
const PCHIP_BASE = `inline-flex items-center gap-[5px] px-[12px] py-[5px] font-[var(--font-sans)] text-[0.72rem] font-medium rounded-[var(--chrome-radius-pill)] border bg-transparent flex-none cursor-pointer transition-colors duration-[120ms] ${CHIP_FOCUS}`;
const PCHIP_INACTIVE =
'border-[var(--chrome-border)] text-[var(--chrome-fg-muted)] hover:bg-[var(--chrome-hover-bg)] hover:border-[var(--chrome-border-strong)] hover:text-[var(--chrome-fg)]';
'border-transparent text-[var(--chrome-fg-muted)] hover:bg-[var(--chrome-hover-bg)] hover:border-transparent hover:text-[var(--chrome-fg)]';
const PCHIP_ACTIVE =
'bg-[var(--chrome-accent-bg)] border-[var(--chrome-accent-border)] text-[var(--chrome-accent)]';
const CHIP_BASE = `font-[var(--font-sans)] font-medium text-[0.68rem] px-[10px] py-[3px] rounded-[var(--chrome-radius-pill)] border bg-transparent whitespace-nowrap cursor-pointer transition-colors duration-[120ms] ${CHIP_FOCUS}`;
const CHIP_INACTIVE =
'border-[var(--chrome-border)] text-[var(--chrome-fg-muted)] hover:text-[var(--chrome-fg)] hover:bg-[var(--chrome-hover-bg)] hover:border-[var(--chrome-border-strong)]';
'border-transparent text-[var(--chrome-fg-muted)] hover:text-[var(--chrome-fg)] hover:bg-[var(--chrome-hover-bg)] hover:border-transparent';
const CHIP_ACTIVE =
'bg-[var(--chrome-accent-bg)] border-[var(--chrome-accent-border)] text-[var(--chrome-accent)]';
@@ -130,7 +130,7 @@ export default function DesignMethodPanel({
(first run) starts expanded. */}
<button
type="button"
className="flex items-center gap-[8px] w-full mt-[4px] mb-[8px] px-[10px] py-[6px] bg-[var(--chrome-hover-bg)] border border-[var(--chrome-border)] rounded-[8px] cursor-pointer text-left transition-[border-color] duration-[var(--dur-fast)] hover:border-[var(--chrome-border-strong)] focus-visible:[outline:2px_solid_var(--chrome-accent)] focus-visible:[outline-offset:1px]"
className="flex items-center gap-[8px] w-full mt-[4px] mb-[8px] px-[10px] py-[6px] bg-[var(--chrome-hover-bg)] border border-transparent rounded-[8px] cursor-pointer text-left transition-[border-color] duration-[var(--dur-fast)] hover:border-transparent focus-visible:[outline:2px_solid_var(--chrome-accent)] focus-visible:[outline-offset:1px]"
onClick={() => setIdentityOpen((o) => !o)}
aria-expanded={identityOpen}
>
+3 -3
View File
@@ -7,11 +7,11 @@ import { Sparkles, Square, Mic } from 'lucide-react';
const MIC_BASE =
'flex flex-col items-center justify-center gap-[var(--space-2)] px-4 py-2 min-w-[70px] rounded-[var(--radius-xl)] text-[length:var(--text-xs)] font-semibold cursor-pointer transition-all duration-[var(--dur-base)] ease-[var(--ease-out)]';
const MIC_IDLE =
'bg-white/[0.03] border border-white/10 text-[var(--color-fg-muted)] hover:border-[var(--color-danger)] hover:text-[var(--color-danger)]';
'bg-[var(--chrome-hover-bg)] border border-transparent text-[var(--color-fg-muted)] hover:border-[var(--color-danger)] hover:text-[var(--color-danger)]';
const MIC_RECORDING =
'bg-[rgba(251,73,52,0.15)] border-2 border-[var(--color-danger)] text-[var(--color-danger)] animate-[pulse_1s_ease-in-out_infinite]';
'bg-[color-mix(in_srgb,var(--color-danger)_15%,transparent)] border-2 border-[var(--color-danger)] text-[var(--color-danger)] animate-[pulse_1s_ease-in-out_infinite]';
const MIC_CLEANING =
'bg-[rgba(184,187,38,0.10)] border border-[rgba(184,187,38,0.20)] text-[#b8bb26] cursor-default';
'bg-[rgba(184,187,38,0.10)] border border-transparent text-[#b8bb26] cursor-default';
export default function MicButton({ isCleaning, isRecording, recordingTime, onStart, onStop }) {
const { t } = useTranslation();
@@ -6,14 +6,14 @@ import { TAGS } from '../../utils/constants';
// Tailwind utilities (shadcn P4). Flat chrome pill, mono face token utilities
// reference the same --chrome-* vars the old rule used, so the look is unchanged.
const TAG_BTN =
'border border-[var(--chrome-border)] bg-transparent text-[var(--chrome-fg-muted)] px-[9px] py-[3px] rounded-[var(--chrome-radius-pill)] font-[var(--chrome-font-mono)] font-medium text-[0.66rem] whitespace-nowrap cursor-pointer transition-colors duration-[120ms] hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--chrome-fg)] hover:border-[var(--chrome-border-strong)]';
'border border-transparent bg-transparent text-[var(--chrome-fg-muted)] px-[9px] py-[3px] rounded-[var(--chrome-radius-pill)] font-[var(--chrome-font-mono)] font-medium text-[0.66rem] whitespace-nowrap cursor-pointer transition-colors duration-[120ms] hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--chrome-fg)] hover:border-transparent';
// Studio shell migrated from the `studio-*` classes + CloneDesignTab.css to
// utilities (fast shadcn). `.studio-panel` stays defined in index.css for the
// dub area; clone reproduces it inline so its bespoke restack (flat stack,
// overflow-visible insert popover) is self-contained.
const STUDIO_PANEL =
'flex flex-col min-h-0 bg-[var(--chrome-bg)] border border-[var(--chrome-border)] rounded-none py-[10px] px-[12px] max-[800px]:px-[10px] max-[600px]:px-[6px] max-[600px]:py-[8px]';
'flex flex-col min-h-0 bg-[var(--chrome-bg)] border border-transparent rounded-none py-[10px] px-[12px] max-[800px]:px-[10px] max-[600px]:px-[6px] max-[600px]:py-[8px]';
export default function ScriptPanel({
t,
@@ -85,8 +85,8 @@ export default function ScriptPanel({
type="button"
className={`absolute right-[8px] bottom-[30px] inline-flex items-center gap-[4px] px-2 py-1 text-[0.66rem] bg-[var(--chrome-bg)] border rounded-[var(--chrome-radius-pill)] cursor-pointer transition-[color,border-color] duration-[var(--dur-fast)] focus-visible:[outline:2px_solid_var(--chrome-accent)] focus-visible:[outline-offset:1px] ${
insertOpen
? 'text-[var(--chrome-fg)] border-[var(--chrome-border-strong)]'
: 'text-[var(--chrome-fg-muted)] border-[var(--chrome-border)] hover:text-[var(--chrome-fg)] hover:border-[var(--chrome-border-strong)]'
? 'text-[var(--chrome-fg)] border-transparent'
: 'text-[var(--chrome-fg-muted)] border-transparent hover:text-[var(--chrome-fg)] hover:border-transparent'
}`}
onClick={() => setInsertOpen((o) => !o)}
aria-expanded={insertOpen}
@@ -100,7 +100,7 @@ export default function ScriptPanel({
)}
{insertOpen && (
<div
className="absolute right-[8px] bottom-[60px] z-20 flex flex-wrap gap-1 max-w-[min(360px,calc(100vw-16px))] max-h-[min(280px,calc(100vh-120px))] overflow-y-auto overscroll-contain p-2 bg-[var(--chrome-bg)] border border-[var(--chrome-border-strong)] rounded-[10px] shadow-[0_8px_24px_rgba(0,0,0,0.45)]"
className="absolute right-[8px] bottom-[60px] z-20 flex flex-wrap gap-1 max-w-[min(360px,calc(100vw-16px))] max-h-[min(280px,calc(100vh-120px))] overflow-y-auto overscroll-contain p-2 bg-[var(--chrome-bg)] border border-transparent rounded-[10px] shadow-[0_8px_24px_rgba(0,0,0,0.45)]"
role="menu"
>
{TAGS.map((tag) => (
@@ -117,7 +117,7 @@ export default function ScriptPanel({
</button>
))}
<button
className={`${TAG_BTN} !border-[#b8bb26] !text-[#b8bb26]`}
className={`${TAG_BTN} !border-transparent !text-[#b8bb26]`}
role="menuitem"
onClick={() => {
insertTag('[B EY1 S]');
+7 -8
View File
@@ -11,11 +11,10 @@ const ERROR_AUTOCLEAR_MS = 12000;
// Export-track toggle chips: flat pill outline, tinted by on/off/success state.
const TRACK_LABEL =
'inline-flex items-center gap-[4px] px-[8px] py-[2px] border border-transparent rounded-[var(--chrome-radius-pill)] cursor-pointer transition-colors';
const TRACK_ON =
'text-[var(--chrome-fg)] border-[var(--chrome-border-strong)] bg-[var(--chrome-hover-bg)]';
const TRACK_ON = 'text-[var(--chrome-fg)] border-transparent bg-[var(--chrome-hover-bg)]';
const TRACK_OFF = 'text-[var(--chrome-fg-dim)]';
const TRACK_ON_SUCCESS =
'text-[var(--chrome-severity-ok)] border-[color-mix(in_srgb,var(--chrome-severity-ok)_45%,transparent)] bg-[color-mix(in_srgb,var(--chrome-severity-ok)_10%,transparent)]';
'text-[var(--chrome-severity-ok)] border-transparent bg-[color-mix(in_srgb,var(--chrome-severity-ok)_10%,transparent)]';
export default function DubFooter({
t,
@@ -43,7 +42,7 @@ export default function DubFooter({
}, [canAutoClear, dubError, onDismissError]);
return (
<div className="px-[var(--space-3)] py-[4px] shrink-0 bg-[var(--chrome-bg)] border border-[var(--chrome-border)]">
<div className="px-[var(--space-3)] py-[4px] shrink-0 bg-[var(--chrome-bg)] border border-transparent">
{dubStep === 'done' && (
<div className="mb-[var(--space-2)]">
<Badge tone="success">
@@ -86,7 +85,7 @@ export default function DubFooter({
)}
{/* Output options + Timing moved to the top of the right (transcript) section. */}
{dubTracks.length > 0 && (
<div className="flex items-center gap-[var(--space-2)] mb-[2px] px-[var(--space-3)] py-[3px] text-[length:var(--text-xs)] text-[var(--chrome-fg-muted)] font-[family-name:var(--font-sans)] bg-[var(--chrome-bg)] rounded-[var(--chrome-radius-pill)] border border-[var(--chrome-border)] flex-wrap">
<div className="flex items-center gap-[var(--space-2)] mb-[2px] px-[var(--space-3)] py-[3px] text-[length:var(--text-xs)] text-[var(--chrome-fg-muted)] font-[family-name:var(--font-sans)] bg-[var(--chrome-bg)] rounded-[var(--chrome-radius-pill)] border border-transparent flex-wrap">
<span className="font-[family-name:var(--chrome-font-mono)] text-[length:var(--chrome-label-size)] tracking-[var(--chrome-label-track)] uppercase text-[var(--chrome-fg-muted)] font-semibold">
{t('dub.export_tracks')}
</span>
@@ -95,7 +94,7 @@ export default function DubFooter({
>
<input
type="checkbox"
className="accent-[var(--chrome-accent)]"
className="accent-[var(--color-brand)]"
checked={exportTracks['original'] !== false}
onChange={(e) => setExportTracks((prev) => ({ ...prev, original: e.target.checked }))}
/>
@@ -108,7 +107,7 @@ export default function DubFooter({
>
<input
type="checkbox"
className="accent-[var(--chrome-accent)]"
className="accent-[var(--color-brand)]"
checked={exportTracks[t] !== false}
onChange={(e) => setExportTracks((prev) => ({ ...prev, [t]: e.target.checked }))}
/>
@@ -132,7 +131,7 @@ export default function DubFooter({
const worst = hot.reduce((a, b) => (a.rate_ratio > b.rate_ratio ? a : b));
return (
<div
className="flex items-start gap-[8px] px-[10px] py-[6px] my-[4px] bg-[color-mix(in_srgb,#fabd2f_12%,transparent)] border border-[color-mix(in_srgb,#fabd2f_35%,transparent)] border-l-2 border-l-[#fabd2f] rounded-[var(--chrome-radius-pill)] text-[0.72rem] text-[var(--chrome-fg)] leading-[1.35]"
className="flex items-start gap-[8px] px-[10px] py-[6px] my-[4px] bg-[color-mix(in_srgb,#fabd2f_12%,transparent)] border border-transparent border-l-2 border-l-transparent rounded-[var(--chrome-radius-pill)] text-[0.72rem] text-[var(--chrome-fg)] leading-[1.35]"
role="status"
>
<span className="text-[#fabd2f] text-[0.9rem] leading-none shrink-0"></span>
+7 -7
View File
@@ -34,24 +34,24 @@ export default function DubHeader({
setExportOpen,
}) {
return (
<div className="flex flex-wrap justify-between items-center gap-x-[var(--space-3)] gap-y-[4px] px-[12px] py-[5px] shrink-0 bg-[rgba(255,255,255,0.015)] [border:1px_solid_rgba(255,255,255,0.04)] rounded-md mb-[2px]">
<div className="flex flex-wrap justify-between items-center gap-x-[var(--space-2)] gap-y-[4px] min-w-0 px-[10px] py-[4px] shrink-0 bg-[var(--color-bg-elev-1)] rounded-md mb-[2px]">
{/* Pipeline spine, inlined onto the header row (Upload → … → Export). */}
<DubPipelineStepper dubStep={dubStep} inline />
<div className="label-row dub-head__title">
<div className="label-row dub-head__title !gap-[6px]">
<FileText className="label-icon" size={11} />
<span className="font-semibold text-[0.85rem] overflow-hidden text-ellipsis whitespace-nowrap text-fg">
<span className="font-medium text-[0.78rem] min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-fg normal-case">
{dubFilename}
</span>
<span className="text-fg-muted font-normal whitespace-nowrap text-[0.72rem]">
<span className="text-fg-muted font-normal whitespace-nowrap text-[0.68rem] normal-case shrink-0">
· {formatTime(dubDuration)} · {dubSegments.length} {t('dub.segs')}
</span>
{activeProjectName && activeProjectName !== dubFilename && (
<span className="text-[#b8bb26] ml-[var(--space-3)] whitespace-nowrap text-[0.72rem]">
<span className="text-[#b8bb26] ml-[var(--space-2)] whitespace-nowrap text-[0.68rem] normal-case overflow-hidden text-ellipsis min-w-0">
{activeProjectName}
</span>
)}
</div>
<div className="flex gap-[var(--space-2)] items-center shrink-0">
<div className="flex gap-[6px] items-center shrink-0">
{/* Icon-only secondary actions (tooltips carry the labels);
Generate Dub keeps its label as the primary verb. */}
<Button
@@ -73,7 +73,7 @@ export default function DubHeader({
<RotateCcw size={12} />
</Button>
{/* Primary actions live on the header bar (compact) — moved up from the footer. */}
<div className="flex gap-[var(--space-2)] items-center pl-[var(--space-2)] [border-left:1px_solid_var(--color-border,#3a3a3a)]">
<div className="flex gap-[6px] items-center pl-[var(--space-2)] ml-[2px]">
{dubStep === 'stopping' ? (
<FooterBtn
sm
+14 -16
View File
@@ -29,23 +29,23 @@ import toast from 'react-hot-toast';
// Translation-settings bar utility class clusters
const SETTINGS_SUMMARY =
'flex items-center gap-[var(--space-2)] px-[var(--space-3)] py-[3px] mb-[3px] bg-[var(--chrome-bg)] border border-[var(--chrome-border)] rounded-[var(--chrome-radius-pill)] font-[family-name:var(--font-sans)] text-[0.66rem] text-[var(--chrome-fg-muted)]';
'flex items-center gap-[var(--space-2)] px-[var(--space-3)] py-[3px] mb-[3px] bg-[var(--chrome-bg)] border border-transparent rounded-[var(--chrome-radius-pill)] font-[family-name:var(--font-sans)] text-[0.66rem] text-[var(--chrome-fg-muted)]';
const SUMMARY_TRIGGER =
'inline-flex items-center gap-[5px] flex-1 min-w-0 bg-transparent border-none text-fg-muted cursor-pointer py-[2px] px-0 [font:inherit] text-left';
const SETTINGS_BAR =
'flex flex-col gap-[3px] max-[900px]:gap-[6px] mb-[4px] px-[8px] py-[4px] bg-[var(--chrome-bg)] border border-[var(--chrome-border)] rounded-[var(--chrome-radius-pill)]';
'flex flex-col gap-[3px] max-[900px]:gap-[6px] mb-[4px] px-[8px] py-[4px] bg-[var(--chrome-bg)] border border-transparent rounded-[var(--chrome-radius-pill)]';
const FIELD = 'flex flex-col gap-[1px] min-w-0';
const FIELD_RESP = 'max-[960px]:basis-full max-[960px]:min-w-0';
const FIELD_LABEL =
'label-row !text-[0.58rem] !text-fg-muted !m-0 whitespace-nowrap overflow-hidden text-ellipsis';
const FIELD_INPUT = 'input-base !w-full !text-[0.65rem] !px-[5px] !py-[3px]';
const ENGINE_CHIP =
'ml-[6px] px-[6px] py-[1px] text-[0.55rem] leading-[1.4] bg-[rgba(211,134,155,0.14)] border border-[rgba(211,134,155,0.35)] text-[#d3869b] rounded-[999px] whitespace-nowrap transition-colors';
// Highlighted accent Install affordance brand accent (#d3869b) filled pill,
// deliberately louder than ENGINE_CHIP so an uninstalled selected engine is an
// obvious call to action rather than a muted footnote.
'ml-[6px] px-[6px] py-[1px] text-[0.55rem] leading-[1.4] bg-[color-mix(in_srgb,var(--color-brand)_14%,transparent)] border border-transparent text-[var(--color-brand)] rounded-[var(--radius-pill)] whitespace-nowrap transition-colors';
// Highlighted accent Install affordance brand-filled pill, deliberately louder
// than ENGINE_CHIP so an uninstalled selected engine is an obvious call to action
// rather than a muted footnote.
const ENGINE_INSTALL_BTN =
'inline-flex items-center gap-[3px] ml-[6px] px-[7px] py-[1px] text-[0.55rem] font-semibold leading-[1.5] bg-[#d3869b] hover:bg-[#e0a0b3] text-[#1d2021] border border-[#d3869b] rounded-[999px] whitespace-nowrap cursor-pointer transition-colors shadow-[0_0_0_2px_rgba(211,134,155,0.25)] disabled:opacity-60 disabled:cursor-default';
'inline-flex items-center gap-[3px] ml-[6px] px-[7px] py-[1px] text-[0.55rem] font-semibold leading-[1.5] bg-[var(--color-brand)] hover:bg-[var(--color-brand-hover)] text-[var(--color-fg-inverse)] border border-transparent rounded-[var(--radius-pill)] whitespace-nowrap cursor-pointer transition-colors shadow-[0_0_0_2px_color-mix(in_srgb,var(--color-brand)_25%,transparent)] disabled:opacity-60 disabled:cursor-default';
export default function DubLeftColumn({
hasDubbedTrack,
@@ -245,7 +245,7 @@ export default function DubLeftColumn({
dropdown. It's also pre-selected on the segments so "new
language = same speaker's voice" works by default. */}
{dubSegments.some((s) => s.speaker_id) && (
<div className="mt-[2px] px-[var(--space-3)] py-[3px] bg-[var(--chrome-bg)] rounded-[var(--chrome-radius-pill)] border border-[var(--chrome-border)]">
<div className="mt-[2px] px-[var(--space-3)] py-[3px] bg-[var(--chrome-bg)] rounded-[var(--chrome-radius-pill)] border border-transparent">
<div className="flex gap-[var(--space-2)] items-center flex-wrap">
<span
className="font-[family-name:var(--chrome-font-mono)] text-[length:var(--chrome-label-size)] text-[var(--chrome-fg-muted)] tracking-[var(--chrome-label-track)] uppercase font-semibold"
@@ -492,7 +492,7 @@ export default function DubLeftColumn({
<div
role="dialog"
aria-label={t('dub.install_popover_title')}
className="absolute z-20 top-[calc(100%+6px)] left-0 w-[290px] max-w-[80vw] p-[10px] flex flex-col gap-[8px] bg-[var(--chrome-bg,#282828)] border border-[var(--chrome-border-strong,#504945)] rounded-[8px] shadow-[0_8px_24px_rgba(0,0,0,0.45)] normal-case text-left"
className="absolute z-20 top-[calc(100%+6px)] left-0 w-[290px] max-w-[80vw] p-[10px] flex flex-col gap-[8px] bg-[var(--chrome-bg,#282828)] border border-transparent rounded-[8px] shadow-[0_8px_24px_rgba(0,0,0,0.45)] normal-case text-left"
>
<div className="text-[0.68rem] font-semibold text-[var(--chrome-fg,#ebdbb2)] normal-case tracking-normal">
{t('dub.install_popover_title')}
@@ -502,12 +502,12 @@ export default function DubLeftColumn({
</p>
{installCmd && (
<div className="flex items-stretch gap-[4px]">
<code className="flex-1 min-w-0 px-[6px] py-[4px] text-[0.6rem] leading-[1.4] font-[family-name:var(--chrome-font-mono,monospace)] text-[var(--chrome-fg,#ebdbb2)] bg-[rgba(0,0,0,0.35)] border border-[var(--chrome-border,#3c3836)] rounded-[5px] overflow-x-auto whitespace-nowrap">
<code className="flex-1 min-w-0 px-[6px] py-[4px] text-[0.6rem] leading-[1.4] font-[family-name:var(--chrome-font-mono,monospace)] text-[var(--chrome-fg,#ebdbb2)] bg-[rgba(0,0,0,0.35)] border border-transparent rounded-[5px] overflow-x-auto whitespace-nowrap">
{installCmd}
</code>
<button
type="button"
className="shrink-0 inline-flex items-center justify-center px-[6px] rounded-[5px] border border-[var(--chrome-border,#3c3836)] text-[var(--chrome-fg-muted,#a89984)] hover:text-[var(--chrome-fg,#ebdbb2)] hover:border-[var(--chrome-border-strong,#504945)] cursor-pointer bg-transparent"
className="shrink-0 inline-flex items-center justify-center px-[6px] rounded-[5px] border border-transparent text-[var(--chrome-fg-muted,#a89984)] hover:text-[var(--chrome-fg,#ebdbb2)] hover:border-transparent cursor-pointer bg-transparent"
onClick={copyInstallCmd}
title={t('dub.copy_command')}
aria-label={t('dub.copy_command')}
@@ -518,7 +518,7 @@ export default function DubLeftColumn({
)}
<button
type="button"
className="inline-flex items-center justify-center gap-[5px] px-[8px] py-[5px] text-[0.64rem] font-semibold bg-[#d3869b] hover:bg-[#e0a0b3] text-[#1d2021] border-none rounded-[6px] cursor-pointer transition-colors"
className="inline-flex items-center justify-center gap-[5px] px-[8px] py-[5px] text-[0.64rem] font-semibold bg-[var(--color-brand)] hover:bg-[var(--color-brand-hover)] text-[var(--color-fg-inverse)] border-none rounded-[var(--radius-lg)] cursor-pointer transition-colors"
onClick={() => {
setTranslateProvider('argos');
setInstallPopoverOpen(false);
@@ -616,13 +616,11 @@ export default function DubLeftColumn({
onChange={(e) => setDubInstruct(e.target.value)}
/>
</div>
<div
className={`${FIELD} basis-full pt-[3px] border-t border-[var(--chrome-border)] mt-[1px]`}
>
<div className={`${FIELD} basis-full pt-[3px] border-t border-transparent mt-[1px]`}>
<label className="flex items-center gap-[6px] text-[0.65rem] text-[var(--chrome-fg-muted)] cursor-pointer mb-[2px]">
<input
type="checkbox"
className="accent-[var(--chrome-accent)] cursor-pointer"
className="accent-[var(--color-brand)] cursor-pointer"
checked={multiLangMode}
onChange={(e) => setMultiLangMode(e.target.checked)}
/>
@@ -16,7 +16,7 @@ const OUT_LABEL =
'flex items-center gap-[var(--space-2)] cursor-pointer hover:text-[var(--chrome-fg)]';
const OUT_TITLE =
'font-[family-name:var(--chrome-font-mono)] text-[length:var(--chrome-label-size)] tracking-[var(--chrome-label-track)] uppercase text-[var(--chrome-fg-muted)] font-semibold';
const CHK = 'accent-[var(--chrome-accent)]';
const CHK = 'accent-[var(--color-brand)]';
const BULK_SELECT = 'input-base !text-[0.62rem] !px-[4px] !py-[2px]';
export default function DubRightColumn({
@@ -173,7 +173,7 @@ export default function DubRightColumn({
{showTranscript ? <ChevronUp size={10} /> : <ChevronDown size={10} />}
</div>
{showTranscript && (
<div className="bg-[var(--chrome-bg)] border border-[var(--chrome-border)] border-t-0 rounded-b-[var(--chrome-radius-pill)] p-[var(--space-3)] text-[length:var(--text-xs)] text-[var(--chrome-fg-muted)] leading-[1.5] max-h-[80px] overflow-y-auto">
<div className="bg-[var(--chrome-bg)] border border-transparent border-t-0 rounded-b-[var(--chrome-radius-pill)] p-[var(--space-3)] text-[length:var(--text-xs)] text-[var(--chrome-fg-muted)] leading-[1.5] max-h-[80px] overflow-y-auto">
{dubTranscript}
</div>
)}
@@ -185,7 +185,7 @@ export default function DubRightColumn({
{dubJobId && !glossaryVisible && (
<button
type="button"
className="inline-flex items-center px-[var(--space-3)] py-[3px] mb-[4px] font-[family-name:var(--chrome-font-mono)] text-[length:var(--chrome-label-size)] tracking-[var(--chrome-label-track)] uppercase text-[var(--chrome-fg-muted)] bg-transparent border border-[var(--chrome-border)] rounded-[var(--chrome-radius-pill)] cursor-pointer transition-colors hover:bg-[var(--chrome-hover-bg)] hover:border-[var(--chrome-border-strong)] hover:text-[var(--chrome-fg)]"
className="inline-flex items-center px-[var(--space-3)] py-[3px] mb-[4px] font-[family-name:var(--chrome-font-mono)] text-[length:var(--chrome-label-size)] tracking-[var(--chrome-label-track)] uppercase text-[var(--chrome-fg-muted)] bg-transparent border border-transparent rounded-[var(--chrome-radius-pill)] cursor-pointer transition-colors hover:bg-[var(--chrome-hover-bg)] hover:border-transparent hover:text-[var(--chrome-fg)]"
onClick={() => {
setGlossaryOpen(true);
setGlossaryHidden(false);
@@ -216,7 +216,7 @@ export default function DubRightColumn({
thing per-speaker (and handles the multi-speaker case cleanly). */}
{selectedSegIds.size > 0 && (
<div className="flex items-center gap-[var(--space-3)] px-[6px] py-[3px] rounded-[var(--radius-md)] mb-[var(--space-2)] text-[length:var(--text-xs)] bg-[rgba(211,134,155,0.08)] border border-[rgba(211,134,155,0.25)]">
<div className="flex items-center gap-[var(--space-3)] px-[6px] py-[3px] rounded-[var(--radius-md)] mb-[var(--space-2)] text-[length:var(--text-xs)] bg-[rgba(211,134,155,0.08)] border border-transparent">
<span className="text-brand font-bold whitespace-nowrap">
{t('dub.selected_count', { count: selectedSegIds.size })}
</span>
+8 -9
View File
@@ -14,20 +14,19 @@ const BASE =
'disabled:opacity-45 disabled:cursor-not-allowed';
const TONES = {
idle: 'text-[var(--chrome-fg-muted)] border-[var(--chrome-border)] hover:bg-[var(--chrome-hover-bg)]',
stopping:
'text-[var(--chrome-fg-muted)] border-[var(--chrome-border)] hover:bg-[var(--chrome-hover-bg)]',
idle: 'text-[var(--chrome-fg-muted)] border-transparent hover:bg-[var(--chrome-hover-bg)]',
stopping: 'text-[var(--chrome-fg-muted)] border-transparent hover:bg-[var(--chrome-hover-bg)]',
danger:
'text-[var(--chrome-severity-err)] border-[color-mix(in_srgb,var(--chrome-severity-err)_45%,transparent)] bg-[color-mix(in_srgb,var(--chrome-severity-err)_10%,transparent)] hover:bg-[color-mix(in_srgb,var(--chrome-severity-err)_18%,transparent)]',
'text-[var(--chrome-severity-err)] border-transparent bg-[color-mix(in_srgb,var(--chrome-severity-err)_10%,transparent)] hover:bg-[color-mix(in_srgb,var(--chrome-severity-err)_18%,transparent)]',
green:
'text-[var(--chrome-severity-ok)] border-[color-mix(in_srgb,var(--chrome-severity-ok)_45%,transparent)] bg-[color-mix(in_srgb,var(--chrome-severity-ok)_10%,transparent)] hover:bg-[color-mix(in_srgb,var(--chrome-severity-ok)_18%,transparent)]',
'text-[var(--chrome-severity-ok)] border-transparent bg-[color-mix(in_srgb,var(--chrome-severity-ok)_10%,transparent)] hover:bg-[color-mix(in_srgb,var(--chrome-severity-ok)_18%,transparent)]',
pink: 'text-[var(--chrome-accent)] border-[var(--chrome-accent-border)] bg-[var(--chrome-accent-bg)] hover:bg-[color-mix(in_srgb,var(--chrome-accent)_20%,transparent)]',
blue: 'text-[#83a598] border-[color-mix(in_srgb,#83a598_45%,transparent)] bg-[color-mix(in_srgb,#83a598_10%,transparent)]',
lime: 'text-[#b8bb26] border-[color-mix(in_srgb,#b8bb26_45%,transparent)] bg-[color-mix(in_srgb,#b8bb26_10%,transparent)]',
blue: 'text-[var(--color-info)] border-transparent bg-[color-mix(in_srgb,var(--color-info)_10%,transparent)]',
lime: 'text-[#b8bb26] border-transparent bg-[color-mix(in_srgb,#b8bb26_10%,transparent)]',
amber:
'text-[var(--chrome-severity-warn)] border-[color-mix(in_srgb,var(--chrome-severity-warn)_45%,transparent)] bg-[color-mix(in_srgb,var(--chrome-severity-warn)_10%,transparent)]',
'text-[var(--chrome-severity-warn)] border-transparent bg-[color-mix(in_srgb,var(--chrome-severity-warn)_10%,transparent)]',
orange:
'text-[#fe8019] border-[color-mix(in_srgb,#fe8019_45%,transparent)] bg-[color-mix(in_srgb,#fe8019_10%,transparent)]',
'text-[var(--color-warn)] border-transparent bg-[color-mix(in_srgb,var(--color-warn)_10%,transparent)]',
};
const FooterBtn = React.forwardRef(function FooterBtn(
+3 -3
View File
@@ -366,7 +366,7 @@ export default function IdleSkeleton({
>
<input
type="checkbox"
className="m-0 accent-[#d3869b]"
className="m-0 accent-[var(--color-brand)]"
checked={fetchYtSubs}
onChange={(e) => setFetchYtSubs(e.target.checked)}
onClick={(e) => e.stopPropagation()}
@@ -402,7 +402,7 @@ export default function IdleSkeleton({
</label>
<button
type="button"
className="inline-flex items-center gap-[5px] px-[10px] py-[5px] text-[0.7rem] text-[var(--chrome-fg-muted)] bg-transparent border border-[var(--chrome-border)] rounded-[var(--chrome-radius-pill,999px)] cursor-pointer transition-colors hover:text-[var(--chrome-fg)] hover:border-[var(--chrome-border-strong)]"
className="inline-flex items-center gap-[5px] px-[10px] py-[5px] text-[0.7rem] text-[var(--chrome-fg-muted)] bg-transparent border border-transparent rounded-[var(--chrome-radius-pill,999px)] cursor-pointer transition-colors hover:text-[var(--chrome-fg)] hover:border-transparent"
onClick={() => setLandingAdvOpen((o) => !o)}
aria-expanded={landingAdvOpen}
>
@@ -581,7 +581,7 @@ export default function IdleSkeleton({
<Play size={11} /> {t('dub.generate_dub')}
</Button>
<button
className="inline-flex items-center gap-[5px] bg-transparent border border-[var(--chrome-border)] text-[var(--chrome-fg-muted)] rounded-[8px] flex-[0_0_auto] px-[8px] py-[4px] text-[0.7rem] opacity-40"
className="inline-flex items-center gap-[5px] bg-transparent border border-transparent text-[var(--chrome-fg-muted)] rounded-[8px] flex-[0_0_auto] px-[8px] py-[4px] text-[0.7rem] opacity-40"
disabled
>
<Download size={11} /> {t('dub.export_btn', { defaultValue: 'Export' })}{' '}
@@ -30,34 +30,41 @@ export default function ArchetypeCard({
const accentLabel = a.facets.accent
? facetLabel(a.facets.accent)
: dialect || (a.language === 'Chinese' ? 'Chinese' : null);
const hasChips = Boolean(accentLabel || a.facets.whisper);
// Borderless by direction: the card keeps a transparent border only to reserve
// the box width; the playing state is conveyed by an accent ring (box-shadow)
// + lift, never a literal border.
const cardBase =
'group relative flex flex-col gap-[10px] p-[13px] rounded-[13px] ' +
'bg-[linear-gradient(180deg,rgba(255,255,255,0.038),rgba(255,255,255,0.012))] border ' +
'transition-[transform,border-color,box-shadow] duration-150 ' +
'group relative flex flex-col gap-[11px] p-[14px] rounded-[13px] border border-transparent ' +
'bg-[linear-gradient(180deg,rgba(255,255,255,0.038),rgba(255,255,255,0.012))] ' +
'transition-[transform,box-shadow] duration-150 ' +
'hover:-translate-y-[2px] hover:shadow-[0_6px_22px_rgba(0,0,0,0.4)] ' +
'motion-reduce:transition-none motion-reduce:hover:translate-y-0';
const cardState = isPlaying
? 'border-[color:var(--card-accent)] shadow-[0_0_0_1px_var(--card-accent),0_6px_22px_rgba(0,0,0,0.4)]'
: 'border-white/[0.07] hover:border-white/[0.13]';
? 'shadow-[0_0_0_1px_var(--card-accent),0_6px_22px_rgba(0,0,0,0.4)]'
: '';
return (
<div className={`${cardBase} ${cardState}`} style={{ '--card-accent': color }}>
<div className="flex items-center gap-[10px]">
{/* Header — the name is the focal point; metadata recedes (smaller, muted). */}
<div className="flex items-center gap-[11px]">
<ArchetypeAvatar item={a} />
<div className="flex-1 min-w-0">
<div className="text-[0.84rem] font-semibold text-[var(--text-primary)] truncate">
<div className="text-[0.86rem] font-semibold leading-tight text-[var(--color-fg)] truncate">
{a.name}
</div>
{sub && (
<div className="text-[0.68rem] text-[var(--text-secondary)] mt-[2px] truncate">
<div className="text-[0.66rem] text-[var(--color-fg-muted)] mt-[3px] truncate">
{sub}
</div>
)}
</div>
<button
className={`flex-shrink-0 flex items-center justify-center w-[26px] h-[26px] rounded-[7px] cursor-pointer transition-colors hover:bg-white/[0.05] ${
isFavorite ? 'text-[#fabd2f]' : 'text-[var(--text-secondary)] hover:text-[#fabd2f]'
className={`flex-shrink-0 flex items-center justify-center w-[26px] h-[26px] rounded-[7px] cursor-pointer transition-[color,background-color,opacity] hover:bg-[var(--chrome-hover-bg)] ${
isFavorite
? 'text-[#fabd2f]'
: 'text-[var(--color-fg-subtle)] opacity-70 group-hover:opacity-100 hover:text-[#fabd2f]'
}`}
onClick={() => onToggleFavorite(a.id)}
title={t('gallery.favorite', { defaultValue: 'Favorite' })}
@@ -66,25 +73,30 @@ export default function ArchetypeCard({
</button>
</div>
{/* Always render the chip row (even when empty) so every card shares the
same height and the action rows align across the grid. */}
<div className="flex flex-wrap items-center gap-[5px] min-h-[21px]">
{accentLabel && (
<span className="inline-flex items-center gap-[5px] pl-[5px] pr-[8px] py-[2px] rounded-[7px] bg-white/[0.05] text-[var(--text-secondary)] text-[0.64rem] leading-[1.6]">
<AccentFlag accent={a.facets.accent} lang={a.language} size={14} />
{accentLabel}
</span>
)}
{a.facets.whisper && (
<span className="inline-flex items-center gap-[5px] px-[8px] py-[2px] rounded-[7px] bg-white/[0.05] text-[var(--text-secondary)] text-[0.64rem] leading-[1.6]">
{t('archetypes.facet_whisper', { defaultValue: 'Whisper' })}
</span>
)}
</div>
{/* Chips only render when present no empty reserved row. Cards without
chips stay compact; the grid stretches each row to equal height so the
`mt-auto` action row still bottom-aligns across the grid. */}
{hasChips && (
<div className="flex flex-wrap items-center gap-[5px]">
{accentLabel && (
<span className="inline-flex items-center gap-[5px] pl-[5px] pr-[8px] py-[2px] rounded-[7px] bg-[var(--color-bg-elev-2)] text-[var(--color-fg-muted)] text-[0.64rem] leading-[1.6]">
<AccentFlag accent={a.facets.accent} lang={a.language} size={14} />
{accentLabel}
</span>
)}
{a.facets.whisper && (
<span className="inline-flex items-center gap-[5px] px-[8px] py-[2px] rounded-[7px] bg-[var(--color-bg-elev-2)] text-[var(--color-fg-muted)] text-[0.64rem] leading-[1.6]">
{t('archetypes.facet_whisper', { defaultValue: 'Whisper' })}
</span>
)}
</div>
)}
{/* Actions quiet Preview (ghost, token hover), confident accent Use voice
(tinted solid accent with inverse text), subtle magic-wand icon. */}
<div className="flex items-center gap-[6px] mt-auto">
<button
className="inline-flex items-center gap-[6px] px-[11px] py-[6px] border border-white/[0.09] bg-white/[0.03] text-[var(--text-primary)] rounded-[8px] text-[0.7rem] cursor-pointer transition-colors hover:border-[color:var(--card-accent)] hover:text-[var(--card-accent)]"
className="inline-flex items-center gap-[6px] px-[11px] py-[6px] rounded-[8px] bg-transparent text-[var(--color-fg-muted)] text-[0.7rem] cursor-pointer transition-colors hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--color-fg)]"
onClick={() => onPreview(a)}
title={t('gallery.preview', { defaultValue: 'Preview' })}
>
@@ -98,13 +110,13 @@ export default function ArchetypeCard({
<span>{t('gallery.preview', { defaultValue: 'Preview' })}</span>
</button>
<button
className="flex-1 inline-flex items-center justify-center gap-[6px] px-[10px] py-[6px] rounded-[8px] border border-[color:color-mix(in_srgb,var(--card-accent)_36%,transparent)] bg-[color-mix(in_srgb,var(--card-accent)_13%,transparent)] text-[var(--card-accent)] text-[0.72rem] font-semibold cursor-pointer transition-colors hover:bg-[var(--card-accent)] hover:border-[color:var(--card-accent)] hover:text-[#1d2021] focus-visible:bg-[var(--card-accent)] focus-visible:border-[color:var(--card-accent)] focus-visible:text-[#1d2021]"
className="flex-1 inline-flex items-center justify-center gap-[6px] px-[10px] py-[6px] rounded-[8px] bg-[color-mix(in_srgb,var(--card-accent)_15%,transparent)] text-[var(--card-accent)] text-[0.72rem] font-semibold cursor-pointer transition-colors hover:bg-[var(--card-accent)] hover:text-[var(--color-fg-inverse)] focus-visible:bg-[var(--card-accent)] focus-visible:text-[var(--color-fg-inverse)]"
onClick={() => onUse(a)}
>
<UserPlus size={14} /> {t('gallery.use_voice', { defaultValue: 'Use voice' })}
</button>
<button
className="inline-flex items-center justify-center w-[30px] h-[30px] flex-shrink-0 border border-white/[0.09] bg-white/[0.03] text-[var(--text-secondary)] rounded-[8px] cursor-pointer opacity-50 transition-[opacity,border-color,color] duration-150 group-hover:opacity-100 focus-visible:opacity-100 hover:text-[var(--card-accent)] hover:border-[color:var(--card-accent)]"
className="inline-flex items-center justify-center w-[30px] h-[30px] flex-shrink-0 rounded-[8px] bg-transparent text-[var(--color-fg-muted)] cursor-pointer opacity-50 transition-[opacity,color,background-color] duration-150 group-hover:opacity-100 focus-visible:opacity-100 hover:bg-[var(--chrome-hover-bg)] hover:text-[var(--card-accent)]"
onClick={() => onDesign(a)}
title={t('gallery.open_designer', { defaultValue: 'Open in Designer' })}
>
@@ -117,7 +117,7 @@ export default function ArchetypesZone({
const facetGroup =
'flex items-center gap-[5px] flex-nowrap min-w-0 overflow-x-auto overflow-y-hidden [scrollbar-width:thin]';
const facetToggle =
'inline-flex items-center gap-[5px] h-[26px] box-border px-[9px] rounded-[7px] border border-[var(--chrome-border)] bg-[var(--chrome-hover-bg)] text-[var(--chrome-fg-muted)] text-[0.68rem] whitespace-nowrap cursor-pointer hover:text-[var(--chrome-fg)] hover:border-[color:var(--chrome-border-strong)]';
'inline-flex items-center gap-[5px] h-[26px] box-border px-[9px] rounded-[7px] border border-transparent bg-[var(--chrome-hover-bg)] text-[var(--chrome-fg-muted)] text-[0.68rem] whitespace-nowrap cursor-pointer hover:text-[var(--chrome-fg)] hover:border-[color:var(--chrome-border-strong)]';
const gridClass =
viewMode === 'grid'
? 'grid grid-cols-[repeat(auto-fill,minmax(248px,1fr))] gap-[10px]'
@@ -125,7 +125,7 @@ export default function ArchetypesZone({
return (
<div className="flex-1 min-h-0 flex flex-col overflow-y-auto">
<div className="flex flex-row items-center gap-[10px] flex-nowrap shrink-0 pt-[2px] pb-[10px] mb-[8px] border-b border-[var(--chrome-border)]">
<div className="flex flex-row items-center gap-[10px] flex-nowrap shrink-0 pt-[2px] pb-[10px] mb-[8px] border-b border-transparent">
{/* Three filter lanes (categories · facets · toggles), each its own
horizontally-scrollable portion; the view toggle is pinned right. */}
<div className={`${facetGroup} flex-[2.4_1_0]`}>
@@ -150,9 +150,7 @@ export default function ArchetypesZone({
))}
</div>
<div
className={`${facetGroup} flex-[1.6_1_0] pl-[10px] border-l border-[var(--chrome-border)]`}
>
<div className={`${facetGroup} flex-[1.6_1_0] pl-[10px] border-l border-transparent`}>
{['gender', 'age', 'pitch', 'accent', 'lang'].map((dim) => (
<Select
key={dim}
@@ -172,9 +170,7 @@ export default function ArchetypesZone({
))}
</div>
<div
className={`${facetGroup} flex-[1_1_0] pl-[10px] border-l border-[var(--chrome-border)]`}
>
<div className={`${facetGroup} flex-[1_1_0] pl-[10px] border-l border-transparent`}>
<label className={facetToggle}>
<input
type="checkbox"
@@ -30,7 +30,7 @@ export default function CommunityZone({
};
const submitBtn =
'inline-flex items-center gap-[5px] px-[10px] py-[6px] border border-white/10 bg-white/[0.03] text-[var(--text-primary)] rounded-[8px] text-[0.7rem] cursor-pointer transition-colors hover:border-[color:var(--accent)] hover:text-[var(--accent)]';
'inline-flex items-center gap-[5px] px-[10px] py-[6px] border border-transparent bg-white/[0.03] text-[var(--text-primary)] rounded-[8px] text-[0.7rem] cursor-pointer transition-colors hover:border-[color:var(--accent)] hover:text-[var(--accent)]';
return (
<div className="flex-1 min-h-0 flex flex-col overflow-y-auto">
@@ -214,7 +214,7 @@ export default function ImportsZone({ t, playingId, loadingPreviewId, onPlayGall
};
const voicePlay =
'flex items-center justify-center w-[28px] h-[28px] rounded-full border border-[var(--chrome-border)] bg-bg-elev-1 text-[var(--text-primary)] cursor-pointer flex-shrink-0 hover:bg-[var(--accent)] hover:border-[color:var(--accent)] hover:text-white';
'flex items-center justify-center w-[28px] h-[28px] rounded-full border border-transparent bg-bg-elev-1 text-[var(--text-primary)] cursor-pointer flex-shrink-0 hover:bg-[var(--accent)] hover:border-[color:var(--accent)] hover:text-white';
const actionBtn =
'flex items-center justify-center w-[24px] h-[24px] bg-transparent text-[var(--text-secondary)] rounded-[4px] cursor-pointer hover:bg-bg-elev-2 hover:text-[var(--text-primary)]';
@@ -305,7 +305,7 @@ export default function ImportsZone({ t, playingId, loadingPreviewId, onPlayGall
{results.map((r, i) => (
<div
key={i}
className="flex justify-between items-center px-[10px] py-[8px] gap-[8px] border-b border-[var(--chrome-border)] last:border-0"
className="flex justify-between items-center px-[10px] py-[8px] gap-[8px] border-b border-transparent last:border-0"
>
<div className="flex-1 min-w-0 flex flex-col gap-[2px]">
<span className="text-[0.75rem] truncate">{r.title}</span>
@@ -85,7 +85,7 @@ export default function ProfileActivity({
<button
type="button"
onClick={() => onOpenProject?.(p.project_id)}
className="flex w-full cursor-pointer items-center gap-[var(--space-3)] rounded-[var(--radius-md)] border border-border bg-[rgba(255,255,255,0.02)] px-[var(--space-4)] py-[var(--space-3)] text-fg [font-size:var(--text-md)] transition-[background,border-color] duration-[var(--dur-fast)] ease-[var(--ease-out)] hover:border-[var(--color-border-strong)] hover:bg-[rgba(255,255,255,0.06)]"
className="flex w-full cursor-pointer items-center gap-[var(--space-3)] rounded-[var(--radius-md)] border border-border bg-[rgba(255,255,255,0.02)] px-[var(--space-4)] py-[var(--space-3)] text-fg [font-size:var(--text-md)] transition-[background,border-color] duration-[var(--dur-fast)] ease-[var(--ease-out)] hover:border-transparent hover:bg-[rgba(255,255,255,0.06)]"
>
<FolderOpen size={11} />
<span className="flex-1 text-left">{p.project_name}</span>
@@ -90,7 +90,7 @@ export default function ProfileDetails({
)}
</Field>
{profile.is_locked && !editing && (
<div className="mt-[var(--space-4)] flex flex-wrap items-center gap-[var(--space-4)] rounded-[var(--radius-md)] border border-[rgba(250,189,47,0.25)] bg-[rgba(250,189,47,0.06)] px-[var(--space-4)] py-[var(--space-3)]">
<div className="mt-[var(--space-4)] flex flex-wrap items-center gap-[var(--space-4)] rounded-[var(--radius-md)] border border-transparent bg-[rgba(250,189,47,0.06)] px-[var(--space-4)] py-[var(--space-3)]">
<Badge tone="warn" dot>
<Lock size={10} /> {t('voice_profile.locked')}
</Badge>
@@ -115,7 +115,7 @@ export default function ProfileDetails({
}
>
{profile.verified_own_voice ? (
<div className="mt-[var(--space-4)] flex flex-wrap items-center gap-[var(--space-4)] rounded-[var(--radius-md)] border border-[rgba(250,189,47,0.25)] bg-[rgba(250,189,47,0.06)] px-[var(--space-4)] py-[var(--space-3)]">
<div className="mt-[var(--space-4)] flex flex-wrap items-center gap-[var(--space-4)] rounded-[var(--radius-md)] border border-transparent bg-[rgba(250,189,47,0.06)] px-[var(--space-4)] py-[var(--space-3)]">
<Badge tone="success" dot>
<ShieldCheck size={10} /> {t('voice_profile.verified')}
</Badge>
@@ -94,8 +94,8 @@ export default function ProfileHeader({
<div
className={`flex h-[54px] w-[54px] shrink-0 items-center justify-center rounded-[16px_20px_14px_22px/18px_14px_22px_16px] border ${
isDesign
? 'border-[rgba(142,192,124,0.35)] bg-[rgba(142,192,124,0.15)] text-success'
: 'border-[rgba(211,134,155,0.35)] bg-[rgba(211,134,155,0.15)] text-brand'
? 'border-transparent bg-[rgba(142,192,124,0.15)] text-success'
: 'border-transparent bg-[rgba(211,134,155,0.15)] text-brand'
}`}
>
<TypeIcon size={22} />
@@ -65,7 +65,7 @@ export default function AppearancePanel() {
onChange={(e) => setUiScale(Number(e.target.value))}
aria-label={scaleLabel}
aria-valuetext={`${Math.round(uiScale * 100)}%`}
className="min-w-0 flex-1 cursor-pointer accent-[var(--chrome-accent)]"
className="min-w-0 flex-1 cursor-pointer accent-[var(--color-brand)]"
/>
<span className="min-w-[40px] text-right text-[length:var(--text-sm)] tabular-nums text-[var(--chrome-fg)]">
{Math.round(uiScale * 100)}%
@@ -29,7 +29,7 @@ export default function SettingsSearch({ value, onChange, onClear }) {
placeholder={t('settings.search_placeholder', { defaultValue: 'Search settings…' })}
aria-label={t('settings.search_placeholder', { defaultValue: 'Search settings…' })}
data-testid="settings-search"
className="w-full min-w-0 box-border rounded-[var(--chrome-radius-pill)] border border-[var(--chrome-border)] bg-[color-mix(in_srgb,var(--chrome-bg)_94%,white)] py-[var(--space-2)] pl-[calc(var(--space-3)*2+13px)] pr-[calc(var(--space-3)*2+13px)] text-[color:var(--chrome-fg)] [font-family:var(--font-sans)] text-[length:var(--text-sm)] focus:border-[var(--chrome-accent)] focus:outline-none [&::-webkit-search-cancel-button]:appearance-none"
className="w-full min-w-0 box-border rounded-[var(--chrome-radius-pill)] border border-transparent bg-[color-mix(in_srgb,var(--chrome-bg)_94%,white)] py-[var(--space-2)] pl-[calc(var(--space-3)*2+13px)] pr-[calc(var(--space-3)*2+13px)] text-[color:var(--chrome-fg)] [font-family:var(--font-sans)] text-[length:var(--text-sm)] focus:border-[var(--chrome-accent)] focus:outline-none [&::-webkit-search-cancel-button]:appearance-none"
/>
{value && (
<button
@@ -35,7 +35,7 @@ export default function SettingsSidebar({ visibleIds, active, onSelect }) {
onChange={(e) => onSelect(e.target.value)}
aria-label={t('settings.title', { defaultValue: 'Settings' })}
data-testid="settings-nav-select"
className="w-full min-w-0 box-border rounded-[var(--chrome-radius-pill)] border border-[var(--chrome-border)] bg-[color-mix(in_srgb,var(--chrome-bg)_94%,white)] px-[var(--space-4)] py-[var(--space-3)] text-[color:var(--chrome-fg)] [font-family:var(--font-sans)] text-[length:var(--text-sm)] focus:border-[var(--chrome-accent)] focus:outline-none"
className="w-full min-w-0 box-border rounded-[var(--chrome-radius-pill)] border border-transparent bg-[color-mix(in_srgb,var(--chrome-bg)_94%,white)] px-[var(--space-4)] py-[var(--space-3)] text-[color:var(--chrome-fg)] [font-family:var(--font-sans)] text-[length:var(--text-sm)] focus:border-[var(--chrome-accent)] focus:outline-none"
>
{GROUPS.map((g) => {
const items = g.items.filter((it) => isVisible(it.id));
@@ -22,7 +22,7 @@ export default function Collapsible({ title, icon: Icon, defaultOpen = false, ba
return (
<div
className={cn(
'border border-[var(--chrome-border)] rounded-[var(--chrome-radius-pill)] mt-[var(--space-4)] overflow-hidden',
'border border-transparent rounded-[var(--chrome-radius-pill)] mt-[var(--space-4)] overflow-hidden',
open && 'is-open',
)}
>
@@ -50,7 +50,7 @@ export default function Collapsible({ title, icon: Icon, defaultOpen = false, ba
)}
</button>
{open && (
<div className="px-[var(--space-5)] pt-[var(--space-2)] pb-[var(--space-4)] border-t border-[var(--chrome-border)] [&>[data-slot=setting-row]:last-child]:pb-0">
<div className="px-[var(--space-5)] pt-[var(--space-2)] pb-[var(--space-4)] border-t border-transparent [&>[data-slot=setting-row]:last-child]:pb-0">
{children}
</div>
)}
@@ -45,7 +45,7 @@ export default function SettingRow({
data-slot="setting-row"
data-mono={mono ? '' : undefined}
className={cn(
'grid gap-y-[1px] py-[var(--space-4)] min-h-0 border-b border-[var(--chrome-border)] last:border-b-0 [font-family:var(--font-sans)]',
'grid gap-y-[1px] py-[var(--space-4)] min-h-0 border-b border-transparent last:border-b-0 [font-family:var(--font-sans)]',
align === 'start' ? 'items-start' : 'items-center',
stack
? 'grid-cols-[1fr] gap-[var(--space-3)]'
@@ -36,7 +36,7 @@ export default function SettingsInput({
data-slot="settings-input"
type={type}
className={cn(
'w-full min-w-0 max-w-[min(360px,100%)] box-border rounded-[var(--chrome-radius-pill)] border border-[var(--chrome-border)] bg-[color-mix(in_srgb,var(--chrome-bg)_94%,white)] px-[var(--space-4)] py-[var(--space-3)] text-[color:var(--chrome-fg)] [font-family:var(--font-sans)] text-[length:var(--text-sm)] focus:outline-none focus:border-[var(--chrome-accent)] disabled:opacity-50 disabled:cursor-not-allowed',
'w-full min-w-0 max-w-[min(360px,100%)] box-border rounded-[var(--chrome-radius-pill)] border border-transparent bg-[color-mix(in_srgb,var(--chrome-bg)_94%,white)] px-[var(--space-4)] py-[var(--space-3)] text-[color:var(--chrome-fg)] [font-family:var(--font-sans)] text-[length:var(--text-sm)] focus:outline-none focus:border-[var(--chrome-accent)] disabled:opacity-50 disabled:cursor-not-allowed',
mono && '[font-family:var(--chrome-font-mono)]',
className,
)}
@@ -25,7 +25,7 @@ import React from 'react';
// to the primitive without re-deriving the token string. `data-slot` is the
// stable hook Settings.css / panel CSS reach into.
export const SETTINGS_SECTION_SURFACE =
'bg-[var(--chrome-bg)] border border-[var(--chrome-border)] rounded-[var(--chrome-radius-pill)] px-[var(--space-6)] py-[var(--space-5)] mb-[var(--space-5)] last:mb-0';
'bg-[var(--chrome-bg)] border border-transparent rounded-[var(--chrome-radius-pill)] px-[var(--space-6)] py-[var(--space-5)] mb-[var(--space-5)] last:mb-0';
export default function SettingsSection({
icon: Icon,
@@ -41,10 +41,10 @@ export default function SettingsSection({
data-slot="settings-section"
className={`${SETTINGS_SECTION_SURFACE} ${className}`.trim()}
>
<header className="flex items-center gap-[var(--space-3)] mb-[var(--space-3)] pb-[var(--space-3)] border-b border-[var(--chrome-border)]">
<header className="flex items-center gap-[var(--space-3)] mb-[var(--space-3)] pb-[var(--space-3)] border-b border-transparent">
{Icon && (
<span
className="shrink-0 inline-flex items-center justify-center w-[20px] h-[20px] rounded-[var(--chrome-radius-pill)] text-[color:var(--chrome-fg-muted)] bg-[color-mix(in_srgb,currentColor_12%,var(--chrome-bg))] border border-[color-mix(in_srgb,currentColor_26%,var(--chrome-border))]"
className="shrink-0 inline-flex items-center justify-center w-[20px] h-[20px] rounded-[var(--chrome-radius-pill)] text-[color:var(--chrome-fg-muted)] bg-[color-mix(in_srgb,currentColor_12%,var(--chrome-bg))] border border-transparent"
style={accent ? { color: accent } : undefined}
aria-hidden="true"
>
@@ -46,10 +46,10 @@ export default function SettingsToggle({
{...rest}
/>
<span
className="absolute inset-0 rounded-[999px] bg-[var(--chrome-hover-bg)] transition-[background] duration-[160ms] ease-in-out [.is-on_&]:bg-[var(--chrome-accent)] peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-[var(--chrome-accent)] peer-focus-visible:outline-offset-2"
className="absolute inset-0 rounded-[var(--radius-pill)] bg-[var(--chrome-hover-bg)] transition-[background] duration-[160ms] ease-in-out [.is-on_&]:bg-[var(--color-brand)] peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-[var(--color-ring)] peer-focus-visible:outline-offset-2"
aria-hidden="true"
>
<span className="absolute top-[3px] left-[3px] w-[18px] h-[18px] rounded-full bg-[var(--chrome-bg)] shadow-[0_1px_2px_rgba(0,0,0,0.35)] transition-transform duration-[160ms] ease-in-out [.is-on_&]:translate-x-[18px]" />
<span className="absolute top-[3px] left-[3px] w-[18px] h-[18px] rounded-full bg-[var(--chrome-bg)] shadow-[var(--shadow-sm)] transition-transform duration-[160ms] ease-in-out [.is-on_&]:translate-x-[18px]" />
</span>
</label>
);
+11 -11
View File
@@ -10,9 +10,9 @@ import { cn } from '@/lib/utils';
* Beyond the stock shadcn variants (default / secondary / destructive /
* outline) the CVA carries the OmniVoice *tones* (neutral / brand / success /
* warn / danger / info / violet) that back the legacy `src/ui/Badge.jsx`
* wrapper. Tones render as chrome chips a mono uppercase pill with an
* explicit border + tinted fill using palette token utilities so each tone
* recolors with every [data-theme].
* wrapper. Tones render as chrome chips a mono uppercase pill with a
* tinted fill (borders removed app-wide) using palette token utilities so
* each tone recolors with every [data-theme].
*/
const badgeVariants = cva(
'inline-flex items-center gap-[2px] rounded-[var(--chrome-radius-pill)] font-mono font-semibold tracking-[var(--chrome-label-track)] uppercase whitespace-nowrap select-none leading-[1.2] [&>svg]:size-3 [&>svg]:pointer-events-none',
@@ -23,15 +23,15 @@ const badgeVariants = cva(
default: 'border border-transparent bg-primary text-primary-foreground',
secondary: 'border border-transparent bg-secondary text-secondary-foreground',
destructive: 'border border-transparent bg-destructive text-destructive-foreground',
outline: 'border border-border text-foreground',
outline: 'border border-transparent bg-secondary text-foreground',
// ── OmniVoice tones ──
neutral: 'text-muted-foreground border border-white/15 bg-transparent',
brand: 'text-primary border border-primary/35 bg-primary/[0.12]',
success: 'text-success border border-success/45 bg-success/10',
warn: 'text-accent border border-accent/45 bg-accent/10',
danger: 'text-destructive border border-destructive/45 bg-destructive/10',
info: 'text-info border border-info/45 bg-info/10',
violet: 'text-muted-foreground border border-white/15 bg-transparent',
neutral: 'text-muted-foreground border border-transparent bg-transparent',
brand: 'text-primary border border-transparent bg-primary/[0.12]',
success: 'text-success border border-transparent bg-success/10',
warn: 'text-accent border border-transparent bg-accent/10',
danger: 'text-destructive border border-transparent bg-destructive/10',
info: 'text-info border border-transparent bg-info/10',
violet: 'text-muted-foreground border border-transparent bg-transparent',
},
size: {
xs: 'px-1.5 py-0 text-[11px]',
+10 -10
View File
@@ -32,7 +32,7 @@ const buttonVariants = cva(
destructive:
'bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20',
outline:
'border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground',
'border border-transparent bg-background shadow-xs hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
@@ -44,20 +44,20 @@ const buttonVariants = cva(
primary:
'border border-transparent bg-primary text-primary-foreground font-semibold shadow-xs hover:bg-primary/90 active:scale-[0.98]',
subtle:
'border border-border bg-transparent text-muted-foreground hover:bg-white/[0.04] hover:text-foreground hover:border-white/15',
'border border-transparent bg-transparent text-muted-foreground hover:bg-[var(--chrome-hover-bg)] hover:text-foreground hover:border-transparent',
softGhost:
'border border-transparent bg-transparent text-muted-foreground hover:bg-white/[0.04] hover:text-foreground',
'border border-transparent bg-transparent text-muted-foreground hover:bg-[var(--chrome-hover-bg)] hover:text-foreground',
danger:
'text-destructive bg-destructive/10 border border-destructive/45 hover:bg-destructive/20 hover:border-destructive',
chip: 'border border-border bg-transparent text-muted-foreground hover:bg-white/[0.04] hover:text-foreground hover:border-white/15',
chipActive: 'text-success bg-success/10 border border-success/45',
'text-destructive bg-destructive/10 border border-transparent hover:bg-destructive/20 hover:border-transparent',
chip: 'border border-transparent bg-transparent text-muted-foreground hover:bg-[var(--chrome-hover-bg)] hover:text-foreground hover:border-transparent',
chipActive: 'text-success bg-success/10 border border-transparent',
preset:
'justify-start text-left border border-border bg-transparent text-muted-foreground hover:bg-white/[0.04] hover:text-foreground hover:border-white/15',
'justify-start text-left border border-transparent bg-transparent text-muted-foreground hover:bg-[var(--chrome-hover-bg)] hover:text-foreground hover:border-transparent',
presetActive:
'justify-start text-left text-primary bg-primary/[0.12] border border-primary/30',
'justify-start text-left text-primary bg-primary/[0.12] border border-transparent',
iconBtn:
'border border-border bg-transparent text-muted-foreground hover:bg-white/[0.04] hover:text-foreground hover:border-white/15',
iconBtnActive: 'text-primary bg-primary/[0.12] border border-primary/30',
'border border-transparent bg-transparent text-muted-foreground hover:bg-[var(--chrome-hover-bg)] hover:text-foreground hover:border-transparent',
iconBtnActive: 'text-primary bg-primary/[0.12] border border-transparent',
},
size: {
// ── stock shadcn ──
+1 -1
View File
@@ -25,7 +25,7 @@ function Card({
<Comp
data-slot="card"
className={cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border border-transparent py-6 shadow-sm',
className,
)}
{...props}
+1 -1
View File
@@ -60,7 +60,7 @@ function DialogContent({
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200',
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border border-transparent p-6 shadow-lg duration-200',
className,
)}
{...props}
+1 -1
View File
@@ -39,7 +39,7 @@ function DropdownMenuContent({
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border border-transparent p-1 shadow-md',
className,
)}
{...props}
+1 -1
View File
@@ -65,7 +65,7 @@ function SelectContent({
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border border-transparent shadow-md',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className,
+1 -1
View File
@@ -23,7 +23,7 @@ const toggleVariants = cva(
outline:
'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground',
// ── OmniVoice segmented option ──
seg: 'font-extrabold border-0 cursor-pointer rounded-[var(--radius-pill)] bg-transparent text-fg-subtle transition-[background,color] duration-[var(--dur-fast)] ease-[var(--ease-out)] data-[state=off]:hover:text-fg data-[state=off]:hover:bg-white/[0.04] data-[state=on]:bg-primary/25 data-[state=on]:text-[#fff9ef]',
seg: 'font-extrabold border-0 cursor-pointer rounded-[var(--radius-pill)] bg-transparent text-fg-subtle transition-[background,color] duration-[var(--dur-fast)] ease-[var(--ease-out)] data-[state=off]:hover:text-fg data-[state=off]:hover:bg-[var(--chrome-hover-bg)] data-[state=on]:bg-primary/25 data-[state=on]:text-fg',
},
size: {
default: 'h-9 px-2 min-w-9',
+159 -57
View File
@@ -288,6 +288,13 @@
/ Inter are no longer referenced by any rule. */
:root {
/* Default theme is Gruvbox Dark (--bg #1d2021 / --chrome-bg #0f1011).
Declaring `color-scheme: dark` (NOT the same as the prefers-color-scheme
media query below) makes UA-rendered form chrome native <select> option
popups, scrollbars, autofill, spin-buttons render in the dark scheme so
they match the app instead of defaulting to the OS light scheme. Every
[data-theme] block re-asserts the scheme matching its own bg lightness. */
color-scheme: dark;
--primary: #d3869b;
--primary-hover: #b16286;
--accent: #fabd2f;
@@ -314,14 +321,27 @@
--chrome-fg: #d5c4a1;
--chrome-fg-muted: #a89984;
--chrome-fg-dim: #7c6f64;
--chrome-accent: #f3a5b6;
--chrome-accent-bg: rgba(243, 165, 182, 0.12);
--chrome-accent-border: rgba(243, 165, 182, 0.35);
/* Accent family aliases the THEMED brand token (--color-brand, re-declared per
[data-theme]) so every accent surface donate/support/commercial CTAs,
active tabs, .btn-primary, status pills, GoalBar/Pip tracks the active
theme instead of the old fixed pink. (--chrome-accent-border stays zeroed by
the app-wide border-removal block below; the visible accent cue is the fg
color + the -bg tint.) */
--chrome-accent: var(--color-brand);
--chrome-accent-bg: color-mix(in srgb, var(--color-brand) 12%, transparent);
--chrome-accent-border: color-mix(in srgb, var(--color-brand) 35%, transparent);
--chrome-severity-err: #fb4934;
--chrome-severity-warn: #fabd2f;
--chrome-severity-ok: #8ec07c;
--chrome-radius-pill: 3px;
--chrome-hover-bg: rgba(255, 255, 255, 0.04);
/* Native <select> caret. One token consumed by both select.input-base and
.ui-select so the chevron color lives in a single place and is overridden
per [data-theme] to track that theme's muted foreground (was a hardcoded
gray %23a1a1aa that ignored theme + accent). Stroke color = --chrome-fg-muted
baked into the data-URI (a background-image SVG can't read a CSS var, so the
color is inlined; each theme block re-declares this with its own fg-muted). */
--select-caret: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2210%22%20height%3D%226%22%3E%3Cpath%20d%3D%22M1%201l4%204%204-4%22%20fill%3D%22none%22%20stroke%3D%22%23a89984%22%20stroke-width%3D%221.5%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3C%2Fsvg%3E");
/* Chrome-label face is IBM Plex Mono now defined in the @theme block as
--font-mono. Keep this alias so existing `var(--chrome-font-mono)` rules
don't need to change; it just picks up the new face automatically. */
@@ -387,6 +407,10 @@
--chrome-fg-muted: #94a3b8;
--chrome-fg-dim: #475569;
--chrome-border: #334155;
/* Midnight Blue — bg #0f172a is dark. */
color-scheme: dark;
--select-caret: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2210%22%20height%3D%226%22%3E%3Cpath%20d%3D%22M1%201l4%204%204-4%22%20fill%3D%22none%22%20stroke%3D%22%2394a3b8%22%20stroke-width%3D%221.5%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3C%2Fsvg%3E");
}
/* ── Nord ──────────────────────────────────────────────────────────── */
@@ -420,6 +444,10 @@
--chrome-fg-muted: #d8dee9;
--chrome-fg-dim: #4c566a;
--chrome-border: #434c5e;
/* Nord — bg #2e3440 is dark. */
color-scheme: dark;
--select-caret: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2210%22%20height%3D%226%22%3E%3Cpath%20d%3D%22M1%201l4%204%204-4%22%20fill%3D%22none%22%20stroke%3D%22%23d8dee9%22%20stroke-width%3D%221.5%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3C%2Fsvg%3E");
}
/* ── Solarized Dark ────────────────────────────────────────────────── */
@@ -455,6 +483,10 @@
--chrome-fg-muted: #899da4;
--chrome-fg-dim: #586e75;
--chrome-border: #073642;
/* Solarized Dark — bg #002b36 is dark. */
color-scheme: dark;
--select-caret: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2210%22%20height%3D%226%22%3E%3Cpath%20d%3D%22M1%201l4%204%204-4%22%20fill%3D%22none%22%20stroke%3D%22%23899da4%22%20stroke-width%3D%221.5%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3C%2Fsvg%3E");
}
/* ── Rose Pine ─────────────────────────────────────────────────────── */
@@ -488,6 +520,10 @@
--chrome-fg-muted: #908caa;
--chrome-fg-dim: #6e6a86;
--chrome-border: #26233a;
/* Rose Pine — bg #191724 is dark. */
color-scheme: dark;
--select-caret: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2210%22%20height%3D%226%22%3E%3Cpath%20d%3D%22M1%201l4%204%204-4%22%20fill%3D%22none%22%20stroke%3D%22%23908caa%22%20stroke-width%3D%221.5%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3C%2Fsvg%3E");
}
/* ── Catppuccin Mocha ──────────────────────────────────────────────── */
@@ -521,6 +557,10 @@
--chrome-fg-muted: #a6adc8;
--chrome-fg-dim: #585b70;
--chrome-border: #45475a;
/* Catppuccin Mocha — bg #1e1e2e is dark. */
color-scheme: dark;
--select-caret: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2210%22%20height%3D%226%22%3E%3Cpath%20d%3D%22M1%201l4%204%204-4%22%20fill%3D%22none%22%20stroke%3D%22%23a6adc8%22%20stroke-width%3D%221.5%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3C%2Fsvg%3E");
}
/* ── System-preference sync ────────────────────────────────────────── */
@@ -529,7 +569,12 @@
community contributions. */
@media (prefers-color-scheme: light) {
[data-theme="auto"] {
/* Future light theme tokens go here */
/* Future light theme tokens go here. NOTE: no light theme ships yet, so
"auto" still renders the dark default tokens even on a light-mode OS
which is why `color-scheme` is intentionally left at the :root `dark`
here (a `light` value would make native form chrome mismatch the still
-dark surface). When a real light theme lands, set `color-scheme: light`
and its own --select-caret in this block. */
}
}
@@ -774,12 +819,9 @@ html[data-zoom-layout='off'] .app-container {
margin-bottom: 0;
}
.glass-panel::before {
content: "";
position: absolute;
top: 0; left: 0; right: 0;
height: 1px;
background: linear-gradient(90deg, rgba(255,255,255,0), rgba(255,255,255,0.08) 30%, rgba(255,255,255,0.12) 50%, rgba(255,255,255,0.08) 70%, rgba(255,255,255,0));
pointer-events: none;
/* Decorative top-highlight hairline removed as part of the app-wide
border/divider removal. */
display: none;
}
/* ═══ HEADER — warm + cute ═══ */
@@ -876,8 +918,10 @@ html[data-zoom-layout='off'] .app-container {
/* ═══ INPUTS ═══ */
.input-base {
width: 100%;
background: var(--chrome-hover-bg);
border: 1px solid var(--chrome-border);
/* Borders are removed app-wide; a recessed surface fill keeps text
fields / selects / textareas perceivable as inputs. */
background: var(--color-bg-elev-2);
border: 1px solid transparent;
border-radius: var(--chrome-radius-pill);
padding: 5px 8px;
color: var(--chrome-fg);
@@ -888,51 +932,65 @@ html[data-zoom-layout='off'] .app-container {
}
.input-base:focus {
outline: none;
border-color: var(--chrome-border-strong);
border-color: transparent;
box-shadow: none;
background: var(--chrome-bg);
/* Subtle elevation shift on focus (keyboard focus also gets the global
:focus-visible ring). */
background: var(--color-bg-elev-1);
}
.input-base::placeholder { color: var(--chrome-fg-dim); }
textarea.input-base { min-height: 60px; resize: vertical; line-height: 1.5; }
select.input-base {
appearance: none;
background-image: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%23a1a1aa%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E");
/* Caret color tracks the theme via --select-caret (see :root / [data-theme]). */
background-image: var(--select-caret);
background-repeat: no-repeat;
background-position: right 6px top 50%;
background-size: 0.4rem auto;
padding-right: 18px;
background-position: right 7px top 50%;
background-size: 0.5rem auto;
padding-right: 20px;
cursor: pointer;
}
select.input-base:hover {
border-color: rgba(255,255,255,0.12);
border-color: transparent;
}
/* Native option popup: paint from theme tokens so engines that honor <option>
styling (Chromium on Windows/Linux) match the app; on macOS WebKit the popup
is UA-drawn and instead follows `color-scheme` (set per theme above). */
select.input-base option,
select.input-base optgroup {
background-color: var(--color-bg-elev-1);
color: var(--chrome-fg);
}
/* ═══ CHECKBOX STYLING ═══ */
/* Checked fill uses the themed brand token so checkboxes recolor with every
[data-theme] and match the primary/active affordances (sliders, segmented,
toggles) not the non-themed chrome-accent. */
input[type="checkbox"] {
accent-color: var(--chrome-accent);
accent-color: var(--color-brand);
width: 13px; height: 13px;
border-radius: 3px;
border-radius: var(--radius-sm);
}
/* ═══ RANGE INPUTS ═══ */
input[type="range"] {
-webkit-appearance: none; width: 100%; height: 3px;
background: rgba(255,255,255,0.08); border-radius: 2px; outline: none; margin-top: 3px;
background: var(--color-bg-elev-2); border-radius: var(--radius-xs); outline: none; margin-top: 3px;
cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; width: 10px; height: 10px; border-radius: 50%;
background: var(--text-primary); cursor: pointer;
box-shadow: 0 0 4px rgba(0,0,0,0.4);
transition: transform var(--transition-fast);
background: var(--color-fg); cursor: pointer;
box-shadow: var(--shadow-sm);
transition: transform var(--dur-fast);
}
input[type="range"]::-webkit-slider-thumb:hover {
transform: scale(1.3);
}
input[type="range"]::-webkit-slider-thumb:active {
transform: scale(1.1);
background: var(--primary);
background: var(--color-brand);
}
/* .val-bubble → Tailwind utilities inline in clone/ActionBar.jsx (P4). */
@@ -987,23 +1045,25 @@ input[type="range"]::-webkit-slider-thumb:active {
max-height: none;
overflow-y: auto;
box-sizing: border-box;
border-right: 1px solid rgba(255,255,255,0.04);
border-right: 1px solid transparent;
}
.history-item {
background: rgba(0,0,0,0.18); border: 1px solid rgba(255,255,255,0.04);
background: rgba(0,0,0,0.18); border: 1px solid transparent;
border-radius: 5px; padding: 6px 8px; margin-bottom: 4px;
transition: all var(--transition-smooth);
animation: fadeIn 0.2s ease-out;
}
.history-item:hover {
background: rgba(0,0,0,0.28);
border-color: rgba(255,255,255,0.08);
border-color: transparent;
}
/* .history-header / .history-badge / .history-time / .history-text migrated to Badge + Sidebar.css. */
.project-active {
border-color: rgba(184,187,38,0.4) !important;
background: rgba(184,187,38,0.06) !important;
box-shadow: inset 0 0 0 1px rgba(184,187,38,0.12), 0 0 8px rgba(184,187,38,0.06);
/* Selection cue via background tint (not a border) so the active project
stays perceivable after the app-wide border removal. */
border-color: transparent !important;
background: rgba(184,187,38,0.14) !important;
box-shadow: none;
}
/* ═══ AUDIO PLAYER ═══ */
@@ -1012,7 +1072,7 @@ audio {
}
audio::-webkit-media-controls-enclosure {
background-color: rgba(40,38,37,0.95);
border: 1px solid rgba(255,255,255,0.04);
border: 1px solid transparent;
border-radius: 4px;
}
audio::-webkit-media-controls-play-button,
@@ -1027,12 +1087,12 @@ audio::-webkit-media-controls-time-remaining-display { color: var(--chrome-fg);
display: flex; align-items: center; justify-content: space-between;
background: rgba(0,0,0,0.18); padding: 4px 8px; border-radius: 4px;
font-size: 0.72rem; color: var(--text-primary); cursor: pointer;
border: 1px solid rgba(255,255,255,0.04); margin-top: 6px;
border: 1px solid transparent; margin-top: 6px;
transition: all var(--transition-smooth);
}
.override-toggle:hover { background: rgba(0,0,0,0.3); border-color: rgba(255,255,255,0.08); }
.override-toggle:hover { background: rgba(0,0,0,0.3); border-color: transparent; }
.override-content {
background: rgba(0,0,0,0.1); border: 1px solid rgba(255,255,255,0.04); border-top: none;
background: rgba(0,0,0,0.1); border: 1px solid transparent; border-top: none;
padding: 6px 8px; border-radius: 0 0 4px 4px; margin-bottom: 6px;
}
.preset-grid {
@@ -1045,14 +1105,14 @@ audio::-webkit-media-controls-time-remaining-display { color: var(--chrome-fg);
.segment-table {
/* No max-height cap the list is virtualised via react-window and measures
its own body container. Capping at 320 px left a huge gap below. */
border: 1px solid rgba(255,255,255,0.04); border-radius: 4px; margin-top: 4px;
border: 1px solid transparent; border-radius: 4px; margin-top: 4px;
}
.segment-header {
display: flex; align-items: center; gap: 4px;
padding: 3px 6px; background: rgba(0,0,0,0.35);
font-size: 0.62rem; font-weight: 600; color: var(--text-secondary);
position: sticky; top: 0; z-index: 1;
border-bottom: 1px solid rgba(255,255,255,0.06);
border-bottom: 1px solid transparent;
letter-spacing: 0.03em;
text-transform: uppercase;
}
@@ -1060,7 +1120,7 @@ audio::-webkit-media-controls-time-remaining-display { color: var(--chrome-fg);
display: flex; align-items: center; gap: 3px;
padding: 2px 4px;
box-sizing: border-box;
border-bottom: 1px solid rgba(255,255,255,0.02);
border-bottom: 1px solid transparent;
transition: background var(--transition-fast);
}
.segment-row:hover { background: var(--chrome-hover-bg); }
@@ -1122,13 +1182,13 @@ audio::-webkit-media-controls-time-remaining-display { color: var(--chrome-fg);
waveform-* family migrated to utilities on WaveformTimeline.jsx (P4). */
.waveform-container {
background: rgba(0,0,0,0.22);
border: 1px solid rgba(255,255,255,0.05);
border: 1px solid transparent;
border-radius: var(--radius);
padding: 4px;
overflow-x: auto;
height: 80px !important;
position: relative;
border-bottom: 1px solid rgba(255,255,255,0.04);
border-bottom: 1px solid transparent;
}
.waveform-container [data-id^="wavesurfer-region"] {
@@ -1140,7 +1200,7 @@ audio::-webkit-media-controls-time-remaining-display { color: var(--chrome-fg);
top: 52px !important;
height: 26px !important;
border-radius: 3px !important;
border: 1px solid rgba(255,255,255,0.15) !important;
border: 1px solid transparent !important;
display: flex !important; align-items: center !important;
padding: 0 3px !important; color: white !important;
font-size: 0.58rem !important;
@@ -1177,6 +1237,16 @@ button:focus:not(:focus-visible),
a:focus:not(:focus-visible),
input:focus:not(:focus-visible),
select:focus:not(:focus-visible) { outline: none; box-shadow: none; }
/* Selects take the themed accent ring (--color-ring --color-brand), matching
the buttons/checkboxes tokenized last phase, instead of the non-theme
-tracking --chrome-accent the base :focus-visible rule uses. */
select:focus-visible,
select.input-base:focus-visible,
.ui-select:focus-visible {
outline: 2px solid color-mix(in srgb, var(--color-ring) 70%, transparent);
outline-offset: 2px;
box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-ring) 16%, transparent);
}
/* 10x P4 (spec §3 a11y gate): one consistent, fully-opaque ring on the
studio's 10x controls shared rule, not per-component restyles. */
@@ -1525,11 +1595,13 @@ select:focus:not(:focus-visible) { outline: none; box-shadow: none; }
/* Textarea — flat chrome surface for design/clone prompt box */
textarea.input-base {
background: var(--chrome-bg);
/* Recessed fill (not --chrome-bg, which equals the panel background and
would be invisible once the border is gone). */
background: var(--color-bg-elev-2);
font-family: var(--font-sans);
font-size: 0.82rem; line-height: 1.55;
color: var(--chrome-fg);
border: 1px solid var(--chrome-border);
border: 1px solid transparent;
border-radius: var(--chrome-radius-pill);
padding: 12px 14px;
}
@@ -2047,15 +2119,22 @@ input[type="file"]::file-selector-button:hover {
.ui-select {
appearance: none;
padding-right: 20px;
background-image: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%23a1a1aa%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E");
/* Caret color tracks the theme via --select-caret (see :root / [data-theme]). */
background-image: var(--select-caret);
background-repeat: no-repeat;
background-position: right 7px center;
background-size: 0.42rem auto;
background-size: 0.5rem auto;
cursor: pointer;
}
.ui-select:hover {
border-color: var(--color-border-strong);
}
/* Native option popup: theme-token colors (see select.input-base option note). */
.ui-select option,
.ui-select optgroup {
background-color: var(--color-bg-elev-1);
color: var(--chrome-fg);
}
/* from Panel (ui/Panel.css) — glass surface only */
/* Layout, border, radius, padding, header/title/actions, and the solid/flat
@@ -2259,7 +2338,7 @@ input[type="file"]::file-selector-button:hover {
}
.voice-selector__btn:hover:not(:disabled) {
color: var(--chrome-fg, #eee);
border-color: var(--chrome-border-strong, rgba(255, 255, 255, 0.22));
border-color: var(--chrome-border-strong, transparent);
}
.voice-selector__btn:disabled {
opacity: 0.5;
@@ -2596,7 +2675,7 @@ input[type="file"]::file-selector-button:hover {
.history-item--dub { --row-accent: #83a598; }
/* ── History-item kind badge variants ────────────────────────── */
.history-kind--audio { color: #83a598; border-color: rgba(131, 165, 152, 0.25); }
.history-kind--audio { color: #83a598; background: rgba(131, 165, 152, 0.13); }
/* Locked project label + italic subtitle */
.history-meta--locked { color: #b8bb26; font-style: italic; }
@@ -2733,7 +2812,7 @@ input[type="file"]::file-selector-button:hover {
background: rgba(18, 18, 22, 0.88);
backdrop-filter: blur(24px) saturate(180%);
-webkit-backdrop-filter: blur(24px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.08);
border: 1px solid transparent;
border-radius: 100px;
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.4),
@@ -3382,7 +3461,7 @@ body:has(.capture-pill) {
height: 22px;
flex: 0 0 auto;
border-radius: 50%;
border: 1.5px solid var(--chrome-border-strong, rgba(255, 255, 255, 0.15));
border: 1.5px solid var(--chrome-border-strong, transparent);
color: var(--chrome-fg-dim, #665c54);
}
.dub-stepper__step.is-done .dub-stepper__icon {
@@ -3410,9 +3489,19 @@ body:has(.capture-pill) {
flex: 0 1 auto;
min-width: 0;
}
/* Tighter footprint when inlined on the header row so the stages, title, and
actions all fit before wrapping. */
.dub-stepper--inline .dub-stepper__step {
gap: 5px;
font-size: 0.66rem;
}
.dub-stepper--inline .dub-stepper__icon {
width: 19px;
height: 19px;
}
.dub-stepper--inline .dub-stepper__step:not(:first-child)::before {
width: 14px;
margin: 0 5px;
width: 10px;
margin: 0 4px;
}
/* ── Idle / drop-zone ─────────────────────────────────────────────────── */
@@ -3424,7 +3513,7 @@ body:has(.capture-pill) {
justify-content: center;
gap: 10px;
cursor: pointer;
border: 2px dashed rgba(255, 255, 255, 0.06);
border: 2px dashed transparent;
border-radius: 8px;
transition: background 0.3s, border-color 0.3s;
margin: 2px;
@@ -3455,7 +3544,7 @@ body:has(.capture-pill) {
border-radius: 4px;
font-size: 0.7rem;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border: 1px solid transparent;
color: #665c54;
cursor: default;
}
@@ -3516,7 +3605,7 @@ body:has(.capture-pill) {
padding: 1px 6px;
border-radius: var(--radius-xs);
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.05);
border: 1px solid transparent;
color: var(--color-fg-subtle);
}
.dub-prep-chip.is-active {
@@ -3649,7 +3738,7 @@ body:has(.capture-pill) {
aspect-ratio: 16 / 9;
max-height: 60vh;
background: #000; border-radius: 4px; overflow: hidden;
border: 1px solid rgba(255,255,255,0.05); display: flex;
border: 1px solid transparent; display: flex;
}
.wfm-wave-wrap {
overflow: hidden; flex: 1 1 auto; min-height: 80px; max-height: 160px;
@@ -3680,7 +3769,7 @@ body:has(.capture-pill) {
.wfm-error {
display: flex; align-items: center; justify-content: center;
padding: 8px; background: rgba(0,0,0,0.15); border-radius: 4px;
border: 1px solid rgba(255,255,255,0.04); color: #a89984; font-size: 0.7rem;
border: 1px solid transparent; color: #a89984; font-size: 0.7rem;
}
/* NOTE: the ErrorBoundary (`errbnd-*`) styles that used to live here were
@@ -4901,3 +4990,16 @@ body:has(.capture-pill) {
pointer-events: none;
max-width: 240px;
}
/*
APP-WIDE BORDER / DIVIDER REMOVAL (owner-approved)
Zero every decorative border/hairline token. Kept LAST in source order so
it wins over the default `:root` and every `[data-theme]` override at equal
specificity. Focus indicators (--color-ring / --focus-ring) are deliberately
NOT zeroed keyboard a11y focus rings stay.
*/
:root, [data-theme] {
--color-border: transparent; --color-border-strong: transparent; --color-border-warm: transparent;
--chrome-border: transparent; --chrome-border-strong: transparent; --chrome-accent-border: transparent;
--glass-border: transparent;
}
+2 -2
View File
@@ -37,8 +37,8 @@ const STATUS_TONE = {
// Per-status card border accent (was .batch-queue__card--{status} in CSS).
const CARD_BORDER = {
running: 'border-[rgba(211,134,155,0.4)]',
failed: 'border-[rgba(251,73,52,0.35)]',
running: 'border-transparent',
failed: 'border-transparent',
};
const STAGE_LABELS = {
+1 -1
View File
@@ -316,7 +316,7 @@ export default function CloneDesignTab(props) {
{/* ═══ VOICE — who says it ═══ */}
<div className="flex flex-col gap-[6px] flex-none min-h-0 relative z-[1]">
<div className="flex flex-col min-h-0 overflow-auto bg-[var(--chrome-bg)] border border-[var(--chrome-border)] rounded-none py-[10px] px-[12px] max-[800px]:px-[10px] max-[600px]:px-[6px] max-[600px]:py-[8px]">
<div className="flex flex-col min-h-0 overflow-auto bg-[var(--chrome-bg)] border border-transparent rounded-none py-[10px] px-[12px] max-[800px]:px-[10px] max-[600px]:px-[6px] max-[600px]:py-[8px]">
<div className="label-row justify-between">
<span className="label-row mb-0">
<Volume2 className="label-icon" size={14} />{' '}
+3 -3
View File
@@ -83,7 +83,7 @@ export default function ContactPage({ onBack }) {
<div className="relative z-[1] mx-auto flex w-full max-w-[640px] flex-1 flex-col justify-center gap-6 px-8 pb-10">
<div className="flex flex-col gap-6">
<div className="text-center">
<span className="mx-auto mb-4 flex size-12 items-center justify-center rounded-md border border-[color-mix(in_srgb,#d3869b_30%,transparent)] bg-[color-mix(in_srgb,#d3869b_12%,transparent)]">
<span className="mx-auto mb-4 flex size-12 items-center justify-center rounded-md border border-transparent bg-[color-mix(in_srgb,#d3869b_12%,transparent)]">
<MessageCircle
size={24}
className="text-[#f3a5b6] drop-shadow-[0_0_12px_rgba(243,165,182,0.5)]"
@@ -110,9 +110,9 @@ export default function ContactPage({ onBack }) {
type="button"
onClick={() => openExternal(c.url)}
style={{ '--card-hue': c.hue }}
className="flex w-full items-center gap-3 overflow-hidden rounded-md border border-border bg-transparent px-3.5 py-2.5 text-left transition-colors hover:border-[color-mix(in_srgb,var(--card-hue)_40%,transparent)] hover:bg-[color-mix(in_srgb,var(--card-hue)_6%,transparent)]"
className="flex w-full items-center gap-3 overflow-hidden rounded-md border border-border bg-transparent px-3.5 py-2.5 text-left transition-colors hover:border-transparent hover:bg-[color-mix(in_srgb,var(--card-hue)_6%,transparent)]"
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-md border border-[color-mix(in_srgb,var(--card-hue)_22%,transparent)] bg-[color-mix(in_srgb,var(--card-hue)_10%,transparent)]">
<span className="flex size-8 shrink-0 items-center justify-center rounded-md border border-transparent bg-[color-mix(in_srgb,var(--card-hue)_10%,transparent)]">
<Icon size={20} />
</span>
<span className="min-w-0 flex-1">
+3 -3
View File
@@ -23,7 +23,7 @@ import ReadinessChecklist from '../components/ReadinessChecklist';
// (P4 shadcn/Tailwind pass). Defined once here so the four card instances stay
// in lockstep; Tailwind's scanner picks the literals up from this file.
const projCard =
'bg-[var(--chrome-bg)] border border-solid border-[var(--chrome-border)] rounded-[var(--chrome-radius-pill)] py-[10px] px-[14px] [transition:background_0.15s,border-color_0.15s] flex items-center gap-[12px] hover:bg-[var(--chrome-hover-bg)] hover:border-[var(--chrome-border-strong)]';
'bg-[var(--chrome-bg)] border border-solid border-transparent rounded-[var(--chrome-radius-pill)] py-[10px] px-[14px] [transition:background_0.15s,border-color_0.15s] flex items-center gap-[12px] hover:bg-[var(--chrome-hover-bg)] hover:border-transparent';
const projIcon =
'w-[32px] h-[32px] rounded-[var(--chrome-radius-pill)] flex items-center justify-center shrink-0';
const projInfo = 'flex-1 min-w-0';
@@ -32,7 +32,7 @@ const projName =
const projMeta =
'[font-family:var(--chrome-font-mono)] text-[0.62rem] text-[color:var(--chrome-fg-dim)] mt-[2px] font-normal whitespace-nowrap overflow-hidden text-ellipsis';
const projAction =
'[font-family:var(--font-sans)] text-[0.7rem] font-medium py-[4px] px-[12px] rounded-[var(--chrome-radius-pill)] bg-transparent border border-solid border-[var(--chrome-border-strong)] text-[color:var(--chrome-fg-muted)] cursor-pointer [transition:background_var(--dur-fast),color_var(--dur-fast),border-color_var(--dur-fast)] shrink-0 whitespace-nowrap [letter-spacing:0.02em] hover:bg-[var(--chrome-hover-bg)] hover:text-[color:var(--chrome-fg)] hover:border-[var(--chrome-fg-muted)]';
'[font-family:var(--font-sans)] text-[0.7rem] font-medium py-[4px] px-[12px] rounded-[var(--chrome-radius-pill)] bg-transparent border border-solid border-transparent text-[color:var(--chrome-fg-muted)] cursor-pointer [transition:background_var(--dur-fast),color_var(--dur-fast),border-color_var(--dur-fast)] shrink-0 whitespace-nowrap [letter-spacing:0.02em] hover:bg-[var(--chrome-hover-bg)] hover:text-[color:var(--chrome-fg)] hover:border-[var(--chrome-fg-muted)]';
// Section divider label ("Cloned Voices" etc.) the trailing dotted rule lives
// on an ::after pseudo, expressed via the after: variant.
const sectionTitle =
@@ -369,7 +369,7 @@ export default function Launchpad({
<div className={`${projMeta} italic`}>{p.instruct}</div>
</div>
{p.is_locked && (
<span className="[font-family:var(--chrome-font-mono)] text-[length:var(--chrome-label-size)] [letter-spacing:var(--chrome-label-track)] py-[1px] px-[7px] rounded-[var(--chrome-radius-pill)] bg-[color-mix(in_srgb,#b8bb26_10%,transparent)] border border-solid border-[color-mix(in_srgb,#b8bb26_40%,transparent)] text-[#b8bb26] font-semibold">
<span className="[font-family:var(--chrome-font-mono)] text-[length:var(--chrome-label-size)] [letter-spacing:var(--chrome-label-track)] py-[1px] px-[7px] rounded-[var(--chrome-radius-pill)] bg-[color-mix(in_srgb,#b8bb26_10%,transparent)] border border-solid border-transparent text-[#b8bb26] font-semibold">
{t('launchpad.locked')}
</span>
)}
+1 -1
View File
@@ -87,7 +87,7 @@ function Card({ kind, accent, title, subtitle, trailing, onClick, IconC, view })
return (
<button
type="button"
className={`flex cursor-pointer rounded-[var(--chrome-radius-pill)] border border-[var(--chrome-border)] [border-left:3px_solid_var(--card-accent,var(--chrome-accent))] bg-[var(--chrome-bg)] text-left [font-family:inherit] text-[var(--chrome-fg)] transition-[border-color,background,transform] duration-[0.12s] hover:border-[var(--chrome-border-strong)] hover:bg-[color-mix(in_srgb,var(--card-accent,var(--chrome-accent))_5%,var(--chrome-bg))] active:translate-y-[1px] ${
className={`flex cursor-pointer rounded-[var(--chrome-radius-pill)] border border-transparent [border-left:3px_solid_var(--card-accent,var(--chrome-accent))] bg-[var(--chrome-bg)] text-left [font-family:inherit] text-[var(--chrome-fg)] transition-[border-color,background,transform] duration-[0.12s] hover:border-transparent hover:bg-[color-mix(in_srgb,var(--card-accent,var(--chrome-accent))_5%,var(--chrome-bg))] active:translate-y-[1px] ${
list
? 'flex-row items-center gap-[14px] px-[12px] py-[6px]'
: 'flex-col gap-[6px] px-[12px] py-[10px]'
+1 -1
View File
@@ -458,7 +458,7 @@ export default function Settings() {
<header className="mb-[var(--space-4)] flex items-center gap-[var(--space-3)]">
{CatIcon && (
<span
className="shrink-0 inline-flex items-center justify-center w-[26px] h-[26px] rounded-[var(--chrome-radius-pill)] text-[color:var(--chrome-accent)] bg-[color-mix(in_srgb,var(--chrome-accent)_12%,var(--chrome-bg))] border border-[color-mix(in_srgb,var(--chrome-accent)_26%,var(--chrome-border))]"
className="shrink-0 inline-flex items-center justify-center w-[26px] h-[26px] rounded-[var(--chrome-radius-pill)] text-[color:var(--chrome-accent)] bg-[color-mix(in_srgb,var(--chrome-accent)_12%,var(--chrome-bg))] border border-transparent"
aria-hidden="true"
>
<CatIcon size={15} />
+13 -11
View File
@@ -41,7 +41,9 @@ const METHODS = [
},
];
const DONATE_HUE = '#d3869b';
// Donate/support accent tracks the themed brand token (per-[data-theme]) so the
// panel recolors with the app theme instead of the old fixed pink.
const DONATE_HUE = 'var(--color-brand)';
// PayPal.me carries the chosen amount straight into the checkout; Ko-fi opens
// its tip page (no reliable preset-amount URL). A non-numeric/"custom" amount
@@ -59,9 +61,9 @@ function LinkCard({ icon, label, desc, value, hue, onClick }) {
type="button"
onClick={onClick}
style={{ '--card-hue': hue }}
className="flex w-full items-center gap-3 overflow-hidden rounded-md border border-border bg-transparent px-3.5 py-2.5 text-left transition-colors hover:border-[color-mix(in_srgb,var(--card-hue)_40%,transparent)] hover:bg-[color-mix(in_srgb,var(--card-hue)_6%,transparent)]"
className="flex w-full items-center gap-3 overflow-hidden rounded-md border border-border bg-transparent px-3.5 py-2.5 text-left transition-colors hover:border-transparent hover:bg-[color-mix(in_srgb,var(--card-hue)_6%,transparent)]"
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-md border border-[color-mix(in_srgb,var(--card-hue)_22%,transparent)] bg-[color-mix(in_srgb,var(--card-hue)_10%,transparent)] text-[1.1rem]">
<span className="flex size-8 shrink-0 items-center justify-center rounded-md border border-transparent bg-[color-mix(in_srgb,var(--card-hue)_10%,transparent)] text-[1.1rem]">
{icon}
</span>
<span className="min-w-0 flex-1">
@@ -115,10 +117,10 @@ function SupportView() {
return (
<div className="flex flex-col gap-6">
<div className="text-center">
<span className="mx-auto mb-4 flex size-12 items-center justify-center rounded-md border border-[color-mix(in_srgb,#d3869b_30%,transparent)] bg-[color-mix(in_srgb,#d3869b_12%,transparent)]">
<span className="mx-auto mb-4 flex size-12 items-center justify-center rounded-md border border-transparent bg-[color-mix(in_srgb,var(--color-brand)_12%,transparent)]">
<Heart
size={24}
className="text-[#f3a5b6] [fill:rgba(243,165,182,0.35)] drop-shadow-[0_0_12px_rgba(243,165,182,0.5)]"
className="text-[var(--color-brand)] [fill:color-mix(in_srgb,var(--color-brand)_35%,transparent)] drop-shadow-[0_0_12px_color-mix(in_srgb,var(--color-brand)_50%,transparent)]"
/>
</span>
<h2 className="relative inline-block font-serif text-[2rem] font-normal leading-tight tracking-[-0.02em] text-[var(--chrome-fg)]">
@@ -167,7 +169,7 @@ function SupportView() {
className={`flex min-h-[52px] flex-col items-center justify-center gap-0.5 rounded-md border px-1.5 py-2 transition-colors ${
selected
? 'border-[var(--chrome-accent)] bg-[var(--chrome-accent-bg)]'
: `${a.common ? 'border-[color-mix(in_srgb,var(--chrome-accent)_35%,transparent)]' : 'border-border'} hover:border-[color-mix(in_srgb,var(--chrome-accent)_40%,transparent)] hover:bg-[color-mix(in_srgb,var(--chrome-accent)_7%,transparent)]`
: `${a.common ? 'border-transparent' : 'border-border'} hover:border-transparent hover:bg-[color-mix(in_srgb,var(--chrome-accent)_7%,transparent)]`
}`}
>
<span className="font-serif text-[1.05rem] font-medium text-[var(--chrome-fg)]">
@@ -188,7 +190,7 @@ function SupportView() {
className={`flex min-h-[52px] flex-col items-center justify-center gap-0.5 rounded-md border px-1.5 py-2 transition-colors ${
amount === 'custom'
? 'border-[var(--chrome-accent)] bg-[var(--chrome-accent-bg)]'
: 'border-border hover:border-[color-mix(in_srgb,var(--chrome-accent)_40%,transparent)] hover:bg-[color-mix(in_srgb,var(--chrome-accent)_7%,transparent)]'
: 'border-border hover:border-transparent hover:bg-[color-mix(in_srgb,var(--chrome-accent)_7%,transparent)]'
}`}
>
<span className="font-mono text-[0.78rem] uppercase tracking-[var(--chrome-label-track)] text-[var(--chrome-fg-muted)]">
@@ -298,7 +300,7 @@ function LicenseView() {
key={label}
className="gap-0 rounded-md border-border bg-transparent p-4 shadow-none transition-colors hover:border-border-strong hover:bg-[var(--chrome-hover-bg)]"
>
<span className="mb-2.5 flex size-[30px] items-center justify-center rounded-md border border-[color-mix(in_srgb,#d3869b_22%,transparent)] bg-[color-mix(in_srgb,#d3869b_10%,transparent)] text-[#d3869b]">
<span className="mb-2.5 flex size-[30px] items-center justify-center rounded-md border border-transparent bg-[color-mix(in_srgb,var(--color-brand)_10%,transparent)] text-[var(--color-brand)]">
<Icon size={16} />
</span>
<div className="mb-1 font-mono text-[0.75rem] font-semibold uppercase tracking-[var(--chrome-label-track)] text-[var(--chrome-fg)]">
@@ -325,7 +327,7 @@ function LicenseView() {
variant="subtle"
leading={<Mail size={13} />}
onClick={() => openExternal(LICENSE_MAILTO)}
className="border-[color-mix(in_srgb,#fe8019_50%,transparent)] bg-[color-mix(in_srgb,#fe8019_18%,transparent)] font-semibold text-[var(--chrome-fg)] hover:border-[color-mix(in_srgb,#fe8019_70%,transparent)] hover:bg-[color-mix(in_srgb,#fe8019_28%,transparent)]"
className="border-transparent bg-[color-mix(in_srgb,#fe8019_18%,transparent)] font-semibold text-[var(--chrome-fg)] hover:border-transparent hover:bg-[color-mix(in_srgb,#fe8019_28%,transparent)]"
>
{t('enterprise.request_quote')}
</Button>
@@ -360,9 +362,9 @@ export default function SupportPage({ onBack, initialView = 'support' }) {
const TAB_INACTIVE =
'border-transparent text-[var(--chrome-fg-muted)] hover:text-[var(--chrome-fg)]';
const TAB_SUPPORT_ACTIVE =
'border-[color-mix(in_srgb,#d3869b_38%,transparent)] bg-[color-mix(in_srgb,#d3869b_18%,transparent)] text-[var(--chrome-fg)]';
'border-transparent bg-[color-mix(in_srgb,var(--color-brand)_18%,transparent)] text-[var(--chrome-fg)]';
const TAB_LICENSE_ACTIVE =
'border-[color-mix(in_srgb,#83a598_40%,transparent)] bg-[color-mix(in_srgb,#83a598_18%,transparent)] text-[var(--chrome-fg)]';
'border-transparent bg-[color-mix(in_srgb,#83a598_18%,transparent)] text-[var(--chrome-fg)]';
return (
<div className="relative isolate flex flex-1 flex-col overflow-y-auto bg-[var(--chrome-bg)]">
@@ -100,7 +100,7 @@ describe('DubLeftColumn — translation-engine install affordance', () => {
// Highlighted accent button (not the muted chip): brand-accent bg class.
const btn = screen.getByRole('button', { name: /install deep_translator/i });
expect(btn.className).toMatch(/bg-\[#d3869b\]/);
expect(btn.className).toMatch(/bg-\[var\(--color-brand\)\]/);
fireEvent.click(btn);
expect(handleInstallEngine).toHaveBeenCalledWith('google');
@@ -122,7 +122,7 @@ describe('DubLeftColumn — translation-engine install affordance', () => {
// The highlighted trigger opens the escape-hatch popover.
const trigger = screen.getByRole('button', { name: /needs install/i });
expect(trigger.className).toMatch(/bg-\[#d3869b\]/);
expect(trigger.className).toMatch(/bg-\[var\(--color-brand\)\]/);
fireEvent.click(trigger);
const dialog = screen.getByRole('dialog');
+4 -4
View File
@@ -15,11 +15,11 @@ import { Card } from '@/components/ui/card';
const VARIANT = {
// glass: surface (gradients + backdrop-filter) + ::before stay in residual.css.
glass: 'border border-[var(--color-border-warm)]',
glass: 'border border-transparent',
solid:
'border border-[var(--color-border-warm)] ' +
'border border-transparent ' +
'[background-image:linear-gradient(160deg,#2a2624_0%,#201c1b_100%)] [box-shadow:var(--shadow-md)]',
flat: 'border border-[var(--color-border)] [background-color:rgba(0,0,0,0.08)]',
flat: 'border border-transparent [background-color:rgba(0,0,0,0.08)]',
};
const PAD = {
@@ -72,7 +72,7 @@ const Panel = forwardRef(function Panel(
<Card asChild className={classes}>
<Tag ref={ref} {...rest}>
{hasHeader && (
<header className="ui-panel__header flex items-center justify-between py-[var(--space-4)] px-[var(--space-5)] gap-[var(--space-4)] [border-bottom:1px_solid_var(--color-border)]">
<header className="ui-panel__header flex items-center justify-between py-[var(--space-4)] px-[var(--space-5)] gap-[var(--space-4)] [border-bottom:1px_solid_transparent]">
{title != null && (
<div className="ui-panel__title flex items-center gap-[var(--space-3)] min-w-0 [font-size:var(--text-md)] font-bold text-fg tracking-[-0.01em]">
{title}
+1 -1
View File
@@ -3,7 +3,7 @@ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group.tsx';
import { cn } from '@/lib/utils';
const ROOT =
'ui-seg inline-flex gap-[2px] bg-black/[0.28] p-[3px] rounded-[var(--radius-pill)] border border-[color:var(--color-border)] shrink';
'ui-seg inline-flex gap-[2px] bg-bg-elev-2 p-[3px] rounded-[var(--radius-pill)] border border-transparent shrink';
const SIZE_MAP = { xs: 'segXs', sm: 'segSm' };
+3 -3
View File
@@ -39,8 +39,8 @@ export default function Tabs({
// The leading utilities also reset shadcn's list/trigger box-model defaults
// (h-9, bg-muted, rounded-lg, flex-1, active shadow) back to OmniVoice's.
const listClass = isPill
? 'h-auto inline-flex shrink-0 gap-[3px] rounded-[var(--chrome-radius-pill)] border border-[var(--chrome-border)] bg-[var(--chrome-bg)] p-[3px]'
: 'h-auto inline-flex shrink-0 gap-[var(--space-5)] rounded-none border-0 border-b border-[var(--chrome-border)] bg-transparent p-0';
? 'h-auto inline-flex shrink-0 gap-[3px] rounded-[var(--chrome-radius-pill)] border border-transparent bg-[var(--chrome-bg)] p-[3px]'
: 'h-auto inline-flex shrink-0 gap-[var(--space-5)] rounded-none border-0 border-b border-transparent bg-transparent p-0';
return (
<ShadcnTabs
@@ -55,7 +55,7 @@ export default function Tabs({
const Icon = item.icon;
const tabClass = isPill
? [
'relative flex flex-none cursor-pointer items-center justify-center gap-[var(--space-3)] rounded-[var(--chrome-radius-pill)] border font-sans tracking-[0.01em] data-[state=active]:shadow-none',
'relative flex flex-none cursor-pointer items-center justify-center gap-[var(--space-3)] rounded-[var(--chrome-radius-pill)] border border-transparent font-sans tracking-[0.01em] data-[state=active]:shadow-none',
'[transition:background_var(--dur-fast)_var(--ease-out),color_var(--dur-fast)_var(--ease-out),border-color_var(--dur-fast)_var(--ease-out)]',
'focus-visible:shadow-[var(--focus-ring)] focus-visible:outline-none',
isSm
-1
View File
@@ -143,7 +143,6 @@ export function ArchetypeAvatar({ item, size = 44 }) {
width: size,
height: size,
background: tint(color, 0.14),
borderColor: tint(color, 0.32),
}}
>
<ArchetypeIcon name={item.icon} size={Math.round(size * 0.46)} color={color} />
+17 -4
View File
@@ -11,20 +11,21 @@ from __future__ import annotations
import pytest
# id → declared gpu_compat. NeMo is CUDA-only (its is_available() hard-fails
# without a GPU), so it legitimately has no cpu path.
# id → declared gpu_compat. nemo-parakeet was CUDA-gated until 2026-07-02,
# when parakeet-tdt-0.6b-v3 was measured at RTF 0.080.23 on an M2 CPU —
# every ASR engine now has a cpu path.
_EXPECTED = {
"whisperx": ("cuda", "cpu"),
"faster-whisper": ("cuda", "cpu"),
"mlx-whisper": ("mps", "cpu"),
"pytorch-whisper": ("cuda", "mps", "cpu"),
"nemo-parakeet": ("cuda",),
"nemo-parakeet": ("cuda", "cpu"),
"moonshine": ("cpu",),
"funasr": ("cuda", "cpu"),
}
# Engines that legitimately have NO cpu path (hard GPU gate in is_available).
_GPU_ONLY = {"nemo-parakeet"}
_GPU_ONLY: set[str] = set()
_VALID = {"cuda", "rocm", "mps", "xpu", "cpu"}
@@ -62,6 +63,18 @@ def test_no_asr_engine_falsely_claims_rocm():
assert "rocm" not in _cls(engine_id).gpu_compat
def test_nemo_parakeet_has_no_cuda_gate(monkeypatch):
"""Regression (CPU un-gating, 2026-07-02): on a CUDA-less host,
is_available() must never claim a GPU is required availability is a
pure nemo_toolkit dependency check now."""
import torch
monkeypatch.setattr(torch.cuda, "is_available", lambda: False)
ok, reason = _cls("nemo-parakeet").is_available()
assert "NVIDIA GPU" not in reason
if not ok: # env without nemo_toolkit — the only legitimate blocker
assert "nemo_toolkit" in reason
def test_indextts2_overrides_cpu_only_default():
from engines.indextts import IndexTTS2Backend
assert IndexTTS2Backend.gpu_compat == ("cuda", "cpu")
+93
View File
@@ -0,0 +1,93 @@
"""WhisperX VRAM preflight (#723).
On an 8 GB card with the TTS model resident, loading whisper large-v3 fp16
dies as a *native* CUDA OOM abort the backend process is killed outright,
no Python exception fires, and the UI reports "Can't reach the local
backend". The load-time fp16→int8 / OOM→CPU fallbacks in `_ensure_asr` never
run because nothing is raised. The only defense is a preflight: re-check the
device pick against actually-free VRAM (`torch.cuda.mem_get_info`) right
before loading, degrading fp16 int8_float16 int8 CPU.
Backend classes are resolved at RUNTIME (see test_asr_gpu_compat.py rationale).
"""
from __future__ import annotations
import pytest
def _backend():
from services.asr_backend import _REGISTRY
b = _REGISTRY["whisperx"].__new__(_REGISTRY["whisperx"]) # skip __init__ (no torch probe)
b._model_name = "large-v3"
return b
def _degrade(b, free_gb, device="cuda", compute="float16"):
b._free_vram_gb = lambda: free_gb
return b._degrade_for_vram(device, compute)
# ── The #723 crash scenario: TTS resident, ~2 GB free, fp16 requested ──────
def test_starved_card_falls_back_to_cpu():
assert _degrade(_backend(), 2.0) == ("cpu", "int8")
def test_mid_vram_degrades_to_int8_on_cuda():
# 3.5 GB free: can't hold fp16 (5.0) or int8_float16 (3.5 is not > needed
# headroom boundary — equal passes), int8 (3.0) certainly fits.
dev, ct = _degrade(_backend(), 3.2)
assert (dev, ct) == ("cuda", "int8")
def test_ample_vram_keeps_fp16():
assert _degrade(_backend(), 7.0) == ("cuda", "float16")
# ── Preflight must never *break* ASR ────────────────────────────────────────
def test_unknown_vram_is_left_alone():
assert _degrade(_backend(), None) == ("cuda", "float16")
def test_cpu_pick_is_untouched():
b = _backend()
b._free_vram_gb = lambda: 0.5
assert b._degrade_for_vram("cpu", "int8") == ("cpu", "int8")
def test_small_models_not_over_evicted():
# A 2 GB-free card comfortably runs whisper-small fp16 (5.0 * 0.25 budget);
# the large-v3 budgets must not evict smaller models from CUDA.
b = _backend()
b._model_name = "small"
assert _degrade(b, 2.0) == ("cuda", "float16")
def test_env_opt_out(monkeypatch):
monkeypatch.setenv("OMNIVOICE_ASR_VRAM_PREFLIGHT", "0")
assert _degrade(_backend(), 0.5) == ("cuda", "float16")
# ── Wiring: _ensure_asr must preflight BEFORE whisperx.load_model ──────────
def test_ensure_asr_applies_preflight_before_load(monkeypatch):
import sys, types
calls = {}
fake_whisperx = types.ModuleType("whisperx")
def _load_model(name, device=None, compute_type=None, **kw):
calls["load"] = (device, compute_type)
return object()
fake_whisperx.load_model = _load_model
monkeypatch.setitem(sys.modules, "whisperx", fake_whisperx)
b = _backend()
b._asr = None
b._device, b._compute_type = "cuda", "float16"
b._free_vram_gb = lambda: 2.0 # the #723 card state
b._allow_vad_pickle_globals = lambda: None
b._ensure_asr()
assert calls["load"] == ("cpu", "int8") # degraded BEFORE the load call
+44
View File
@@ -0,0 +1,44 @@
"""Confucius4-TTS engine scaffold (#590).
The engine is opt-in (gated behind OMNIVOICE_CONFUCIUS4_TTS_DIR) and
subprocess-isolated, so it must be wired into the registry yet completely inert
on a default install never importing the (unvalidated) upstream package, never
reporting available without a clone. These tests pin exactly that.
"""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "backend"))
def test_registered_in_lazy_registry():
from services.tts_backend import _LAZY_REGISTRY
assert _LAZY_REGISTRY.get("confucius4-tts") == ("engines.confucius4", "Confucius4Backend")
def test_backend_class_metadata():
from engines.confucius4 import Confucius4Backend
assert Confucius4Backend.id == "confucius4-tts"
assert Confucius4Backend.gpu_compat == ("cuda", "cpu") # CPU validated E2E; no MPS claim
assert Confucius4Backend.supports_voice_design is False
def test_inert_without_clone_dir(monkeypatch):
monkeypatch.delenv("OMNIVOICE_CONFUCIUS4_TTS_DIR", raising=False)
from engines.confucius4 import bootstrap
bootstrap.invalidate()
assert bootstrap.is_confucius4_installed() is False
from engines.confucius4 import Confucius4Backend
ok, reason = Confucius4Backend.is_available()
assert ok is False
assert "OMNIVOICE_CONFUCIUS4_TTS_DIR" in reason
def test_resolve_raises_actionable_without_clone(monkeypatch):
monkeypatch.delenv("OMNIVOICE_CONFUCIUS4_TTS_DIR", raising=False)
from engines.confucius4 import bootstrap
bootstrap.invalidate()
import pytest
with pytest.raises(RuntimeError, match="OMNIVOICE_CONFUCIUS4_TTS_DIR"):
bootstrap.resolve_confucius4_venv()
+183
View File
@@ -0,0 +1,183 @@
"""Confucius4-TTS sidecar unit tests (#590 — finalization).
The upstream synthesis API (``confuciustts.cli.inference.ConfuciusTTS``
``generate(text, lang, prompt_wav)`` tensor, ``model.sample_rate``) is
**validated end-to-end** (2026-07-02, Apple Silicon, CPU): audible speech at
22 050 Hz. Full generation needs ~5 GB of weights, so it can't run in CI — but
the sidecar's *pure* logic (language normalization, tensor→PCM, config-path
resolution, sys.path clone injection, wire framing) and the bootstrap probe are
fully testable here, with the model mocked.
The sidecar is stdlib-only at import time (the model/torch imports are lazy),
so we import it directly without spawning the engine venv.
"""
from __future__ import annotations
import base64
import importlib.util
import io
import os
import struct
from pathlib import Path
import numpy as np
import pytest
_SIDECAR = (
Path(__file__).resolve().parent.parent
/ "backend" / "engines" / "confucius4" / "main.py"
)
def _load_sidecar():
spec = importlib.util.spec_from_file_location("confucius4_sidecar_main", _SIDECAR)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
@pytest.fixture(scope="module")
def sc():
return _load_sidecar()
# ── Language normalization ────────────────────────────────────────────────
@pytest.mark.parametrize("raw,expected", [
("en", "en"), ("EN", "en"), ("zh", "zh"), ("zh-CN", "zh"),
("ja", "ja"), ("", "en"), ("auto", "en"), ("AUTO", "en"),
(None, "en"), ("Vietnamese", "vi"), (" fr ", "fr"),
])
def test_normalize_language(sc, raw, expected):
assert sc._normalize_language(raw) == expected
# ── Tensor → PCM base64 ───────────────────────────────────────────────────
def test_pcm_roundtrip_mono(sc):
import torch
t = torch.tensor([0.0, 0.5, -0.5, 1.0, -1.0])
b64, sr, n = sc._tensor_to_pcm_b64(t, 24000)
pcm = np.frombuffer(base64.b64decode(b64), dtype=np.int16)
assert (sr, n) == (24000, 5)
assert pcm.max() == 32767 and pcm.min() == -32767 # full-scale clamp
def test_pcm_clips_out_of_range(sc):
import torch
t = torch.tensor([2.0, -3.0]) # beyond [-1, 1]
b64, _sr, n = sc._tensor_to_pcm_b64(t, 24000)
pcm = np.frombuffer(base64.b64decode(b64), dtype=np.int16)
assert n == 2 and pcm.max() == 32767 and pcm.min() == -32767
def test_pcm_downmixes_stereo(sc):
import torch
stereo = torch.tensor([[0.2, 0.4], [0.6, 0.8]]) # (2, 2)
_b64, _sr, n = sc._tensor_to_pcm_b64(stereo, 24000)
assert n == 2 # mean over channel dim → 2 samples
def test_pcm_accepts_numpy(sc):
arr = np.array([0.1, -0.1, 0.0], dtype=np.float32)
_b64, sr, n = sc._tensor_to_pcm_b64(arr, 16000)
assert (sr, n) == (16000, 3)
# ── Sample rate (confirmed 22 050 Hz by the 2026-07-02 live run) ──────────
def test_sample_rate_constant_is_confirmed_upstream_rate(sc):
# Upstream config target_sample_rate — regression-pins the live-run value
# so the pre-validation 24 000 guess can't come back.
assert sc.CONFUCIUS_SAMPLE_RATE == 22050
def test_sample_rate_lockstep_with_backend_default(sc):
import os as _os, sys as _sys
_sys.path.insert(0, _os.path.join(
_os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))), "backend"))
from engines.confucius4 import Confucius4Backend
assert Confucius4Backend._DEFAULT_SAMPLE_RATE == sc.CONFUCIUS_SAMPLE_RATE
# ── Clone sys.path injection (upstream is not pip-installable) ────────────
def test_clone_dir_inserted_at_sys_path_front(sc, monkeypatch):
import sys as _sys
monkeypatch.setenv("OMNIVOICE_CONFUCIUS4_TTS_DIR", "/clone")
monkeypatch.setattr(_sys, "path", ["existing"])
sc._ensure_clone_on_sys_path()
assert _sys.path[0] == "/clone"
sc._ensure_clone_on_sys_path() # idempotent — no dup
assert _sys.path.count("/clone") == 1
def test_no_sys_path_change_without_clone_dir(sc, monkeypatch):
import sys as _sys
monkeypatch.delenv("OMNIVOICE_CONFUCIUS4_TTS_DIR", raising=False)
monkeypatch.setattr(_sys, "path", ["existing"])
sc._ensure_clone_on_sys_path()
assert _sys.path == ["existing"]
# ── Config path resolution ────────────────────────────────────────────────
def test_config_path_explicit_override(sc, monkeypatch):
monkeypatch.setenv("OMNIVOICE_CONFUCIUS4_CONFIG", "/x/custom.yaml")
assert sc._config_path() == "/x/custom.yaml"
def test_config_path_from_clone_dir(sc, monkeypatch):
monkeypatch.delenv("OMNIVOICE_CONFUCIUS4_CONFIG", raising=False)
monkeypatch.setenv("OMNIVOICE_CONFUCIUS4_TTS_DIR", "/clone")
assert sc._config_path() == os.path.join("/clone", "config", "inference_config.yaml")
# ── Wire framing (length-prefixed JSON) ───────────────────────────────────
def test_send_recv_roundtrip(sc):
buf = io.BytesIO()
sc._send(buf, {"op": "ready", "engine": "confucius4-tts"})
buf.seek(0)
assert sc._recv(buf) == {"op": "ready", "engine": "confucius4-tts"}
def test_recv_eof_returns_none(sc):
assert sc._recv(io.BytesIO(b"")) is None
def test_recv_rejects_oversize_frame(sc):
hdr = struct.pack("!I", sc.MAX_FRAME_BYTES + 1)
with pytest.raises(IOError):
sc._recv(io.BytesIO(hdr))
# ── synthesize dispatch (model mocked — no GPU/weights) ───────────────────
def test_synthesize_calls_generate_and_emits_audio(sc, monkeypatch):
import torch
class _FakeModel:
sample_rate = 22050
def generate(self, **kw):
_FakeModel.last_kwargs = kw
return torch.tensor([0.0, 1.0, -1.0])
monkeypatch.setattr(sc, "_load_model", lambda stdout: _FakeModel())
out = io.BytesIO()
sc._handle_synthesize(
{"text": "hello", "language": "AUTO", "ref_audio": "/ref.wav"}, out,
)
out.seek(0)
frame = sc._recv(out)
assert frame["op"] == "audio"
assert frame["sample_rate"] == 22050 # read from model.sample_rate
assert frame["n_samples"] == 3
# language normalized, ref audio forwarded as prompt_wav
assert _FakeModel.last_kwargs == {"text": "hello", "lang": "en", "prompt_wav": "/ref.wav"}
def test_synthesize_rejects_empty_text(sc):
with pytest.raises(ValueError, match="text"):
sc._handle_synthesize({"text": ""}, io.BytesIO())
+167
View File
@@ -0,0 +1,167 @@
"""Hard rule: no decorative literal-color borders in the frontend.
The app-wide border/divider removal (owner-approved) stripped every decorative
border, hairline, divider, and panel frame. Perceivability is preserved through
background/elevation cues (selection, inputs, chips) and the kept
``:focus-visible`` / ``--color-ring`` / ``--focus-ring`` focus indicators.
This guard fails if the *regression class* reappears the hardcoded neutral
hairlines and inline border colors that ``--color-border`` token-zeroing can't
reach:
1. Neutral (white / black) literal ``border[-x]:`` colors in ``index.css``.
2. ``border-white/`` / ``border-black/`` Tailwind utilities, or
``border-[#…]`` / ``border-[rgb(a)(…)]`` / ``border-[hsl(…)]`` literal-color
arbitrary utilities, in ``*.jsx`` / ``*.tsx``.
3. Inline ``style={{ borderColor: }}`` (non-transparent) in ``*.jsx`` /
``*.tsx``.
4. Token-based STRUCTURAL border utilities in ``*.jsx`` / ``*.tsx``
``border[-trbl]-[var(--chrome-border)]`` and ``-[var(--color-border)]``
(incl. ``-strong`` / ``-warm``). These render a hairline the moment the
``--*-border`` token doesn't resolve transparent (a theme re-declare, or a
bare ``border`` with no color under Tailwind v4's currentColor default), so
the panel/aside/card/row frames were physically removed and converted to
``border-transparent``. This catches their reappearance.
It deliberately does NOT flag: ``:focus-visible`` / ring rules, the
``--color-ring`` / ``--focus-ring`` focus tokens, ``border-transparent`` /
``border-0``; the intentional ``--chrome-accent-border`` accent state cue and
severity token borders; the ``border-border`` / ``border-input`` design-system
aliases (also zeroed) and the arbitrary ``[border:var(---border)]`` property
form; the dashed drop-zone affordance; or borders kept in the functional
waveform / segment editor and shadcn form-control primitives (allowlisted).
"""
import re
from pathlib import Path
import pytest
_REPO = Path(__file__).resolve().parents[1]
_SRC = _REPO / "frontend" / "src"
_INDEX_CSS = _SRC / "index.css"
# This enforcement file's own pattern literals must not trip the scan.
_SELF = Path(__file__).name
# ── 1) Neutral literal border colors in index.css ────────────────────────────
# `(?<![\w-])` keeps `--color-border` / `--chrome-border` custom-property
# *definitions* (which are token-zeroed by the trailing override block) from
# matching; only real `border[-x]:` declarations using a white/black rgba fill.
_CSS_NEUTRAL_BORDER = re.compile(
r"(?<![\w-])border[a-z-]*\s*:[^;{}]*?"
r"rgba\(\s*(?:255\s*,\s*255\s*,\s*255|0\s*,\s*0\s*,\s*0)\s*,",
)
# ── 2) Literal-color border utilities in JSX/TSX ─────────────────────────────
_JSX_BORDER_UTIL = re.compile(
r"border(?:-[trblxy])?-(?:white|black)(?:/|\b)"
r"|border(?:-[trblxy])?-\[(?:#|rgba?\(|hsl\()",
)
# ── 3) Inline borderColor (non-transparent) ──────────────────────────────────
_JSX_BORDERCOLOR = re.compile(r"borderColor\s*:")
# ── 4) Token-based structural border utilities in JSX/TSX ────────────────────
# `border[-trbl]-[var(--chrome-border…)]` / `…-[var(--color-border…)]` (any
# direction, incl. `-strong` / `-warm`, and any variant prefix). NOT matched:
# `--chrome-accent-border` (intentional accent state cue) — the char after
# `--chrome-`/`--color-` must be `border`.
_JSX_TOKEN_BORDER = re.compile(
r"border(?:-[trblxy])?-\[var\(--(?:chrome|color)-border",
)
# Functional-affordance files whose remaining borders are intentional and must
# NOT be stripped: the waveform / segment editor (selection ring, drag handles,
# segment boundaries) and the shadcn form-control primitives (the border IS the
# control's own outline — removing it makes the field invisible).
_BORDER_ALLOW = {
"components/AudioTrimmer.jsx",
"components/WaveformPlayer.jsx",
"components/SegmentTrack.jsx",
"components/ui/input.tsx",
"components/ui/textarea.tsx",
"components/ui/select.tsx",
"components/ui/slider.tsx",
"components/ui/table.tsx",
}
def _iter_frontend_files(suffixes):
for p in _SRC.rglob("*"):
if p.suffix in suffixes and p.name != _SELF:
yield p
def test_no_neutral_literal_borders_in_index_css():
offenders = []
for i, line in enumerate(_INDEX_CSS.read_text().splitlines(), 1):
if _CSS_NEUTRAL_BORDER.search(line):
offenders.append(f"index.css:{i}: {line.strip()}")
assert not offenders, (
"Decorative neutral (white/black) literal borders reappeared in "
"index.css. Use `border: … transparent` (or drop the border) — the "
"app-wide border removal zeroed these.\n" + "\n".join(offenders)
)
def test_no_literal_color_border_utilities_in_jsx():
offenders = []
for p in _iter_frontend_files({".jsx", ".tsx"}):
for i, line in enumerate(p.read_text().splitlines(), 1):
for m in _JSX_BORDER_UTIL.finditer(line):
token = m.group(0)
if "border-transparent" in token:
continue
offenders.append(f"{p.relative_to(_REPO)}:{i}: …{token}")
assert not offenders, (
"Literal-color border utilities reappeared. Use `border-transparent` "
"(keep the width to suppress the no-Preflight UA border) or a "
"background tint for active/selected state.\n" + "\n".join(offenders)
)
def test_no_inline_border_color_in_jsx():
offenders = []
for p in _iter_frontend_files({".jsx", ".tsx"}):
for i, line in enumerate(p.read_text().splitlines(), 1):
if _JSX_BORDERCOLOR.search(line) and "transparent" not in line:
offenders.append(f"{p.relative_to(_REPO)}:{i}: {line.strip()}")
assert not offenders, (
"Inline `borderColor` reappeared. Convey selection/error via a "
"background tint (see WorkspaceHistory / DubSegmentRow) instead.\n"
+ "\n".join(offenders)
)
def test_no_token_border_utilities_in_jsx():
offenders = []
for p in _iter_frontend_files({".jsx", ".tsx"}):
if p.relative_to(_SRC).as_posix() in _BORDER_ALLOW:
continue
for i, line in enumerate(p.read_text().splitlines(), 1):
if _JSX_TOKEN_BORDER.search(line):
offenders.append(f"{p.relative_to(_REPO)}:{i}: {line.strip()[:120]}")
assert not offenders, (
"Token-based structural border utilities reappeared. A "
"`border-[var(--chrome-border…)]` / `border-[var(--color-border…)]` "
"renders a stray frame the moment the token isn't transparent. Drop the "
"border or use `border-transparent` (keep the width to suppress the "
"no-Preflight UA border); convey active/selected state with a background "
"tint (`--chrome-accent-bg` / `--chrome-hover-bg` / `--color-bg-elev-*`)."
"\n" + "\n".join(offenders)
)
def test_focus_indicators_are_preserved():
"""Regression guard: the border removal must not strip focus a11y."""
css = _INDEX_CSS.read_text()
assert "--color-ring:" in css, "--color-ring focus token was removed"
assert "--focus-ring:" in css, "--focus-ring token was removed"
assert ":focus-visible" in css, ":focus-visible ring rules were removed"
# The token-zeroing override must NOT have zeroed the focus ring.
assert "--color-ring: transparent" not in css
assert "--focus-ring: transparent" not in css
if __name__ == "__main__":
raise SystemExit(pytest.main([__file__, "-v"]))
+55
View File
@@ -113,3 +113,58 @@ def test_cinematic_reflect_failure_returns_literal(monkeypatch):
assert res["text"] == "Hola"
assert res["literal"] == "Hola"
assert "reflect" in res.get("error", "")
# ── Cinematic pass wall-clock budget (#stall follow-up) ────────────────────
def test_cinematic_budget_degrades_slow_segments_to_literal(monkeypatch):
"""A slow LLM must not hang the translate: the pass returns within the
budget and unfinished segments fall back to their literal translation."""
import asyncio
import time
from concurrent.futures import ThreadPoolExecutor
monkeypatch.setenv("OMNIVOICE_CINEMATIC_BUDGET_S", "0.3")
def _slow(src, lit, **kw):
time.sleep(3.0) # far over the 0.3s budget
return {"text": "REFINED", "literal": lit, "critique": ""}
monkeypatch.setattr(tr, "cinematic_refine_sync", _slow)
pairs = [("s1", "hi", "hola"), ("s2", "world", "mundo")]
async def _run():
ex = ThreadPoolExecutor(max_workers=4)
t0 = time.time()
out = await tr.cinematic_refine_many(
pairs, source_lang="en", target_lang="es", executor=ex,
)
return time.time() - t0, out
dt, out = asyncio.run(_run())
assert dt < 2.0, f"budget did not bound the pass (took {dt:.1f}s)"
assert [r["id"] for r in out] == ["s1", "s2"] # order + length preserved
for r in out:
assert r["text"] == r["literal"] # degraded to literal
assert r.get("error") == "cinematic-budget"
def test_cinematic_budget_disabled_runs_to_completion(monkeypatch):
"""Budget <= 0 disables the bound — every segment gets its refine."""
import asyncio
from concurrent.futures import ThreadPoolExecutor
monkeypatch.setenv("OMNIVOICE_CINEMATIC_BUDGET_S", "0")
monkeypatch.setattr(
tr, "cinematic_refine_sync",
lambda src, lit, **kw: {"text": f"R:{lit}", "literal": lit, "critique": ""},
)
async def _run():
return await tr.cinematic_refine_many(
[("s1", "hi", "hola")], source_lang="en", target_lang="es",
executor=ThreadPoolExecutor(max_workers=2),
)
out = asyncio.run(_run())
assert out[0]["text"] == "R:hola" and "error" not in out[0]
+42
View File
@@ -0,0 +1,42 @@
"""Launch-window contract (owner decision, 2026-07-02): the app must ALWAYS
open maximized never fullscreen on every platform.
Two halves enforce it, and both must hold:
1. tauri.conf.json declares `maximized: true` + `fullscreen: false`.
2. lib.rs denylists BOTH "widget" and "main" in tauri-plugin-window-state
otherwise restored geometry silently overrides the config, and one manual
resize makes every later launch reopen at that smaller size.
"""
from __future__ import annotations
import json
import re
from pathlib import Path
_ROOT = Path(__file__).resolve().parent.parent
_CONF = _ROOT / "frontend" / "src-tauri" / "tauri.conf.json"
_LIB = _ROOT / "frontend" / "src-tauri" / "src" / "lib.rs"
def _main_window() -> dict:
windows = json.loads(_CONF.read_text())["app"]["windows"]
mains = [w for w in windows if w.get("label", "main") == "main"]
assert len(mains) == 1, f"expected exactly one main window, got {len(mains)}"
return mains[0]
def test_main_window_opens_maximized_not_fullscreen():
win = _main_window()
assert win.get("maximized") is True
assert win.get("fullscreen") is False
def test_window_state_plugin_denylists_main_and_widget():
src = _LIB.read_text()
m = re.search(r"with_denylist\(&\[(?P<labels>[^\]]*)\]\)", src)
assert m, "tauri-plugin-window-state denylist not found in lib.rs"
labels = set(re.findall(r'"([^"]+)"', m.group("labels")))
assert {"main", "widget"} <= labels, (
f"window-state denylist must include main+widget, got {labels}"
"without 'main', restored geometry overrides maximized-on-open"
)