fix(gallery): OmniVoice Gallery rename, dark dropdowns, and noisy/stale archetype previews (#241)

* fix(gallery): rename to "OmniVoice Gallery" + fix dark-theme dropdown colors

The gallery heading now reads "OmniVoice Gallery" (gallery.title, all 21
locales — brand prefix on each localized word).

The facet filter <select>s (Gender/Age/Pitch/Accent/Language) rendered with
the OS-default light control surface on the dark theme: .facet-select set
background/border from --bg-tertiary / --border-color, which are defined
nowhere. An undefined var() reads as transparent on the sibling <div> filters
(fine over the dark page) but falls back to the native light background on a
form control. Switch to the defined dark-chrome tokens and add
color-scheme: dark + an explicit dark option list so the popup matches across
WebKit / WebView2 / WebKitGTK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(gallery): archetype previews render a noise buzz instead of voice

The Hype Host, The Podcaster and The Vlogger previews played a loud tonal
buzz, not speech. The preview renderer pinned num_step=16 and seed=42; the
"social" sample script at that exact point lands on a degenerate diffusion
trajectory and collapses to a near-pure tone. The blank-audio guard missed it
because the buzz is loud (peaks near -2 dBFS), not silent — so the garbage was
cached and served. The cache key is (instruct, language) only, so it never
self-corrected.

- Bump preview num_step 16 -> 32: reliably converges to speech across the
  gallery's instruct/script space (one-time, cached render cost).
- Add a spectral-flatness floor (_is_unusable_audio) so a degenerate tonal
  render is rejected like a blank one, reusing the existing retry-on-new-seed
  path. Whisper/breathy voices are broadband (high flatness) so they're safe.

Verified: flatness Hype Host 0.001->0.050, Podcaster 0.0002->0.083,
Vlogger 0.004->0.039; whisper control (Calm Guide) 0.239, not flagged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(gallery): stop preview playback replaying stale cached audio

Preview audio is re-rendered server-side when an archetype is fixed, but the
URL is stable and the response carried no Cache-Control — so the WebView's
HTTP cache replayed the first clip it ever fetched (e.g. the old buzz)
indefinitely, even after the server file was corrected.

- Frontend: fetch previews with { cache: 'no-store' } so playback always
  pulls current bytes.
- Backend: send Cache-Control: no-cache on the preview response so any client
  revalidates against the ETag instead of serving a stale clip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(e2e): add Playwright UI smoke + gallery specs and preview-quality unit test

UI testing system to catch regressions like "Use design → Importing a module
script failed" (a dead Vite/module server) and the noisy-preview bug.

- Playwright (frontend/e2e): drives the system chromium (no browser download)
  against the Vite dev server. ui-smoke mounts all 12 routable views and fails
  on any code-split/import failure, uncaught exception, or ErrorBoundary
  fallback. gallery.spec asserts the "OmniVoice Gallery" heading, the dark
  facet dropdowns (computed bg = rgba(255,255,255,0.04), not the OS-default
  light surface), and that opening an archetype in the Designer mounts the
  lazy CloneDesignTab. `bun run e2e`.
- backend/tests/test_archetype_preview_quality.py: unit-tests the
  _spectral_flatness / _is_unusable_audio guard with synthetic signals
  (tone < threshold < speech < noise; loud tone + silence are unusable) and
  pins the render constants. CI-safe — no model/GPU.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Palash Debnath
2026-06-02 08:34:46 +05:30
committed by GitHub
co-authored by Claude Opus 4.8
parent d58e5a9e38
commit c857be816a
32 changed files with 350 additions and 33 deletions
+2
View File
@@ -129,3 +129,5 @@ marketing.md
.claude/skills/speckit-*/
.antigravitycli/
playwright-report/
.last-run.json
+62 -8
View File
@@ -41,6 +41,17 @@ _PREVIEW_DIR = Path(OUTPUTS_DIR) / "archetype_previews"
# Seed fixed so repeated renders of the same archetype are reproducible
# (mirrors scripts/render_demos_omnivoice.py).
_PREVIEW_SEED = 42
# Diffusion steps for previews. 16 under-converges: certain (script, seed)
# points — notably the "social" sample script at seed 42 — collapse to a
# degenerate tonal buzz (The Hype Host / Podcaster / Vlogger, issue follow-up).
# 32 reliably converges to speech across the gallery's instruct/script space
# at a one-time (cached) render cost.
_PREVIEW_NUM_STEP = 32
# Spectral-flatness floor below which a render is a degenerate tonal artifact
# rather than speech. Real, mastered speech sits ~0.040.07; a tonal buzz
# collapses to <0.005. 0.015 separates the two with wide margin and sits well
# below even breathy/whisper voices (which are broadband → high flatness).
_DEGENERATE_FLATNESS = 0.015
def _preview_key(a: dict) -> str:
@@ -80,6 +91,41 @@ def _is_blank_audio(audio_tensor) -> bool:
return False
def _spectral_flatness(audio_tensor) -> Optional[float]:
"""Geometric-mean / arithmetic-mean of the power spectrum.
~1.0 for broadband noise, →0 for a pure tone. The degenerate diffusion
renders this guards against are near-pure tonal buzzes (flatness <0.005),
distinct from both silence (caught by ``_is_blank_audio``) and real speech
(~0.04+). Returns ``None`` if it can't be computed so callers don't act on
a bad measurement.
"""
try:
import torch
t = audio_tensor if isinstance(audio_tensor, torch.Tensor) else torch.as_tensor(audio_tensor)
t = t.detach().to("cpu", dtype=torch.float32).flatten()
if t.numel() < 1024 or not torch.isfinite(t).all():
return None
spec = torch.fft.rfft(t * torch.hann_window(t.numel())).abs().pow(2) + 1e-12
return float(torch.exp(torch.mean(torch.log(spec))) / torch.mean(spec))
except Exception: # never let the checker itself block a render
return None
def _is_unusable_audio(audio_tensor) -> bool:
"""True if a render is silent/non-finite OR a degenerate tonal buzz.
The blank guard alone misses the tonal-collapse failure mode: a buzz is
*loud* (peaks near -2 dBFS after normalize), so it sails past the silence
floor and — without this — gets cached and served as the preview.
"""
if _is_blank_audio(audio_tensor):
return True
flatness = _spectral_flatness(audio_tensor)
return flatness is not None and flatness < _DEGENERATE_FLATNESS
async def _render_archetype_wav(a: dict, out_path: Path) -> None:
"""Render an archetype's sample script to ``out_path`` using the live engine.
@@ -112,7 +158,7 @@ async def _render_archetype_wav(a: dict, out_path: Path) -> None:
None, # ref_text
a["instruct"], # instruct
None, # duration
16, # num_step
_PREVIEW_NUM_STEP, # num_step
2.0, # guidance_scale
1.0, # speed
None, # t_shift
@@ -126,13 +172,14 @@ async def _render_archetype_wav(a: dict, out_path: Path) -> None:
)
audio_tensor = await loop.run_in_executor(_gpu_pool, _infer, _PREVIEW_SEED)
if _is_blank_audio(audio_tensor):
# Static message only — the archetype id derives from the request path
# param, and CodeQL flags logging request-derived data (clear-text /
# log-injection). The seed is a module constant, safe to log.
logger.warning("Archetype rendered blank at seed %d — retrying once", _PREVIEW_SEED)
if _is_unusable_audio(audio_tensor):
# Blank OR a degenerate tonal buzz — retry once on a different seed to
# step off the bad diffusion trajectory. Static message only: the
# archetype id is request-derived (CodeQL log-injection); the seed is a
# module constant, safe to log.
logger.warning("Archetype rendered unusable at seed %d — retrying once", _PREVIEW_SEED)
audio_tensor = await loop.run_in_executor(_gpu_pool, _infer, _PREVIEW_SEED + 1)
if _is_blank_audio(audio_tensor):
if _is_unusable_audio(audio_tensor):
raise RuntimeError("the voice engine returned no audible audio for this archetype")
out_path.parent.mkdir(parents=True, exist_ok=True)
@@ -200,7 +247,14 @@ async def preview_archetype(archetype_id: str):
f"unavailable. See Settings → Logs → Backend. Error: {e}"
),
)
return FileResponse(str(cache_path), media_type="audio/wav")
# no-cache (not no-store): the URL is stable but its bytes change when an
# archetype's preview is re-rendered, so force the client to revalidate
# against the ETag instead of serving a stale cached clip indefinitely.
return FileResponse(
str(cache_path),
media_type="audio/wav",
headers={"Cache-Control": "no-cache"},
)
@router.post("/archetypes/{archetype_id}/use")
@@ -0,0 +1,103 @@
"""Unit tests for the archetype-preview quality guard (``api.routers.archetypes``).
Background: the Hype Host / Podcaster / Vlogger previews shipped a loud tonal
*buzz* instead of speech. The renderer pinned ``num_step=16`` + ``seed=42`` and
the "social" sample script collapsed to a near-pure tone at that point; the
old silence-only guard missed it (the buzz is loud, not silent) so the garbage
was cached and served.
These tests cover the fix *without the 5 GB model / a GPU*: they drive the pure
``_spectral_flatness`` / ``_is_unusable_audio`` helpers with synthetic signals,
and assert the render constants didn't regress. The real end-to-end render is
verified manually (spectral flatness back in the speech range + Whisper ASR).
"""
from __future__ import annotations
import math
import os
import sys
import tempfile
import types
from pathlib import Path
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
# Stub core.config before the router imports OUTPUTS_DIR / VOICES_DIR from it.
_TMP = tempfile.mkdtemp(prefix="omnivoice_preview_q_")
_config = types.ModuleType("core.config")
_config.DATA_DIR = _TMP
_config.VOICES_DIR = str(Path(_TMP) / "voices")
_config.OUTPUTS_DIR = str(Path(_TMP) / "outputs")
sys.modules["core.config"] = _config
torch = pytest.importorskip("torch") # noqa: E402
from api.routers import archetypes as arch # noqa: E402
SR = 24_000
N = SR * 3 # 3 s clips
def _pure_tone(hz: float = 220.0) -> "torch.Tensor":
t = torch.arange(N, dtype=torch.float32) / SR
return 0.8 * torch.sin(2 * math.pi * hz * t)
def _white_noise() -> "torch.Tensor":
g = torch.Generator().manual_seed(0)
return 0.5 * (torch.rand(N, generator=g) * 2 - 1)
def _speech_like() -> "torch.Tensor":
"""Broadband + harmonic + amplitude-modulated — a coarse stand-in for voiced
speech: several harmonics (formant-ish), additive noise (consonants), and a
syllabic envelope (word gaps). Flatness lands between a pure tone and noise.
"""
g = torch.Generator().manual_seed(1)
t = torch.arange(N, dtype=torch.float32) / SR
harm = sum(torch.sin(2 * math.pi * f * t) / (i + 1)
for i, f in enumerate((130.0, 260.0, 390.0, 520.0)))
noise = 0.3 * (torch.rand(N, generator=g) * 2 - 1)
env = 0.5 + 0.5 * torch.sin(2 * math.pi * 4.0 * t).clamp(min=0) # ~4 Hz syllables
sig = (harm + noise) * env
return 0.7 * sig / sig.abs().max()
# ── _spectral_flatness ──────────────────────────────────────────────────────
def test_flatness_orders_tone_below_speech_below_noise():
tone = arch._spectral_flatness(_pure_tone())
speech = arch._spectral_flatness(_speech_like())
noise = arch._spectral_flatness(_white_noise())
assert tone is not None and speech is not None and noise is not None
assert tone < arch._DEGENERATE_FLATNESS < speech < noise
def test_flatness_returns_none_on_too_short_or_nonfinite():
assert arch._spectral_flatness(torch.zeros(16)) is None
bad = torch.full((4096,), float("nan"))
assert arch._spectral_flatness(bad) is None
# ── _is_unusable_audio ──────────────────────────────────────────────────────
def test_pure_tone_is_unusable():
# The degenerate-buzz failure mode: loud (passes the silence guard) but tonal.
tone = _pure_tone()
assert tone.abs().max() > 0.02 # not silent
assert arch._is_unusable_audio(tone) is True
def test_silence_is_unusable():
assert arch._is_unusable_audio(torch.zeros(N)) is True
def test_speech_like_is_usable():
assert arch._is_unusable_audio(_speech_like()) is False
# ── Constants didn't regress ────────────────────────────────────────────────
def test_preview_render_constants():
# 16 steps under-converged on the social script; the fix bumped it.
assert arch._PREVIEW_NUM_STEP >= 24
assert 0 < arch._DEGENERATE_FLATNESS < 0.03
+3
View File
@@ -54,6 +54,7 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.60.0",
"@tauri-apps/api": "^2.11.0",
"@tauri-apps/cli": "^2.11.0",
"@testing-library/jest-dom": "^6.9.1",
@@ -203,6 +204,8 @@
"@oxc-project/types": ["@oxc-project/types@0.127.0", "", {}, "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ=="],
"@playwright/test": ["@playwright/test@1.60.0", "", { "dependencies": { "playwright": "1.60.0" }, "bin": { "playwright": "cli.js" } }, "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag=="],
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
+49
View File
@@ -0,0 +1,49 @@
import type { Page } from '@playwright/test';
/** Every routable view (the `mode` values in App.jsx). */
export const MODES = [
'launchpad', 'clone', 'design', 'gallery', 'dub', 'stories',
'projects', 'queue', 'tools', 'transcriptions', 'settings', 'donate',
] as const;
/**
* Fatal client errors that mean a view failed to LOAD — code-split chunk /
* dynamic-import failures (the "Use design → Importing a module script failed"
* regression) and uncaught exceptions. Deliberately NOT matching network/API
* noise (5xx, fetch failures) — backend health is covered elsewhere, and a
* flaky API shouldn't fail a UI-mount test.
*/
const FATAL = [
/Importing a module script failed/i,
/Failed to fetch dynamically imported module/i,
/error loading dynamically imported module/i,
/ChunkLoadError/i,
];
export type ErrorSink = { fatal: string[]; all: string[] };
/** Attach console/pageerror listeners; returns a sink you assert on later. */
export function collectErrors(page: Page): ErrorSink {
const sink: ErrorSink = { fatal: [], all: [] };
const record = (text: string) => {
sink.all.push(text);
if (FATAL.some((re) => re.test(text))) sink.fatal.push(text);
};
page.on('pageerror', (err) => record(`pageerror: ${err.message}`));
page.on('console', (msg) => {
if (msg.type() === 'error') record(`console.error: ${msg.text()}`);
});
return sink;
}
/**
* Land directly on a view by seeding the zustand-persist store (key
* `omnivoice.app`) before the app boots. A shallow merge over slice defaults,
* so only `mode` is forced.
*/
export async function gotoMode(page: Page, mode: string): Promise<void> {
await page.addInitScript((m) => {
localStorage.setItem('omnivoice.app', JSON.stringify({ state: { mode: m }, version: 4 }));
}, mode);
await page.goto('/');
}
+39
View File
@@ -0,0 +1,39 @@
import { test, expect } from '@playwright/test';
import { collectErrors, gotoMode } from './_helpers';
test.describe('OmniVoice Gallery', () => {
test('heading is "OmniVoice Gallery"', async ({ page }) => {
await gotoMode(page, 'gallery');
await expect(page.getByRole('heading', { name: /OmniVoice Gallery/i })).toBeVisible();
});
test('facet dropdowns use the dark theme, not the OS-default light surface', async ({ page }) => {
await gotoMode(page, 'gallery');
const select = page.locator('select.facet-select').first();
await expect(select).toBeVisible();
// Regression guard for the undefined-var fallback: the fixed style resolves
// --chrome-hover-bg → rgba(255,255,255,0.04), NOT an opaque UA light surface
// and NOT transparent (rgba(0,0,0,0), the broken undefined-var state).
const bg = await select.evaluate((el) => getComputedStyle(el).backgroundColor);
expect(bg).toBe('rgba(255, 255, 255, 0.04)');
});
test('opening an archetype in the Designer mounts the design view (no chunk-load failure)', async ({ page }) => {
const errors = collectErrors(page);
await gotoMode(page, 'gallery');
// Cards load from the backend; wait for the first one.
const designerBtn = page.locator('.archetype-card .designer-btn').first();
await expect(designerBtn).toBeVisible({ timeout: 20_000 });
await designerBtn.click();
// The design view (CloneDesignTab — the lazy chunk that failed when Vite
// was down) must mount. Its prompt/personality UI is the tell.
await expect(
page.getByText(/personality|prompt|steps/i).first()
).toBeVisible({ timeout: 15_000 });
await expect(page.getByText(/this tab hit a snag/i)).toHaveCount(0);
expect(errors.fatal, errors.fatal.join('\n')).toEqual([]);
});
});
+24
View File
@@ -0,0 +1,24 @@
import { test, expect } from '@playwright/test';
import { MODES, collectErrors, gotoMode } from './_helpers';
// Every view must mount without a code-split/import failure or an uncaught
// exception, and without tripping the ErrorBoundary fallback. This is the
// regression guard for "Use design → Importing a module script failed" (a dead
// Vite/module server) and any lazy() page that fails to load.
for (const mode of MODES) {
test(`view "${mode}" mounts without fatal client errors`, async ({ page }) => {
const errors = collectErrors(page);
await gotoMode(page, mode);
// Give the lazy chunk time to fetch + the Suspense boundary to resolve.
// (No networkidle wait — views with a live WS/SSE log stream, e.g. Settings,
// never reach it.)
await page.waitForTimeout(2000);
// The ErrorBoundary fallback copy ("This tab hit a snag.") must not show.
const snag = page.getByText(/this tab hit a snag/i);
await expect(snag).toHaveCount(0);
expect(errors.fatal, `fatal errors in "${mode}":\n${errors.fatal.join('\n')}`).toEqual([]);
});
}
+3 -1
View File
@@ -14,7 +14,8 @@
"test:watch": "vitest",
"test:legacy": "node --experimental-strip-types --no-warnings --test ../tests/frontend/*.test.mjs",
"preview": "vite preview",
"tauri": "tauri"
"tauri": "tauri",
"e2e": "playwright test"
},
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
@@ -54,6 +55,7 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.60.0",
"@tauri-apps/api": "^2.11.0",
"@tauri-apps/cli": "^2.11.0",
"@testing-library/jest-dom": "^6.9.1",
+31
View File
@@ -0,0 +1,31 @@
import { defineConfig, devices } from '@playwright/test';
// E2E runs against the Vite dev server (UI on :3901, backend on :3900). It
// drives the SYSTEM chromium (no `playwright install` browser download) — set
// PLAYWRIGHT_CHROMIUM to override the path. reuseExistingServer keeps a dev
// session you already have running; CI starts its own `bun run dev`.
const PORT = Number(process.env.E2E_PORT || 3901);
export default defineConfig({
testDir: './e2e',
timeout: 45_000,
expect: { timeout: 10_000 },
fullyParallel: false,
retries: process.env.CI ? 1 : 0,
reporter: [['list']],
use: {
baseURL: `http://localhost:${PORT}`,
headless: true,
trace: 'retain-on-failure',
launchOptions: {
executablePath: process.env.PLAYWRIGHT_CHROMIUM || '/usr/bin/chromium',
},
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
webServer: {
command: 'bun run dev',
url: `http://localhost:${PORT}`,
reuseExistingServer: true,
timeout: 60_000,
},
});
+1 -1
View File
@@ -851,7 +851,7 @@
"add_to_queue": "إضافة إلى قائمة الانتظار"
},
"gallery": {
"title": "معرض",
"title": "OmniVoice معرض",
"search_placeholder": "بحث في يوتيوب...",
"all_voices": "جميع الأصوات ({{count}})",
"no_voices": "لا توجد أصوات بعد",
+1 -1
View File
@@ -852,7 +852,7 @@
"add_to_queue": "Zur Warteschlange hinzufügen"
},
"gallery": {
"title": "Galerie",
"title": "OmniVoice Galerie",
"search_placeholder": "YouTube durchsuchen…",
"all_voices": "Alle Stimmen ({{count}})",
"no_voices": "Noch keine Stimmen",
+1 -1
View File
@@ -730,7 +730,7 @@
"add_to_queue": "Add to Queue"
},
"gallery": {
"title": "Gallery",
"title": "OmniVoice Gallery",
"search_placeholder": "Search YouTube…",
"all_voices": "All Voices ({{count}})",
"no_voices": "No voices yet",
+1 -1
View File
@@ -852,7 +852,7 @@
"add_to_queue": "Agregar a la cola"
},
"gallery": {
"title": "Galería",
"title": "OmniVoice Galería",
"search_placeholder": "Buscar en YouTube…",
"all_voices": "Todas las voces ({{count}})",
"no_voices": "Aún no hay voces",
+1 -1
View File
@@ -852,7 +852,7 @@
"add_to_queue": "Ajouter à la file d'attente"
},
"gallery": {
"title": "Galerie",
"title": "OmniVoice Galerie",
"search_placeholder": "Rechercher sur YouTube…",
"all_voices": "Toutes les voix ({{count}})",
"no_voices": "Pas encore de voix",
+1 -1
View File
@@ -851,7 +851,7 @@
"add_to_queue": "कतार में जोड़ें"
},
"gallery": {
"title": "गैलरी",
"title": "OmniVoice गैलरी",
"search_placeholder": "यूट्यूब पर खोजें...",
"all_voices": "सभी आवाज़ें ({{count}})",
"no_voices": "अभी तक कोई आवाज़ नहीं",
+1 -1
View File
@@ -851,7 +851,7 @@
"add_to_queue": "Tambahkan ke Antrean"
},
"gallery": {
"title": "Galeri",
"title": "OmniVoice Galeri",
"search_placeholder": "Telusuri YouTube…",
"all_voices": "Semua Suara ({{count}})",
"no_voices": "Belum ada suara",
+1 -1
View File
@@ -851,7 +851,7 @@
"add_to_queue": "Aggiungi alla coda"
},
"gallery": {
"title": "Galleria",
"title": "OmniVoice Galleria",
"search_placeholder": "Cerca su YouTube...",
"all_voices": "Tutte le voci ({{count}})",
"no_voices": "Nessuna voce ancora",
+1 -1
View File
@@ -852,7 +852,7 @@
"add_to_queue": "キューに追加"
},
"gallery": {
"title": "ギャラリー",
"title": "OmniVoice ギャラリー",
"search_placeholder": "YouTube を検索…",
"all_voices": "すべての声 ({{count}})",
"no_voices": "まだ声はありません",
+1 -1
View File
@@ -851,7 +851,7 @@
"add_to_queue": "대기열에 추가"
},
"gallery": {
"title": "갤러리",
"title": "OmniVoice 갤러리",
"search_placeholder": "유튜브 검색…",
"all_voices": "모든 음색({{count}})",
"no_voices": "아직 음성이 없습니다.",
+1 -1
View File
@@ -851,7 +851,7 @@
"add_to_queue": "Toevoegen aan wachtrij"
},
"gallery": {
"title": "Galerij",
"title": "OmniVoice Galerij",
"search_placeholder": "Zoek op YouTube...",
"all_voices": "Alle stemmen ({{count}})",
"no_voices": "Nog geen stemmen",
+1 -1
View File
@@ -851,7 +851,7 @@
"add_to_queue": "Dodaj do kolejki"
},
"gallery": {
"title": "Galeria",
"title": "OmniVoice Galeria",
"search_placeholder": "Wyszukaj w YouTube…",
"all_voices": "Wszystkie głosy ({{count}})",
"no_voices": "Nie ma jeszcze głosów",
+1 -1
View File
@@ -851,7 +851,7 @@
"add_to_queue": "Adicionar à fila"
},
"gallery": {
"title": "Galeria",
"title": "OmniVoice Galeria",
"search_placeholder": "Pesquisar no YouTube…",
"all_voices": "Todas as vozes ({{count}})",
"no_voices": "Ainda não há vozes",
+1 -1
View File
@@ -851,7 +851,7 @@
"add_to_queue": "Добавить в очередь"
},
"gallery": {
"title": "Галерея",
"title": "OmniVoice Галерея",
"search_placeholder": "Искать на YouTube…",
"all_voices": "Все голоса ({{count}})",
"no_voices": "Голосов пока нет",
+1 -1
View File
@@ -851,7 +851,7 @@
"add_to_queue": "Lägg till i kö"
},
"gallery": {
"title": "Galleri",
"title": "OmniVoice Galleri",
"search_placeholder": "Sök på YouTube...",
"all_voices": "Alla röster ({{count}})",
"no_voices": "Inga röster än",
+1 -1
View File
@@ -851,7 +851,7 @@
"add_to_queue": "เพิ่มเข้าคิว"
},
"gallery": {
"title": "แกลเลอรี่",
"title": "OmniVoice แกลเลอรี่",
"search_placeholder": "ค้นหา YouTube...",
"all_voices": "ทุกเสียง ({{count}})",
"no_voices": "ยังไม่มีเสียง.",
+1 -1
View File
@@ -851,7 +851,7 @@
"add_to_queue": "Kuyruğa Ekle"
},
"gallery": {
"title": "Galeri",
"title": "OmniVoice Galeri",
"search_placeholder": "YouTube'da ara…",
"all_voices": "Tüm Sesler ({{count}})",
"no_voices": "Henüz ses yok",
+1 -1
View File
@@ -851,7 +851,7 @@
"add_to_queue": "Додати в чергу"
},
"gallery": {
"title": "Галерея",
"title": "OmniVoice Галерея",
"search_placeholder": "Пошук на YouTube…",
"all_voices": "Усі голоси ({{count}})",
"no_voices": "Голосів ще немає",
+1 -1
View File
@@ -851,7 +851,7 @@
"add_to_queue": "Thêm vào hàng đợi"
},
"gallery": {
"title": "Thư viện ảnh",
"title": "OmniVoice Thư viện ảnh",
"search_placeholder": "Tìm kiếm YouTube…",
"all_voices": "Tất cả các giọng nói ({{count}})",
"no_voices": "Chưa có tiếng nói nào",
+1 -1
View File
@@ -821,7 +821,7 @@
"add_to_queue": "添加到队列"
},
"gallery": {
"title": "音色库",
"title": "OmniVoice 音色库",
"search_placeholder": "搜索 YouTube…",
"all_voices": "全部声音({{count}}",
"no_voices": "暂无声音",
+1 -1
View File
@@ -851,7 +851,7 @@
"add_to_queue": "添加到隊列"
},
"gallery": {
"title": "畫廊",
"title": "OmniVoice 畫廊",
"search_placeholder": "搜尋 YouTube...",
"all_voices": "所有聲音 ({{count}})",
"no_voices": "還沒有聲音",
+8 -1
View File
@@ -363,7 +363,14 @@
.use-case-chips { display: flex; flex-wrap: wrap; gap: 5px; }
.chip-emoji { font-size: 0.8rem; line-height: 1; }
.facet-selects { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.facet-select { padding: 5px 8px; border-radius: 8px; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary); font-size: 0.7rem; cursor: pointer; }
/* Native <select> falls back to the OS-default (light) control surface when its
background resolves to an undefined var, so pin real dark-chrome tokens. The
undefined --border-color/--bg-tertiary read as transparent on the sibling
<div> filters (fine on the dark page) but render light on a form control. */
.facet-select { padding: 5px 8px; border-radius: 8px; border: 1px solid var(--chrome-border); background: var(--chrome-hover-bg); color: var(--chrome-fg); font-size: 0.7rem; cursor: pointer; color-scheme: dark; }
/* Belt-and-suspenders: explicit dark option list for WebViews that ignore
color-scheme on the popup (cross-platform parity: WebKit / WebView2 / WebKitGTK). */
.facet-select option { background: var(--chrome-bg); color: var(--chrome-fg); }
.facet-toggle { display: inline-flex; align-items: center; gap: 4px; font-size: 0.7rem; color: var(--text-secondary); cursor: pointer; }
.facet-reset { display: inline-flex; align-items: center; gap: 4px; padding: 5px 8px; border: 1px solid var(--border-color); background: transparent; color: var(--text-secondary); border-radius: 8px; font-size: 0.7rem; cursor: pointer; }
.facet-reset:hover { border-color: var(--border-hover); color: var(--text-primary); }
+5 -2
View File
@@ -97,7 +97,10 @@ export default function VoiceGallery() {
stopPlayback();
setLoadingPreviewId(id);
try {
const resp = await fetch(fullUrl);
// no-store: preview audio is re-rendered server-side when an archetype is
// fixed/changed, but the URL is stable. Without this the WebView's HTTP
// cache replays the first clip it ever fetched (a stale render) forever.
const resp = await fetch(fullUrl, { cache: 'no-store' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const blob = await resp.blob();
setPlayingId(id);
@@ -131,7 +134,7 @@ export default function VoiceGallery() {
<div className="gallery-header">
<div className="header-top">
<div className="header-text">
<h2>{t('gallery.title', { defaultValue: 'Voice Gallery' })}</h2>
<h2>{t('gallery.title', { defaultValue: 'OmniVoice Gallery' })}</h2>
<p className="gallery-sub">
{t('gallery.subtitle', { defaultValue: 'Hundreds of ready-made designed voices — pick one and go.' })}
</p>