Polish Electron navigation and theme, refresh README and agent skills
@@ -1,172 +1,29 @@
|
||||
---
|
||||
name: omnivoice
|
||||
description: "Local TTS, voice cloning, voice design, and video dubbing via the VoiceStudio MCP server (open-source ElevenLabs alternative; nothing leaves the machine, runs on MPS/CUDA/CPU). Use when: (1) generating speech from text in any of 646 languages, (2) cloning a voice from a 3-second reference clip, (3) designing a voice by gender/age/accent/pitch/style, (4) dubbing a video into another language, (5) listing voice profiles or personality presets, (6) producing narration where privacy, cost, or absent API keys matter, (7) non-English narration where Edge TTS/kokoro fall short, (8) batch audio for blog posts or content pipelines. Triggers: 'omnivoice', 'voice clone', 'clone this voice', 'tts', 'narrate', 'generate speech', 'voice synthesis', 'dub video', 'voice design', 'local tts', 'multilingual voice', 'narrate this post', 'elevenlabs alternative'."
|
||||
description: Legacy VoiceStudio skill alias for existing Claude installations. Generate local speech, discover saved voices, and transcribe audio through the running VoiceStudio backend.
|
||||
---
|
||||
|
||||
# VoiceStudio
|
||||
# VoiceStudio compatibility entry
|
||||
|
||||
The canonical cross-agent package lives at `skills/omnivoice/SKILL.md`. This
|
||||
Claude-specific package retains the MCP lifecycle helpers and references.
|
||||
The current cross-agent package is [voicestudio](../../../skills/voicestudio/SKILL.md).
|
||||
For new installations use `npx skills add debpalash/VoiceStudio --skill voicestudio`.
|
||||
|
||||
## Overview
|
||||
Use the running backend at the user's configured address (default
|
||||
`http://localhost:3900`). Check `/health`, discover `/openapi.json` and
|
||||
`/v1/audio/voices`, then use the installed schema for speech, transcription,
|
||||
profiles, and jobs. The HTTP MCP endpoint is `/mcp`; discover tools from the
|
||||
connected server instead of assuming this older package's tool inventory.
|
||||
|
||||
Generate audio locally via the VoiceStudio MCP server. Tools: `generate_speech`, `list_voices`, `list_personalities`, `list_languages`, `check_health`. Resources: `voice://{id}`, `history://recent`.
|
||||
Launch the installed Electron app if the backend is unavailable. For source
|
||||
development follow the checkout's Electron README. Existing helpers in
|
||||
`scripts/` support legacy source installations; inspect their environment
|
||||
and dependency assumptions before running them.
|
||||
|
||||
## Prerequisites — Backend Must Be Running
|
||||
Model downloads and remote services require the user's choice. Never silently
|
||||
install models, promise fixed latency, or treat compatibility voice names as
|
||||
real provider voices. Validate saved audio and asynchronous job completion
|
||||
before reporting success. Protected backends require configured credentials;
|
||||
never disable authentication to make an example work.
|
||||
|
||||
The MCP tools all hit `$OMNIVOICE_API_URL` (default `http://localhost:3900`). If the backend is down, every tool returns a connection error. Install + boot:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/debpalash/VoiceStudio.git "$OMNIVOICE_HOME"
|
||||
cd "$OMNIVOICE_HOME"
|
||||
uv sync
|
||||
VIRTUAL_ENV="$(pwd)/.venv" uv pip install 'mcp[cli]'
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```bash
|
||||
scripts/check-health.sh # exit 0 if up
|
||||
scripts/start-backend.sh # boot in background (MPS/CUDA auto-detected)
|
||||
```
|
||||
|
||||
First synthesis call lazy-downloads the `k2-fsa/OmniVoice` model (~2.4 GB) from HuggingFace — cached on subsequent boots.
|
||||
|
||||
## Task Index — Pick the Right Tool
|
||||
|
||||
| Task | Tool | Notes |
|
||||
|---|---|---|
|
||||
| Verify backend is up | `check_health` | Returns `{"status":"ok","device":"mps|cuda|cpu"}` |
|
||||
| Text → audio with a saved voice | `generate_speech(text, profile_id)` | Returns base64 WAV. `profile_id="demo0001"` is the bundled demo voice |
|
||||
| Text → audio without a clone (voice design) | `generate_speech(text, instruct="…")` | Omit `profile_id`; pass an `instruct` like `"warm middle-aged female narrator, calm pace"` |
|
||||
| Multilingual narration | `generate_speech(text, language="es")` | Any ISO 639 code or `"Auto"` |
|
||||
| List existing voices | `list_voices` | Returns id, name, type, personality |
|
||||
| List personality presets | `list_personalities` | Returns narrator / casual / news-anchor / etc. with their `instruct` strings |
|
||||
| List supported languages | `list_languages` | 646 total; returns 20 popular + the full count |
|
||||
|
||||
For non-trivial decisions (which engine to use, when to pick VoiceStudio over kokoro / Edge TTS / ElevenLabs), see [references/engines-comparison.md](references/engines-comparison.md).
|
||||
|
||||
For MCP wiring details, backend lifecycle, troubleshooting, and a clean teardown, see [references/mcp-setup.md](references/mcp-setup.md).
|
||||
|
||||
## Common Workflows
|
||||
|
||||
### 1. One-shot narration with the demo voice
|
||||
|
||||
```python
|
||||
# As called through the MCP client (your agent will do this for you):
|
||||
result = generate_speech(
|
||||
text="Hello — this is VoiceStudio generating speech locally.",
|
||||
profile_id="demo0001",
|
||||
language="English",
|
||||
steps=16, # 8 = fast/draft · 16 = balanced · 32 = quality
|
||||
)
|
||||
# result is JSON with audio_id, generation_time_s, audio_duration_s, format, wav_base64
|
||||
```
|
||||
|
||||
Benchmark: 4.2 s of audio in ~24 s server-side on Apple Silicon MPS at 16 diffusion steps.
|
||||
|
||||
### 2. Save the WAV to disk and play
|
||||
|
||||
Tool returns base64 PCM WAV (16-bit, mono, 24 kHz). Decode + write:
|
||||
|
||||
```python
|
||||
import base64, json
|
||||
payload = json.loads(result_text) # parse JSON the tool returns
|
||||
open("out.wav","wb").write(base64.b64decode(payload["wav_base64"]))
|
||||
```
|
||||
|
||||
On macOS: `afplay out.wav`. Convert to MP3 with `ffmpeg -i out.wav -codec:a libmp3lame -b:a 128k out.mp3`.
|
||||
|
||||
### 3. Voice clone — end-to-end recipe
|
||||
|
||||
Cloning needs a 3-10 second reference clip the model will use as a speaker embedding. The MCP server does NOT expose profile creation — it only reads existing profiles. Two paths to create one:
|
||||
|
||||
**Path A — bundled helper (macOS, recommended for fresh clones):**
|
||||
|
||||
```bash
|
||||
scripts/record-reference.sh ~/Downloads/my-ref.wav 12 1
|
||||
# args: output_path raw_duration_sec mic_index
|
||||
# Default mic_index=1 (MacBook built-in); list devices via:
|
||||
# ffmpeg -f avfoundation -list_devices true -i ""
|
||||
```
|
||||
|
||||
The script gives **audible** countdown + start/stop cues via macOS `say` + `/System/Library/Sounds/Ping.aiff` so the user knows when to speak (terminal stdout is buffered — text "speak now" prompts arrive too late). It records a longer raw window, then trims to ~10 seconds of speech via `silenceremove + atrim`, plays back for verification, and prints the next-step `curl` command.
|
||||
|
||||
**Path B — manual:**
|
||||
|
||||
```bash
|
||||
# 1. Record (mono, 24 kHz native — matches model's internal rate)
|
||||
ffmpeg -f avfoundation -i ":1" -t 12 -ac 1 -ar 24000 raw.wav
|
||||
|
||||
# 2. Trim leading silence + take first 10 sec of speech
|
||||
ffmpeg -i raw.wav \
|
||||
-af "silenceremove=start_periods=1:start_silence=0.05:start_threshold=-40dB,atrim=end=10" \
|
||||
-ac 1 -ar 24000 ref.wav
|
||||
|
||||
# 3. Verify
|
||||
ffmpeg -i ref.wav -af volumedetect -f null - 2>&1 | grep volume # max should be > -20 dB
|
||||
afplay ref.wav
|
||||
```
|
||||
|
||||
**POST to /profiles** (multipart/form-data — required fields: `name`, `ref_audio`):
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:3900/profiles \
|
||||
-F "name=carlos-clone" \
|
||||
-F "ref_audio=@ref.wav" \
|
||||
-F "ref_text=The exact text spoken in the clip" \
|
||||
-F "language=English" \
|
||||
| python3 -m json.tool
|
||||
# returns { "id": "abc12345", "name": "carlos-clone" }
|
||||
```
|
||||
|
||||
Once created, pass `profile_id` to `generate_speech` (via MCP) or directly via `POST /generate`. Profiles persist in SQLite + reference-audio files at `~/Library/Application Support/OmniVoice/voices/<id>.<ext>` (the backend preserves the uploaded extension — `.wav` if you uploaded a WAV, `.mp3` if MP3, etc.). State persists across backend restarts.
|
||||
|
||||
**Reference clip tips that materially affect quality:**
|
||||
|
||||
| Factor | Why it matters |
|
||||
|---|---|
|
||||
| Single speaker | Mixed speakers blur the embedding |
|
||||
| Clean speech, no music/noise | Model embeds the noise too |
|
||||
| Natural prosody (avoid pangrams) | Diffusion samples replicate prosody, not just timbre |
|
||||
| 3-10 sec is the sweet spot | < 3 s lacks information; > 10 s adds compute without quality gain |
|
||||
| Match `ref_text` to what's spoken | Improves alignment, especially on noisy refs |
|
||||
| `language` correct | Wrong language → cross-lingual transfer artifacts |
|
||||
| Loudness peak ≥ -15 dB | Quiet refs work but normalize poorly |
|
||||
|
||||
### 4. Voice design (no reference clip)
|
||||
|
||||
Skip `profile_id`; provide an `instruct` string describing the desired voice:
|
||||
|
||||
```python
|
||||
generate_speech(
|
||||
text="Welcome to the future of agentic systems.",
|
||||
instruct="warm middle-aged female narrator, calm authoritative pace, documentary style",
|
||||
)
|
||||
```
|
||||
|
||||
Get pre-made instructs via `list_personalities` and copy the one matching the brief (narrator, casual, news-anchor, etc.).
|
||||
|
||||
### 5. Video dubbing (web UI only)
|
||||
|
||||
The MCP server does not expose the dubbing endpoint. The full transcribe → translate → re-voice → mux pipeline lives behind the desktop UI (`bun run desktop` in `$OMNIVOICE_HOME`) and the `/dub/*` REST routes. When the user asks to dub a video, point them to the UI; surface this skill only for the synthesis primitives above.
|
||||
|
||||
## When NOT to use VoiceStudio
|
||||
|
||||
- **Fast English-only narration on weak hardware** → `kokoro-tts` is ~10× smaller and 2× realtime on CPU (see [references/engines-comparison.md](references/engines-comparison.md))
|
||||
- **Lowest-friction one-off TTS** → Edge TTS needs no install or backend
|
||||
- **Highest possible quality regardless of cost** → ElevenLabs still wins on English narration polish; VoiceStudio ties or wins on multilingual + cloning
|
||||
- **Real-time streaming dictation** → use the VoiceStudio desktop widget (`⌘+⇧+Space`), not the MCP server
|
||||
|
||||
## Resources
|
||||
|
||||
- [references/engines-comparison.md](references/engines-comparison.md) — Decision tree across VoiceStudio / kokoro / Voicebox / Edge TTS / ElevenLabs / cloud APIs
|
||||
- [references/mcp-setup.md](references/mcp-setup.md) — MCP wiring, backend lifecycle, env vars, troubleshooting
|
||||
- [scripts/check-health.sh](scripts/check-health.sh) — `curl /health`, exit 0/1
|
||||
- [scripts/start-backend.sh](scripts/start-backend.sh) — Start uvicorn on 127.0.0.1:3900 with health probe
|
||||
- [scripts/stop-backend.sh](scripts/stop-backend.sh) — Clean shutdown via `kill -TERM` on the bound PID
|
||||
- [scripts/record-reference.sh](scripts/record-reference.sh) — macOS-only: record + trim + verify a reference clip for cloning, with audible cues (`say` + system beeps) that bypass terminal output buffering
|
||||
|
||||
Backend Swagger / OpenAPI: `http://127.0.0.1:3900/docs` (when backend is up).
|
||||
|
||||
Upstream: github.com/debpalash/VoiceStudio. The app uses AGPL-3.0-only; optional engines and downloaded models retain their own licenses. See `LICENSE-NOTICE.md` in the repository.
|
||||
Source and current setup documentation:
|
||||
https://github.com/debpalash/VoiceStudio
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
|
||||

|
||||
|
||||
<p align="center"><sub>Captured from the running Electron preview with the bundled demo voice. Published installers may look different.</sub></p>
|
||||
|
||||
## Create with VoiceStudio
|
||||
|
||||
@@ -79,7 +78,7 @@ See [Electron setup](electron/README.md) for prerequisites and backend configura
|
||||
| Integrations | [Local API](docs/speech-platform.md) · [MCP](docs/mcp.md) · [Examples](examples/README.md) |
|
||||
| Development | [Contributing](.github/CONTRIBUTING.md) · [Electron](electron/README.md) · [Changelog](CHANGELOG.md) |
|
||||
|
||||
Agent skills: `npx skills add debpalash/VoiceStudio`
|
||||
Agent skills: `npx skills add debpalash/VoiceStudio` — choose **voicestudio** for audio workflows or **oss-maintainer** for repository maintenance.
|
||||
|
||||
## Support VoiceStudio
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 154 KiB After Width: | Height: | Size: 330 KiB |
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 296 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 299 KiB |
|
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 362 KiB |
|
Before Width: | Height: | Size: 3.4 MiB After Width: | Height: | Size: 4.3 MiB |
@@ -123,7 +123,7 @@ Appearance and General have direct routes and share a breadcrumb header, searcha
|
||||
sidebar, max-w-4xl scroll frame, grouped sections, and consistent setting rows.
|
||||
Sidebar active/hover surfaces use the shared T3 theme tokens.
|
||||
|
||||
The local palette library includes Signal, Canopy, Current, Hearth, and Orchid, with
|
||||
The local palette library includes VoiceStudio Original, Canopy, Current, Hearth, and Orchid, with
|
||||
upstream light/dark color definitions with VoiceStudio display names from T3 Code (MIT). Each appearance keeps
|
||||
its own selected palette. System mode follows live OS appearance changes; the
|
||||
sidebar toggle explicitly switches to light or dark. Choices persist under
|
||||
@@ -206,3 +206,9 @@ open a form in the browser; they do not publish a voice automatically.
|
||||
Saved voice editor > Export persona downloads a portable `.ovsvoice` bundle.
|
||||
Include voice clip controls whether the original reference accompanies the
|
||||
watermarked preview. Gallery > My Imports accepts the exported bundle again.
|
||||
|
||||
Workspace navigation groups Clone, Design, Profiles, and Gallery under Voice;
|
||||
Stories and Audiobook under Stories; and single/batch dubbing under Dubbing.
|
||||
The current workflow opens automatically. Group buttons can expand or collapse
|
||||
without navigating; the compact rail opens the same destinations in a flyout.
|
||||
Transcribe, Projects, Tools, and Integrations remain directly accessible.
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
height: 34px;
|
||||
flex: 0 0 34px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--primary);
|
||||
filter: drop-shadow(0 3px 6px color-mix(in srgb, var(--primary) 25%, transparent));
|
||||
@@ -139,7 +139,7 @@
|
||||
z-index: 0;
|
||||
inset: 5px 4px 3px;
|
||||
content: '';
|
||||
clip-path: polygon(50% 0, 100% 100%, 0 100%);
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at 50% 42%, color-mix(in srgb, var(--primary) 58%, transparent), transparent 65%);
|
||||
filter: blur(2px);
|
||||
opacity: 0.9;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { SponsorInquiry } from './sponsor-inquiry';
|
||||
import {
|
||||
ArrowUpRightIcon,
|
||||
BlocksIcon,
|
||||
CircleIcon,
|
||||
SearchIcon,
|
||||
GemIcon,
|
||||
PlusIcon,
|
||||
@@ -265,7 +266,7 @@ export function SponsorFooter() {
|
||||
}
|
||||
>
|
||||
<span aria-hidden="true" className="sponsor-book-mark">
|
||||
<TriangleIcon />
|
||||
<CircleIcon />
|
||||
<span className="sponsor-book-question">?</span>
|
||||
</span>
|
||||
<span className="sponsor-book-copy">
|
||||
|
||||
@@ -82,3 +82,74 @@
|
||||
@media (max-width: 560px) {
|
||||
.sponsor-inquiry-perks { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
/* Editorial header: one focal point, three concrete placements, quiet navigation. */
|
||||
.sponsor-inquiry-dialog {
|
||||
gap: 20px;
|
||||
background: var(--sidebar);
|
||||
}
|
||||
.sponsor-inquiry-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding-right: 24px;
|
||||
}
|
||||
.sponsor-inquiry-heading h2 {
|
||||
font-size: 21px;
|
||||
line-height: 1.25;
|
||||
letter-spacing: -0.035em;
|
||||
font-weight: 600;
|
||||
}
|
||||
.sponsor-inquiry-heading p {
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.sponsor-inquiry-hero-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex-basis: 44px;
|
||||
color: color-mix(in srgb, var(--primary) 45%, var(--sidebar-foreground));
|
||||
background: color-mix(in srgb, var(--primary) 8%, var(--sidebar));
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
.sponsor-inquiry-perks {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 22px;
|
||||
padding: 0 0 4px;
|
||||
}
|
||||
.sponsor-inquiry-perks span { gap: 7px; }
|
||||
.sponsor-inquiry-perks svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
color: color-mix(in srgb, var(--primary) 40%, var(--sidebar-foreground));
|
||||
}
|
||||
.sponsor-inquiry-perks small { font-size: 12px; line-height: 1.4; }
|
||||
.sponsor-inquiry-methods {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid var(--sidebar-border);
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.sponsor-inquiry-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.sponsor-inquiry-tab { padding-inline: 14px; }
|
||||
.sponsor-inquiry-tab[aria-selected='true'] {
|
||||
border-color: transparent;
|
||||
background: color-mix(in srgb, var(--sidebar-foreground) 10%, var(--sidebar));
|
||||
box-shadow: none;
|
||||
}
|
||||
.sponsor-inquiry-form-link { width: auto; padding-inline: 8px; }
|
||||
@media (max-width: 560px) {
|
||||
.sponsor-inquiry-heading h2 { font-size: 18px; }
|
||||
.sponsor-inquiry-dialog { gap: 16px; }
|
||||
.sponsor-inquiry-perks { gap: 8px 16px; }
|
||||
}
|
||||
|
||||
@@ -2,13 +2,10 @@ import { useState } from 'react';
|
||||
import {
|
||||
BlocksIcon,
|
||||
BookOpenIcon,
|
||||
BadgeCheckIcon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
EyeIcon,
|
||||
ExternalLinkIcon,
|
||||
MailIcon,
|
||||
ShieldCheckIcon,
|
||||
PinIcon,
|
||||
XIcon,
|
||||
} from 'lucide-react';
|
||||
@@ -69,57 +66,31 @@ export function SponsorInquiry({
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
</DialogClose>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="sponsor-inquiry-heading">
|
||||
<span className="sponsor-inquiry-hero-icon" aria-hidden="true">
|
||||
<PinIcon />
|
||||
</span>
|
||||
<div className="grid gap-1">
|
||||
<DialogTitle>{t('sponsorSlot.title')}</DialogTitle>
|
||||
<DialogDescription>{t('sponsorSlot.description')}</DialogDescription>
|
||||
<DialogTitle>{t('sponsorSlot.partner_heading')}</DialogTitle>
|
||||
<DialogDescription>{t('sponsorSlot.partner_subtitle')}</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sponsor-inquiry-perks">
|
||||
<span>
|
||||
<EyeIcon aria-hidden="true" />
|
||||
<small>{t('sponsorSlot.visibility')}</small>
|
||||
</span>
|
||||
<span>
|
||||
<BlocksIcon aria-hidden="true" />
|
||||
<small>{t('sponsorSlot.integration')}</small>
|
||||
</span>
|
||||
<span>
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
<small>{t('sponsorSlot.installs')}</small>
|
||||
</span>
|
||||
<span>
|
||||
<BookOpenIcon aria-hidden="true" />
|
||||
<small>{t('sponsorSlot.distribution')}</small>
|
||||
</span>
|
||||
<span>
|
||||
<BadgeCheckIcon aria-hidden="true" />
|
||||
<small>{t('sponsorSlot.partner')}</small>
|
||||
</span>
|
||||
<span>
|
||||
<ShieldCheckIcon aria-hidden="true" />
|
||||
<small>{t('sponsorSlot.privacy')}</small>
|
||||
</span>
|
||||
<span><EyeIcon aria-hidden="true" /><small>{t('sponsorSlot.app_placement')}</small></span>
|
||||
<span><BlocksIcon aria-hidden="true" /><small>{t('sponsorSlot.integration_page')}</small></span>
|
||||
<span><BookOpenIcon aria-hidden="true" /><small>{t('sponsorSlot.readme_exposure')}</small></span>
|
||||
</div>
|
||||
<div
|
||||
className="sponsor-inquiry-tabs grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] gap-1 rounded-xl p-1"
|
||||
role="tablist"
|
||||
aria-label={t('sponsorSlot.title')}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={mode === 'form' ? 'secondary' : 'ghost'}
|
||||
className="sponsor-inquiry-tab"
|
||||
role="tab"
|
||||
aria-selected={mode === 'form'}
|
||||
onClick={() => setMode('form')}
|
||||
>
|
||||
{t('sponsorSlot.form')}
|
||||
</Button>
|
||||
<div className="sponsor-inquiry-methods">
|
||||
<div className="sponsor-inquiry-tabs" role="tablist" aria-label={t('sponsorSlot.partner_heading')}>
|
||||
<Button type="button" size="sm" variant="ghost" className="sponsor-inquiry-tab"
|
||||
role="tab" aria-selected={mode === 'form'} onClick={() => setMode('form')}>
|
||||
{t('sponsorSlot.form')}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="ghost" className="sponsor-inquiry-tab"
|
||||
role="tab" aria-selected={mode === 'email'} onClick={() => setMode('email')}>
|
||||
<MailIcon aria-hidden="true" />{t('sponsorSlot.email')}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -137,17 +108,7 @@ export function SponsorInquiry({
|
||||
}}
|
||||
>
|
||||
<ExternalLinkIcon aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={mode === 'email' ? 'secondary' : 'ghost'}
|
||||
className="sponsor-inquiry-tab"
|
||||
role="tab"
|
||||
aria-selected={mode === 'email'}
|
||||
onClick={() => setMode('email')}
|
||||
>
|
||||
{t('sponsorSlot.email')}
|
||||
<span className="text-xs">{t('network.open_in_browser')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
{mode === 'form' ? (
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { expect, it, vi } from 'vitest';
|
||||
|
||||
const route = vi.hoisted(() => ({ pathname: '/gallery' }));
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
useRouterState: ({ select }: any) => select({ location: route }),
|
||||
Link: ({ to, activeProps, children, ...props }: any) => (
|
||||
<a href={to} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }));
|
||||
vi.mock('@/lib/store/workspace', () => ({ setWorkspace: vi.fn() }));
|
||||
import { WorkspaceNavigation } from './workspace-menu';
|
||||
|
||||
it('opens the current workflow, lets users collapse it, and follows route changes', () => {
|
||||
const { rerender } = render(<WorkspaceNavigation />);
|
||||
const voice = screen.getByRole('button', { name: 'nav.voice' });
|
||||
expect(voice).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(screen.getByRole('link', { name: 'nav.gallery' })).toHaveAttribute('href', '/gallery');
|
||||
fireEvent.click(voice);
|
||||
expect(voice).toHaveAttribute('aria-expanded', 'false');
|
||||
route.pathname = '/audiobook';
|
||||
rerender(<WorkspaceNavigation />);
|
||||
expect(screen.getByRole('button', { name: 'nav.stories' })).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'true',
|
||||
);
|
||||
expect(screen.getByRole('link', { name: 'audiobook.title' })).toHaveAttribute(
|
||||
'href',
|
||||
'/audiobook',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps grouped destinations reachable from the compact rail', async () => {
|
||||
render(<WorkspaceNavigation compact />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'nav.voice' }));
|
||||
expect(await screen.findByRole('link', { name: 'nav.clone_short' })).toHaveAttribute(
|
||||
'href',
|
||||
'/clone',
|
||||
);
|
||||
expect(screen.getByRole('link', { name: 'nav.gallery' })).toHaveAttribute('href', '/gallery');
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useEffect, useId, useState } from 'react';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/popover';
|
||||
import { Link, useRouterState } from '@tanstack/react-router';
|
||||
import {
|
||||
AudioLinesIcon,
|
||||
@@ -40,25 +42,22 @@ type Destination = readonly [
|
||||
|
||||
const openSaved = () => setWorkspace({ libraryOpen: true, libraryTab: 'voices' });
|
||||
|
||||
const compactDestinations: Destination[] = [
|
||||
const voiceDestinations: Destination[] = [
|
||||
['/clone', 'nav.clone_short', FingerprintIcon, openSaved],
|
||||
['/stories', 'nav.stories', AudioLinesIcon],
|
||||
['/dub', 'dubWorkspace.title', FilmIcon],
|
||||
['/batch', 'nav.batch_dub', LayersIcon],
|
||||
['/design', 'designWorkspace.title', WandSparklesIcon],
|
||||
['/personas', 'nav.saved', UsersRoundIcon, openSaved],
|
||||
['/gallery', 'nav.gallery', LibraryIcon],
|
||||
['/transcriptions', 'nav.transcribe', MicIcon],
|
||||
['/design', 'designWorkspace.title', WandSparklesIcon],
|
||||
['/audiobook', 'audiobook.title', BookOpenIcon],
|
||||
['/projects', 'projects.title', FolderIcon],
|
||||
['/tools', 'tools.title', WrenchIcon],
|
||||
['/integrations', 'integrationCatalog.title', BlocksIcon],
|
||||
];
|
||||
|
||||
const storyDestinations: Destination[] = [
|
||||
['/stories', 'nav.stories', AudioLinesIcon],
|
||||
['/audiobook', 'audiobook.title', BookOpenIcon],
|
||||
];
|
||||
const dubDestinations: Destination[] = [
|
||||
['/dub', 'dubWorkspace.title', FilmIcon],
|
||||
['/batch', 'nav.batch_dub', LayersIcon],
|
||||
];
|
||||
const laterDestinations: Destination[] = [
|
||||
['/transcriptions', 'nav.transcribe', MicIcon],
|
||||
['/design', 'designWorkspace.title', WandSparklesIcon],
|
||||
['/audiobook', 'audiobook.title', BookOpenIcon],
|
||||
['/projects', 'projects.title', FolderIcon],
|
||||
['/tools', 'tools.title', WrenchIcon],
|
||||
['/integrations', 'integrationCatalog.title', BlocksIcon],
|
||||
@@ -110,48 +109,74 @@ function NavigationLink({
|
||||
function NavigationGroup({
|
||||
label,
|
||||
icon: Icon,
|
||||
to,
|
||||
active,
|
||||
onActivate,
|
||||
children,
|
||||
compact,
|
||||
pathname,
|
||||
}: {
|
||||
label: string;
|
||||
icon: typeof AudioLinesIcon;
|
||||
to: '/dub' | '/personas';
|
||||
active: boolean;
|
||||
onActivate?: () => void;
|
||||
children: Destination[];
|
||||
compact: boolean;
|
||||
pathname: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const active = children.some(([to]) => pathname === to || pathname.startsWith(to + '/'));
|
||||
const [expanded, setExpanded] = useState(active);
|
||||
const [popupOpen, setPopupOpen] = useState(false);
|
||||
const id = useId();
|
||||
useEffect(() => {
|
||||
setExpanded(active);
|
||||
setPopupOpen(false);
|
||||
}, [pathname, active]);
|
||||
const triggerClass = cn(
|
||||
itemClass,
|
||||
'w-full',
|
||||
compact ? 'justify-center' : 'gap-2.5 px-2.5 font-medium',
|
||||
active &&
|
||||
'bg-sidebar-accent/65 text-sidebar-foreground ring-1 ring-inset ring-sidebar-border/50',
|
||||
);
|
||||
if (compact)
|
||||
return (
|
||||
<Popover open={popupOpen} onOpenChange={setPopupOpen}>
|
||||
<PopoverTrigger aria-label={t(label)} className={triggerClass}>
|
||||
<Icon className={iconClass} aria-hidden="true" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="right" className="w-52 p-2">
|
||||
<div className="px-2 pb-2 pt-1 text-xs font-medium text-muted-foreground">{t(label)}</div>
|
||||
<div onClick={() => setPopupOpen(false)}>
|
||||
{children.map((destination) => (
|
||||
<NavigationLink key={destination[0]} destination={destination} />
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
return (
|
||||
<div className="py-0.5">
|
||||
<Link
|
||||
to={to}
|
||||
onClick={onActivate}
|
||||
aria-expanded={active}
|
||||
className={cn(
|
||||
itemClass,
|
||||
'gap-2.5 px-2.5 font-medium',
|
||||
active &&
|
||||
'bg-sidebar-accent/65 text-sidebar-foreground shadow-sm ring-1 ring-inset ring-sidebar-border/50',
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
aria-controls={id}
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
className={triggerClass}
|
||||
>
|
||||
<Icon className={iconClass} aria-hidden="true" />
|
||||
<span className="truncate">{t(label)}</span>
|
||||
<ChevronRightIcon
|
||||
className={cn(
|
||||
'ml-auto size-3.5 shrink-0 text-muted-foreground/70 transition-[color,transform] duration-200 group-hover:text-sidebar-foreground motion-reduce:transform-none',
|
||||
active && 'rotate-90 text-sidebar-foreground',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'ml-auto size-3.5 shrink-0 transition-transform duration-200 motion-reduce:transition-none',
|
||||
expanded && 'rotate-90',
|
||||
)}
|
||||
/>
|
||||
</Link>
|
||||
</button>
|
||||
<div
|
||||
aria-hidden={!active}
|
||||
inert={!active}
|
||||
id={id}
|
||||
aria-hidden={!expanded}
|
||||
inert={!expanded}
|
||||
className={cn(
|
||||
'grid transition-[grid-template-rows,opacity] duration-200 motion-reduce:transition-none',
|
||||
active ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0',
|
||||
expanded ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0',
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
@@ -180,40 +205,30 @@ export function WorkspaceNavigation({ compact = false }: { compact?: boolean })
|
||||
compact ? 'space-y-0.5 px-1.5' : 'shrink-0 space-y-0.5 px-3',
|
||||
)}
|
||||
>
|
||||
{compact ? (
|
||||
compactDestinations.map((destination) => (
|
||||
<NavigationLink key={destination[0]} destination={destination} compact />
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<NavigationLink destination={['/clone', 'nav.clone_short', FingerprintIcon]} />
|
||||
<NavigationLink destination={['/stories', 'nav.stories', AudioLinesIcon]} />
|
||||
<NavigationGroup
|
||||
label="nav.dub"
|
||||
icon={FilmIcon}
|
||||
to="/dub"
|
||||
active={pathname === '/dub' || pathname === '/batch'}
|
||||
children={[
|
||||
['/dub', 'dubWorkspace.title', FilmIcon],
|
||||
['/batch', 'nav.batch_dub', LayersIcon],
|
||||
]}
|
||||
/>
|
||||
<NavigationGroup
|
||||
label="nav.persona"
|
||||
icon={UsersRoundIcon}
|
||||
to="/personas"
|
||||
onActivate={openSaved}
|
||||
active={pathname === '/personas' || pathname === '/gallery'}
|
||||
children={[
|
||||
['/personas', 'nav.saved', UsersRoundIcon, openSaved],
|
||||
['/gallery', 'nav.gallery', LibraryIcon],
|
||||
]}
|
||||
/>
|
||||
{laterDestinations.map((destination) => (
|
||||
<NavigationLink key={destination[0]} destination={destination} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<NavigationGroup
|
||||
label="nav.voice"
|
||||
icon={FingerprintIcon}
|
||||
children={voiceDestinations}
|
||||
compact={compact}
|
||||
pathname={pathname}
|
||||
/>
|
||||
<NavigationGroup
|
||||
label="nav.stories"
|
||||
icon={AudioLinesIcon}
|
||||
children={storyDestinations}
|
||||
compact={compact}
|
||||
pathname={pathname}
|
||||
/>
|
||||
<NavigationGroup
|
||||
label="nav.dub"
|
||||
icon={FilmIcon}
|
||||
children={dubDestinations}
|
||||
compact={compact}
|
||||
pathname={pathname}
|
||||
/>
|
||||
{laterDestinations.map((destination) => (
|
||||
<NavigationLink key={destination[0]} destination={destination} compact={compact} />
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,14 +15,14 @@ function Switch({
|
||||
data-slot="switch"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none group-has-[:focus-visible]/field-label:border-transparent group-has-[:focus-visible]/field-label:ring-0 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/20 data-[size=default]:h-[16.6px] data-[size=default]:w-[28px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
|
||||
"app-switch peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none group-has-[:focus-visible]/field-label:border-transparent group-has-[:focus-visible]/field-label:ring-0 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/20 data-[size=default]:h-[16.6px] data-[size=default]:w-[28px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-3.5 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
|
||||
className="app-switch-thumb pointer-events-none block rounded-full ring-0 transition-transform motion-reduce:transition-none group-data-[size=default]/switch:size-3.5 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
|
||||
@@ -545,12 +545,12 @@ export function SettingsPage() {
|
||||
appearance.update({
|
||||
font: 'inter',
|
||||
scale: 100,
|
||||
glass: false,
|
||||
glass: true,
|
||||
});
|
||||
updateTheme({
|
||||
mode: 'dark',
|
||||
light: 'default',
|
||||
dark: 'default',
|
||||
light: 'signal',
|
||||
dark: 'signal',
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -9,11 +9,11 @@ export function parseAppearance(raw: string | null): Appearance {
|
||||
const value = JSON.parse(raw ?? '{}');
|
||||
return {
|
||||
font: value?.font === 'system' ? 'system' : 'inter',
|
||||
glass: value?.glass === true,
|
||||
glass: value?.glass !== false,
|
||||
scale: appearanceScales.includes(value?.scale) ? value.scale : 100,
|
||||
};
|
||||
} catch {
|
||||
return { font: 'inter', scale: 100, glass: false };
|
||||
return { font: 'inter', scale: 100, glass: true };
|
||||
}
|
||||
}
|
||||
let current: Appearance;
|
||||
|
||||
@@ -1915,6 +1915,7 @@
|
||||
"test_text": "مرحبًا – هذا اختبار لهذا الصوت."
|
||||
},
|
||||
"nav": {
|
||||
"voice": "الصوت",
|
||||
"clone_short": "استنساخ",
|
||||
"workspaces": "مساحات العمل",
|
||||
"stories": "قصص",
|
||||
@@ -2596,6 +2597,11 @@
|
||||
"fitting": "ضبط التوقيت"
|
||||
},
|
||||
"sponsorSlot": {
|
||||
"partner_heading": "شارك VoiceStudio",
|
||||
"partner_subtitle": "اعرض منتجك أمام من يبنون باستخدام الصوت.",
|
||||
"app_placement": "ظهور داخل التطبيق",
|
||||
"integration_page": "صفحة التكامل",
|
||||
"readme_exposure": "ظهور في README",
|
||||
"visibility": "الظهور",
|
||||
"integration": "تكامل المنتج",
|
||||
"installs": "تثبيت مباشر",
|
||||
|
||||
@@ -1907,6 +1907,7 @@
|
||||
"test_text": "Hallo, dies ist ein Test dieser Stimme."
|
||||
},
|
||||
"nav": {
|
||||
"voice": "Stimme",
|
||||
"clone_short": "Klonen",
|
||||
"workspaces": "Arbeitsbereiche",
|
||||
"stories": "Geschichten",
|
||||
@@ -2588,6 +2589,11 @@
|
||||
"fitting": "Timing anpassen"
|
||||
},
|
||||
"sponsorSlot": {
|
||||
"partner_heading": "Partner von VoiceStudio werden",
|
||||
"partner_subtitle": "Erreiche Menschen, die mit Sprache entwickeln.",
|
||||
"app_placement": "Platzierung in der App",
|
||||
"integration_page": "Integrationsseite",
|
||||
"readme_exposure": "Präsenz in der README",
|
||||
"visibility": "Sichtbarkeit",
|
||||
"integration": "Produktintegration",
|
||||
"installs": "Direkte Installationen",
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
"toast_flush_failed": "Flush failed: {{message}}"
|
||||
},
|
||||
"nav": {
|
||||
"voice": "Voice",
|
||||
"clone_short": "Clone",
|
||||
"clone": "Voice cloning",
|
||||
"design": "Voice design",
|
||||
@@ -2588,6 +2589,11 @@
|
||||
"fitting": "Adjusting timing"
|
||||
},
|
||||
"sponsorSlot": {
|
||||
"partner_heading": "Partner with VoiceStudio",
|
||||
"partner_subtitle": "Put your product in front of people building with voice.",
|
||||
"app_placement": "In-app placement",
|
||||
"integration_page": "Integration page",
|
||||
"readme_exposure": "README exposure",
|
||||
"visibility": "Visibility",
|
||||
"integration": "Product integration",
|
||||
"installs": "Direct installs",
|
||||
|
||||
@@ -1909,6 +1909,7 @@
|
||||
"test_text": "Hola, esta es una prueba de esta voz."
|
||||
},
|
||||
"nav": {
|
||||
"voice": "Voz",
|
||||
"clone_short": "Clonar",
|
||||
"workspaces": "Espacios de trabajo",
|
||||
"stories": "Historias",
|
||||
@@ -2590,6 +2591,11 @@
|
||||
"fitting": "Ajustando tiempos"
|
||||
},
|
||||
"sponsorSlot": {
|
||||
"partner_heading": "Colabora con VoiceStudio",
|
||||
"partner_subtitle": "Presenta tu producto a quienes crean con voz.",
|
||||
"app_placement": "Presencia en la app",
|
||||
"integration_page": "Página de integración",
|
||||
"readme_exposure": "Visibilidad en README",
|
||||
"visibility": "Visibilidad",
|
||||
"integration": "Integración del producto",
|
||||
"installs": "Instalaciones directas",
|
||||
|
||||
@@ -1909,6 +1909,7 @@
|
||||
"test_text": "Bonjour, c'est un test de cette voix."
|
||||
},
|
||||
"nav": {
|
||||
"voice": "Voix",
|
||||
"clone_short": "Cloner",
|
||||
"workspaces": "Espaces de travail",
|
||||
"stories": "Histoires",
|
||||
@@ -2590,6 +2591,11 @@
|
||||
"fitting": "Ajustement du minutage"
|
||||
},
|
||||
"sponsorSlot": {
|
||||
"partner_heading": "Devenez partenaire de VoiceStudio",
|
||||
"partner_subtitle": "Présentez votre produit aux créateurs qui utilisent la voix.",
|
||||
"app_placement": "Présence dans l’application",
|
||||
"integration_page": "Page d’intégration",
|
||||
"readme_exposure": "Visibilité dans le README",
|
||||
"visibility": "Visibilité",
|
||||
"integration": "Intégration produit",
|
||||
"installs": "Installations directes",
|
||||
|
||||
@@ -1907,6 +1907,7 @@
|
||||
"test_text": "नमस्ते - यह इस आवाज़ का परीक्षण है।"
|
||||
},
|
||||
"nav": {
|
||||
"voice": "आवाज़",
|
||||
"clone_short": "क्लोन",
|
||||
"workspaces": "वर्कस्पेस",
|
||||
"stories": "कहानियां",
|
||||
@@ -2588,6 +2589,11 @@
|
||||
"fitting": "समय समायोजित हो रहा है"
|
||||
},
|
||||
"sponsorSlot": {
|
||||
"partner_heading": "VoiceStudio के साथ साझेदारी करें",
|
||||
"partner_subtitle": "अपना उत्पाद आवाज़ से निर्माण करने वालों तक पहुँचाएँ।",
|
||||
"app_placement": "ऐप में स्थान",
|
||||
"integration_page": "इंटीग्रेशन पेज",
|
||||
"readme_exposure": "README में मौजूदगी",
|
||||
"visibility": "दृश्यता",
|
||||
"integration": "उत्पाद एकीकरण",
|
||||
"installs": "सीधे इंस्टॉल",
|
||||
|
||||
@@ -1907,6 +1907,7 @@
|
||||
"test_text": "Halo - ini adalah ujian untuk suara ini."
|
||||
},
|
||||
"nav": {
|
||||
"voice": "Suara",
|
||||
"clone_short": "Klon",
|
||||
"workspaces": "Ruang kerja",
|
||||
"stories": "Cerita",
|
||||
@@ -2588,6 +2589,11 @@
|
||||
"fitting": "Menyesuaikan waktu"
|
||||
},
|
||||
"sponsorSlot": {
|
||||
"partner_heading": "Bermitra dengan VoiceStudio",
|
||||
"partner_subtitle": "Perkenalkan produk Anda kepada para kreator berbasis suara.",
|
||||
"app_placement": "Penempatan dalam aplikasi",
|
||||
"integration_page": "Halaman integrasi",
|
||||
"readme_exposure": "Eksposur README",
|
||||
"visibility": "Visibilitas",
|
||||
"integration": "Integrasi produk",
|
||||
"installs": "Instalasi langsung",
|
||||
|
||||
@@ -1909,6 +1909,7 @@
|
||||
"test_text": "Ciao, questo è un test di questa voce."
|
||||
},
|
||||
"nav": {
|
||||
"voice": "Voce",
|
||||
"clone_short": "Clona",
|
||||
"workspaces": "Aree di lavoro",
|
||||
"stories": "Storie",
|
||||
@@ -2590,6 +2591,11 @@
|
||||
"fitting": "Regolazione dei tempi"
|
||||
},
|
||||
"sponsorSlot": {
|
||||
"partner_heading": "Diventa partner di VoiceStudio",
|
||||
"partner_subtitle": "Presenta il tuo prodotto a chi crea con la voce.",
|
||||
"app_placement": "Visibilità nell’app",
|
||||
"integration_page": "Pagina integrazione",
|
||||
"readme_exposure": "Visibilità nel README",
|
||||
"visibility": "Visibilità",
|
||||
"integration": "Integrazione del prodotto",
|
||||
"installs": "Installazioni dirette",
|
||||
|
||||
@@ -1907,6 +1907,7 @@
|
||||
"test_text": "こんにちは — これはこの音声のテストです。"
|
||||
},
|
||||
"nav": {
|
||||
"voice": "音声",
|
||||
"clone_short": "クローン",
|
||||
"workspaces": "ワークスペース",
|
||||
"stories": "ストーリー",
|
||||
@@ -2588,6 +2589,11 @@
|
||||
"fitting": "タイミングを調整中"
|
||||
},
|
||||
"sponsorSlot": {
|
||||
"partner_heading": "VoiceStudio のパートナーに",
|
||||
"partner_subtitle": "音声で新しいものを作る人に製品を届けましょう。",
|
||||
"app_placement": "アプリ内掲載",
|
||||
"integration_page": "連携ページ",
|
||||
"readme_exposure": "README 掲載",
|
||||
"visibility": "認知度",
|
||||
"integration": "製品連携",
|
||||
"installs": "直接インストール",
|
||||
|
||||
@@ -1907,6 +1907,7 @@
|
||||
"test_text": "안녕하세요. 이 목소리에 대한 테스트입니다."
|
||||
},
|
||||
"nav": {
|
||||
"voice": "음성",
|
||||
"clone_short": "복제",
|
||||
"workspaces": "작업 공간",
|
||||
"stories": "스토리",
|
||||
@@ -2588,6 +2589,11 @@
|
||||
"fitting": "타이밍 조정 중"
|
||||
},
|
||||
"sponsorSlot": {
|
||||
"partner_heading": "VoiceStudio와 파트너 되기",
|
||||
"partner_subtitle": "음성으로 만드는 사람들에게 제품을 소개하세요.",
|
||||
"app_placement": "앱 내 노출",
|
||||
"integration_page": "통합 페이지",
|
||||
"readme_exposure": "README 노출",
|
||||
"visibility": "가시성",
|
||||
"integration": "제품 통합",
|
||||
"installs": "직접 설치",
|
||||
|
||||
@@ -1907,6 +1907,7 @@
|
||||
"test_text": "Hallo – dit is een test van deze stem."
|
||||
},
|
||||
"nav": {
|
||||
"voice": "Stem",
|
||||
"clone_short": "Klonen",
|
||||
"workspaces": "Werkruimtes",
|
||||
"stories": "Verhalen",
|
||||
@@ -2588,6 +2589,11 @@
|
||||
"fitting": "Timing aanpassen"
|
||||
},
|
||||
"sponsorSlot": {
|
||||
"partner_heading": "Word partner van VoiceStudio",
|
||||
"partner_subtitle": "Bereik mensen die met spraak bouwen.",
|
||||
"app_placement": "Plaatsing in de app",
|
||||
"integration_page": "Integratiepagina",
|
||||
"readme_exposure": "Vermelding in README",
|
||||
"visibility": "Zichtbaarheid",
|
||||
"integration": "Productintegratie",
|
||||
"installs": "Directe installaties",
|
||||
|
||||
@@ -1911,6 +1911,7 @@
|
||||
"test_text": "Witamy — to jest test tego głosu."
|
||||
},
|
||||
"nav": {
|
||||
"voice": "Głos",
|
||||
"clone_short": "Klonuj",
|
||||
"workspaces": "Obszary robocze",
|
||||
"stories": "Historie",
|
||||
@@ -2592,6 +2593,11 @@
|
||||
"fitting": "Dostosowywanie czasu"
|
||||
},
|
||||
"sponsorSlot": {
|
||||
"partner_heading": "Zostań partnerem VoiceStudio",
|
||||
"partner_subtitle": "Przedstaw produkt osobom tworzącym z użyciem głosu.",
|
||||
"app_placement": "Obecność w aplikacji",
|
||||
"integration_page": "Strona integracji",
|
||||
"readme_exposure": "Obecność w README",
|
||||
"visibility": "Widoczność",
|
||||
"integration": "Integracja produktu",
|
||||
"installs": "Instalacje bezpośrednie",
|
||||
|
||||
@@ -1909,6 +1909,7 @@
|
||||
"test_text": "Olá - este é um teste desta voz."
|
||||
},
|
||||
"nav": {
|
||||
"voice": "Voz",
|
||||
"clone_short": "Clonar",
|
||||
"workspaces": "Áreas de trabalho",
|
||||
"stories": "Histórias",
|
||||
@@ -2590,6 +2591,11 @@
|
||||
"fitting": "Ajustando o tempo"
|
||||
},
|
||||
"sponsorSlot": {
|
||||
"partner_heading": "Seja parceiro do VoiceStudio",
|
||||
"partner_subtitle": "Apresente seu produto a quem cria com voz.",
|
||||
"app_placement": "Destaque no app",
|
||||
"integration_page": "Página de integração",
|
||||
"readme_exposure": "Visibilidade no README",
|
||||
"visibility": "Visibilidade",
|
||||
"integration": "Integração do produto",
|
||||
"installs": "Instalações diretas",
|
||||
|
||||
@@ -1911,6 +1911,7 @@
|
||||
"test_text": "Привет — это тест этого голоса."
|
||||
},
|
||||
"nav": {
|
||||
"voice": "Голос",
|
||||
"clone_short": "Клонировать",
|
||||
"workspaces": "Рабочие пространства",
|
||||
"stories": "Истории",
|
||||
@@ -2592,6 +2593,11 @@
|
||||
"fitting": "Настройка времени"
|
||||
},
|
||||
"sponsorSlot": {
|
||||
"partner_heading": "Станьте партнёром VoiceStudio",
|
||||
"partner_subtitle": "Представьте продукт тем, кто создаёт с помощью голоса.",
|
||||
"app_placement": "Размещение в приложении",
|
||||
"integration_page": "Страница интеграции",
|
||||
"readme_exposure": "Размещение в README",
|
||||
"visibility": "Видимость",
|
||||
"integration": "Интеграция продукта",
|
||||
"installs": "Прямые установки",
|
||||
|
||||
@@ -1907,6 +1907,7 @@
|
||||
"test_text": "Hej – det här är ett test av denna röst."
|
||||
},
|
||||
"nav": {
|
||||
"voice": "Röst",
|
||||
"clone_short": "Klona",
|
||||
"workspaces": "Arbetsytor",
|
||||
"stories": "Berättelser",
|
||||
@@ -2588,6 +2589,11 @@
|
||||
"fitting": "Justerar tidsplacering"
|
||||
},
|
||||
"sponsorSlot": {
|
||||
"partner_heading": "Bli partner med VoiceStudio",
|
||||
"partner_subtitle": "Visa din produkt för dem som skapar med röst.",
|
||||
"app_placement": "Placering i appen",
|
||||
"integration_page": "Integrationssida",
|
||||
"readme_exposure": "Synlighet i README",
|
||||
"visibility": "Synlighet",
|
||||
"integration": "Produktintegration",
|
||||
"installs": "Direkta installationer",
|
||||
|
||||
@@ -1907,6 +1907,7 @@
|
||||
"test_text": "สวัสดี — นี่คือการทดสอบเสียงนี้"
|
||||
},
|
||||
"nav": {
|
||||
"voice": "เสียง",
|
||||
"clone_short": "โคลน",
|
||||
"workspaces": "พื้นที่ทำงาน",
|
||||
"stories": "เรื่องราว",
|
||||
@@ -2588,6 +2589,11 @@
|
||||
"fitting": "กำลังปรับเวลา"
|
||||
},
|
||||
"sponsorSlot": {
|
||||
"partner_heading": "เป็นพันธมิตรกับ VoiceStudio",
|
||||
"partner_subtitle": "แนะนำผลิตภัณฑ์ของคุณให้ผู้ที่สร้างสรรค์ด้วยเสียง",
|
||||
"app_placement": "แสดงในแอป",
|
||||
"integration_page": "หน้าการเชื่อมต่อ",
|
||||
"readme_exposure": "แสดงใน README",
|
||||
"visibility": "การมองเห็น",
|
||||
"integration": "การผสานรวมผลิตภัณฑ์",
|
||||
"installs": "ติดตั้งโดยตรง",
|
||||
|
||||
@@ -1907,6 +1907,7 @@
|
||||
"test_text": "Merhaba — bu, bu sesin bir testidir."
|
||||
},
|
||||
"nav": {
|
||||
"voice": "Ses",
|
||||
"clone_short": "Klonla",
|
||||
"workspaces": "Çalışma alanları",
|
||||
"stories": "Hikayeler",
|
||||
@@ -2588,6 +2589,11 @@
|
||||
"fitting": "Zamanlama ayarlanıyor"
|
||||
},
|
||||
"sponsorSlot": {
|
||||
"partner_heading": "VoiceStudio ile ortak olun",
|
||||
"partner_subtitle": "Ürününüzü sesle çalışan geliştiricilere tanıtın.",
|
||||
"app_placement": "Uygulama içi yerleşim",
|
||||
"integration_page": "Entegrasyon sayfası",
|
||||
"readme_exposure": "README görünürlüğü",
|
||||
"visibility": "Görünürlük",
|
||||
"integration": "Ürün entegrasyonu",
|
||||
"installs": "Doğrudan kurulumlar",
|
||||
|
||||
@@ -1911,6 +1911,7 @@
|
||||
"test_text": "Привіт — це перевірка цього голосу."
|
||||
},
|
||||
"nav": {
|
||||
"voice": "Голос",
|
||||
"clone_short": "Клонувати",
|
||||
"workspaces": "Робочі простори",
|
||||
"stories": "оповідання",
|
||||
@@ -2592,6 +2593,11 @@
|
||||
"fitting": "Налаштування часу"
|
||||
},
|
||||
"sponsorSlot": {
|
||||
"partner_heading": "Станьте партнером VoiceStudio",
|
||||
"partner_subtitle": "Представте продукт тим, хто створює за допомогою голосу.",
|
||||
"app_placement": "Розміщення в застосунку",
|
||||
"integration_page": "Сторінка інтеграції",
|
||||
"readme_exposure": "Розміщення в README",
|
||||
"visibility": "Видимість",
|
||||
"integration": "Інтеграція продукту",
|
||||
"installs": "Прямі встановлення",
|
||||
|
||||
@@ -1907,6 +1907,7 @@
|
||||
"test_text": "Xin chào - đây là bài kiểm tra giọng nói này."
|
||||
},
|
||||
"nav": {
|
||||
"voice": "Giọng nói",
|
||||
"clone_short": "Nhân bản",
|
||||
"workspaces": "Không gian làm việc",
|
||||
"stories": "Truyện",
|
||||
@@ -2588,6 +2589,11 @@
|
||||
"fitting": "Đang điều chỉnh thời gian"
|
||||
},
|
||||
"sponsorSlot": {
|
||||
"partner_heading": "Hợp tác cùng VoiceStudio",
|
||||
"partner_subtitle": "Giới thiệu sản phẩm đến những người sáng tạo bằng giọng nói.",
|
||||
"app_placement": "Hiển thị trong ứng dụng",
|
||||
"integration_page": "Trang tích hợp",
|
||||
"readme_exposure": "Xuất hiện trong README",
|
||||
"visibility": "Khả năng hiển thị",
|
||||
"integration": "Tích hợp sản phẩm",
|
||||
"installs": "Cài đặt trực tiếp",
|
||||
|
||||
@@ -1911,6 +1911,7 @@
|
||||
"test_text": "你好——这是对该声音的测试。"
|
||||
},
|
||||
"nav": {
|
||||
"voice": "声音",
|
||||
"clone_short": "克隆",
|
||||
"workspaces": "工作区",
|
||||
"stories": "故事",
|
||||
@@ -2592,6 +2593,11 @@
|
||||
"fitting": "正在调整时间"
|
||||
},
|
||||
"sponsorSlot": {
|
||||
"partner_heading": "成为 VoiceStudio 合作伙伴",
|
||||
"partner_subtitle": "让使用语音进行创作的人发现你的产品。",
|
||||
"app_placement": "应用内展示",
|
||||
"integration_page": "集成专页",
|
||||
"readme_exposure": "README 展示",
|
||||
"visibility": "品牌曝光",
|
||||
"integration": "产品集成",
|
||||
"installs": "直接安装",
|
||||
|
||||
@@ -1907,6 +1907,7 @@
|
||||
"test_text": "你好——這是對這個聲音的測試。"
|
||||
},
|
||||
"nav": {
|
||||
"voice": "聲音",
|
||||
"clone_short": "複製",
|
||||
"workspaces": "工作區",
|
||||
"stories": "故事",
|
||||
@@ -2588,6 +2589,11 @@
|
||||
"fitting": "正在調整時間"
|
||||
},
|
||||
"sponsorSlot": {
|
||||
"partner_heading": "成為 VoiceStudio 合作夥伴",
|
||||
"partner_subtitle": "讓使用語音進行創作的人發現你的產品。",
|
||||
"app_placement": "應用程式內展示",
|
||||
"integration_page": "整合專頁",
|
||||
"readme_exposure": "README 展示",
|
||||
"visibility": "品牌曝光",
|
||||
"integration": "產品整合",
|
||||
"installs": "直接安裝",
|
||||
|
||||
@@ -124,7 +124,7 @@ export type ThemeDefinition = Readonly<{
|
||||
|
||||
export const T3_CHAT_THEME: ThemeDefinition = {
|
||||
id: 'signal',
|
||||
label: 'Signal',
|
||||
label: 'VoiceStudio Original',
|
||||
appearance: 'light',
|
||||
colors: {
|
||||
canvas: 'oklch(0.982446 0.010114 325.653)',
|
||||
|
||||
@@ -12,18 +12,18 @@ describe('theme preferences', () => {
|
||||
it('preserves the old appearance and tolerates malformed saved preferences', () => {
|
||||
expect(parseThemePreferences('{', 'light')).toEqual({
|
||||
mode: 'light',
|
||||
light: 'default',
|
||||
dark: 'default',
|
||||
light: 'signal',
|
||||
dark: 'signal',
|
||||
});
|
||||
expect(parseThemePreferences('{"mode":"invalid","light":"missing","dark":"current"}')).toEqual({
|
||||
mode: 'dark',
|
||||
light: 'default',
|
||||
light: 'signal',
|
||||
dark: 'current',
|
||||
});
|
||||
expect(parseThemePreferences('null')).toEqual({
|
||||
mode: 'dark',
|
||||
light: 'default',
|
||||
dark: 'default',
|
||||
light: 'signal',
|
||||
dark: 'signal',
|
||||
});
|
||||
});
|
||||
it('resolves independent halves when the operating system changes', () => {
|
||||
|
||||
@@ -32,7 +32,7 @@ export function parseThemePreferences(
|
||||
const id = typeof value === 'string' ? (aliases[value] ?? value) : value;
|
||||
return typeof id === 'string' && AVAILABLE_PALETTES.some((theme) => theme.id === id)
|
||||
? id
|
||||
: 'default';
|
||||
: 'signal';
|
||||
};
|
||||
return {
|
||||
mode: ['light', 'dark', 'system'].includes(value.mode ?? '')
|
||||
|
||||
@@ -628,3 +628,28 @@
|
||||
html[data-theme-id='heritage'] {
|
||||
--glass-light-color: #ad82d9;
|
||||
}
|
||||
|
||||
/* Keep controls opaque: Glass mode clears the bg-background surface utility. */
|
||||
[data-slot='switch'].app-switch {
|
||||
background: color-mix(in srgb, var(--foreground) 32%, var(--background));
|
||||
border-color: color-mix(in srgb, var(--foreground) 55%, var(--background));
|
||||
transition-property: background-color, border-color, box-shadow;
|
||||
}
|
||||
[data-slot='switch'].app-switch[data-checked] {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
[data-slot='switch-thumb'].app-switch-thumb {
|
||||
background: var(--foreground);
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 25%);
|
||||
}
|
||||
[data-slot='switch'][data-checked] .app-switch-thumb {
|
||||
background: var(--primary-foreground);
|
||||
}
|
||||
[data-slot='switch'].app-switch:focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-slot='switch'].app-switch { transition: none; }
|
||||
}
|
||||
|
||||
@@ -12,9 +12,10 @@ const browser = await chromium.launch({ headless: true, ...(process.env.CHROMIUM
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 960 }, deviceScaleFactor: 1, colorScheme: 'dark', recordVideo: { dir: '/tmp/voicestudio-readme-video', size: { width: 1440, height: 960 } } });
|
||||
await context.addInitScript(() => {
|
||||
localStorage.setItem('voicestudio.setup.complete.v1', '1');
|
||||
localStorage.setItem('voicestudio.theme.v2', JSON.stringify({mode:'dark',light:'default',dark:'default'}));
|
||||
localStorage.setItem('voicestudio.theme.v2', JSON.stringify({mode:'dark',light:'signal',dark:'signal'}));
|
||||
localStorage.setItem('voicestudio.clone.settings.v1', JSON.stringify({selectedProfileId:'demo0001',text:'Every voice has a story. Bring yours to life with VoiceStudio — created on your machine, in your own way.'}));
|
||||
localStorage.setItem('omnivoice.demoClonePrompted', '1');
|
||||
localStorage.setItem('voicestudio.appearance', JSON.stringify({font:'inter',scale:100,glass:true}));
|
||||
});
|
||||
// Capture only bundled demo voices; never publish personal voices or project history.
|
||||
await context.route('**/api/**', async route => {
|
||||
@@ -37,6 +38,12 @@ try {
|
||||
for (const [route, file] of [['/design','voice-design'],['/dub','dubbing'],['/settings/models','models']]) {
|
||||
await page.evaluate(route => {location.hash=route;},route);
|
||||
await page.waitForTimeout(1800);
|
||||
if (route === '/design') {
|
||||
const fields = page.locator('textarea');
|
||||
await page.getByRole('button', {name:'Narrator',exact:true}).click();
|
||||
if (await fields.count() > 1) await fields.last().fill('Beyond the city lights, a quieter world begins. Every trail holds a story, and every journey starts with a little curiosity.');
|
||||
await page.waitForTimeout(1600);
|
||||
}
|
||||
if (route === '/dub') {
|
||||
await page.getByRole('button', {name:'Play',exact:true}).first().click();
|
||||
await page.waitForTimeout(2200);
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
---
|
||||
name: omnivoice
|
||||
description: Speak and transcribe through the user's local VoiceStudio — free, offline, no API key. Text-to-speech (including the user's cloned voices) and speech-to-text via the OpenAI-compatible API at localhost:3900.
|
||||
---
|
||||
|
||||
# VoiceStudio — local TTS & STT
|
||||
|
||||
The user runs [VoiceStudio](https://github.com/debpalash/VoiceStudio), a fully-local voice app exposing an OpenAI-compatible audio API at `http://localhost:3900/v1`. Use it whenever the user asks to generate speech, narrate text, clone a voice, or transcribe audio — it costs nothing, works offline, and their audio never leaves the machine.
|
||||
|
||||
## Before the first call
|
||||
|
||||
Check the backend is up:
|
||||
|
||||
```sh
|
||||
curl -sf http://localhost:3900/health
|
||||
```
|
||||
|
||||
If it fails, tell the user to launch VoiceStudio (or `bun run desktop-prod` from a source checkout) — don't fall back to a cloud API without asking; local-first is why they installed it.
|
||||
|
||||
## Text-to-speech
|
||||
|
||||
```sh
|
||||
curl -s http://localhost:3900/v1/audio/speech \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model": "tts-1", "voice": "alloy", "input": "TEXT HERE", "response_format": "wav"}' \
|
||||
--output speech.wav
|
||||
```
|
||||
|
||||
- `model`: `tts-1` or `tts-1-hd` — both map to the user's active TTS engine.
|
||||
- `voice`: OpenAI names (`alloy`, `echo`, `nova`, …) work, **but the real power is the user's own cloned voice-profile IDs** — discover them first (below) and prefer a named clone when the user says "my voice" / "the narrator voice" / a profile by name.
|
||||
- `response_format`: `wav`, `mp3`, `flac`, `opus`, or `pcm`.
|
||||
- Long texts are fine — the engine chunks at sentence boundaries internally.
|
||||
|
||||
## Discover the user's voices
|
||||
|
||||
```sh
|
||||
curl -s http://localhost:3900/v1/audio/voices
|
||||
```
|
||||
|
||||
Lists every cloned/designed voice profile (id + name) and the installed engines. Use a profile's id as the `voice` value in `/speech`.
|
||||
|
||||
## Speech-to-text
|
||||
|
||||
```sh
|
||||
curl -s http://localhost:3900/v1/audio/transcriptions \
|
||||
-F file=@clip.wav -F model=whisper-1 -F response_format=json
|
||||
```
|
||||
|
||||
- `model`: `whisper-1` maps to the active ASR engine (WhisperX by default; the user picks in Model Catalogue → Engines).
|
||||
- `response_format`: `json`, `text`, `verbose_json` (per-segment timestamps), `srt`, or `vtt` — use `srt`/`vtt` directly when the user wants subtitles.
|
||||
|
||||
## Python (openai SDK)
|
||||
|
||||
```python
|
||||
from openai import OpenAI
|
||||
client = OpenAI(base_url="http://localhost:3900/v1", api_key="none") # any string; nothing checks it
|
||||
|
||||
audio = client.audio.speech.create(model="tts-1", voice="alloy", input="Hello!", response_format="wav")
|
||||
text = client.audio.transcriptions.create(model="whisper-1", file=open("clip.wav", "rb")).text
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- **No API key, no rate limits, no billing** — it's the user's own hardware. First synthesis after a cold start may take longer (model loading); subsequent calls are fast.
|
||||
- Anything beyond speech/transcription (video dubbing, batch jobs, voice design, audiobooks) lives in the full REST API — the interactive reference is embedded in the app at **Settings → OpenAPI Reference**, or ask the user to open it.
|
||||
- If a call errors with an engine/model message, the actionable detail is usually in the response body — surface it to the user verbatim; VoiceStudio's errors are written to be user-fixable (e.g. which Settings toggle to flip).
|
||||
@@ -1,56 +1,42 @@
|
||||
---
|
||||
name: oss-maintainer
|
||||
description: Run an open-source project's issue/PR/release loop like a careful human maintainer — triage to root cause, absorb community PRs before duplicating them, gate every merge, ship honest releases, and thank the people doing your QA for free.
|
||||
description: Triage GitHub issues, review contributor pull requests, diagnose CI, and prepare explicitly requested releases using the target repository's rules. Use for repository maintenance work, not ordinary VoiceStudio audio generation.
|
||||
---
|
||||
|
||||
# OSS Maintainer
|
||||
# OSS maintenance
|
||||
|
||||
You are operating an open-source project's maintenance loop: incoming issues, community PRs, CI, releases, and community channels. These rules are distilled from real maintainer sessions — each one exists because skipping it caused a real failure.
|
||||
Read the target repository's AGENTS.md, CLAUDE.md, contribution guide, and release documentation first. Their policies override this general workflow. Installing this skill does not authorize merges, releases, issue closures, or messages to contributors.
|
||||
|
||||
## The prime directive
|
||||
## Triage and implementation
|
||||
|
||||
**The queue has two exit states: absorbed or declined. Never limbo.** Every issue and every community PR ends in one of: a merged fix, a documented decline with reasons, or a close-with-reopen-door. "Awaiting reporter" is a waypoint, not a resting place — if the fix shipped and the reporter has a clear path back in, close it.
|
||||
- Inspect current code and existing PRs before duplicating a reported fix. Reuse contributor work where it solves the problem; retain attribution.
|
||||
- Reproduce the reported behavior where possible. Separate confirmed failures from diagnosis, and record meaningful limitations in the PR.
|
||||
- Fix the cause with the smallest complete change. Test observable behavior where it can regress; do not mistake a source-text assertion for an end-to-end test.
|
||||
- Preserve local changes. Use an isolated worktree when branch switching would disturb them.
|
||||
- Read existing CI failures and reviewer comments before rerunning work. Diagnose failures before retries; never hide a failing gate behind a successful piped command.
|
||||
- Keep user-facing docs and required translations aligned with the implementation.
|
||||
|
||||
## Before you write any fix
|
||||
## Review and authorized landing
|
||||
|
||||
1. **Check the open-PR queue first.** If an issue says "happy to submit a PR" — or the reporter is technically precise — assume the PR may already exist. Run `gh pr list` and search before implementing. Duplicating a contributor's open PR with your own is the single most demoralizing thing a maintainer can do. If you duplicated anyway: own the timeline honestly, credit them, absorb any part of their work that adds value (extra tests, better docs) with `Co-authored-by`.
|
||||
2. **Read the actual code, not your memory of it.** Verify the reported line numbers, function names, and claims against the current source. Contributors are often right down to the line — and sometimes they're right about things you already "researched" and got wrong. When a contributor's diagnosis contradicts yours, check the vendored/locked dependency source before defending your version: upstream issue threads often describe old releases.
|
||||
3. **Reproduce when possible; say so when you can't.** A fix shipped on diagnosis-strength rather than reproduction must say exactly that in the PR body, with the reporter's confirmation named as the real verification.
|
||||
Review the current diff, bot findings, and required checks against current main. Resolve material findings on the PR branch before landing; do not merge first and promise a follow-up. Refresh stale branches using the repository's policy and preserve contributor commits.
|
||||
|
||||
## Fix quality bar
|
||||
Before an authorized merge, verify required checks are green, the head has not changed, and the PR is mergeable. After landing, inspect main's own runs; investigate regressions immediately. Report remaining blockers without inventing a successful verification.
|
||||
|
||||
- **Root-cause fully, then fix the class, not the instance.** If one call site dropped a parameter, grep for every sibling call site. If one error message lied, audit the whole error surface.
|
||||
- **Every fix carries a fail-before/pass-after regression test.** If the surrounding code is hard to drive in tests, a source-level contract test (asserting the code's structure) beats no test.
|
||||
- **Harden against recurrence.** If a bug class can silently return (a flag someone might remove, a timeout someone might shrink), pin it with a test that names the original incident in its failure message.
|
||||
- The smallest correct change that is also recurrence-proof. Extra effort, not extra verbosity.
|
||||
## Release preparation
|
||||
|
||||
## Merging: gates, not vibes
|
||||
Preparing CI or packaging is distinct from publishing. Do not tag, bump versions, enable publishing, or cut a release unless that action is authorized.
|
||||
|
||||
- Merge on a **structural check**, evaluated at merge time, never sequentially assumed: required test check = pass AND mergeable = clean. Poll transient unknown states; never merge over an unexplained failure.
|
||||
- **Flaky vs. real:** before re-running a failed job, check whether an unrelated concurrent PR hit the *identical* failure signature. Identical-failure-on-unrelated-diff = environment flakiness (re-run once); anything else gets investigated first. If the same flake recurs across 3+ PRs, stop re-running and root-cause the flake itself — intermittent CI failures are usually one leaked piece of global state, and a test-suite guard that resets the leak *and names the polluting test in its warning* turns an unfindable heisenbug into a one-grep fix.
|
||||
- **Never trust a piped exit code.** `pytest | tail` exits with tail's status. Capture full output to a file and echo the real `$?` explicitly for anything gating a merge or release.
|
||||
- Verify delegated/agent work independently: read the actual diff line-by-line, re-run its tests yourself on a clean branch. Never relay an agent's own success claims as your verification.
|
||||
- Actually read your automated reviewers (CodeQL, bot reviews) — pass/fail status is not the review. Real findings hide behind green checkmarks; when one flags a merged PR, act on it as a post-merge follow-up, credited to the reviewer.
|
||||
|
||||
## Releases
|
||||
|
||||
- **Run the full gates BEFORE mutating any version file.** Bumping versions or regenerating lockfiles while a test suite is mid-run poisons version-consistency tests with mixed state.
|
||||
- Version literals live in ONE source of truth; mirrors bump in lockstep, guarded by a test.
|
||||
- **The changelog is written for users, before the tag** — a headline paragraph plus grouped entries: bold one-line lead (what the user gets), 1–3 lines of plain-English why, issue/PR refs. Never ship an auto-generated commit dump as release notes. Credit contributors by name in the headline when the release is theirs.
|
||||
- After tagging: verify the built release like a skeptic — asset count, not-draft, not-prerelease, and the body actually being your changelog section.
|
||||
- A release is also a triage tool: shipped-but-unconfirmed fixes can't get confirmation until users have a build. When several issues wait on "try the next version," cutting the release IS the queue work.
|
||||
Use the repository's version source of truth, changelog format, supported platforms, and distribution channels. Validate packaging and workflows without publishing when that is the requested scope. After an authorized release, verify actual artifacts, release notes, updater metadata, and requested registry channels; a green build alone does not prove distribution.
|
||||
|
||||
## Communication
|
||||
|
||||
- **Thank every issue and PR author — specifically.** Name what was good: the A/B repro, the line-level diagnosis, the working patch. Generic thanks reads as no thanks.
|
||||
- **Lead with the outcome, stay honest.** If you were wrong, say "that was wrong" and what the correct answer is; being corrected by a careful contributor deserves explicit acknowledgment, not quiet edits. If a close was premature, correct the record plainly — don't gloss.
|
||||
- Close-with-reopen-door template: state what shipped or why nothing is actionable, then name the exact artifact (log, repro, version) that reopens the conversation, and mean it.
|
||||
- Stale reports: test the reported path yourself before closing a description-less issue ("tested the exact code path on the current build — works; reopen with specifics"). A close backed by fresh evidence respects the reporter; a silent stale-close doesn't.
|
||||
- Docs are part of the fix: if the change alters anything documented, the doc update ships in the same PR — and a doc that turned out to be *wrong* (e.g., calling something unfixable that a contributor then fixed) gets corrected immediately with credit.
|
||||
Lead with the outcome and evidence. Credit concrete contributor work. Do not close stale issues merely because of age, or claim reporter confirmation that has not occurred. For authorized closures, give the resolution and what evidence would justify reopening.
|
||||
|
||||
## Judgment defaults
|
||||
## VoiceStudio-specific routing
|
||||
|
||||
- Old-version reports: ask the reporter to update past the relevant fixes before investigating deeply; close stale-version reports with an update path and reopen door.
|
||||
- Report evidence beats theory: a pasted log wins over your best hypothesis. Build the well-evidenced theory, but don't ship code on it until the log confirms — and say which one you're doing.
|
||||
- Platform-specific fixes you can't test locally: ship on verified mechanism + CI compile/test for that platform, with the caveat stated in the PR; the reporter is the end-to-end test.
|
||||
- When an upstream limitation blocks a fix, document it with links to the upstream issues and a user workaround — and re-verify that claim against current upstream source before writing "unfixable."
|
||||
When maintaining debpalash/VoiceStudio, consult its current rules rather than old architecture assumptions:
|
||||
- Electron development and packaging: `electron/README.md`, `electron/package.json`, and `.github/workflows/`.
|
||||
- Backend contracts: running `/openapi.json`, `backend/api/`, and targeted tests.
|
||||
- Release channels and version ownership: `docs/RELEASING.md` and CLAUDE.md. Never infer a version bump from a fix request.
|
||||
- Read CodeRabbit/Greptile findings and required CI before merging. Follow main's post-merge CI.
|
||||
- Preserve local-first behavior, cross-platform behavior, model-install consent, synthetic-audio marking, and localization requirements.
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
name: voicestudio
|
||||
description: Generate local speech with saved or designed voices, transcribe audio, and automate dubbing or narration workflows through VoiceStudio's REST API or MCP server. Use when the user wants to work with VoiceStudio audio or connect it to an agent or automation.
|
||||
---
|
||||
|
||||
# VoiceStudio
|
||||
|
||||
Use the user's running [VoiceStudio](https://github.com/debpalash/VoiceStudio) backend. Prefer local processing; remote workers, cloud translation, model downloads, and external integrations require the user's choice. Installed models can run offline; do not promise that every configured workflow is offline.
|
||||
|
||||
## Connect and discover
|
||||
|
||||
The default backend is `http://localhost:3900`; honor the user's configured address. Electron's renderer and development proxy are separate services, not the public backend API.
|
||||
|
||||
1. Check `GET /health`.
|
||||
2. Discover the running version's contracts with `GET /openapi.json`, and voices/engines with `GET /v1/audio/voices`. Do not invent profile IDs or infer installed models from a catalog listing.
|
||||
3. If unavailable, launch the installed app. For an existing source checkout, follow its Electron README (`bun install`, then `cd electron && bun run dev`). Do not install a second backend or overwrite an existing checkout.
|
||||
4. Local configurations may permit unauthenticated calls; protected deployments require the configured credentials. Treat 401/403 as authentication failures, not permission to disable auth. Never print tokens or use a placeholder key as if it were a real credential.
|
||||
|
||||
## Generate speech
|
||||
|
||||
Save this JSON to `speech-request.json`, replacing `voice` with a discovered profile ID when the user selected a voice:
|
||||
|
||||
```json
|
||||
{"model":"tts-1","voice":"alloy","input":"Every voice has a story. Let's tell yours.","response_format":"wav"}
|
||||
```
|
||||
|
||||
```sh
|
||||
curl --fail-with-body --show-error http://localhost:3900/v1/audio/speech \
|
||||
-H 'Content-Type: application/json' --data-binary @speech-request.json \
|
||||
--output speech.wav
|
||||
```
|
||||
|
||||
`tts-1` and `tts-1-hd` are aliases, not quality guarantees. OpenAI voice names are compatibility aliases, not those providers' actual voices. Prefer a discovered saved voice for repeatable narration. Check the installed engine's language and voice-design capabilities before promising a result.
|
||||
|
||||
Check the HTTP status and decode/probe the output before calling it audio: an error response can be written to the output path. Report the saved path and actual duration/format. Never fabricate a successful generation, transcript, or job completion.
|
||||
|
||||
## Transcribe
|
||||
|
||||
```sh
|
||||
curl --fail-with-body --show-error http://localhost:3900/v1/audio/transcriptions \
|
||||
-F file=@clip.wav -F model=whisper-1 -F response_format=verbose_json
|
||||
```
|
||||
|
||||
Use `json` or `text` for plain transcripts, `verbose_json` for timestamps, and `srt`/`vtt` for subtitles when supported by the running schema. Check transcription-model readiness before long recordings; explain a missing model and obtain download authorization rather than silently installing it.
|
||||
|
||||
## Cloning, design, dubbing, and longer workflows
|
||||
|
||||
Use the installed OpenAPI schema to discover profile creation, reference uploads, generation, dubbing, and long-form job operations. Their native endpoints are broader than the OpenAI compatibility API and evolve independently.
|
||||
|
||||
- Clone only voices the user is authorized to use. Preserve reference files and existing profiles.
|
||||
- For design, capture the requested tone, delivery, language, and sample text; check engine support.
|
||||
- For dubbing, retain original media, speaker assignments, segment timing, and background-audio intent. Translation completion is not rendered-dub completion.
|
||||
- For asynchronous work, use returned job IDs and documented progress/status endpoints. Inspect terminal errors; avoid blindly resubmitting timed-out generation.
|
||||
- Return usable files and honest limitations, including missing models or unavailable capabilities.
|
||||
|
||||
## MCP and automation
|
||||
|
||||
The running backend mounts an MCP endpoint at `http://localhost:3900/mcp`. Use the client's supported HTTP transport and discover its tools at runtime. Reuse an existing connection rather than spawning another service. For stdio-only clients, consult the project's [MCP guide](https://github.com/debpalash/VoiceStudio/blob/main/docs/mcp.md) for its shim.
|
||||
|
||||
For n8n, calling agents, containers, or other hosts, make the backend address reachable from that environment: container `localhost` refers to the container. Keep authentication and explicit remote-routing choices intact. An integration-directory listing does not mean the integration is connected.
|
||||
|
||||
On errors, read the response body, distinguish unavailable backend, missing model, unsupported capability, authentication, and busy hardware. Fix the reported condition; do not switch to a hosted provider or download a model without authorization.
|
||||
@@ -12,7 +12,7 @@ def test_readme_installs_skills_from_the_canonical_repository() -> None:
|
||||
|
||||
|
||||
def test_public_skill_surfaces_use_current_identity_and_license() -> None:
|
||||
canonical = (ROOT / "skills/omnivoice/SKILL.md").read_text(encoding="utf-8")
|
||||
canonical = (ROOT / "skills/voicestudio/SKILL.md").read_text(encoding="utf-8")
|
||||
claude = (ROOT / ".claude/skills/omnivoice/SKILL.md").read_text(encoding="utf-8")
|
||||
launcher = (
|
||||
ROOT / ".claude/skills/omnivoice/scripts/start-backend.sh"
|
||||
|
||||