mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-10-19 15:44:27 +00:00
feat(local): voice podcasts with server audio models
Remove the local-only gate on the audio slot, so a podcast can be voiced
by an audio model on an OpenAI-compatible server as well as by audio.cpp
on this computer.
- Speech client: one POST /audio/speech per line. Asks for WAV, switches
the episode to MP3 when the server refuses (OpenRouter), and joins the
lines into one file, splitting replies that hold several WAVs back to
back. A failure is not retried, since a retry would bill the episode
again.
- Model types: audio output follows models.json and the endpoint's
declared outputs, as for image and video. OpenRouter's `speech` output
maps to the audio slot, and the extra listing request asks for
output_modalities=all.
- Voices come from one place, llm/voices/: the local model's own list,
the server's GET /audio/voices, or voices the user adds in Settings.
Each added voice is played once before it is kept, in a new general
`settings` JSON column on selected_models (migration 0019), cleared
when the slot takes another model. No voice is guessed.
- Settings: "Voices for this model" under the model in use, with play and
remove, and a link to the model's page for OpenRouter only. The Try
dialog's voice field is optional; empty uses the server's default.
- Podcast brief: a "Voiced by {model} on {server}" line, otherwise the
local form's labels. A server model with no voices shows only
"Set up voices", and Generate is disabled.
- Test lines are spoken in the interface's language.
- Docs: studio, connections and data-model.
This commit is contained in:
parent
68ea7105bb
commit
2ee8e19137
69 changed files with 2827 additions and 396 deletions
|
|
@ -31,6 +31,7 @@ SurfSense, Studio, llama.cpp, GGUF, Hugging Face, API, MCP, and every model and
|
|||
| quiz | クイズ | 퀴즈 | 测验 | क्विज़ | Quiz † | cuestionario | Quiz | Quiz | Тест |
|
||||
| study guide | 学習ガイド | 학습 가이드 | 学习指南 | स्टडी गाइड | Lernzettel † | guía de estudio | Guide de révision | Guia de estudos | конспект |
|
||||
| podcast | ポッドキャスト | 팟캐스트 | 播客 | पॉडकास्ट | Podcast | podcast | Podcast | Podcast | подкаст |
|
||||
| speech (a model reading text aloud) | 音声合成 | 음성 | 语音 | वॉइस | Sprache | voz | voix | voz | речь |
|
||||
| workspace | ワークスペース | 워크스페이스 | 工作区 | वर्कस्पेस | Workspace | espacio de trabajo | espace de travail | Workspace | рабочая область |
|
||||
| artifact (a Studio output) | 生成物 | 생성물 | 产物 | आर्टिफ़ैक्ट | Artefakt | artefacto | artefact | artefato | артефакт |
|
||||
| chunk (a cited passage) | チャンク | 청크 | 片段 | चंक | Abschnitt | fragmento | extrait | trecho | фрагмент |
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ A base URL must be `http` or `https` with a host, and may not carry credentials,
|
|||
| `GET` | `/llm/connections/{connection_id}/models` | the endpoint's live model list |
|
||||
| `POST` | `/llm/connections/{connection_id}/chat-test` | one short answer from a chosen model |
|
||||
| `POST` | `/llm/connections/{connection_id}/image-test` | one image from a chosen model |
|
||||
| `POST` | `/llm/connections/{connection_id}/speech-test` | one spoken line from a chosen model |
|
||||
|
||||
The write body:
|
||||
|
||||
|
|
@ -80,11 +81,11 @@ The write body:
|
|||
|
||||
## Live models
|
||||
|
||||
`GET .../models` calls `{base_url}/models` and, in parallel, `{base_url}/models?output_modalities=image`. A valid answer to the second is merged in and any failure of it is ignored: OpenRouter's default listing leaves out most of its image models, and an endpoint that ignores the parameter returns the same set, so no provider has to be recognised. Ids are deduplicated within the connection and sorted. A failed baseline call is a `502`, and the connection stays.
|
||||
`GET .../models` calls `{base_url}/models` and, in parallel, `{base_url}/models?output_modalities=all`. A valid answer to the second is merged in and any failure of it is ignored: OpenRouter's default listing holds 464 of its 635 models and leaves out most of its image and all of its speech models, and an endpoint that ignores the parameter returns the same set, so no provider has to be recognised. Ids are deduplicated within the connection and sorted. A failed baseline call is a `502`, and the connection stays.
|
||||
|
||||
Each model's `types` come from the first of three sources that knows it, and nothing is guessed; `capability_source` says which:
|
||||
|
||||
1. `declared`: output modalities the endpoint publishes: text is `text_gen`, image `image_gen`, video `video_gen` and audio `audio_gen`.
|
||||
1. `declared`: output modalities the endpoint publishes: text is `text_gen`, image `image_gen`, video `video_gen`, and audio or OpenRouter's `speech` `audio_gen`. Any other word, such as OpenRouter's `transcription` or `embeddings`, is a known answer that fills no slot.
|
||||
2. `catalog`: the remote model manifest, [`catalog/remote/manifest/models.json`](../../surfsense_local/backend/modules/llm/catalog/remote/manifest/models.json). `scripts/refresh_remote_manifest.py` builds it offline from models.dev, keyed provider then model, with the evidence the classifier reads rather than a verdict; a person reviews the diff and commits it, and nothing fetches models.dev at runtime. A connection with a `catalog_provider` reads that provider's entry first. Otherwise, and for an id its provider does not carry, the lookup reads the maker's own entry when the id's prefix names one, otherwise the types every provider carrying the id agrees on, trying the full id and then its last path segment ([`lookup.py`](../../surfsense_local/backend/modules/llm/catalog/remote/manifest/lookup.py)). A model found with no types, such as an embedder, is known to fill no slot.
|
||||
3. `unknown`: neither source knows the id.
|
||||
|
||||
|
|
@ -142,6 +143,8 @@ Loading a connection runs the egress check for its host. The bundled sd-server s
|
|||
|
||||
- `chat-test` streams one answer from the chosen model, by default to a prompt asking for one short sentence, capped at 1,024 tokens and 600 characters, so a model whose capability is `unknown` can be seen answering before it becomes the chat model. An empty reply is a `502`, which is what a reasoning model returns when it spends the whole budget thinking.
|
||||
- `image-test` generates one image, by default a blue circle on white, through the real image client and returns the bytes with `Cache-Control: no-store`. It creates no artifact.
|
||||
- `speech-test` voices the `prompt` sent, the Try dialog's line in the interface's language, through the podcast's speech client, in the `voice` sent, and returns the audio with `Cache-Control: no-store`. Without one the request carries no `voice`, so the server speaks in its own default where it has one, and says so where it has none; no voice is assumed. The Try dialog has an optional voice field for audio models and plays the clip.
|
||||
- `GET`, `POST` and `DELETE /llm/selection/audio_gen/voices` read the audio selection's voices (the server's list, else the user's), add one once the server has voiced the `text` sent (`502` with the server's words when it refuses), and remove one. A model on this computer is a `409`.
|
||||
- Selecting a model never runs inference. The server group offers a test before the model is used, and "Use without testing" for a trusted internal endpoint, whose first real request then reports any error normally. Image tests run only on an explicit action, because they are real inference and may cost money.
|
||||
|
||||
## Where keys live
|
||||
|
|
@ -165,7 +168,6 @@ Keys are protected by envelope encryption ([ADR 0018](../adr/0018-keychain-envel
|
|||
- Each model section in Settings, Chat and Image, shows every connection as a group under the local models. A group loads its models only when opened, so a slow or failed endpoint does not hold up the others, and lists only those whose `selectable_for` includes that section's slot; the model in use, if it comes from that server, stays shown above the list whether the group is open or closed. Open, its list scrolls inside its own capped-height area rather than lengthening the page. A model's row shows its type chips, and a Vision chip when `reads_images` is true. A model is assigned after an optional test; an exact ID the listing lacks can be typed in. A new connection is added from **Use a server** on the section's **Add model** page. Adding and editing a connection happen in a dialog over the page. Saving a new connection returns to the list with its group open on its models, since choosing one is why it was added; saving an edit only refreshes.
|
||||
- Edit and Disconnect sit on each group. Edit opens the same form in that dialog. A connection serves every slot, so Disconnect names each model it will clear, Chat, Image or both, whichever section it is disconnected from.
|
||||
- Onboarding's model steps use the same groups and the same dialog; with nothing connected yet, their **Connect** opens the dialog directly, and saving opens the server page on the new connection's models.
|
||||
- A model type no server model can fill yet offers no servers anywhere, in Settings or onboarding, and its copy drops the mention: audio today, since no server model voices podcasts. The list is `serversCanServe()` in `features/models/remote/servers-can-serve.ts`.
|
||||
- The connection form picks a provider from the remote manifest, through `GET /llm/catalog/remote`, or "Local or custom server". A ready provider fills its URL, which stays editable so a proxy in front of it still works; a provider that needs account details asks for each field and builds the URL from its template; a provider that needs a URL leaves it to the user; an unreachable one is listed, disabled, with its reason. A provider that takes no key, a loopback server, hides the key field. "Local or custom server" leaves the URL to the user, since its port is whatever its owner set, with `http://localhost:11434/v1` as placeholder text only; a loopback server the manifest lists, such as LM Studio, fills the manifest's URL like any ready provider. The save sends `catalog_provider`.
|
||||
|
||||
## Known gaps
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ An artifact's searchable body is a `Document` with `document_type = ARTIFACT`; `
|
|||
| Table | Columns | Notes |
|
||||
|---|---|---|
|
||||
| `provider_connections` | `id`, `label`, `provider`, `base_url`, `catalog_provider`, `api_key_ciphertext`, timestamps | `label` unique case-insensitively; `provider` must be `openai_compatible`; `catalog_provider` is a remote manifest provider id or `custom`, since `0014`; the key is Fernet ciphertext since `0007` |
|
||||
| `selected_models` | `model_type`, `provider`, `connection_id`, `name`, `params_b`, `vendor`, `line`, `updated_at` | one row per model type: `text_gen`, `image_gen`, `image_edit`, `video_gen` or `audio_gen` |
|
||||
| `selected_models` | `model_type`, `provider`, `connection_id`, `name`, `params_b`, `vendor`, `line`, `settings`, `updated_at` | one row per model type: `text_gen`, `image_gen`, `image_edit`, `video_gen` or `audio_gen`; `settings` is JSON for what the user set that no endpoint states, keyed by the slice that owns each entry, and cleared when the slot takes another model |
|
||||
| `onboarding_completion` | `id`, `completed_at` | a singleton (`CHECK id = 1`) whose presence means onboarding is done |
|
||||
|
||||
- A CHECK on `selected_models` allows `llamacpp`, `sdcpp` and `audiocpp` only without a connection and `openai_compatible` only with one, and a second, `local_runtime_type`, lets `llamacpp` hold only `text_gen`, `sdcpp` only `image_gen`, `image_edit` and `video_gen`, and `audiocpp` only `audio_gen`. `connection_id` cascades, so deleting a connection clears exactly the selections that used it.
|
||||
|
|
@ -303,6 +303,7 @@ erDiagram
|
|||
| `0014` | `0014_connection_catalog_provider.py` | `provider_connections.catalog_provider`, `custom` for every existing connection; downgrading drops the column in place, because a table rebuild would cascade into `selected_models` |
|
||||
| `0015` | `0015_image_selection_by_build.py` | a local `image_gen` selection is renamed from the old list's name to its curated build's id (`stable-diffusion-1.5` to `v1-5-pruned_Q4_0`, and the two SDXL models); the map is frozen in the migration, and downgrading reverses it |
|
||||
| `0016` | `0016_local_audio_provider.py` | `selected_models` rebuilt so `audiocpp` may hold `audio_gen`, and only that, without a connection; downgrading drops an `audiocpp` selection |
|
||||
| `0019` | `0019_selection_settings.py` | `selected_models.settings`, a nullable JSON column added in place; its first entry is a server audio model's `voices` |
|
||||
|
||||
- Migrations run on every API start and are idempotent. Autogenerate is off: it renders a rename as a drop plus an add, which deletes a column's data silently, and `env.py` carries no `target_metadata`, so it cannot be used by accident.
|
||||
- SQLite cannot alter a CHECK constraint in place, so `0004`, `0009`, `0012` and `0013` copy `selected_models` into a new table.
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ choosing or clearing a model never touches.
|
|||
| `text_gen` | `llamacpp`, the bundled runtime | `openai_compatible`, with a `connection_id` | chat, titles, Studio's writing |
|
||||
| `image_gen` | `sdcpp`, the bundled sd-server | `openai_compatible`, with a `connection_id` | Studio's `image` and `infographic` |
|
||||
| `image_edit` | `sdcpp`, a model whose entry names `edit` | `openai_compatible`, with a `connection_id` | nothing yet |
|
||||
| `audio_gen` | `audiocpp`, the bundled audio.cpp server | `openai_compatible`, with a `connection_id`, which nothing reads yet | Studio's `podcast` |
|
||||
| `audio_gen` | `audiocpp`, the bundled audio.cpp server | `openai_compatible`, with a `connection_id` | Studio's `podcast` |
|
||||
| `video_gen` | `sdcpp`, a model whose entry has a `video` block | `openai_compatible`, with a `connection_id` | nothing yet |
|
||||
|
||||
A type no feature reads can still be chosen; the feature that first reads one
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ The catalog is a tuple of twelve `Format` rows in [`formats.py`](../../surfsense
|
|||
| `infographic` | Infographic | image_gen, text_gen | `media/visual/infographic/` | the image |
|
||||
|
||||
- `requires_model_types` is a tuple in the order the pipeline's `render()` takes its models. A format is available when every required model type has a selection; otherwise the reason names every missing one in a fixed reading order: "Needs a chat model", "Needs an image model", "Needs an audio model", or two of them joined, as "Needs a chat model and an audio model". Naming only the first missing type made selecting it look like the gate moving to the other.
|
||||
- `podcast` needs its `audio_gen` model on this computer: an audio model chosen from a server is refused with "Needs an audio model on this computer", because nothing calls a remote speech endpoint yet. Opening a podcast brief without an audio model answers `409` with "Needs an audio model". The app ships Kokoro and chooses it at startup when no audio model is chosen, so that answer needs the choice cleared since the last start, or a build without audio.cpp ([`local-models/selection.md`](local-models/selection.md)).
|
||||
- `podcast` runs with an `audio_gen` model on this computer or on a server. The format list and the brief read the model's voices without calling it, so neither needs the server's host allowed; the job does. Opening a podcast brief without an audio model answers `409` with "Needs an audio model". The app ships Kokoro and chooses it at startup when no audio model is chosen, so that answer needs the choice cleared since the last start, or a build without audio.cpp ([`local-models/selection.md`](local-models/selection.md)).
|
||||
- Which formats are available is the server's answer to what is selected, so the panel asks again whenever the chat selection changes or the settings dialog closes, since the image model is chosen inside settings and nothing else reports it.
|
||||
- An image selection can resolve to the bundled sd-server as well as to a remote connection, so needing an image model does not mean needing a key or a network.
|
||||
- [`tests/unit/worker/test_studio_job_router.py`](../../surfsense_local/backend/tests/unit/worker/test_studio_job_router.py) asserts that `job_router.py` names every catalog key and nothing else, that each key has a pipeline, and that each pipeline takes its models, the sources, the prompt and, for a format with options, the options.
|
||||
|
|
@ -61,7 +61,7 @@ Eight formats follow it:
|
|||
|
||||
Four formats break it. DOCX, PPTX, XLSX and PDF go through `office/`: the tier prompt asks the model to "write one standalone Python script" for the format's library (python-docx, python-pptx, xlsxwriter or ReportLab), with a `SKILL.md` of authoring guidance beside each format, and [`office/runner.py`](../../surfsense_local/backend/worker/studio/office/runner.py) runs the reply with `exec()` on a thread in the worker process. The script must leave the file's bytes in `output_bytes` and may set `title` and `summary`. A failing script goes back to the model with its error, for up to three attempts. The 120-second limit is a `thread.join`: it bounds how long the job waits, but it cannot stop a thread that ignores it, and the code runs with the worker's privileges. The module says so and names an out-of-process sandbox runner as the way up.
|
||||
|
||||
There is no Electron `printToPDF` and no ffmpeg in `surfsense_local`. A PDF is ReportLab through that path, and a podcast is one `audio/wav` joined from audio.cpp's WAV for each turn.
|
||||
There is no Electron `printToPDF` and no ffmpeg in `surfsense_local`. A PDF is ReportLab through that path, and a podcast is one `audio/wav` joined from each turn's WAV, or one `audio/mpeg` from a server that sends only MP3.
|
||||
|
||||
## Voicing a podcast
|
||||
|
||||
|
|
@ -73,6 +73,19 @@ There is no Electron `printToPDF` and no ffmpeg in `surfsense_local`. A PDF is R
|
|||
- **A turn the server fails ends the episode with the server's own words**: "audio.cpp could not voice turn 3 of 40: unknown Kokoro voice id: nobody". The server was reached, so this is not "The model could not be reached", which stays for a server that does not answer.
|
||||
- **The model is unloaded when voicing ends**, with `POST /v1/tasks/unload_all_models`, success or failure, so its memory is back before the chat model's next job. The server's five-minute idle unload is the backstop.
|
||||
|
||||
### On a server
|
||||
|
||||
[`providers/openai_compatible/speech.py`](../../surfsense_local/backend/modules/llm/providers/openai_compatible/speech.py) voices a podcast with an `audio_gen` model chosen from a connection:
|
||||
|
||||
- **The voices are the server's, or the user's, never guessed.** The OpenAI API has no route that lists voices and models.dev carries none, so the server is asked `GET {base}/audio/voices`, Kokoro-FastAPI's route ([`voice_list.py`](../../surfsense_local/backend/modules/llm/providers/openai_compatible/voice_list.py)), held for the process and never stored. A server that lists none, as OpenAI, Groq and OpenRouter do, takes the voices the user adds in Settings › Audio generation models, inside that server's section under its model in use, and in onboarding's server path alike: each is voiced once before it is kept, under `voices` in the selection's `settings`, which is cleared when the slot takes another model. For OpenRouter alone the section links the model's page, whose "Request fields" name its voices; no other provider publishes them at an address the app can build. One slice, [`llm/voices/`](../../surfsense_local/backend/modules/llm/voices/), answers which voices the chosen audio model offers, local or server, for the brief and Settings alike. The server is asked only once its host is allowed, and the format list never asks.
|
||||
- **Nothing says which languages a server's voice speaks**: not the API, not `/audio/voices`, not models.dev. So a voice is heard, in Settings and in the Try dialog, saying a line in the interface's language, the one the user likeliest writes in, and the brief offers a fixed list of script languages. A voice that cannot speak the chosen language reads it anyway, badly: no server reports it as an error.
|
||||
- **The brief reads like the local one.** It adds one line, "Voiced by {model} on {server}. Each line is billed by the provider.", and otherwise keeps the local labels; only its lists differ, the voices one plain list with no gender the server did not state. With no voices yet, the brief shows only a Set up voices button, which opens Settings, and Generate is held. The brief response carries `voices_source` (`local`, `server` or `saved`) and `voiced_by`.
|
||||
- **No voice is checked before drafting.** A listed voice came from the server and an added one was heard when it was added, so the brief's own check, that each speaker's voice is one of them, is what stands between the user and a voice the server refuses.
|
||||
- **Only OpenAI's `/audio/speech` is spoken.** ElevenLabs, Gemini and other providers with their own speech APIs are reached through a gateway that speaks it for them, OpenRouter or a LiteLLM proxy, rather than through an adapter per provider here.
|
||||
- **One `POST /audio/speech` per turn**, asking for `wav`, because only WAV takes a pause between speakers. When the first line's `wav` is refused, the episode asks for `mp3` instead: OpenRouter takes only `mp3` or `pcm`, and its `pcm` states no sample rate. The format is read from the bytes, not from what was asked. WAV turns join as audio.cpp's do, and a reply holding several WAVs back to back is one turn. MP3 turns join frame to frame without a pause, each turn's ID3 tag and Xing frame dropped so a player reads the whole episode's length. Anything else ends the episode with "the server answered, but not as WAV or MP3".
|
||||
- **Nothing is checked for memory**, since the server spends it.
|
||||
- **A failure is not retried.** A refused turn, an unreachable server or an unreadable answer ends the episode with the server's own words, and the job is not retried, because a retry would draft and bill the whole episode again.
|
||||
|
||||
## Grounding
|
||||
|
||||
Studio does not call `retrieve()`. [`shared/gather.py`](../../surfsense_local/backend/worker/studio/shared/gather.py) loads the selected documents' markdown in selection order, up to 24,000 characters in total, and [`shared/generate.py`](../../surfsense_local/backend/worker/studio/shared/generate.py) sends it as one user message, each document under its title as a heading, after the format's system prompt. The code marks the flat cap as a ceiling, fine for summarising a handful of local documents, with retrieval scoped to the selection as the way up.
|
||||
|
|
@ -114,7 +127,7 @@ Every pipeline returns a `Built`: a `title`, the `markdown` that is always the i
|
|||
|---|---|---|
|
||||
| `GET` | `/workspaces/{workspace_id}/studio/formats` | the catalog, each format with `available` and `unavailable_reason` |
|
||||
| `POST` | `/workspaces/{workspace_id}/studio/jobs` | `{format, document_ids, prompt?, options?}`; `201` with the artifact |
|
||||
| `GET` | `/workspaces/{workspace_id}/studio/podcast/brief` | the podcast brief and voices to review before submitting |
|
||||
| `GET` | `/workspaces/{workspace_id}/studio/podcast/brief` | the podcast brief to review before submitting, the model's `voices` (`null` when they are typed) and the `languages` it may use |
|
||||
| `GET` | `/workspaces/{workspace_id}/artifacts` | the workspace's artifacts, newest first |
|
||||
| `GET` | `/artifacts/{artifact_id}` | detail: the body, the files, quiz or flashcard progress |
|
||||
| `POST` | `/artifacts/{artifact_id}/regenerate` | run the job again; `202` |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
"""Keep what the user sets for a selected model that no endpoint states.
|
||||
|
||||
A JSON column, general to every slot, keyed by the slice that owns each entry.
|
||||
The first is a server audio model's voices, added by the user when the server
|
||||
lists none. SQLite adds a nullable column in place, with no table rebuild.
|
||||
|
||||
Revision ID: 0019
|
||||
Revises: 0018
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0019"
|
||||
down_revision: str | Sequence[str] | None = "0018"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("selected_models", sa.Column("settings", sa.JSON(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("selected_models") as table:
|
||||
table.drop_column("settings")
|
||||
|
|
@ -5,10 +5,17 @@ from pydantic import BaseModel, Field, StringConstraints, ValidationError
|
|||
from sqlalchemy.orm import Session
|
||||
|
||||
from modules.llm.providers.protocols import Voice
|
||||
from modules.llm.resolution import resolve_text_to_speech
|
||||
from modules.llm.voices.roster import speech_voices
|
||||
|
||||
MAX_SPEAKERS = 6
|
||||
DEFAULT_LANGUAGE = "en-US"
|
||||
# What a script may be written in when no voice states its languages, as a
|
||||
# server's voices never do. The product's choice of languages to offer, not a
|
||||
# claim about the model.
|
||||
SCRIPT_LANGUAGES = (
|
||||
"en", "ar", "de", "es", "fr", "hi", "id", "it", "ja", "ko", "nl", "pl",
|
||||
"pt", "ru", "sv", "tr", "uk", "vi", "zh",
|
||||
) # fmt: skip
|
||||
|
||||
|
||||
class Style(StrEnum):
|
||||
|
|
@ -44,10 +51,14 @@ Name = Annotated[
|
|||
]
|
||||
|
||||
|
||||
# A voice id as the model takes it: from its roster, or a server's.
|
||||
VoiceId = Annotated[str, StringConstraints(strip_whitespace=True, max_length=100)]
|
||||
|
||||
|
||||
class Speaker(BaseModel):
|
||||
name: Name
|
||||
role: Role
|
||||
voice: str
|
||||
voice: VoiceId
|
||||
|
||||
|
||||
class PodcastBrief(BaseModel):
|
||||
|
|
@ -59,10 +70,22 @@ class PodcastBrief(BaseModel):
|
|||
speakers: list[Speaker] = Field(min_length=1, max_length=MAX_SPEAKERS)
|
||||
|
||||
|
||||
def languages(voices: list[Voice]) -> list[str]:
|
||||
"""What the brief may be written in: what the roster speaks, in its order,
|
||||
or the script languages when no voice says."""
|
||||
stated = [lang for voice in voices for lang in voice.languages]
|
||||
return list(dict.fromkeys(stated)) if stated else list(SCRIPT_LANGUAGES)
|
||||
|
||||
|
||||
def _speaks(voice: Voice, language: str) -> bool:
|
||||
"""A voice that states no language is not refused one."""
|
||||
return not voice.languages or language in voice.languages
|
||||
|
||||
|
||||
def default_language(voices: list[Voice]) -> str:
|
||||
"""American English where a voice speaks it, else any English, else the
|
||||
first language the model's roster names: Kitten lists English as `en`."""
|
||||
spoken = [language for voice in voices for language in voice.languages]
|
||||
spoken = languages(voices)
|
||||
if DEFAULT_LANGUAGE in spoken:
|
||||
return DEFAULT_LANGUAGE
|
||||
english = [language for language in spoken if language.split("-")[0] == "en"]
|
||||
|
|
@ -70,27 +93,29 @@ def default_language(voices: list[Voice]) -> str:
|
|||
|
||||
|
||||
def proposed(voices: list[Voice], language: str | None = None) -> PodcastBrief:
|
||||
"""The defaults the form opens with: two speakers in a language they speak."""
|
||||
"""The defaults the form opens with: two speakers in a language they speak.
|
||||
A server model with no voices yet opens with one voiceless speaker, which
|
||||
the form shows as voices to set up."""
|
||||
language = language or default_language(voices)
|
||||
spoken = [voice for voice in voices if language in voice.languages][:2]
|
||||
chosen = [v.id for v in voices if _speaks(v, language)][:2] or [""]
|
||||
return PodcastBrief(
|
||||
language=language,
|
||||
speakers=[
|
||||
Speaker(name=_default_name(slot), role=_ROLE_BY_SLOT[slot], voice=voice.id)
|
||||
for slot, voice in enumerate(spoken)
|
||||
Speaker(name=_default_name(slot), role=_ROLE_BY_SLOT[slot], voice=voice)
|
||||
for slot, voice in enumerate(chosen)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def validate_options(session: Session, raw: dict | None) -> dict:
|
||||
"""The Format hook: the brief, checked against the chosen model's voices."""
|
||||
return validated(resolve_text_to_speech(session).voices(), raw).model_dump(
|
||||
mode="json"
|
||||
)
|
||||
return validated(speech_voices(session).voices, raw).model_dump(mode="json")
|
||||
|
||||
|
||||
def validated(voices: list[Voice], raw: dict | None) -> PodcastBrief:
|
||||
"""The brief a job may run with; raises ValueError with one readable line."""
|
||||
if not voices:
|
||||
raise ValueError("add voices for the audio model in Settings first")
|
||||
if raw is None:
|
||||
return proposed(voices)
|
||||
try:
|
||||
|
|
@ -100,10 +125,12 @@ def validated(voices: list[Voice], raw: dict | None) -> PodcastBrief:
|
|||
location = ".".join(str(part) for part in first["loc"])
|
||||
raise ValueError(f"{location}: {first['msg']}") from error
|
||||
|
||||
if brief.language not in languages(voices):
|
||||
raise ValueError(f"language: {brief.language} is not one to write in")
|
||||
by_id = {voice.id: voice for voice in voices}
|
||||
for speaker in brief.speakers:
|
||||
voice = by_id.get(speaker.voice)
|
||||
if voice is None or brief.language not in voice.languages:
|
||||
if voice is None or not _speaks(voice, brief.language):
|
||||
raise ValueError(f"{speaker.name}: pick a {brief.language} voice")
|
||||
if len({speaker.voice for speaker in brief.speakers}) < len(brief.speakers):
|
||||
raise ValueError("each speaker needs their own voice")
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
from api.dependencies import SessionDep
|
||||
from modules.artifacts.podcast.brief import PodcastBrief
|
||||
from modules.artifacts.podcast.brief import PodcastBrief, languages
|
||||
from modules.artifacts.podcast.service import open_brief
|
||||
from modules.llm.providers.protocols import Voice
|
||||
from modules.workspaces.dependencies import WorkspaceDep
|
||||
|
|
@ -10,9 +12,19 @@ from modules.workspaces.dependencies import WorkspaceDep
|
|||
router = APIRouter(tags=["studio"])
|
||||
|
||||
|
||||
class VoicedByRead(BaseModel):
|
||||
server: str
|
||||
model: str
|
||||
|
||||
|
||||
class BriefRead(BaseModel):
|
||||
brief: PodcastBrief
|
||||
# Empty only for a server model with no voices added yet.
|
||||
voices: list[Voice]
|
||||
voices_source: Literal["local", "server", "saved"]
|
||||
# The server that voices it; None for a model on this computer.
|
||||
voiced_by: VoicedByRead | None
|
||||
languages: list[str]
|
||||
|
||||
|
||||
@router.get(
|
||||
|
|
@ -21,5 +33,12 @@ class BriefRead(BaseModel):
|
|||
summary="The podcast brief to review before generating",
|
||||
)
|
||||
def podcast_brief(workspace: WorkspaceDep, session: SessionDep) -> BriefRead:
|
||||
brief, voices = open_brief(session, workspace)
|
||||
return BriefRead(brief=brief, voices=voices)
|
||||
brief, roster = open_brief(session, workspace)
|
||||
by = roster.voiced_by
|
||||
return BriefRead(
|
||||
brief=brief,
|
||||
voices=roster.voices,
|
||||
voices_source=roster.source,
|
||||
voiced_by=VoicedByRead(server=by.server, model=by.model) if by else None,
|
||||
languages=languages(roster.voices),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ from sqlalchemy.orm import Session
|
|||
|
||||
from modules.artifacts.models import Artifact
|
||||
from modules.artifacts.podcast import brief
|
||||
from modules.llm.providers.protocols import Voice
|
||||
from modules.llm.resolution import ModelResolutionError, resolve_text_to_speech
|
||||
from modules.llm.resolution import ModelResolutionError
|
||||
from modules.llm.voices.roster import SpeechRoster, speech_voices
|
||||
from modules.workspaces.models import Workspace
|
||||
|
||||
FORMAT = "podcast"
|
||||
|
|
@ -13,14 +13,14 @@ FORMAT = "podcast"
|
|||
|
||||
def open_brief(
|
||||
session: Session, workspace: Workspace
|
||||
) -> tuple[brief.PodcastBrief, list[Voice]]:
|
||||
) -> tuple[brief.PodcastBrief, SpeechRoster]:
|
||||
"""The brief the form opens with, and the voices it may pick from.
|
||||
|
||||
The last episode's brief comes back when it still fits the catalog, so a
|
||||
returning user changes only what differs; otherwise the defaults.
|
||||
"""
|
||||
try:
|
||||
voices = resolve_text_to_speech(session).voices()
|
||||
roster = speech_voices(session)
|
||||
except ModelResolutionError as error:
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, "Needs an audio model") from error
|
||||
|
||||
|
|
@ -33,7 +33,7 @@ def open_brief(
|
|||
options = (last.artifact_metadata or {}).get("options") if last else None
|
||||
if options is not None:
|
||||
try:
|
||||
return brief.validated(voices, options), voices
|
||||
return brief.validated(roster.voices, options), roster
|
||||
except ValueError:
|
||||
pass
|
||||
return brief.proposed(voices), voices
|
||||
return brief.proposed(roster.voices), roster
|
||||
|
|
|
|||
|
|
@ -13,8 +13,7 @@ from modules.llm.model_type import ModelType
|
|||
from modules.llm.models import SelectedModel
|
||||
from modules.llm.resolution import (
|
||||
ModelResolutionError,
|
||||
VoiceNotLocalError,
|
||||
resolve_text_to_speech,
|
||||
speech_selected,
|
||||
)
|
||||
from modules.workspaces.models import Workspace
|
||||
from worker.jobs import cancel_studio_job
|
||||
|
|
@ -185,9 +184,7 @@ def _availability(session: Session, fmt: Format) -> tuple[bool, str | None]:
|
|||
return False, _required(missing)
|
||||
if ModelType.AUDIO_GEN in fmt.requires_model_types:
|
||||
try:
|
||||
resolve_text_to_speech(session)
|
||||
except VoiceNotLocalError:
|
||||
return False, "Needs an audio model on this computer"
|
||||
speech_selected(session)
|
||||
except ModelResolutionError:
|
||||
return False, _required([ModelType.AUDIO_GEN])
|
||||
return True, None
|
||||
|
|
|
|||
|
|
@ -23,6 +23,11 @@ from modules.llm.providers.openai_compatible import (
|
|||
OpenAICompatibleChatProvider,
|
||||
OpenAICompatibleImageProvider,
|
||||
)
|
||||
from modules.llm.providers.openai_compatible.speech import (
|
||||
NonRetryableSpeechError,
|
||||
RemoteSpeech,
|
||||
)
|
||||
from modules.llm.providers.protocols import SpokenTurn
|
||||
from modules.llm.providers.types import Message
|
||||
from modules.llm.schemas import (
|
||||
ChatTestRead,
|
||||
|
|
@ -36,6 +41,7 @@ from modules.llm.selectable import selectable_for
|
|||
router = APIRouter(prefix="/connections")
|
||||
|
||||
DEFAULT_IMAGE_TEST_PROMPT = "A simple blue circle centered on a plain white background."
|
||||
DEFAULT_SPEECH_TEST_TEXT = "Hello. This is how your podcasts will sound."
|
||||
DEFAULT_CHAT_TEST_PROMPT = "Reply with one short sentence confirming you can answer."
|
||||
# Enough that a thinking model reaches its answer, and still little enough that
|
||||
# testing cannot run a bill up. A plain answer never approaches this, because the
|
||||
|
|
@ -290,3 +296,31 @@ async def test_connection_image(
|
|||
media_type=image.media_type,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{connection_id}/speech-test")
|
||||
async def test_connection_speech(
|
||||
connection_id: int, payload: ModelTestWrite, session: SessionDep
|
||||
) -> Response:
|
||||
"""One line in the typed voice, or the server's default, so an audio model
|
||||
can be heard before it is chosen."""
|
||||
connection = await transact(session, allowed_connection, connection_id)
|
||||
model = _requested_model(payload)
|
||||
speech = RemoteSpeech(
|
||||
model,
|
||||
base_url=connection.base_url,
|
||||
api_key=connection.api_key,
|
||||
)
|
||||
# Empty leaves the voice to the server's default, where it has one.
|
||||
voice = (payload.voice or "").strip()
|
||||
line = SpokenTurn(voice, payload.prompt or DEFAULT_SPEECH_TEST_TEXT)
|
||||
try:
|
||||
# Only audio.cpp reads the language; a server infers it from the text.
|
||||
audio = await speech.synthesize([line], "")
|
||||
except NonRetryableSpeechError as error:
|
||||
raise HTTPException(status.HTTP_502_BAD_GATEWAY, str(error)) from error
|
||||
return Response(
|
||||
content=audio.content,
|
||||
media_type=audio.media_type,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ _DECLARED_AS = {
|
|||
"image": ModelType.IMAGE_GEN,
|
||||
"video": ModelType.VIDEO_GEN,
|
||||
"audio": ModelType.AUDIO_GEN,
|
||||
# OpenRouter's word for text-to-speech output.
|
||||
"speech": ModelType.AUDIO_GEN,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -114,10 +116,13 @@ def _classify(
|
|||
# The same answer the remote catalog gives, from the one rule in lookup.py.
|
||||
unusable = lookup.unusable_reason(name, catalog_provider)
|
||||
if modalities:
|
||||
# `audio` and `speech` name one type; a model declaring both has it once.
|
||||
declared = tuple(
|
||||
model_type
|
||||
for modality, model_type in _DECLARED_AS.items()
|
||||
if modality in modalities
|
||||
dict.fromkeys(
|
||||
model_type
|
||||
for modality, model_type in _DECLARED_AS.items()
|
||||
if modality in modalities
|
||||
)
|
||||
)
|
||||
return DiscoveredModel(name, declared, "declared", unusable)
|
||||
catalogued = lookup.classify(name, provider=catalog_provider)
|
||||
|
|
@ -138,9 +143,9 @@ async def _request_entries(
|
|||
base_url: str,
|
||||
api_key: str | None,
|
||||
*,
|
||||
image_only: bool = False,
|
||||
every_modality: bool = False,
|
||||
) -> dict[str, set[str]]:
|
||||
params = {"output_modalities": "image"} if image_only else None
|
||||
params = {"output_modalities": "all"} if every_modality else None
|
||||
async with httpx.AsyncClient(
|
||||
timeout=DISCOVERY_TIMEOUT,
|
||||
headers=key_headers(base_url, api_key),
|
||||
|
|
@ -158,7 +163,7 @@ async def probe_connection(base_url: str, api_key: str | None) -> list[Discovere
|
|||
async def discover_models(connection: ProviderConnection) -> list[DiscoveredModel]:
|
||||
baseline, optional = await asyncio.gather(
|
||||
_request_entries(connection.base_url, connection.api_key),
|
||||
_optional_image_entries(connection.base_url, connection.api_key),
|
||||
_optional_other_entries(connection.base_url, connection.api_key),
|
||||
)
|
||||
merged = {name: set(modalities) for name, modalities in baseline.items()}
|
||||
for name, modalities in optional.items():
|
||||
|
|
@ -171,17 +176,17 @@ async def discover_models(connection: ProviderConnection) -> list[DiscoveredMode
|
|||
)
|
||||
|
||||
|
||||
async def _optional_image_entries(
|
||||
async def _optional_other_entries(
|
||||
base_url: str, api_key: str | None
|
||||
) -> dict[str, set[str]]:
|
||||
"""Ask again for image models, because a default listing may not hold them.
|
||||
"""Ask again for every modality, because a default listing may not hold them.
|
||||
|
||||
OpenRouter serves 444 models from /models and 54 from the image-filtered
|
||||
query, 43 of which the first call never returns at all. This is enumeration,
|
||||
not classification. An endpoint that ignores the parameter answers with the
|
||||
OpenRouter's /models holds 464 models; `output_modalities=all` holds 635,
|
||||
among them its 46 image and 21 speech models. This is enumeration, not
|
||||
classification. An endpoint that ignores the parameter answers with the
|
||||
same set, so the union is a no-op and no provider has to be recognised.
|
||||
"""
|
||||
try:
|
||||
return await _request_entries(base_url, api_key, image_only=True)
|
||||
return await _request_entries(base_url, api_key, every_modality=True)
|
||||
except (httpx.HTTPError, ValueError):
|
||||
return {}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from dataclasses import replace
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import CheckConstraint, ForeignKey, String, UniqueConstraint, func
|
||||
from sqlalchemy import JSON, CheckConstraint, ForeignKey, String, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from modules.llm.model_type import ModelType
|
||||
|
|
@ -54,6 +54,10 @@ class SelectedModel(Base):
|
|||
params_b: Mapped[float | None]
|
||||
vendor: Mapped[str | None]
|
||||
line: Mapped[Line | None] = mapped_column(text_enum(Line))
|
||||
# What the user set for this model that no endpoint states, keyed by the
|
||||
# slice that owns each entry: `voices` belongs to `llm/voices`. Null until
|
||||
# something is set, and cleared when the slot takes another model.
|
||||
settings: Mapped[dict | None] = mapped_column(JSON)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from collections.abc import Sequence
|
|||
GAP_SECONDS = 0.35
|
||||
|
||||
|
||||
def joined_wav(turns: Sequence[bytes]) -> bytes:
|
||||
def joined_wav(turns: Sequence[bytes], gap: float = GAP_SECONDS) -> bytes:
|
||||
"""The turns in order, which must share one format, as one model's do."""
|
||||
frames: list[bytes] = []
|
||||
params = None
|
||||
|
|
@ -23,7 +23,7 @@ def joined_wav(turns: Sequence[bytes]) -> bytes:
|
|||
raise ValueError("the turns came back in different audio formats")
|
||||
if index:
|
||||
channels, width, rate = params
|
||||
frames.append(b"\x00" * round(GAP_SECONDS * rate) * width * channels)
|
||||
frames.append(b"\x00" * round(gap * rate) * width * channels)
|
||||
frames.append(turn.readframes(turn.getnframes()))
|
||||
if params is None:
|
||||
raise ValueError("nothing was voiced")
|
||||
|
|
|
|||
|
|
@ -38,9 +38,7 @@ class OpenAICompatibleImageProvider:
|
|||
async def generate(self, model: str, prompt: str) -> GeneratedImage:
|
||||
key = (self._connection_id, self._base_url)
|
||||
preferred = _route_cache.get(key, STANDARD_ROUTE)
|
||||
alternate = (
|
||||
EXTENSION_ROUTE if preferred == STANDARD_ROUTE else STANDARD_ROUTE
|
||||
)
|
||||
alternate = EXTENSION_ROUTE if preferred == STANDARD_ROUTE else STANDARD_ROUTE
|
||||
|
||||
try:
|
||||
status, payload = await self._post(preferred, model, prompt)
|
||||
|
|
@ -50,9 +48,7 @@ class OpenAICompatibleImageProvider:
|
|||
else:
|
||||
route = preferred
|
||||
if status >= 400:
|
||||
raise NonRetryableImageError(
|
||||
f"image endpoint returned HTTP {status}"
|
||||
)
|
||||
raise NonRetryableImageError(f"image endpoint returned HTTP {status}")
|
||||
image = await self._normalize(payload)
|
||||
except NonRetryableImageError:
|
||||
raise
|
||||
|
|
@ -62,9 +58,7 @@ class OpenAICompatibleImageProvider:
|
|||
_route_cache[key] = route
|
||||
return image
|
||||
|
||||
async def _post(
|
||||
self, route: str, model: str, prompt: str
|
||||
) -> tuple[int, object]:
|
||||
async def _post(self, route: str, model: str, prompt: str) -> tuple[int, object]:
|
||||
async with (
|
||||
httpx.AsyncClient(timeout=TIMEOUT, headers=self._headers()) as client,
|
||||
client.stream(
|
||||
|
|
@ -86,9 +80,7 @@ class OpenAICompatibleImageProvider:
|
|||
async def _normalize(self, payload: object) -> GeneratedImage:
|
||||
if not isinstance(payload, dict) or not isinstance(payload.get("data"), list):
|
||||
raise ValueError("image endpoint returned no data list")
|
||||
entry = next(
|
||||
(item for item in payload["data"] if isinstance(item, dict)), None
|
||||
)
|
||||
entry = next((item for item in payload["data"] if isinstance(item, dict)), None)
|
||||
if entry is None:
|
||||
raise ValueError("image endpoint returned no image")
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
"""One episode from each line's reply, in the format the server sent.
|
||||
|
||||
The format is read from the bytes, not from what was asked for: a server can
|
||||
ignore `response_format`. WAV joins frame by frame with a pause between
|
||||
speakers. MP3 joins frame to frame with no pause, because a silent frame would
|
||||
need the episode's encoder settings, which no reply states.
|
||||
"""
|
||||
|
||||
import struct
|
||||
from collections.abc import Sequence
|
||||
|
||||
from modules.llm.providers.audiocpp.joined_wav import joined_wav
|
||||
from modules.llm.providers.protocols import SynthesizedAudio
|
||||
|
||||
__all__ = ["joined_replies"]
|
||||
|
||||
|
||||
def joined_replies(replies: Sequence[bytes]) -> SynthesizedAudio:
|
||||
"""Raises ValueError when the replies are neither WAV nor MP3, or mixed."""
|
||||
kinds = {_kind(reply) for reply in replies}
|
||||
if kinds == {"wav"}:
|
||||
# A line's pieces run on; only a new line gets the pause.
|
||||
lines = [joined_wav(_stacked_wavs(reply), gap=0) for reply in replies]
|
||||
return SynthesizedAudio(joined_wav(lines), "audio/wav")
|
||||
if kinds == {"mp3"}:
|
||||
return SynthesizedAudio(b"".join(map(_mp3_frames, replies)), "audio/mpeg")
|
||||
raise ValueError(f"the lines came back as {', '.join(sorted(kinds))}")
|
||||
|
||||
|
||||
def _kind(reply: bytes) -> str:
|
||||
if reply[:4] == b"RIFF" and reply[8:12] == b"WAVE":
|
||||
return "wav"
|
||||
if reply[:3] == b"ID3" or _frame_length(reply, 0):
|
||||
return "mp3"
|
||||
return "neither WAV nor MP3"
|
||||
|
||||
|
||||
def _stacked_wavs(reply: bytes) -> list[bytes]:
|
||||
"""Each WAV in a reply, by the size its RIFF header states. A streamed WAV
|
||||
states no size, and is taken whole."""
|
||||
parts = []
|
||||
at = 0
|
||||
while reply[at : at + 4] == b"RIFF":
|
||||
(size,) = struct.unpack_from("<I", reply, at + 4)
|
||||
end = at + 8 + size
|
||||
if size in (0, 0xFFFFFFFF) or end > len(reply):
|
||||
parts.append(reply[at:])
|
||||
return parts
|
||||
parts.append(reply[at:end])
|
||||
at = end
|
||||
return parts or [reply]
|
||||
|
||||
|
||||
def _mp3_frames(reply: bytes) -> bytes:
|
||||
"""The audio frames alone: without the ID3v2 tag, and without the Xing or
|
||||
Info frame, which states one line's length for the whole file."""
|
||||
at = 0
|
||||
if reply[:3] == b"ID3":
|
||||
size = _syncsafe(reply[6:10])
|
||||
at = 10 + size + (10 if reply[5] & 0x10 else 0)
|
||||
length = _frame_length(reply, at)
|
||||
if length and (b"Xing" in reply[at : at + 40] or b"Info" in reply[at : at + 40]):
|
||||
at += length
|
||||
return reply[at:]
|
||||
|
||||
|
||||
def _syncsafe(four: bytes) -> int:
|
||||
return (four[0] << 21) | (four[1] << 14) | (four[2] << 7) | four[3]
|
||||
|
||||
|
||||
# Kbit/s by index for MPEG-1 and for MPEG-2/2.5, Layer III.
|
||||
_BITRATES = {
|
||||
1: (0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320),
|
||||
2: (0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160),
|
||||
}
|
||||
_RATES = {3: (44100, 48000, 32000), 2: (22050, 24000, 16000), 0: (11025, 12000, 8000)}
|
||||
|
||||
|
||||
def _frame_length(data: bytes, at: int) -> int:
|
||||
"""The byte length of the Layer III frame at `at`, or 0 when none starts there."""
|
||||
if len(data) < at + 4 or data[at] != 0xFF or data[at + 1] & 0xE0 != 0xE0:
|
||||
return 0
|
||||
version = (data[at + 1] >> 3) & 0x3
|
||||
layer = (data[at + 1] >> 1) & 0x3
|
||||
bitrate_index = data[at + 2] >> 4
|
||||
rate_index = (data[at + 2] >> 2) & 0x3
|
||||
if version == 1 or layer != 1 or bitrate_index in (0, 15) or rate_index == 3:
|
||||
return 0
|
||||
mpeg1 = version == 3
|
||||
bitrate = _BITRATES[1 if mpeg1 else 2][bitrate_index] * 1000
|
||||
rate = _RATES[version][rate_index]
|
||||
padding = (data[at + 2] >> 1) & 0x1
|
||||
return (144 if mpeg1 else 72) * bitrate // rate + padding
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
"""Podcast voices from a server's /audio/speech, one request per turn."""
|
||||
|
||||
import logging
|
||||
import wave
|
||||
|
||||
import httpx
|
||||
|
||||
from modules.llm.connections.key_headers import key_headers
|
||||
from modules.llm.providers.openai_compatible.joined_replies import joined_replies
|
||||
from modules.llm.providers.protocols import SpokenTurn, SynthesizedAudio
|
||||
from shared import cancellation
|
||||
|
||||
__all__ = ["NonRetryableSpeechError", "RemoteSpeech"]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TURN_TIMEOUT = httpx.Timeout(120.0, connect=10.0)
|
||||
|
||||
|
||||
class NonRetryableSpeechError(RuntimeError):
|
||||
"""Voicing began, so repeating the job would bill the episode twice."""
|
||||
|
||||
|
||||
class RemoteSpeech:
|
||||
def __init__(
|
||||
self,
|
||||
model: str,
|
||||
*,
|
||||
base_url: str,
|
||||
api_key: str | None,
|
||||
transport: httpx.AsyncBaseTransport | None = None,
|
||||
) -> None:
|
||||
self._model = model
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._api_key = api_key
|
||||
self._transport = transport
|
||||
|
||||
async def check_memory(self) -> None:
|
||||
"""The server voices it, so nothing here has to fit."""
|
||||
|
||||
async def synthesize(
|
||||
self, turns: list[SpokenTurn], language: str
|
||||
) -> SynthesizedAudio:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=TURN_TIMEOUT,
|
||||
headers=key_headers(self._base_url, self._api_key),
|
||||
transport=self._transport,
|
||||
) as client:
|
||||
voiced = []
|
||||
# WAV first, because only WAV takes a pause between speakers.
|
||||
# OpenRouter takes only mp3 or pcm, and pcm states no sample rate.
|
||||
chosen = "wav"
|
||||
for index, turn in enumerate(turns, start=1):
|
||||
reply = await self._voice(client, index, len(turns), turn, chosen)
|
||||
if reply.is_error and index == 1:
|
||||
# Asked once, on the first line: a refusal after it is
|
||||
# that line's own, not the format's.
|
||||
retry = await self._voice(client, index, len(turns), turn, "mp3")
|
||||
if not retry.is_error:
|
||||
chosen, reply = "mp3", retry
|
||||
if reply.is_error:
|
||||
raise NonRetryableSpeechError(
|
||||
f"the server could not voice turn {index} of {len(turns)}: "
|
||||
f"{_server_message(reply)}"
|
||||
)
|
||||
voiced.append(reply.content)
|
||||
try:
|
||||
return joined_replies(voiced)
|
||||
except (wave.Error, EOFError, ValueError) as error:
|
||||
raise NonRetryableSpeechError(
|
||||
f"the server answered, but not as WAV or MP3: {error}"
|
||||
) from error
|
||||
|
||||
async def _voice(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
index: int,
|
||||
total: int,
|
||||
turn: SpokenTurn,
|
||||
response_format: str,
|
||||
) -> httpx.Response:
|
||||
# A request in flight cannot be stopped; a turn not yet sent can.
|
||||
cancellation.raise_if_cancelled()
|
||||
logger.info(
|
||||
"speech: turn %s/%s voice=%s %s chars as %s",
|
||||
index,
|
||||
total,
|
||||
turn.voice,
|
||||
len(turn.text),
|
||||
response_format,
|
||||
)
|
||||
try:
|
||||
body = {
|
||||
"model": self._model,
|
||||
"input": turn.text,
|
||||
"response_format": response_format,
|
||||
}
|
||||
# No voice leaves it to the server's default; only a test sends none.
|
||||
if turn.voice:
|
||||
body["voice"] = turn.voice
|
||||
return await client.post(f"{self._base_url}/audio/speech", json=body)
|
||||
except httpx.HTTPError as error:
|
||||
raise NonRetryableSpeechError(
|
||||
f"the server could not voice turn {index} of {total}: {error}"
|
||||
) from error
|
||||
|
||||
|
||||
def _server_message(reply: httpx.Response) -> str:
|
||||
"""OpenAI's shape, {"error": {"message": ...}}, which most servers copy."""
|
||||
try:
|
||||
return str(reply.json()["error"]["message"])
|
||||
except (ValueError, KeyError, TypeError):
|
||||
return f"HTTP {reply.status_code}"
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
"""A server's voices, asked of the server itself.
|
||||
|
||||
The OpenAI API has no route that lists voices, and models.dev carries none,
|
||||
so nothing else knows them. `GET /audio/voices` is Kokoro-FastAPI's; a server
|
||||
without it leaves the user to type the voices.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
|
||||
from modules.llm.connections.key_headers import key_headers
|
||||
from modules.llm.providers.protocols import Voice
|
||||
|
||||
__all__ = ["listed_voices"]
|
||||
|
||||
LIST_TIMEOUT = httpx.Timeout(10.0, connect=5.0)
|
||||
|
||||
# Held for the process, never stored: a server gains voices on its own
|
||||
# schedule, and the next start asks again. Only answers are held; a server
|
||||
# that could not be reached is asked again.
|
||||
_answers: dict[tuple[int, str], list[Voice] | None] = {}
|
||||
|
||||
|
||||
def listed_voices(
|
||||
connection_id: int,
|
||||
base_url: str,
|
||||
api_key: str | None,
|
||||
*,
|
||||
transport: httpx.BaseTransport | None = None,
|
||||
) -> list[Voice] | None:
|
||||
"""The server's voices, or None when it lists none. Ids alone: no server
|
||||
states a voice's gender or languages here, so none is claimed."""
|
||||
key = (connection_id, base_url)
|
||||
if key in _answers:
|
||||
return _answers[key]
|
||||
try:
|
||||
with httpx.Client(
|
||||
timeout=LIST_TIMEOUT,
|
||||
headers=key_headers(base_url, api_key),
|
||||
transport=transport,
|
||||
) as client:
|
||||
reply = client.get(f"{base_url.rstrip('/')}/audio/voices")
|
||||
except httpx.HTTPError:
|
||||
return None
|
||||
_answers[key] = _voices(reply)
|
||||
return _answers[key]
|
||||
|
||||
|
||||
def _voices(reply: httpx.Response) -> list[Voice] | None:
|
||||
if reply.is_error:
|
||||
return None
|
||||
try:
|
||||
listed = reply.json()["voices"]
|
||||
ids = [entry["id"] for entry in listed]
|
||||
except (ValueError, KeyError, TypeError):
|
||||
return None
|
||||
if not ids or not all(isinstance(id, str) and id for id in ids):
|
||||
return None
|
||||
return [Voice(id, id, None, ()) for id in dict.fromkeys(ids)]
|
||||
|
|
@ -82,7 +82,8 @@ class ImageGenerator(Protocol):
|
|||
class Voice:
|
||||
id: str
|
||||
label: str
|
||||
gender: Literal["female", "male"]
|
||||
# None where the source does not say: OpenAI documents none for its voices.
|
||||
gender: Literal["female", "male"] | None
|
||||
# The languages this voice speaks, as the model's entry names them: one for
|
||||
# a Kokoro voice, every one the model speaks for a Supertonic voice.
|
||||
languages: tuple[str, ...]
|
||||
|
|
@ -103,9 +104,7 @@ class SynthesizedAudio:
|
|||
|
||||
|
||||
class TextToSpeech(Protocol):
|
||||
"""Anything that voices a script: audio.cpp on this computer today."""
|
||||
|
||||
def voices(self) -> list[Voice]: ...
|
||||
"""Anything that voices a script: audio.cpp here, or a server's /audio/speech."""
|
||||
|
||||
async def check_memory(self) -> None:
|
||||
"""Raise, with the sentence a person reads, when voicing cannot fit."""
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ from modules.llm.providers.openai_compatible import (
|
|||
OpenAICompatibleChatProvider,
|
||||
OpenAICompatibleImageProvider,
|
||||
)
|
||||
from modules.llm.providers.openai_compatible.speech import RemoteSpeech
|
||||
from modules.llm.providers.protocols import Generator, ImageGenerator, TextToSpeech
|
||||
from modules.llm.providers.sdcpp import provider as sdcpp
|
||||
from modules.llm.providers.sdcpp.generator import LocalImageGenerator
|
||||
|
|
@ -24,10 +25,6 @@ class ModelResolutionError(RuntimeError):
|
|||
pass
|
||||
|
||||
|
||||
class VoiceNotLocalError(ModelResolutionError):
|
||||
"""The audio model is a server's, and nothing calls a remote speech endpoint."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolvedGeneration:
|
||||
selection: SelectedModel
|
||||
|
|
@ -96,14 +93,41 @@ def resolve_image_generation(session: Session) -> ResolvedImageGeneration:
|
|||
)
|
||||
|
||||
|
||||
def speech_selected(session: Session) -> None:
|
||||
"""Raise unless the chosen audio model can be reached for, without reaching
|
||||
it: the format list asks, and must not call a server to answer."""
|
||||
selected = selected_audio(session)
|
||||
if selected.provider == audiocpp.PROVIDER:
|
||||
local_speech(selected)
|
||||
else:
|
||||
stored_connection(session, selected)
|
||||
|
||||
|
||||
def resolve_text_to_speech(session: Session) -> TextToSpeech:
|
||||
selected = selected_audio(session)
|
||||
if selected.provider == audiocpp.PROVIDER:
|
||||
return local_speech(selected)
|
||||
return _remote_speech(selected, _connection(session, selected))
|
||||
|
||||
|
||||
def _remote_speech(
|
||||
selected: SelectedModel, connection: ProviderConnection
|
||||
) -> RemoteSpeech:
|
||||
return RemoteSpeech(
|
||||
selected.name,
|
||||
base_url=connection.base_url,
|
||||
api_key=connection.api_key,
|
||||
)
|
||||
|
||||
|
||||
def selected_audio(session: Session) -> SelectedModel:
|
||||
selected = session.get(SelectedModel, ModelType.AUDIO_GEN)
|
||||
if selected is None:
|
||||
raise ModelResolutionError("no audio model selected")
|
||||
if selected.provider != audiocpp.PROVIDER:
|
||||
raise VoiceNotLocalError(
|
||||
"podcasts are voiced by an audio model on this computer"
|
||||
)
|
||||
return selected
|
||||
|
||||
|
||||
def local_speech(selected: SelectedModel) -> AudioCppSpeech:
|
||||
engine = get_local_catalog().audiocpp
|
||||
installed = engine.installed_model(selected.name)
|
||||
if installed is None:
|
||||
|
|
@ -119,10 +143,16 @@ def resolve_text_to_speech(session: Session) -> TextToSpeech:
|
|||
|
||||
|
||||
def _connection(session: Session, selected: SelectedModel) -> ProviderConnection:
|
||||
"""The selection's connection, once egress to its host is allowed."""
|
||||
connection = stored_connection(session, selected)
|
||||
egress.require(session, egress.host_destination(connection.base_url))
|
||||
return connection
|
||||
|
||||
|
||||
def stored_connection(session: Session, selected: SelectedModel) -> ProviderConnection:
|
||||
if selected.provider != "openai_compatible" or selected.connection_id is None:
|
||||
raise ModelResolutionError(f"unknown provider: {selected.provider}")
|
||||
connection = session.get(ProviderConnection, selected.connection_id)
|
||||
if connection is None:
|
||||
raise ModelResolutionError("selected model connection no longer exists")
|
||||
egress.require(session, egress.host_destination(connection.base_url))
|
||||
return connection
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ from modules.llm.schemas import (
|
|||
)
|
||||
from modules.llm.selectable import selectable_for
|
||||
from modules.llm.selection import choose_model, complete_onboarding
|
||||
from modules.llm.voices.router import router as voices_router
|
||||
from shared.config import get_llm_settings
|
||||
|
||||
router = APIRouter(prefix="/llm", tags=["llm"])
|
||||
|
|
@ -42,6 +43,7 @@ router.include_router(local_catalog_router)
|
|||
router.include_router(install_jobs_router)
|
||||
router.include_router(remote_catalog_router)
|
||||
router.include_router(connections_router)
|
||||
router.include_router(voices_router)
|
||||
|
||||
|
||||
@router.get(
|
||||
|
|
|
|||
|
|
@ -91,6 +91,8 @@ class ModelTestWrite(BaseModel):
|
|||
|
||||
model: str = Field(min_length=1, max_length=512)
|
||||
prompt: str | None = Field(default=None, max_length=2000)
|
||||
# A speech test's voice; the first reviewed one when omitted, if any.
|
||||
voice: str | None = Field(default=None, max_length=100)
|
||||
|
||||
|
||||
class ChatTestRead(BaseModel):
|
||||
|
|
|
|||
|
|
@ -110,6 +110,13 @@ def _store(
|
|||
if selected is None:
|
||||
selected = SelectedModel(model_type=model_type, name=model_name)
|
||||
session.add(selected)
|
||||
elif (selected.provider, selected.connection_id, selected.name) != (
|
||||
provider_name,
|
||||
connection_id,
|
||||
model_name,
|
||||
):
|
||||
# Settings are the model's own; another model starts without them.
|
||||
selected.settings = None
|
||||
selected.provider = provider_name
|
||||
selected.connection_id = connection_id
|
||||
selected.name = model_name
|
||||
|
|
|
|||
3
surfsense_local/backend/modules/llm/voices/__init__.py
Normal file
3
surfsense_local/backend/modules/llm/voices/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
"""The voices the chosen audio model offers podcasts, from one place: the local
|
||||
model's reviewed roster, a server's own list, or the ones the user added, kept
|
||||
in the selection's settings."""
|
||||
75
surfsense_local/backend/modules/llm/voices/roster.py
Normal file
75
surfsense_local/backend/modules/llm/voices/roster.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"""Which voices the chosen audio model offers, and where they came from."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from modules.egress import service as egress
|
||||
from modules.llm.models import ProviderConnection, SelectedModel
|
||||
from modules.llm.providers import audiocpp
|
||||
from modules.llm.providers.openai_compatible.voice_list import listed_voices
|
||||
from modules.llm.providers.protocols import Voice
|
||||
from modules.llm.resolution import local_speech, selected_audio, stored_connection
|
||||
from modules.llm.voices.saved import saved_voices
|
||||
|
||||
__all__ = ["SpeechRoster", "VoicedBy", "server_roster", "speech_voices", "voices_page"]
|
||||
|
||||
Source = Literal["local", "server", "saved"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VoicedBy:
|
||||
"""The server that voices a podcast, named for the brief."""
|
||||
|
||||
server: str
|
||||
model: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SpeechRoster:
|
||||
"""The voices a brief may pick, and where they came from. Empty only for a
|
||||
server model with none added yet."""
|
||||
|
||||
voices: list[Voice]
|
||||
source: Source
|
||||
voiced_by: VoicedBy | None
|
||||
|
||||
|
||||
def speech_voices(session: Session) -> SpeechRoster:
|
||||
selected = selected_audio(session)
|
||||
if selected.provider == audiocpp.PROVIDER:
|
||||
return SpeechRoster(local_speech(selected).voices(), "local", None)
|
||||
connection = stored_connection(session, selected)
|
||||
source, ids = server_roster(session, selected, connection)
|
||||
return SpeechRoster(
|
||||
# Ids alone: no server states a voice's gender or languages.
|
||||
[Voice(voice, voice, None, ()) for voice in ids],
|
||||
source,
|
||||
VoicedBy(connection.label, selected.name),
|
||||
)
|
||||
|
||||
|
||||
def server_roster(
|
||||
session: Session, selected: SelectedModel, connection: ProviderConnection
|
||||
) -> tuple[Literal["server", "saved"], list[str]]:
|
||||
"""The server's own list when it has one; otherwise the voices the user
|
||||
added. The server is asked only once its host is allowed; until then the
|
||||
added voices stand, since reading them reaches nothing."""
|
||||
try:
|
||||
egress.require(session, egress.host_destination(connection.base_url))
|
||||
except egress.EgressDeniedError:
|
||||
listed = None
|
||||
else:
|
||||
listed = listed_voices(connection.id, connection.base_url, connection.api_key)
|
||||
if listed is not None:
|
||||
return "server", [voice.id for voice in listed]
|
||||
return "saved", saved_voices(selected)
|
||||
|
||||
|
||||
def voices_page(connection: ProviderConnection, model: str) -> str | None:
|
||||
"""The page that names a model's voices, where its provider documents one:
|
||||
OpenRouter's docs send readers to each model's page."""
|
||||
if connection.catalog_provider == "openrouter":
|
||||
return f"https://openrouter.ai/{model}"
|
||||
return None
|
||||
95
surfsense_local/backend/modules/llm/voices/router.py
Normal file
95
surfsense_local/backend/modules/llm/voices/router.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Response, status
|
||||
from pydantic import BaseModel, Field, StringConstraints
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.dependencies import SessionDep, transact
|
||||
from modules.llm.connections.router import allowed_connection
|
||||
from modules.llm.model_type import ModelType
|
||||
from modules.llm.models import ProviderConnection, SelectedModel
|
||||
from modules.llm.providers.openai_compatible.speech import (
|
||||
NonRetryableSpeechError,
|
||||
RemoteSpeech,
|
||||
)
|
||||
from modules.llm.providers.protocols import SpokenTurn
|
||||
from modules.llm.voices.roster import server_roster, voices_page
|
||||
from modules.llm.voices.saved import forget, keep
|
||||
|
||||
router = APIRouter(prefix="/selection/audio_gen/voices", tags=["llm"])
|
||||
|
||||
# Heard before it is kept: a voice the server refuses never reaches a podcast.
|
||||
TRIAL_LINE = "Hello. This is how your podcasts will sound."
|
||||
|
||||
VoiceId = Annotated[
|
||||
str, StringConstraints(strip_whitespace=True, min_length=1, max_length=100)
|
||||
]
|
||||
|
||||
|
||||
class VoicesRead(BaseModel):
|
||||
# What a voice is played through: the connection's speech test.
|
||||
connection_id: int
|
||||
model: str
|
||||
source: str
|
||||
voices: list[str]
|
||||
voices_page: str | None
|
||||
|
||||
|
||||
class VoiceWrite(BaseModel):
|
||||
voice: VoiceId
|
||||
# The line to hear it say, in the interface's language; English otherwise.
|
||||
text: str | None = Field(default=None, min_length=1, max_length=300)
|
||||
|
||||
|
||||
@router.get("", response_model=VoicesRead)
|
||||
def list_voices(session: SessionDep) -> VoicesRead:
|
||||
selected, connection = _server_selection(session)
|
||||
source, voices = server_roster(session, selected, connection)
|
||||
session.commit() # egress stamps the host's last call
|
||||
return VoicesRead(
|
||||
connection_id=connection.id,
|
||||
model=selected.name,
|
||||
source=source,
|
||||
voices=voices,
|
||||
voices_page=voices_page(connection, selected.name),
|
||||
)
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def add_voice(payload: VoiceWrite, session: SessionDep) -> Response:
|
||||
"""Voice one line in it, then keep it; a refusal keeps nothing."""
|
||||
selected, connection = await transact(session, _server_selection)
|
||||
speech = RemoteSpeech(
|
||||
selected.name, base_url=connection.base_url, api_key=connection.api_key
|
||||
)
|
||||
try:
|
||||
await speech.synthesize(
|
||||
[SpokenTurn(payload.voice, payload.text or TRIAL_LINE)], ""
|
||||
)
|
||||
except NonRetryableSpeechError as error:
|
||||
raise HTTPException(status.HTTP_502_BAD_GATEWAY, str(error)) from error
|
||||
await transact(session, _keep, payload.voice)
|
||||
return Response(status_code=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
@router.delete("", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def remove_voice(voice: VoiceId, session: SessionDep) -> Response:
|
||||
selected, _ = _server_selection(session)
|
||||
forget(session, selected, voice)
|
||||
session.commit()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
def _server_selection(session: Session) -> tuple[SelectedModel, ProviderConnection]:
|
||||
"""The audio slot's server model and its connection, once egress allows it."""
|
||||
selected = session.get(SelectedModel, ModelType.AUDIO_GEN)
|
||||
if selected is None or selected.connection_id is None:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT, "the audio model is not a server's"
|
||||
)
|
||||
return selected, allowed_connection(session, selected.connection_id)
|
||||
|
||||
|
||||
def _keep(session: Session, voice: str) -> None:
|
||||
selected, _ = _server_selection(session)
|
||||
keep(session, selected, voice)
|
||||
41
surfsense_local/backend/modules/llm/voices/saved.py
Normal file
41
surfsense_local/backend/modules/llm/voices/saved.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
"""The voices the user added for the audio slot's server model, kept under
|
||||
`voices` in the selection's settings. Only this module reads or writes it."""
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from modules.llm.models import SelectedModel
|
||||
|
||||
__all__ = ["forget", "keep", "saved_voices"]
|
||||
|
||||
KEY = "voices"
|
||||
|
||||
|
||||
def saved_voices(selected: SelectedModel) -> list[str]:
|
||||
voices = (selected.settings or {}).get(KEY)
|
||||
return (
|
||||
[voice for voice in voices if isinstance(voice, str)]
|
||||
if isinstance(voices, list)
|
||||
else []
|
||||
)
|
||||
|
||||
|
||||
def keep(session: Session, selected: SelectedModel, voice: str) -> None:
|
||||
voices = saved_voices(selected)
|
||||
if voice not in voices:
|
||||
_write(selected, [*voices, voice])
|
||||
session.flush()
|
||||
|
||||
|
||||
def forget(session: Session, selected: SelectedModel, voice: str) -> None:
|
||||
_write(selected, [kept for kept in saved_voices(selected) if kept != voice])
|
||||
session.flush()
|
||||
|
||||
|
||||
def _write(selected: SelectedModel, voices: list[str]) -> None:
|
||||
# Reassigned, not mutated: a JSON column tracks assignment only.
|
||||
settings = {
|
||||
key: value for key, value in (selected.settings or {}).items() if key != KEY
|
||||
}
|
||||
if voices:
|
||||
settings[KEY] = voices
|
||||
selected.settings = settings or None
|
||||
|
|
@ -14,6 +14,8 @@ from modules.llm.models import ProviderConnection, SelectedModel
|
|||
from shared.config import get_llm_settings, get_storage_settings
|
||||
from shared.db import create_session_factory
|
||||
from shared.queue import studio_queue
|
||||
from tests.integration.llm import conftest as speech_server
|
||||
from tests.integration.llm.conftest import openai_server # noqa: F401
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
|
@ -124,14 +126,11 @@ async def test_podcast_is_gated_on_an_audio_model(
|
|||
assert podcast["requires_model_types"] == ["text_gen", "audio_gen"]
|
||||
|
||||
|
||||
async def test_a_podcast_voices_only_on_this_computer(
|
||||
client: AsyncClient, engine: Engine, workspace_id: int, choose_model: None
|
||||
) -> None:
|
||||
"""A server's audio model can be chosen, but nothing calls a remote speech
|
||||
endpoint yet, so the podcast says where the model must run."""
|
||||
def choose_server_voice(engine: Engine, base_url: str, name: str = "kokoro") -> None:
|
||||
"""A server's audio model as the podcast's voice."""
|
||||
with create_session_factory(engine)() as session:
|
||||
server = ProviderConnection(
|
||||
label="speech server", provider="openai_compatible", base_url="http://tts"
|
||||
label="speech server", provider="openai_compatible", base_url=base_url
|
||||
)
|
||||
session.add(server)
|
||||
session.flush()
|
||||
|
|
@ -140,16 +139,114 @@ async def test_a_podcast_voices_only_on_this_computer(
|
|||
model_type=ModelType.AUDIO_GEN,
|
||||
provider="openai_compatible",
|
||||
connection_id=server.id,
|
||||
name="tts-1",
|
||||
name=name,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
|
||||
def podcast_job(engine: Engine, workspace_id: int, *voices: str) -> dict:
|
||||
"""A podcast job whose speakers take `voices`, one each, written in French."""
|
||||
speakers = [
|
||||
{"name": name, "role": "host", "voice": voice}
|
||||
for name, voice in zip(("Ana", "Ben"), voices, strict=False)
|
||||
]
|
||||
return {
|
||||
"format": "podcast",
|
||||
"document_ids": [make_ready_source(engine, workspace_id)],
|
||||
"options": {"language": "fr", "speakers": speakers},
|
||||
}
|
||||
|
||||
|
||||
async def test_a_server_that_lists_its_voices_fills_the_brief(
|
||||
client: AsyncClient,
|
||||
engine: Engine,
|
||||
workspace_id: int,
|
||||
choose_model: None,
|
||||
openai_server: str, # noqa: F811
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Asked of the server, as Kokoro-FastAPI answers: its ids fill the picker,
|
||||
and only they voice a job. It states no language, so any script one goes."""
|
||||
monkeypatch.setattr(speech_server, "AUDIO_VOICES", ["af_heart", "am_adam"])
|
||||
choose_server_voice(engine, openai_server)
|
||||
|
||||
brief_url = f"/workspaces/{workspace_id}/studio/podcast/brief"
|
||||
opened = (await client.get(brief_url)).json()
|
||||
assert opened["voices_source"] == "server"
|
||||
assert opened["voiced_by"] == {"server": "speech server", "model": "kokoro"}
|
||||
assert [v["id"] for v in opened["voices"]] == ["af_heart", "am_adam"]
|
||||
assert {v["gender"] for v in opened["voices"]} == {None}
|
||||
assert "fr" in opened["languages"]
|
||||
assert [s["voice"] for s in opened["brief"]["speakers"]] == ["af_heart", "am_adam"]
|
||||
|
||||
url = f"/workspaces/{workspace_id}/studio/jobs"
|
||||
unlisted = await client.post(url, json=podcast_job(engine, workspace_id, "bf_emma"))
|
||||
assert unlisted.status_code == 422
|
||||
created = await client.post(
|
||||
url, json=podcast_job(engine, workspace_id, "af_heart", "am_adam")
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
|
||||
|
||||
async def test_a_server_that_lists_no_voices_takes_the_ones_added_for_it(
|
||||
client: AsyncClient,
|
||||
engine: Engine,
|
||||
workspace_id: int,
|
||||
choose_model: None,
|
||||
openai_server: str, # noqa: F811
|
||||
) -> None:
|
||||
"""None is guessed: until voices are added in Settings the brief has none
|
||||
and a job is refused; then they are its picker."""
|
||||
choose_server_voice(engine, openai_server)
|
||||
brief_url = f"/workspaces/{workspace_id}/studio/podcast/brief"
|
||||
url = f"/workspaces/{workspace_id}/studio/jobs"
|
||||
|
||||
opened = (await client.get(brief_url)).json()
|
||||
assert (opened["voices_source"], opened["voices"]) == ("saved", [])
|
||||
refused = await client.post(url, json=podcast_job(engine, workspace_id, "alloy"))
|
||||
assert refused.status_code == 422
|
||||
assert "Settings" in refused.json()["detail"]
|
||||
|
||||
for voice in ("alloy", "echo"):
|
||||
added = await client.post("/llm/selection/audio_gen/voices", json={"voice": voice})
|
||||
assert added.status_code == 201, added.text
|
||||
|
||||
opened = (await client.get(brief_url)).json()
|
||||
assert [v["id"] for v in opened["voices"]] == ["alloy", "echo"]
|
||||
created = await client.post(
|
||||
url, json=podcast_job(engine, workspace_id, "alloy", "echo")
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
|
||||
|
||||
async def test_a_server_not_yet_allowed_is_not_asked(
|
||||
client: AsyncClient, engine: Engine, workspace_id: int, choose_model: None
|
||||
) -> None:
|
||||
"""The format list reaches no server, and the brief asks one only once its
|
||||
host is allowed: until then only the added voices stand."""
|
||||
choose_server_voice(engine, "https://tts.example.com/v1")
|
||||
|
||||
url = f"/workspaces/{workspace_id}/studio/formats"
|
||||
podcast = next(f for f in (await client.get(url)).json() if f["key"] == "podcast")
|
||||
assert podcast["available"] is True
|
||||
|
||||
assert podcast["available"] is False
|
||||
assert podcast["unavailable_reason"] == "Needs an audio model on this computer"
|
||||
brief_url = f"/workspaces/{workspace_id}/studio/podcast/brief"
|
||||
opened = (await client.get(brief_url)).json()
|
||||
assert (opened["voices_source"], opened["voices"]) == ("saved", [])
|
||||
|
||||
|
||||
async def test_a_model_on_this_computer_voices_from_its_roster(
|
||||
client: AsyncClient,
|
||||
workspace_id: int,
|
||||
choose_model: None,
|
||||
local_voice: None,
|
||||
) -> None:
|
||||
"""Nothing on a server: no line saying who voices it, grouped by gender."""
|
||||
brief_url = f"/workspaces/{workspace_id}/studio/podcast/brief"
|
||||
opened = (await client.get(brief_url)).json()
|
||||
|
||||
assert (opened["voices_source"], opened["voiced_by"]) == ("local", None)
|
||||
|
||||
|
||||
KOKORO = ("kokoro-82m-q8_0", "Q8_0")
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import io
|
||||
import json
|
||||
import threading
|
||||
import wave
|
||||
from collections.abc import Iterator
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
|
@ -203,6 +205,11 @@ REMOTE_REQUESTS: list[tuple[str, str]] = []
|
|||
# Set to an HTTP status to make /models refuse, as a provider rejecting the key
|
||||
# or rate-limiting would; the fixture resets it.
|
||||
MODELS_STATUS: int | None = None
|
||||
# The voices GET /audio/voices lists, as Kokoro-FastAPI does; None is a 404,
|
||||
# as OpenAI, Groq and OpenRouter answer.
|
||||
AUDIO_VOICES: list[str] | None = None
|
||||
# Voices /audio/speech refuses with a 400, as a server answers an unknown one.
|
||||
REFUSED_VOICES: set[str] = set()
|
||||
|
||||
|
||||
class StubOpenAICompatible(BaseHTTPRequestHandler):
|
||||
|
|
@ -210,11 +217,15 @@ class StubOpenAICompatible(BaseHTTPRequestHandler):
|
|||
|
||||
def do_GET(self) -> None:
|
||||
parsed = urlsplit(self.path)
|
||||
if parsed.path == "/models" and MODELS_STATUS is not None:
|
||||
if parsed.path == "/audio/voices" and AUDIO_VOICES is not None:
|
||||
self._json({"voices": [{"id": voice} for voice in AUDIO_VOICES]})
|
||||
elif parsed.path == "/models" and MODELS_STATUS is not None:
|
||||
self.send_error(MODELS_STATUS)
|
||||
elif parsed.path == "/models":
|
||||
image_only = parse_qs(parsed.query).get("output_modalities") == ["image"]
|
||||
models = REMOTE_MODELS[1:] if image_only else REMOTE_MODELS[:1]
|
||||
# As OpenRouter: the default listing holds only the chat models,
|
||||
# and `output_modalities=all` every model it serves.
|
||||
every = parse_qs(parsed.query).get("output_modalities") == ["all"]
|
||||
models = REMOTE_MODELS if every else REMOTE_MODELS[:1]
|
||||
self._json({"object": "list", "data": models})
|
||||
else:
|
||||
self.send_error(404)
|
||||
|
|
@ -235,6 +246,23 @@ class StubOpenAICompatible(BaseHTTPRequestHandler):
|
|||
]
|
||||
frames.append("data: [DONE]\n\n")
|
||||
self._send("".join(frames).encode())
|
||||
elif (
|
||||
self.path == "/audio/speech"
|
||||
and json.loads(body).get("voice") in REFUSED_VOICES
|
||||
):
|
||||
self.send_response(400)
|
||||
error = json.dumps({"error": {"message": "unknown voice"}}).encode()
|
||||
self.send_header("Content-Length", str(len(error)))
|
||||
self.end_headers()
|
||||
self.wfile.write(error)
|
||||
elif self.path == "/audio/speech":
|
||||
out = io.BytesIO()
|
||||
with wave.open(out, "wb") as clip:
|
||||
clip.setnchannels(1)
|
||||
clip.setsampwidth(2)
|
||||
clip.setframerate(24000)
|
||||
clip.writeframes(b"\x00\x00" * 2400)
|
||||
self._send(out.getvalue())
|
||||
elif self.path == "/images/generations":
|
||||
encoded = "iVBORw0KGgpmYWtl"
|
||||
self._json({"data": [{"b64_json": encoded, "media_type": "image/png"}]})
|
||||
|
|
@ -257,9 +285,11 @@ class StubOpenAICompatible(BaseHTTPRequestHandler):
|
|||
@pytest.fixture
|
||||
def openai_server() -> Iterator[str]:
|
||||
"""A real OpenAI-compatible endpoint on a real port."""
|
||||
global MODELS_STATUS
|
||||
global MODELS_STATUS, AUDIO_VOICES, REFUSED_VOICES
|
||||
REMOTE_REQUESTS.clear()
|
||||
MODELS_STATUS = None
|
||||
AUDIO_VOICES = None
|
||||
REFUSED_VOICES = set()
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), StubOpenAICompatible)
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
url = f"http://127.0.0.1:{server.server_port}"
|
||||
|
|
|
|||
|
|
@ -697,3 +697,51 @@ async def test_an_unreachable_provider_is_named_as_unreachable(
|
|||
|
||||
assert listed.status_code == 502
|
||||
assert listed.json()["detail"]["code"] == "provider_unreachable"
|
||||
|
||||
|
||||
async def test_a_speech_model_is_heard_before_it_voices_podcasts(
|
||||
client: AsyncClient, openai_server: str
|
||||
) -> None:
|
||||
"""One short line as a clip the dialog plays: in the typed voice, or with
|
||||
none, so the server speaks in its own default where it has one."""
|
||||
connection = await _connect(client, openai_server)
|
||||
url = f"/llm/connections/{connection['id']}/speech-test"
|
||||
|
||||
tested = await client.post(url, json={"model": "tts-1", "voice": " af_heart "})
|
||||
assert tested.status_code == 200, tested.text
|
||||
assert tested.headers["content-type"] == "audio/wav"
|
||||
assert tested.headers["cache-control"] == "no-store"
|
||||
path, body = REMOTE_REQUESTS[-1]
|
||||
assert (path, json.loads(body)["voice"]) == ("/audio/speech", "af_heart")
|
||||
|
||||
unvoiced = await client.post(url, json={"model": "tts-1"})
|
||||
assert unvoiced.status_code == 200, unvoiced.text
|
||||
assert "voice" not in json.loads(REMOTE_REQUESTS[-1][1])
|
||||
|
||||
|
||||
async def test_a_speech_model_only_the_full_listing_holds_fills_the_audio_slot(
|
||||
client: AsyncClient, openai_server: str, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""OpenRouter leaves its speech models out of /models; its `speech` output
|
||||
is the audio slot."""
|
||||
monkeypatch.setattr(
|
||||
conftest,
|
||||
"REMOTE_MODELS",
|
||||
[
|
||||
*conftest.REMOTE_MODELS,
|
||||
{
|
||||
"id": "hexgrad/kokoro-82m",
|
||||
"architecture": {"output_modalities": ["speech"]},
|
||||
},
|
||||
],
|
||||
)
|
||||
connection = await _connect(client, openai_server)
|
||||
|
||||
models = {
|
||||
model["name"]: model
|
||||
for model in (
|
||||
await client.get(f"/llm/connections/{connection['id']}/models")
|
||||
).json()
|
||||
}
|
||||
|
||||
assert models["hexgrad/kokoro-82m"]["selectable_for"] == ["audio_gen"]
|
||||
|
|
|
|||
162
surfsense_local/backend/tests/integration/llm/test_voices.py
Normal file
162
surfsense_local/backend/tests/integration/llm/test_voices.py
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
"""The voices a server's audio model offers podcasts: listed by the server,
|
||||
or added by the user to the audio selection once each has been heard."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from . import conftest
|
||||
from .conftest import REMOTE_REQUESTS
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
URL = "/llm/selection/audio_gen/voices"
|
||||
|
||||
|
||||
async def _choose(
|
||||
client: AsyncClient,
|
||||
base_url: str,
|
||||
model: str = "tts-1",
|
||||
catalog_provider: str = "custom",
|
||||
) -> int:
|
||||
"""A server's audio model in the audio slot; returns its connection id."""
|
||||
reply = await client.post(
|
||||
"/llm/connections",
|
||||
json={
|
||||
"label": f"Speech {model}",
|
||||
"provider": "openai_compatible",
|
||||
"base_url": base_url,
|
||||
"api_key": "secret",
|
||||
"catalog_provider": catalog_provider,
|
||||
},
|
||||
)
|
||||
assert reply.status_code == 201, reply.text
|
||||
connection = reply.json()["id"]
|
||||
await _select(client, connection, model)
|
||||
return connection
|
||||
|
||||
|
||||
async def _select(client: AsyncClient, connection: int, model: str) -> None:
|
||||
chosen = await client.put(
|
||||
"/llm/selection/audio_gen",
|
||||
json={
|
||||
"provider": "openai_compatible",
|
||||
"connection_id": connection,
|
||||
"name": model,
|
||||
"allow_unlisted": True,
|
||||
},
|
||||
)
|
||||
assert chosen.status_code == 200, chosen.text
|
||||
|
||||
|
||||
async def test_a_server_that_lists_its_voices_offers_those(
|
||||
client: AsyncClient, openai_server: str, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Asked of the server, as Kokoro-FastAPI answers; nothing to add."""
|
||||
monkeypatch.setattr(conftest, "AUDIO_VOICES", ["af_heart", "am_adam"])
|
||||
connection = await _choose(client, openai_server, "kokoro")
|
||||
|
||||
assert (await client.get(URL)).json() == {
|
||||
"connection_id": connection,
|
||||
"model": "kokoro",
|
||||
"source": "server",
|
||||
"voices": ["af_heart", "am_adam"],
|
||||
"voices_page": None,
|
||||
}
|
||||
|
||||
|
||||
async def test_a_voice_is_kept_only_once_the_server_has_voiced_it(
|
||||
client: AsyncClient, openai_server: str, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A server that lists none takes the user's own: each heard before it is
|
||||
kept, so a podcast never meets a voice the server refuses."""
|
||||
monkeypatch.setattr(conftest, "REFUSED_VOICES", {"nobody"})
|
||||
connection = await _choose(client, openai_server)
|
||||
|
||||
assert (await client.get(URL)).json() == {
|
||||
"connection_id": connection,
|
||||
"model": "tts-1",
|
||||
"source": "saved",
|
||||
"voices": [],
|
||||
"voices_page": None,
|
||||
}
|
||||
|
||||
refused = await client.post(URL, json={"voice": "nobody"})
|
||||
assert refused.status_code == 502
|
||||
assert "unknown voice" in refused.json()["detail"]
|
||||
|
||||
added = await client.post(URL, json={"voice": " alloy "})
|
||||
assert added.status_code == 201, added.text
|
||||
path, body = REMOTE_REQUESTS[-1]
|
||||
assert (path, json.loads(body)["voice"]) == ("/audio/speech", "alloy")
|
||||
assert (await client.post(URL, json={"voice": "alloy"})).status_code == 201
|
||||
await client.post(URL, json={"voice": "echo"})
|
||||
|
||||
assert (await client.get(URL)).json()["voices"] == ["alloy", "echo"]
|
||||
|
||||
removed = await client.delete(f"{URL}?voice=alloy")
|
||||
assert removed.status_code == 204
|
||||
assert (await client.get(URL)).json()["voices"] == ["echo"]
|
||||
|
||||
|
||||
async def test_choosing_another_model_starts_its_voices_afresh(
|
||||
client: AsyncClient, openai_server: str
|
||||
) -> None:
|
||||
"""The voices belong to the model in the slot; another model has its own."""
|
||||
connection = await _choose(client, openai_server)
|
||||
await client.post(URL, json={"voice": "alloy"})
|
||||
|
||||
await _select(client, connection, "tts-1")
|
||||
assert (await client.get(URL)).json()["voices"] == ["alloy"]
|
||||
await _select(client, connection, "tts-1-hd")
|
||||
assert (await client.get(URL)).json()["voices"] == []
|
||||
|
||||
|
||||
async def test_openrouter_links_the_page_that_names_a_models_voices(
|
||||
client: AsyncClient, openai_server: str
|
||||
) -> None:
|
||||
"""OpenRouter lists no voices by API; its docs point to each model's page."""
|
||||
await _choose(
|
||||
client,
|
||||
openai_server,
|
||||
"bytedance-seed/seed-audio-1-0",
|
||||
catalog_provider="openrouter",
|
||||
)
|
||||
|
||||
assert (await client.get(URL)).json()[
|
||||
"voices_page"
|
||||
] == "https://openrouter.ai/bytedance-seed/seed-audio-1-0"
|
||||
|
||||
|
||||
async def test_a_model_on_this_computer_has_no_server_voices(
|
||||
client: AsyncClient,
|
||||
) -> None:
|
||||
"""Its voices are its reviewed roster; nothing here is for it."""
|
||||
assert (await client.get(URL)).status_code == 409
|
||||
|
||||
|
||||
async def test_a_removed_server_takes_its_voices_with_it(
|
||||
client: AsyncClient, openai_server: str
|
||||
) -> None:
|
||||
"""They go with the audio selection, which goes with its connection."""
|
||||
connection = await _choose(client, openai_server)
|
||||
await client.post(URL, json={"voice": "alloy"})
|
||||
|
||||
await client.delete(f"/llm/connections/{connection}")
|
||||
await _choose(client, openai_server)
|
||||
|
||||
assert (await client.get(URL)).json()["voices"] == []
|
||||
|
||||
|
||||
async def test_a_voice_is_heard_in_the_line_it_is_given(
|
||||
client: AsyncClient, openai_server: str
|
||||
) -> None:
|
||||
"""The app sends the line in the interface's language, so the voice is
|
||||
heard speaking it; nothing says which languages a server's voice speaks."""
|
||||
await _choose(client, openai_server)
|
||||
|
||||
await client.post(URL, json={"voice": "ff_siwis", "text": "Bonjour."})
|
||||
|
||||
path, body = REMOTE_REQUESTS[-1]
|
||||
assert (path, json.loads(body)["input"]) == ("/audio/speech", "Bonjour.")
|
||||
|
|
@ -14,6 +14,7 @@ from modules.llm.providers.audiocpp.memory import NotEnoughMemoryError
|
|||
from modules.llm.providers.audiocpp.speech import AudioCppSpeech, VoicedModel
|
||||
from modules.llm.providers.llamacpp import RouterClient
|
||||
from modules.llm.providers.openai_compatible import NonRetryableImageError
|
||||
from modules.llm.providers.openai_compatible.speech import NonRetryableSpeechError
|
||||
from modules.llm.providers.protocols import (
|
||||
GeneratedImage,
|
||||
SpokenTurn,
|
||||
|
|
@ -530,6 +531,32 @@ def test_a_memory_refusal_is_not_retried(
|
|||
assert artifact.document.error_message == SHORT
|
||||
|
||||
|
||||
class RefusingServer(ShortOfMemoryAtVoicing):
|
||||
async def synthesize(self, turns: list[SpokenTurn], language: str):
|
||||
raise NonRetryableSpeechError("the server could not voice turn 1 of 2: bad key")
|
||||
|
||||
|
||||
def test_a_servers_voicing_refusal_is_not_retried(
|
||||
session: Session, stub_model: None, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A retry would draft and bill the whole episode again."""
|
||||
_capture_model(
|
||||
monkeypatch,
|
||||
'{"title": "T", "segments": [{"title": "One"}]}',
|
||||
'{"turns": [{"speaker": 1, "text": "Hi."}, {"speaker": 2, "text": "Hello."}]}',
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"worker.studio.job.resolve_text_to_speech", lambda _session: RefusingServer()
|
||||
)
|
||||
artifact = make_artifact(session, fmt="podcast", options=BRIEF)
|
||||
|
||||
run(artifact.id) # returning, not raising, is what spares a Huey retry
|
||||
|
||||
session.expire_all()
|
||||
assert artifact.document.status is DocumentStatus.FAILED
|
||||
assert "bad key" in artifact.document.error_message
|
||||
|
||||
|
||||
def test_a_podcast_cancelled_while_voicing_stops_at_the_next_turn(
|
||||
session: Session, stub_model: None, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
|
|
|
|||
|
|
@ -95,6 +95,13 @@ def test_a_model_that_emits_two_media_carries_two_types() -> None:
|
|||
}
|
||||
|
||||
|
||||
def test_a_model_that_only_speaks_is_an_audio_model() -> None:
|
||||
"""tts-1 declares text in, audio out, and no context window."""
|
||||
assert classify("tts-1", _entry(["text"], ["audio"], context=None)) == {
|
||||
ModelType.AUDIO_GEN
|
||||
}
|
||||
|
||||
|
||||
def test_video_out_is_video_generation() -> None:
|
||||
"""Named because the type exists in the source, not because a tab wants it."""
|
||||
assert classify("happyhorse-1.1-t2v", _entry(["text"], ["video"])) == {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,199 @@
|
|||
"""Podcast voices from a server's /audio/speech, against a stub of it."""
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import wave
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from modules.llm.providers.openai_compatible.speech import (
|
||||
NonRetryableSpeechError,
|
||||
RemoteSpeech,
|
||||
)
|
||||
from modules.llm.providers.protocols import SpokenTurn
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def wav(frames: int, rate: int = 24000) -> bytes:
|
||||
"""What a server answers a WAV speech request with: 16-bit mono PCM."""
|
||||
out = io.BytesIO()
|
||||
with wave.open(out, "wb") as w:
|
||||
w.setnchannels(1)
|
||||
w.setsampwidth(2)
|
||||
w.setframerate(rate)
|
||||
w.writeframes(b"\x01\x00" * frames)
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
class StubServer:
|
||||
def __init__(self, fail_on: int | None = None) -> None:
|
||||
self.fail_on = fail_on
|
||||
self.requests: list[httpx.Request] = []
|
||||
|
||||
def __call__(self, request: httpx.Request) -> httpx.Response:
|
||||
self.requests.append(request)
|
||||
if len(self.requests) == self.fail_on:
|
||||
return httpx.Response(
|
||||
400, json={"error": {"message": "voice 'nobody' is not valid"}}
|
||||
)
|
||||
return httpx.Response(200, content=wav(1000))
|
||||
|
||||
def bodies(self) -> list[dict]:
|
||||
return [json.loads(request.content) for request in self.requests]
|
||||
|
||||
|
||||
def speak(server: StubServer, turns: list[SpokenTurn]):
|
||||
"""Voice `turns` through the adapter against `server`."""
|
||||
speech = RemoteSpeech(
|
||||
"tts-1",
|
||||
base_url="https://api.example.com/v1",
|
||||
api_key="secret",
|
||||
transport=httpx.MockTransport(server),
|
||||
)
|
||||
return asyncio.run(speech.synthesize(turns, "en"))
|
||||
|
||||
|
||||
def test_each_turn_is_one_speech_request_joined_into_one_wav() -> None:
|
||||
"""WAV is asked for every time, because only WAV joins without an encoder."""
|
||||
server = StubServer()
|
||||
|
||||
audio = speak(server, [SpokenTurn("coral", "Hi."), SpokenTurn("onyx", "Hello.")])
|
||||
|
||||
assert [str(r.url) for r in server.requests] == [
|
||||
"https://api.example.com/v1/audio/speech"
|
||||
] * 2
|
||||
assert server.requests[0].headers["authorization"] == "Bearer secret"
|
||||
assert server.bodies() == [
|
||||
{"model": "tts-1", "voice": "coral", "input": "Hi.", "response_format": "wav"},
|
||||
{
|
||||
"model": "tts-1",
|
||||
"voice": "onyx",
|
||||
"input": "Hello.",
|
||||
"response_format": "wav",
|
||||
},
|
||||
]
|
||||
assert audio.media_type == "audio/wav"
|
||||
with wave.open(io.BytesIO(audio.content)) as joined:
|
||||
# Two 1000-frame turns and the 0.35 s pause between speakers.
|
||||
assert joined.getnframes() == 2000 + 8400
|
||||
|
||||
|
||||
def test_a_refused_turn_names_the_servers_reason_and_is_not_retried() -> None:
|
||||
"""A retry would draft and bill the episode again, and fail the same way."""
|
||||
server = StubServer(fail_on=2)
|
||||
|
||||
with pytest.raises(NonRetryableSpeechError, match="voice 'nobody' is not valid"):
|
||||
speak(server, [SpokenTurn("coral", "Hi."), SpokenTurn("onyx", "Hello.")])
|
||||
|
||||
|
||||
def test_voicing_on_a_server_needs_no_memory_here() -> None:
|
||||
"""Drafting is never refused for memory the server, not this machine, spends."""
|
||||
speech = RemoteSpeech("tts-1", base_url="http://tts", api_key=None)
|
||||
|
||||
asyncio.run(speech.check_memory())
|
||||
|
||||
|
||||
def test_a_server_that_answers_in_another_format_says_so() -> None:
|
||||
"""Only WAV and MP3 join; Ogg back from a server that ignored the format
|
||||
is refused rather than stored as an episode nothing can play whole."""
|
||||
|
||||
def ogg(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, content=b"OggS\x00\x02fake opus")
|
||||
|
||||
speech = RemoteSpeech(
|
||||
"tts-1",
|
||||
base_url="http://tts",
|
||||
api_key=None,
|
||||
transport=httpx.MockTransport(ogg),
|
||||
)
|
||||
|
||||
with pytest.raises(NonRetryableSpeechError, match="not as WAV or MP3"):
|
||||
asyncio.run(speech.synthesize([SpokenTurn("coral", "Hi.")], "en"))
|
||||
|
||||
|
||||
def test_a_reply_of_several_wavs_back_to_back_joins_as_all_of_them() -> None:
|
||||
"""Some servers stream a long line as one WAV per sentence in one body."""
|
||||
|
||||
def stacked(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, content=wav(600) + wav(400))
|
||||
|
||||
speech = RemoteSpeech(
|
||||
"tts-1",
|
||||
base_url="http://tts",
|
||||
api_key=None,
|
||||
transport=httpx.MockTransport(stacked),
|
||||
)
|
||||
audio = asyncio.run(speech.synthesize([SpokenTurn("coral", "Hi.")], "en"))
|
||||
|
||||
with wave.open(io.BytesIO(audio.content)) as joined:
|
||||
assert joined.getnframes() == 1000
|
||||
|
||||
|
||||
# MPEG-1 Layer III, 128 kbit/s, 44.1 kHz, no padding: 417 bytes a frame.
|
||||
FRAME = b"\xff\xfb\x90\x00" + b"\x00" * 413
|
||||
|
||||
|
||||
def mp3(frames: int, *, tag: bool = True, xing: bool = True) -> bytes:
|
||||
"""An mp3 as a server sends one: an ID3v2 tag, a Xing frame, audio frames."""
|
||||
head = b"ID3\x04\x00\x00\x00\x00\x00\x05" + b"x" * 5 if tag else b""
|
||||
info = b"\xff\xfb\x90\x00" + b"\x00" * 32 + b"Xing" + b"\x00" * 377 if xing else b""
|
||||
return head + info + FRAME * frames
|
||||
|
||||
|
||||
class MpegOnlyServer:
|
||||
"""OpenRouter's shape: `wav` is refused, `mp3` answered."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.formats: list[str] = []
|
||||
|
||||
def __call__(self, request: httpx.Request) -> httpx.Response:
|
||||
wanted = json.loads(request.content)["response_format"]
|
||||
self.formats.append(wanted)
|
||||
if wanted != "mp3":
|
||||
return httpx.Response(
|
||||
400, json={"error": {"message": "response_format must be mp3 or pcm"}}
|
||||
)
|
||||
return httpx.Response(200, content=mp3(3))
|
||||
|
||||
|
||||
def test_a_server_that_refuses_wav_voices_the_episode_as_one_mp3() -> None:
|
||||
"""Asked once, then mp3 for every line; each line's own tag and Xing
|
||||
frame go, or a player reads the first line's length as the episode's."""
|
||||
server = MpegOnlyServer()
|
||||
speech = RemoteSpeech(
|
||||
"hexgrad/kokoro-82m",
|
||||
base_url="http://tts",
|
||||
api_key=None,
|
||||
transport=httpx.MockTransport(server),
|
||||
)
|
||||
|
||||
audio = asyncio.run(
|
||||
speech.synthesize(
|
||||
[SpokenTurn("af_heart", "Hi."), SpokenTurn("am_adam", "Hello.")], "en"
|
||||
)
|
||||
)
|
||||
|
||||
assert server.formats == ["wav", "mp3", "mp3"]
|
||||
assert audio.media_type == "audio/mpeg"
|
||||
assert audio.content == FRAME * 6
|
||||
|
||||
|
||||
def test_a_refusal_wav_did_not_cause_is_not_asked_again_as_mp3() -> None:
|
||||
"""Only the first line tries mp3; a later refusal is that line's own."""
|
||||
server = StubServer(fail_on=2)
|
||||
|
||||
with pytest.raises(NonRetryableSpeechError, match="turn 2 of 2"):
|
||||
speak(server, [SpokenTurn("coral", "Hi."), SpokenTurn("onyx", "Hello.")])
|
||||
assert [b["response_format"] for b in server.bodies()] == ["wav", "wav"]
|
||||
|
||||
|
||||
def test_a_line_with_no_voice_leaves_the_voice_to_the_server() -> None:
|
||||
"""Only "Test voice" sends one: the server speaks in its default, if any."""
|
||||
server = StubServer()
|
||||
|
||||
speak(server, [SpokenTurn("", "Hi.")])
|
||||
|
||||
assert "voice" not in server.bodies()[0]
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
"""A server's voices, asked of the server: nothing is kept in the repo."""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from modules.llm.providers.openai_compatible.voice_list import listed_voices
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def ask(handler, connection_id: int = 1):
|
||||
"""The voices the server behind `handler` lists, or None."""
|
||||
return listed_voices(
|
||||
connection_id,
|
||||
f"http://tts-{connection_id}/v1",
|
||||
"secret",
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
|
||||
|
||||
def test_a_server_that_lists_its_voices_fills_the_picker() -> None:
|
||||
"""Kokoro-FastAPI's shape. It states ids alone, so no gender or language
|
||||
is given: the brief does not claim one for them."""
|
||||
seen: list[httpx.Request] = []
|
||||
|
||||
def kokoro(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(request)
|
||||
return httpx.Response(
|
||||
200, json={"voices": [{"id": "af_heart"}, {"id": "am_adam"}]}
|
||||
)
|
||||
|
||||
voices = ask(kokoro, connection_id=1)
|
||||
|
||||
assert str(seen[0].url) == "http://tts-1/v1/audio/voices"
|
||||
assert seen[0].headers["authorization"] == "Bearer secret"
|
||||
assert [(v.id, v.label, v.gender, v.languages) for v in voices] == [
|
||||
("af_heart", "af_heart", None, ()),
|
||||
("am_adam", "am_adam", None, ()),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reply",
|
||||
[
|
||||
httpx.Response(404),
|
||||
httpx.Response(200, json={"data": []}),
|
||||
httpx.Response(200, json={"voices": [{"name": "no id"}]}),
|
||||
httpx.Response(200, text="<html>"),
|
||||
],
|
||||
)
|
||||
def test_a_server_that_cannot_list_them_leaves_the_user_to_type_them(
|
||||
reply: httpx.Response,
|
||||
) -> None:
|
||||
"""OpenAI, Groq and OpenRouter have no such route: nothing is guessed."""
|
||||
assert ask(lambda request: reply, connection_id=2) is None
|
||||
|
||||
|
||||
def test_the_answer_is_asked_once_per_server() -> None:
|
||||
"""Held for the process and never stored, so the next start asks again."""
|
||||
calls: list[int] = []
|
||||
|
||||
def server(request: httpx.Request) -> httpx.Response:
|
||||
calls.append(1)
|
||||
return httpx.Response(404)
|
||||
|
||||
ask(server, connection_id=3)
|
||||
ask(server, connection_id=3)
|
||||
|
||||
assert calls == [1]
|
||||
|
||||
|
||||
def test_an_unreachable_server_is_asked_again_next_time() -> None:
|
||||
"""No answer is not an answer: it says nothing about the route."""
|
||||
calls: list[int] = []
|
||||
|
||||
def flaky(request: httpx.Request) -> httpx.Response:
|
||||
calls.append(1)
|
||||
if len(calls) == 1:
|
||||
raise httpx.ConnectError("down")
|
||||
return httpx.Response(200, json={"voices": [{"id": "af_heart"}]})
|
||||
|
||||
assert ask(flaky, connection_id=4) is None
|
||||
assert [v.id for v in ask(flaky, connection_id=4)] == ["af_heart"]
|
||||
|
|
@ -100,6 +100,34 @@ def test_types_come_from_the_endpoint_then_the_catalogue_then_nowhere() -> None:
|
|||
assert models[name].capability_known is False
|
||||
|
||||
|
||||
def test_an_endpoints_own_word_for_speech_is_the_audio_slot() -> None:
|
||||
"""OpenRouter declares `speech` for text-to-speech and `transcription` for
|
||||
speech-to-text; each output a model declares is a type, as with images."""
|
||||
models = {
|
||||
model.name: model
|
||||
for model in parse_models(
|
||||
{
|
||||
"data": [
|
||||
{"id": "omni", "output_modalities": ["text", "audio"]},
|
||||
{
|
||||
"id": "hexgrad/kokoro-82m",
|
||||
"architecture": {"output_modalities": ["speech"]},
|
||||
},
|
||||
{
|
||||
"id": "openai/whisper-1",
|
||||
"architecture": {"output_modalities": ["transcription"]},
|
||||
},
|
||||
]
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
assert models["omni"].types == (ModelType.TEXT_GEN, ModelType.AUDIO_GEN)
|
||||
assert models["hexgrad/kokoro-82m"].types == (ModelType.AUDIO_GEN,)
|
||||
assert models["openai/whisper-1"].types == ()
|
||||
assert models["openai/whisper-1"].capability_known is True
|
||||
|
||||
|
||||
def test_delta_reads_openai_sse_and_ignores_done() -> None:
|
||||
"""Chat streaming retains the existing OpenAI delta behavior."""
|
||||
assert _delta('data: {"choices":[{"delta":{"content":"hi"}}]}') == Delta("hi")
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from modules.documents.models import Document, DocumentStatus
|
|||
from modules.llm.model_type import ModelType
|
||||
from modules.llm.providers.audiocpp.memory import NotEnoughMemoryError
|
||||
from modules.llm.providers.openai_compatible import NonRetryableImageError
|
||||
from modules.llm.providers.openai_compatible.speech import NonRetryableSpeechError
|
||||
from modules.llm.providers.protocols import TextToSpeech
|
||||
from modules.llm.resolution import (
|
||||
ModelResolutionError,
|
||||
|
|
@ -124,7 +125,10 @@ def _generate(session: Session, artifact: Artifact) -> None:
|
|||
document.error_message,
|
||||
)
|
||||
# A retry would repeat minutes of drafting and fail the same way.
|
||||
if isinstance(failure, NonRetryableImageError | NotEnoughMemoryError):
|
||||
if isinstance(
|
||||
failure,
|
||||
NonRetryableImageError | NonRetryableSpeechError | NotEnoughMemoryError,
|
||||
):
|
||||
return
|
||||
raise # Huey retries; a later success clears the message.
|
||||
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ import {
|
|||
PencilEdit02Icon as PencilEdit02IconData,
|
||||
Pdf01Icon as Pdf01IconData,
|
||||
PencilIcon as PencilIconData,
|
||||
PlayIcon as PlayIconData,
|
||||
PlusIcon as PlusIconData,
|
||||
PodcastIcon as PodcastIconData,
|
||||
Presentation02Icon as Presentation02IconData,
|
||||
|
|
@ -149,6 +150,7 @@ export const FilterIcon = createIcon(FilterIconData)
|
|||
export const FolderOpenIcon = createIcon(FolderOpenIconData)
|
||||
export const Image01Icon = createIcon(Image01IconData)
|
||||
export const AudioWaveformIcon = createIcon(AudioWaveformIconData)
|
||||
export const PlayIcon = createIcon(PlayIconData)
|
||||
export const InformationCircleIcon = createIcon(InformationCircleIconData)
|
||||
export const LayoutGridIcon = createIcon(LayoutGridIconData)
|
||||
export const LicenseIcon = createIcon(LicenseIconData)
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ function WorkspaceDashboard({
|
|||
onModelRequired,
|
||||
onModelSelected,
|
||||
onOpenLicense,
|
||||
onOpenAudioSettings,
|
||||
modelsVisited,
|
||||
}: {
|
||||
workspace: Workspace
|
||||
|
|
@ -94,6 +95,7 @@ function WorkspaceDashboard({
|
|||
onModelRequired: () => void
|
||||
onModelSelected: (selection: ModelSelection) => void
|
||||
onOpenLicense: () => void
|
||||
onOpenAudioSettings: () => void
|
||||
modelsVisited: number
|
||||
}) {
|
||||
const [inspect, setInspect] = useState<Inspect>(null)
|
||||
|
|
@ -347,6 +349,7 @@ function WorkspaceDashboard({
|
|||
isCreating={studio.isCreating}
|
||||
error={studio.error}
|
||||
onGenerate={studio.create}
|
||||
onSetUpVoices={onOpenAudioSettings}
|
||||
/>
|
||||
}
|
||||
artifacts={
|
||||
|
|
@ -564,6 +567,7 @@ export function DashboardPage({
|
|||
onModelRequired={() => openSettings("chat-models")}
|
||||
onModelSelected={onModelSelected}
|
||||
onOpenLicense={() => openSettings("license")}
|
||||
onOpenAudioSettings={() => openSettings("audio-models")}
|
||||
modelsVisited={modelsVisited}
|
||||
/>
|
||||
<SettingsDialog
|
||||
|
|
|
|||
|
|
@ -49,6 +49,28 @@ export async function testConnectionChat(
|
|||
return reply
|
||||
}
|
||||
|
||||
/** `text` spoken once. `voice` is sent only when typed; without one the
|
||||
* server speaks in its own default, where it has one. */
|
||||
export async function testConnectionSpeech(
|
||||
id: number,
|
||||
model: string,
|
||||
voice: string,
|
||||
text: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<Blob> {
|
||||
const typed = voice.trim()
|
||||
const body = typed
|
||||
? { model, voice: typed, prompt: text }
|
||||
: { model, prompt: text }
|
||||
const response = await request(`/llm/connections/${id}/speech-test`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
})
|
||||
return response.blob()
|
||||
}
|
||||
|
||||
export async function testConnectionImage(
|
||||
id: number,
|
||||
model: string,
|
||||
|
|
|
|||
|
|
@ -60,6 +60,14 @@ const gatewayModels = [
|
|||
capability_source: "catalog",
|
||||
selectable_for: ["text_gen"],
|
||||
},
|
||||
{
|
||||
connection_id: 1,
|
||||
connection_label: "Chat gateway",
|
||||
name: "tts-1",
|
||||
types: ["audio_gen"],
|
||||
capability_source: "catalog",
|
||||
selectable_for: ["audio_gen"],
|
||||
},
|
||||
{
|
||||
connection_id: 1,
|
||||
connection_label: "Chat gateway",
|
||||
|
|
@ -257,6 +265,50 @@ describe("choosing a model from a server", () => {
|
|||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it("plays a test line in the typed voice before the model takes the audio slot", async () => {
|
||||
const sent: unknown[] = []
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
serving((path, init) => {
|
||||
if (
|
||||
path !== "/llm/connections/1/speech-test" ||
|
||||
init?.method !== "POST"
|
||||
)
|
||||
return null
|
||||
sent.push(JSON.parse(String(init.body)))
|
||||
return new Response(new Uint8Array([1, 2, 3]), {
|
||||
headers: { "Content-Type": "audio/wav" },
|
||||
})
|
||||
})
|
||||
)
|
||||
vi.stubGlobal("URL", {
|
||||
...URL,
|
||||
createObjectURL: vi.fn(() => "blob:test-voice"),
|
||||
revokeObjectURL: vi.fn(),
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<ServerModelPicker onEdit={() => undefined} modelType="audio_gen" />)
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Show audio models" })
|
||||
)
|
||||
await user.click(await screen.findByRole("button", { name: "Use tts-1" }))
|
||||
|
||||
expect(screen.getByText(/Speech support is confirmed/)).toBeTruthy()
|
||||
await user.type(screen.getByRole("textbox", { name: "Voice" }), "af_heart")
|
||||
await user.click(screen.getByRole("button", { name: "Test voice" }))
|
||||
const clip = await screen.findByLabelText("Test voiced by tts-1")
|
||||
expect(clip.getAttribute("src")).toBe("blob:test-voice")
|
||||
// The line is spoken in the interface's language.
|
||||
expect(sent).toEqual([
|
||||
{
|
||||
model: "tts-1",
|
||||
voice: "af_heart",
|
||||
prompt: "Hello. This is how your podcasts will sound.",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("assigns a typed model id without the listing's check", async () => {
|
||||
const fetchMock = serving()
|
||||
vi.stubGlobal("fetch", fetchMock)
|
||||
|
|
@ -407,4 +459,47 @@ describe("choosing a model from a server", () => {
|
|||
)[1]
|
||||
expect(JSON.parse(String(finalCreate[1]?.body)).allow_unverified).toBe(true)
|
||||
})
|
||||
|
||||
it("sets up the voices of the server's audio model inside that server", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
serving((path, init) => {
|
||||
if (path === "/llm/selection/audio_gen" && !init?.method)
|
||||
return Response.json({
|
||||
model_type: "audio_gen",
|
||||
provider: "openai_compatible",
|
||||
connection_id: 1,
|
||||
name: "tts-1",
|
||||
updated_at: "2026-09-10T00:00:00Z",
|
||||
})
|
||||
if (path === "/llm/selection/audio_gen/voices")
|
||||
return Response.json({
|
||||
connection_id: 1,
|
||||
model: "tts-1",
|
||||
source: "saved",
|
||||
voices: [],
|
||||
voices_page: null,
|
||||
})
|
||||
return null
|
||||
})
|
||||
)
|
||||
|
||||
render(<ServerModelPicker onEdit={() => undefined} modelType="audio_gen" />)
|
||||
|
||||
const server = await screen.findByRole("region", { name: /Chat gateway/ })
|
||||
expect(
|
||||
await within(server).findByRole("heading", {
|
||||
name: "Voices for this model",
|
||||
})
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it("has no voices to set up for a server's chat model", async () => {
|
||||
vi.stubGlobal("fetch", serving())
|
||||
|
||||
render(<ServerModelPicker onEdit={() => undefined} modelType="text_gen" />)
|
||||
|
||||
await screen.findByRole("region", { name: /Chat gateway/ })
|
||||
expect(screen.queryByText("Voices for this model")).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { intl } from "@/i18n/intl"
|
|||
import type { ModelType } from "../../model-type"
|
||||
import type { ModelSelection } from "../../selection/api"
|
||||
import { useSelection } from "../../selection/use-selection"
|
||||
import { ServerVoices } from "../voices/server-voices"
|
||||
import type { Connection } from "../connections/api"
|
||||
import { DisconnectButton } from "../connections/disconnect-button"
|
||||
import type { ConnectionModel } from "./api"
|
||||
|
|
@ -188,6 +189,12 @@ export function ServerModels({
|
|||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{current && modelType === "audio_gen" ? (
|
||||
// Its voices belong to this server's model, so they are set up here.
|
||||
<div className="border-b px-3 py-3">
|
||||
<ServerVoices />
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { ApiError } from "@/lib/api"
|
||||
import { intl } from "@/i18n/intl"
|
||||
|
|
@ -29,6 +30,7 @@ import { useSelect } from "../../selection/use-selection"
|
|||
import {
|
||||
testConnectionChat,
|
||||
testConnectionImage,
|
||||
testConnectionSpeech,
|
||||
type ConnectionModel,
|
||||
} from "./api"
|
||||
|
||||
|
|
@ -49,6 +51,19 @@ function Explanation({
|
|||
model: ConnectionModel
|
||||
}) {
|
||||
const image = modelType === "image_gen"
|
||||
if (modelType === "audio_gen") {
|
||||
return model.capability_source === "unknown"
|
||||
? intl.formatMessage({
|
||||
id: "models_try_dialog_speech_unconfirmed_body",
|
||||
defaultMessage:
|
||||
"This endpoint does not publish capabilities, so speech support is unconfirmed. It must implement /audio/speech. Testing voices one short line and may cost money.",
|
||||
})
|
||||
: intl.formatMessage({
|
||||
id: "models_try_dialog_speech_confirmed_body",
|
||||
defaultMessage:
|
||||
"Speech support is confirmed. Testing voices one short line and may cost money.",
|
||||
})
|
||||
}
|
||||
if (model.capability_source === "unknown") {
|
||||
return image
|
||||
? intl.formatMessage({
|
||||
|
|
@ -102,6 +117,7 @@ export function TryModelDialog({
|
|||
const [reply, setReply] = useState<string | null>(null)
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [voice, setVoice] = useState("")
|
||||
const [confirmUnlisted, setConfirmUnlisted] = useState<string | null>(null)
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
|
||||
|
|
@ -115,12 +131,21 @@ export function TryModelDialog({
|
|||
const runTest = () => {
|
||||
setTesting(true)
|
||||
setError(null)
|
||||
const preview = (blob: Blob) => setPreviewUrl(URL.createObjectURL(blob))
|
||||
const attempt =
|
||||
modelType === "image_gen"
|
||||
? testConnectionImage(model.connection_id, model.name).then((blob) =>
|
||||
setPreviewUrl(URL.createObjectURL(blob))
|
||||
)
|
||||
: testConnectionChat(model.connection_id, model.name).then(setReply)
|
||||
? testConnectionImage(model.connection_id, model.name).then(preview)
|
||||
: modelType === "audio_gen"
|
||||
? testConnectionSpeech(
|
||||
model.connection_id,
|
||||
model.name,
|
||||
voice,
|
||||
intl.formatMessage({
|
||||
id: "models_try_dialog_voice_trial_body",
|
||||
defaultMessage: "Hello. This is how your podcasts will sound.",
|
||||
})
|
||||
).then(preview)
|
||||
: testConnectionChat(model.connection_id, model.name).then(setReply)
|
||||
void attempt
|
||||
.catch((cause: unknown) => setError(messageFrom(cause)))
|
||||
.finally(() => setTesting(false))
|
||||
|
|
@ -182,7 +207,48 @@ export function TryModelDialog({
|
|||
<Explanation modelType={modelType} model={model} />
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{previewUrl ? (
|
||||
{modelType === "audio_gen" ? (
|
||||
<div className="space-y-1.5">
|
||||
<Input
|
||||
aria-label={intl.formatMessage({
|
||||
id: "models_try_dialog_voice_aria",
|
||||
defaultMessage: "Voice",
|
||||
})}
|
||||
placeholder={intl.formatMessage({
|
||||
id: "models_try_dialog_voice_placeholder",
|
||||
defaultMessage: "Voice ID (optional)",
|
||||
})}
|
||||
className="select-text"
|
||||
value={voice}
|
||||
maxLength={100}
|
||||
spellCheck={false}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
onChange={(event) => setVoice(event.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{intl.formatMessage({
|
||||
id: "models_try_dialog_voice_body",
|
||||
defaultMessage:
|
||||
"Empty uses the server’s default voice, if it has one.",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{previewUrl && modelType === "audio_gen" ? (
|
||||
<audio
|
||||
controls
|
||||
src={previewUrl}
|
||||
aria-label={intl.formatMessage(
|
||||
{
|
||||
id: "models_try_dialog_speech_preview_aria",
|
||||
defaultMessage: "Test voiced by {model}",
|
||||
},
|
||||
{ model: model.name }
|
||||
)}
|
||||
className="w-full"
|
||||
/>
|
||||
) : previewUrl ? (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={intl.formatMessage(
|
||||
|
|
@ -217,10 +283,15 @@ export function TryModelDialog({
|
|||
id: "models_try_dialog_test_image_button",
|
||||
defaultMessage: "Test image",
|
||||
})
|
||||
: intl.formatMessage({
|
||||
id: "models_try_dialog_test_chat_button",
|
||||
defaultMessage: "Test chat",
|
||||
})}
|
||||
: modelType === "audio_gen"
|
||||
? intl.formatMessage({
|
||||
id: "models_try_dialog_test_speech_button",
|
||||
defaultMessage: "Test voice",
|
||||
})
|
||||
: intl.formatMessage({
|
||||
id: "models_try_dialog_test_chat_button",
|
||||
defaultMessage: "Test chat",
|
||||
})}
|
||||
</Button>
|
||||
<Button disabled={select.isPending} onClick={() => assign(unlisted)}>
|
||||
{select.isPending ? <Spinner data-icon="inline-start" /> : null}
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
import type { ModelType } from "../model-type"
|
||||
|
||||
// Types no server model can fill yet. Every place that offers servers, in
|
||||
// Settings and onboarding, asks here, so adding a type hides them everywhere.
|
||||
const LOCAL_ONLY: ReadonlySet<ModelType> = new Set([
|
||||
// No server model can voice podcasts yet.
|
||||
"audio_gen",
|
||||
])
|
||||
|
||||
export function serversCanServe(modelType: ModelType): boolean {
|
||||
return !LOCAL_ONLY.has(modelType)
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import { request, requestJson } from "@/lib/api"
|
||||
|
||||
const URL = "/llm/selection/audio_gen/voices"
|
||||
|
||||
/** The audio selection's voices: the server's own list, or the ones the user
|
||||
* added after hearing each. `connection_id` and `model` play a voice. */
|
||||
export type ServerVoicesRead = {
|
||||
connection_id: number
|
||||
model: string
|
||||
source: "server" | "saved"
|
||||
voices: string[]
|
||||
/** Where the provider documents the model's voices, when it does. */
|
||||
voices_page: string | null
|
||||
}
|
||||
|
||||
export function readServerVoices(
|
||||
signal?: AbortSignal
|
||||
): Promise<ServerVoicesRead> {
|
||||
return requestJson(URL, { signal })
|
||||
}
|
||||
|
||||
/** Voices `text` in it first; the server's refusal keeps nothing. */
|
||||
export async function addServerVoice({
|
||||
voice,
|
||||
text,
|
||||
}: {
|
||||
voice: string
|
||||
text: string
|
||||
}): Promise<void> {
|
||||
await request(URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ voice, text }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function removeServerVoice(voice: string): Promise<void> {
|
||||
await request(`${URL}?voice=${encodeURIComponent(voice)}`, {
|
||||
method: "DELETE",
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import { cleanup, screen, within } from "@testing-library/react"
|
||||
import userEvent from "@testing-library/user-event"
|
||||
|
||||
import { render } from "@/test-utils"
|
||||
|
||||
import { ServerVoices } from "./server-voices"
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
const URL_ = "/llm/selection/audio_gen/voices"
|
||||
|
||||
/** The audio selection's voices, as the backend keeps them. */
|
||||
function serving(
|
||||
initial: { source: "server" | "saved"; voices: string[] },
|
||||
refused: string[] = [],
|
||||
sent: unknown[] = []
|
||||
) {
|
||||
let voices = [...initial.voices]
|
||||
return vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const path = String(input)
|
||||
if (path === URL_ && (init?.method ?? "GET") === "GET") {
|
||||
return Response.json({
|
||||
connection_id: 1,
|
||||
model: "seed-audio",
|
||||
source: initial.source,
|
||||
voices,
|
||||
voices_page: "https://openrouter.ai/seed-audio",
|
||||
})
|
||||
}
|
||||
if (path === URL_ && init?.method === "POST") {
|
||||
sent.push(JSON.parse(String(init.body)))
|
||||
const voice = JSON.parse(String(init.body)).voice as string
|
||||
if (refused.includes(voice)) {
|
||||
return Response.json(
|
||||
{ detail: "the server could not voice turn 1 of 1: unknown voice" },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
voices = [...voices, voice]
|
||||
return new Response(null, { status: 201 })
|
||||
}
|
||||
if (path.startsWith(`${URL_}?voice=`) && init?.method === "DELETE") {
|
||||
const voice = decodeURIComponent(path.split("=")[1])
|
||||
voices = voices.filter((kept) => kept !== voice)
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
return Response.json({ detail: "not found" }, { status: 404 })
|
||||
})
|
||||
}
|
||||
|
||||
describe("a server audio model's voices", () => {
|
||||
it("shows the voices the server lists, with nothing to add", async () => {
|
||||
vi.stubGlobal("fetch", serving({ source: "server", voices: ["af_heart"] }))
|
||||
render(<ServerVoices />)
|
||||
|
||||
expect(await screen.findByText("Listed by the server")).toBeTruthy()
|
||||
expect(screen.getByText("af_heart")).toBeTruthy()
|
||||
expect(screen.queryByRole("textbox", { name: "Voice ID" })).toBeNull()
|
||||
})
|
||||
|
||||
it("keeps a voice only once the server has voiced it, and removes it", async () => {
|
||||
const sent: unknown[] = []
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
serving({ source: "saved", voices: [] }, ["nobody"], sent)
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
render(<ServerVoices />)
|
||||
|
||||
expect(
|
||||
await screen.findByText(/This server doesn’t list its voices/)
|
||||
).toBeTruthy()
|
||||
expect(
|
||||
screen
|
||||
.getByRole("link", { name: "See this model’s voices" })
|
||||
.getAttribute("href")
|
||||
).toBe("https://openrouter.ai/seed-audio")
|
||||
|
||||
const field = screen.getByRole("textbox", { name: "Voice ID" })
|
||||
await user.type(field, "nobody")
|
||||
await user.click(screen.getByRole("button", { name: "Add and test" }))
|
||||
expect(await screen.findByText(/unknown voice/)).toBeTruthy()
|
||||
expect(screen.queryByRole("listitem")).toBeNull()
|
||||
|
||||
await user.clear(field)
|
||||
await user.type(field, "en_paul_neutral")
|
||||
await user.click(screen.getByRole("button", { name: "Add and test" }))
|
||||
const row = await screen.findByRole("listitem")
|
||||
// Heard saying a line in the interface's language.
|
||||
expect(sent.at(-1)).toEqual({
|
||||
voice: "en_paul_neutral",
|
||||
text: "Hello. This is how your podcasts will sound.",
|
||||
})
|
||||
expect(within(row).getByText("en_paul_neutral")).toBeTruthy()
|
||||
expect((field as HTMLInputElement).value).toBe("")
|
||||
|
||||
await user.click(
|
||||
within(row).getByRole("button", { name: "Remove en_paul_neutral" })
|
||||
)
|
||||
await vi.waitFor(() => expect(screen.queryByRole("listitem")).toBeNull())
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,230 @@
|
|||
import { useEffect, useState } from "react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ExternalLinkIcon, PlayIcon, Trash2Icon } from "@/components/ui/icons"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { intl } from "@/i18n/intl"
|
||||
|
||||
import { testConnectionSpeech } from "../models/api"
|
||||
import type { ServerVoicesRead } from "./api"
|
||||
import { useServerVoices } from "./use-server-voices"
|
||||
|
||||
/** What a voice is heard saying: the interface's language, the one the user
|
||||
* is likeliest to write podcasts in, since no server says what it speaks. */
|
||||
const trialLine = () =>
|
||||
intl.formatMessage({
|
||||
id: "models_server_voices_trial_body",
|
||||
defaultMessage: "Hello. This is how your podcasts will sound.",
|
||||
})
|
||||
|
||||
function messageFrom(cause: unknown) {
|
||||
return cause instanceof Error
|
||||
? cause.message
|
||||
: intl.formatMessage({
|
||||
id: "models_server_voices_request_error",
|
||||
defaultMessage: "The request failed",
|
||||
})
|
||||
}
|
||||
|
||||
/** The voices a server audio model offers podcasts, under the model in use:
|
||||
* the server's own list, or the user's, each heard before it is kept. */
|
||||
export function ServerVoices() {
|
||||
const { voices, add, remove } = useServerVoices()
|
||||
const [draft, setDraft] = useState("")
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
if (!voices.data) return null
|
||||
const { source, voices_page: page } = voices.data
|
||||
const listed = source === "server"
|
||||
|
||||
const submit = (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
const voice = draft.trim()
|
||||
if (!voice || add.isPending) return
|
||||
setError(null)
|
||||
add
|
||||
.mutateAsync({ voice, text: trialLine() })
|
||||
.then(() => setDraft(""))
|
||||
.catch((cause: unknown) => setError(messageFrom(cause)))
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-2">
|
||||
<h4 className="text-sm font-medium">
|
||||
{intl.formatMessage({
|
||||
id: "models_server_voices_title",
|
||||
defaultMessage: "Voices for this model",
|
||||
})}
|
||||
</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{listed
|
||||
? intl.formatMessage({
|
||||
id: "models_server_voices_listed_body",
|
||||
defaultMessage: "Listed by the server",
|
||||
})
|
||||
: intl.formatMessage({
|
||||
id: "models_server_voices_saved_body",
|
||||
defaultMessage:
|
||||
"This server doesn’t list its voices. Add the IDs you want; each is played once before it’s kept. A podcast needs one per speaker.",
|
||||
})}
|
||||
{page ? (
|
||||
<>
|
||||
{" "}
|
||||
<a
|
||||
href={page}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-foreground underline-offset-4 hover:underline"
|
||||
>
|
||||
{intl.formatMessage({
|
||||
id: "models_server_voices_page_link",
|
||||
defaultMessage: "See this model’s voices",
|
||||
})}
|
||||
<ExternalLinkIcon className="size-3.5" />
|
||||
</a>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
|
||||
{voices.data.voices.length ? (
|
||||
<ul className="divide-y rounded-lg border">
|
||||
{voices.data.voices.map((voice) => (
|
||||
<VoiceRow
|
||||
key={voice}
|
||||
voice={voice}
|
||||
read={voices.data}
|
||||
onRemove={
|
||||
listed
|
||||
? undefined
|
||||
: () =>
|
||||
void remove
|
||||
.mutateAsync(voice)
|
||||
.catch((cause: unknown) => setError(messageFrom(cause)))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{listed ? null : (
|
||||
<form className="flex gap-2" onSubmit={submit}>
|
||||
<Input
|
||||
aria-label={intl.formatMessage({
|
||||
id: "models_server_voices_voice_aria",
|
||||
defaultMessage: "Voice ID",
|
||||
})}
|
||||
placeholder={intl.formatMessage({
|
||||
id: "models_server_voices_voice_placeholder",
|
||||
defaultMessage: "Voice ID",
|
||||
})}
|
||||
className="select-text"
|
||||
value={draft}
|
||||
maxLength={100}
|
||||
spellCheck={false}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
/>
|
||||
<Button type="submit" disabled={!draft.trim() || add.isPending}>
|
||||
{add.isPending ? <Spinner data-icon="inline-start" /> : null}
|
||||
{intl.formatMessage({
|
||||
id: "models_server_voices_add_button",
|
||||
defaultMessage: "Add and test",
|
||||
})}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function VoiceRow({
|
||||
voice,
|
||||
read,
|
||||
onRemove,
|
||||
}: {
|
||||
voice: string
|
||||
read: ServerVoicesRead
|
||||
onRemove?: () => void
|
||||
}) {
|
||||
const [clip, setClip] = useState<string | null>(null)
|
||||
const [playing, setPlaying] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (clip) URL.revokeObjectURL(clip)
|
||||
},
|
||||
[clip]
|
||||
)
|
||||
|
||||
const play = () => {
|
||||
setPlaying(true)
|
||||
setError(null)
|
||||
testConnectionSpeech(read.connection_id, read.model, voice, trialLine())
|
||||
.then((blob) => setClip(URL.createObjectURL(blob)))
|
||||
.catch((cause: unknown) => setError(messageFrom(cause)))
|
||||
.finally(() => setPlaying(false))
|
||||
}
|
||||
|
||||
return (
|
||||
<li className="space-y-2 px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex-1 truncate font-mono text-sm select-text">
|
||||
{voice}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
disabled={playing}
|
||||
aria-label={intl.formatMessage(
|
||||
{
|
||||
id: "models_server_voices_play_aria",
|
||||
defaultMessage: "Play {voice}",
|
||||
},
|
||||
{ voice }
|
||||
)}
|
||||
onClick={play}
|
||||
>
|
||||
{playing ? <Spinner /> : <PlayIcon />}
|
||||
</Button>
|
||||
{onRemove ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="icon-xs"
|
||||
aria-label={intl.formatMessage(
|
||||
{
|
||||
id: "models_server_voices_remove_aria",
|
||||
defaultMessage: "Remove {voice}",
|
||||
},
|
||||
{ voice }
|
||||
)}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<Trash2Icon />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{clip ? (
|
||||
<audio
|
||||
controls
|
||||
autoPlay
|
||||
src={clip}
|
||||
className="w-full"
|
||||
aria-label={intl.formatMessage(
|
||||
{
|
||||
id: "models_server_voices_clip_aria",
|
||||
defaultMessage: "{voice}, as the server voices it",
|
||||
},
|
||||
{ voice }
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
|
||||
import { MODELS_QUERY_KEY } from "../../models-query"
|
||||
import { addServerVoice, readServerVoices, removeServerVoice } from "./api"
|
||||
|
||||
export const serverVoicesQueryKey = [
|
||||
...MODELS_QUERY_KEY,
|
||||
"remote",
|
||||
"voices",
|
||||
] as const
|
||||
|
||||
/** Keyed under the models, so choosing another model asks again. */
|
||||
export function useServerVoices() {
|
||||
const client = useQueryClient()
|
||||
const refresh = () =>
|
||||
client.invalidateQueries({ queryKey: serverVoicesQueryKey })
|
||||
const voices = useQuery({
|
||||
queryKey: serverVoicesQueryKey,
|
||||
queryFn: ({ signal }) => readServerVoices(signal),
|
||||
})
|
||||
const add = useMutation({ mutationFn: addServerVoice, onSuccess: refresh })
|
||||
const remove = useMutation({
|
||||
mutationFn: removeServerVoice,
|
||||
onSuccess: refresh,
|
||||
})
|
||||
return { voices, add, remove }
|
||||
}
|
||||
|
|
@ -19,7 +19,6 @@ import type { LocalBuild, LocalRow } from "@/features/models/local/chat/api"
|
|||
import { useInstall } from "@/features/models/local/installs/use-install"
|
||||
import { ConnectionDialog } from "@/features/models/remote/connections/connection-dialog"
|
||||
import { useConnections } from "@/features/models/remote/connections/use-connections"
|
||||
import { serversCanServe } from "@/features/models/remote/servers-can-serve"
|
||||
import { useSelect } from "@/features/models/selection/use-selection"
|
||||
import { DeleteModelDialog } from "@/features/models/your-models/delete-model-dialog"
|
||||
import type { YourModelRow } from "@/features/models/your-models/your-model-row"
|
||||
|
|
@ -40,9 +39,8 @@ const COPY: Record<
|
|||
OnboardingSlot,
|
||||
{
|
||||
title: () => string
|
||||
// `servers`: whether this slot offers servers (`serversCanServe`).
|
||||
description: (servers: boolean) => string
|
||||
noLocal: (servers: boolean) => string
|
||||
description: () => string
|
||||
noLocal: () => string
|
||||
/** Hugging Face search, for llama.cpp's GGUF models only. */
|
||||
searchable: boolean
|
||||
}
|
||||
|
|
@ -53,24 +51,18 @@ const COPY: Record<
|
|||
id: "onboarding_chat_step_title",
|
||||
defaultMessage: "Choose a text generation model",
|
||||
}),
|
||||
description: (servers) =>
|
||||
intl.formatMessage(
|
||||
{
|
||||
id: "onboarding_chat_step_body",
|
||||
defaultMessage:
|
||||
"Answers you in chat. {servers, select, yes {Run one on this computer so your chats stay private, or use one from a server.} other {Run one on this computer so your chats stay private.}}",
|
||||
},
|
||||
{ servers: servers ? "yes" : "no" }
|
||||
),
|
||||
noLocal: (servers) =>
|
||||
intl.formatMessage(
|
||||
{
|
||||
id: "onboarding_chat_step_no_local_empty",
|
||||
defaultMessage:
|
||||
"No tested model can run on this computer.{servers, select, yes { Use a server instead.} other {}}",
|
||||
},
|
||||
{ servers: servers ? "yes" : "no" }
|
||||
),
|
||||
description: () =>
|
||||
intl.formatMessage({
|
||||
id: "onboarding_chat_step_body",
|
||||
defaultMessage:
|
||||
"Answers you in chat. Run one on this computer so your chats stay private, or use one from a server.",
|
||||
}),
|
||||
noLocal: () =>
|
||||
intl.formatMessage({
|
||||
id: "onboarding_chat_step_no_local_empty",
|
||||
defaultMessage:
|
||||
"No tested model can run on this computer. Use a server instead.",
|
||||
}),
|
||||
searchable: true,
|
||||
},
|
||||
image_gen: {
|
||||
|
|
@ -79,24 +71,18 @@ const COPY: Record<
|
|||
id: "onboarding_image_step_title",
|
||||
defaultMessage: "Choose an image generation model",
|
||||
}),
|
||||
description: (servers) =>
|
||||
intl.formatMessage(
|
||||
{
|
||||
id: "onboarding_image_step_body",
|
||||
defaultMessage:
|
||||
"Creates images for you. {servers, select, yes {Run one on this computer, or use one from a server.} other {Run one on this computer.}}",
|
||||
},
|
||||
{ servers: servers ? "yes" : "no" }
|
||||
),
|
||||
noLocal: (servers) =>
|
||||
intl.formatMessage(
|
||||
{
|
||||
id: "onboarding_image_step_no_local_empty",
|
||||
defaultMessage:
|
||||
"Image models cannot run on this computer.{servers, select, yes { Use a server instead.} other {}}",
|
||||
},
|
||||
{ servers: servers ? "yes" : "no" }
|
||||
),
|
||||
description: () =>
|
||||
intl.formatMessage({
|
||||
id: "onboarding_image_step_body",
|
||||
defaultMessage:
|
||||
"Creates images for you. Run one on this computer, or use one from a server.",
|
||||
}),
|
||||
noLocal: () =>
|
||||
intl.formatMessage({
|
||||
id: "onboarding_image_step_no_local_empty",
|
||||
defaultMessage:
|
||||
"Image models cannot run on this computer. Use a server instead.",
|
||||
}),
|
||||
// sd.cpp has no search: its models are the few the catalog ships.
|
||||
searchable: false,
|
||||
},
|
||||
|
|
@ -106,24 +92,18 @@ const COPY: Record<
|
|||
id: "onboarding_image_edit_step_title",
|
||||
defaultMessage: "Choose an image editing model",
|
||||
}),
|
||||
description: (servers) =>
|
||||
intl.formatMessage(
|
||||
{
|
||||
id: "onboarding_image_edit_step_body",
|
||||
defaultMessage:
|
||||
"Edits images for you. {servers, select, yes {Run one on this computer, or use one from a server.} other {Run one on this computer.}}",
|
||||
},
|
||||
{ servers: servers ? "yes" : "no" }
|
||||
),
|
||||
noLocal: (servers) =>
|
||||
intl.formatMessage(
|
||||
{
|
||||
id: "onboarding_image_edit_step_no_local_empty",
|
||||
defaultMessage:
|
||||
"Image editing models cannot run on this computer.{servers, select, yes { Use a server instead.} other {}}",
|
||||
},
|
||||
{ servers: servers ? "yes" : "no" }
|
||||
),
|
||||
description: () =>
|
||||
intl.formatMessage({
|
||||
id: "onboarding_image_edit_step_body",
|
||||
defaultMessage:
|
||||
"Edits images for you. Run one on this computer, or use one from a server.",
|
||||
}),
|
||||
noLocal: () =>
|
||||
intl.formatMessage({
|
||||
id: "onboarding_image_edit_step_no_local_empty",
|
||||
defaultMessage:
|
||||
"Image editing models cannot run on this computer. Use a server instead.",
|
||||
}),
|
||||
// Nor has it for editing: the same few models.
|
||||
searchable: false,
|
||||
},
|
||||
|
|
@ -133,24 +113,18 @@ const COPY: Record<
|
|||
id: "onboarding_video_step_title",
|
||||
defaultMessage: "Choose a video generation model",
|
||||
}),
|
||||
description: (servers) =>
|
||||
intl.formatMessage(
|
||||
{
|
||||
id: "onboarding_video_step_body",
|
||||
defaultMessage:
|
||||
"Makes short video clips. {servers, select, yes {Run one on this computer, or use one from a server.} other {Run one on this computer.}}",
|
||||
},
|
||||
{ servers: servers ? "yes" : "no" }
|
||||
),
|
||||
noLocal: (servers) =>
|
||||
intl.formatMessage(
|
||||
{
|
||||
id: "onboarding_video_step_no_local_empty",
|
||||
defaultMessage:
|
||||
"Video models cannot run on this computer.{servers, select, yes { Use a server instead.} other {}}",
|
||||
},
|
||||
{ servers: servers ? "yes" : "no" }
|
||||
),
|
||||
description: () =>
|
||||
intl.formatMessage({
|
||||
id: "onboarding_video_step_body",
|
||||
defaultMessage:
|
||||
"Makes short video clips. Run one on this computer, or use one from a server.",
|
||||
}),
|
||||
noLocal: () =>
|
||||
intl.formatMessage({
|
||||
id: "onboarding_video_step_no_local_empty",
|
||||
defaultMessage:
|
||||
"Video models cannot run on this computer. Use a server instead.",
|
||||
}),
|
||||
// Nor for video.
|
||||
searchable: false,
|
||||
},
|
||||
|
|
@ -160,24 +134,18 @@ const COPY: Record<
|
|||
id: "onboarding_audio_step_title",
|
||||
defaultMessage: "Choose an audio model",
|
||||
}),
|
||||
description: (servers) =>
|
||||
intl.formatMessage(
|
||||
{
|
||||
id: "onboarding_audio_step_body",
|
||||
defaultMessage:
|
||||
"Creates podcasts for you. {servers, select, yes {Run one on this computer, or use one from a server.} other {Run one on this computer.}}",
|
||||
},
|
||||
{ servers: servers ? "yes" : "no" }
|
||||
),
|
||||
noLocal: (servers) =>
|
||||
intl.formatMessage(
|
||||
{
|
||||
id: "onboarding_audio_step_no_local_empty",
|
||||
defaultMessage:
|
||||
"Audio models cannot run on this computer.{servers, select, yes { Use a server instead.} other {}}",
|
||||
},
|
||||
{ servers: servers ? "yes" : "no" }
|
||||
),
|
||||
description: () =>
|
||||
intl.formatMessage({
|
||||
id: "onboarding_audio_step_body",
|
||||
defaultMessage:
|
||||
"Creates podcasts for you. Run one on this computer, or use one from a server.",
|
||||
}),
|
||||
noLocal: () =>
|
||||
intl.formatMessage({
|
||||
id: "onboarding_audio_step_no_local_empty",
|
||||
defaultMessage:
|
||||
"Audio models cannot run on this computer. Use a server instead.",
|
||||
}),
|
||||
// Nor has audio.cpp.
|
||||
searchable: false,
|
||||
},
|
||||
|
|
@ -218,7 +186,6 @@ export function ModelStep({
|
|||
const select = useSelect(modelType)
|
||||
const remove = slotDeletes[modelType]()
|
||||
const connections = useConnections()
|
||||
const servers = serversCanServe(modelType)
|
||||
const [onServer, setOnServer] = useState(false)
|
||||
// With nothing connected yet there is no server page to show: Connect
|
||||
// opens the dialog here, and a server saved from it opens on its models.
|
||||
|
|
@ -354,7 +321,7 @@ export function ModelStep({
|
|||
/>
|
||||
) : (
|
||||
<p className="rounded-xl border border-dashed p-5 text-sm text-muted-foreground">
|
||||
{copy.noLocal(servers)}
|
||||
{copy.noLocal()}
|
||||
</p>
|
||||
)}
|
||||
{copy.searchable ? (
|
||||
|
|
@ -367,19 +334,13 @@ export function ModelStep({
|
|||
) : null}
|
||||
</section>
|
||||
|
||||
{servers ? (
|
||||
<>
|
||||
<Separator />
|
||||
<ServerOption
|
||||
connections={connections.data ?? []}
|
||||
onOpen={() =>
|
||||
connections.data?.length
|
||||
? setOnServer(true)
|
||||
: setConnecting(true)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<Separator />
|
||||
<ServerOption
|
||||
connections={connections.data ?? []}
|
||||
onOpen={() =>
|
||||
connections.data?.length ? setOnServer(true) : setConnecting(true)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -391,7 +352,7 @@ export function ModelStep({
|
|||
<h1 className="font-heading text-xl text-balance">{copy.title()}</h1>
|
||||
</CardTitle>
|
||||
<CardDescription className="max-w-lg text-pretty">
|
||||
{copy.description(servers)}
|
||||
{copy.description()}
|
||||
</CardDescription>
|
||||
{onSkip ? (
|
||||
<CardAction>
|
||||
|
|
|
|||
|
|
@ -831,15 +831,18 @@ describe("onboarding", () => {
|
|||
).toBe(true)
|
||||
})
|
||||
|
||||
it("offers no server on the audio step, which only runs models here", async () => {
|
||||
it("offers a server on the audio step, as on every other", async () => {
|
||||
vi.stubGlobal("fetch", backend({ selections: { ...chatChosen } }))
|
||||
const user = userEvent.setup()
|
||||
render(<OnboardingPage onComplete={() => undefined} />)
|
||||
await toAudioStep(user)
|
||||
|
||||
await screen.findByText(/Audio models cannot run on this computer/)
|
||||
expect(screen.queryByRole("region", { name: "Use a server" })).toBeNull()
|
||||
expect(screen.queryByText(/server/i)).toBeNull()
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"Audio models cannot run on this computer. Use a server instead."
|
||||
)
|
||||
).toBeTruthy()
|
||||
expect(screen.getByRole("region", { name: "Use a server" })).toBeTruthy()
|
||||
})
|
||||
|
||||
it("says when audio models cannot run here", async () => {
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ function serving(
|
|||
}
|
||||
|
||||
describe("audio model settings", () => {
|
||||
it("says audio models cannot run where no audio runtime shipped", async () => {
|
||||
it("offers a server where no audio runtime shipped", async () => {
|
||||
// No audio.cpp staged: the catalog carries chat rows and no audio rows.
|
||||
vi.stubGlobal("fetch", serving([chatRow]))
|
||||
const user = userEvent.setup()
|
||||
|
|
@ -136,6 +136,7 @@ describe("audio model settings", () => {
|
|||
expect(
|
||||
screen.getByRole("heading", { name: "Add an audio model" })
|
||||
).toBeTruthy()
|
||||
expect(screen.getByRole("button", { name: "Connect" })).toBeTruthy()
|
||||
expect(
|
||||
await screen.findByText("Audio models cannot run on this computer")
|
||||
).toBeTruthy()
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ import type { Connection } from "@/features/models/remote/connections/api"
|
|||
import { ConnectionDialog } from "@/features/models/remote/connections/connection-dialog"
|
||||
import { useConnections } from "@/features/models/remote/connections/use-connections"
|
||||
import { ServerModelPicker } from "@/features/models/remote/models/server-model-picker"
|
||||
import { serversCanServe } from "@/features/models/remote/servers-can-serve"
|
||||
import type { ModelSelection } from "@/features/models/selection/api"
|
||||
import { InUseSummary } from "@/features/models/your-models/in-use-summary"
|
||||
import { LocalModelsGroup } from "@/features/models/your-models/local-models-group"
|
||||
|
|
@ -59,7 +58,6 @@ export function ModelSlotSettings({
|
|||
onSelected?: (selection: ModelSelection) => void
|
||||
onChatCleared?: () => void
|
||||
}) {
|
||||
const servers = serversCanServe(modelType)
|
||||
const connections = useConnections()
|
||||
const [page, setPage] = useState<Page>("list")
|
||||
// Edited in a dialog over the list; saving leaves the list as it was.
|
||||
|
|
@ -85,29 +83,22 @@ export function ModelSlotSettings({
|
|||
},
|
||||
{ slot }
|
||||
)}
|
||||
description={
|
||||
servers
|
||||
? intl.formatMessage({
|
||||
id: "settings_models_add_page_body",
|
||||
defaultMessage:
|
||||
"Run one on this computer, or use one from a server you already run.",
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
description={intl.formatMessage({
|
||||
id: "settings_models_add_page_body",
|
||||
defaultMessage:
|
||||
"Run one on this computer, or use one from a server you already run.",
|
||||
})}
|
||||
back={back}
|
||||
scrollable="all"
|
||||
>
|
||||
<AddModelOptions
|
||||
download={download}
|
||||
onConnected={servers ? showNewServer : undefined}
|
||||
/>
|
||||
<AddModelOptions download={download} onConnected={showNewServer} />
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
|
||||
const empty =
|
||||
!models.isPending &&
|
||||
(!servers || connections.data?.length === 0) &&
|
||||
connections.data?.length === 0 &&
|
||||
models.local.length === 0 &&
|
||||
!pending &&
|
||||
models.inUse === null
|
||||
|
|
@ -146,20 +137,18 @@ export function ModelSlotSettings({
|
|||
{ slot }
|
||||
)}
|
||||
</EmptyTitle>
|
||||
{servers ? (
|
||||
<EmptyDescription>
|
||||
{models.canDownload
|
||||
? intl.formatMessage({
|
||||
id: "settings_models_empty_download_body",
|
||||
defaultMessage:
|
||||
"Download one to run on this computer, or use one from a server you already run.",
|
||||
})
|
||||
: intl.formatMessage({
|
||||
id: "settings_models_empty_server_body",
|
||||
defaultMessage: "Use one from a server you already run.",
|
||||
})}
|
||||
</EmptyDescription>
|
||||
) : null}
|
||||
<EmptyDescription>
|
||||
{models.canDownload
|
||||
? intl.formatMessage({
|
||||
id: "settings_models_empty_download_body",
|
||||
defaultMessage:
|
||||
"Download one to run on this computer, or use one from a server you already run.",
|
||||
})
|
||||
: intl.formatMessage({
|
||||
id: "settings_models_empty_server_body",
|
||||
defaultMessage: "Use one from a server you already run.",
|
||||
})}
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>{add}</EmptyContent>
|
||||
</Empty>
|
||||
|
|
@ -180,18 +169,16 @@ export function ModelSlotSettings({
|
|||
/>
|
||||
) : null}
|
||||
|
||||
{servers ? (
|
||||
<ServerModelPicker
|
||||
modelType={modelType}
|
||||
openServerId={openServerId}
|
||||
onEdit={(connection) => {
|
||||
setEditing(connection)
|
||||
setEditOpen(true)
|
||||
}}
|
||||
onSelected={onSelected}
|
||||
onChatCleared={onChatCleared}
|
||||
/>
|
||||
) : null}
|
||||
<ServerModelPicker
|
||||
modelType={modelType}
|
||||
openServerId={openServerId}
|
||||
onEdit={(connection) => {
|
||||
setEditing(connection)
|
||||
setEditOpen(true)
|
||||
}}
|
||||
onSelected={onSelected}
|
||||
onChatCleared={onChatCleared}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ConnectionDialog
|
||||
|
|
|
|||
|
|
@ -100,18 +100,35 @@ export type PodcastBrief = {
|
|||
export const VOICE_GENDERS = ["female", "male"] as const
|
||||
|
||||
/** One voice, and the languages it speaks: every one of the model's for a
|
||||
* Supertonic voice. */
|
||||
* Supertonic voice. `gender` is null where the provider never states it. */
|
||||
export type Voice = {
|
||||
id: string
|
||||
label: string
|
||||
gender: (typeof VOICE_GENDERS)[number]
|
||||
gender: (typeof VOICE_GENDERS)[number] | null
|
||||
languages: string[]
|
||||
}
|
||||
|
||||
/** Where a brief's voices come from: the local model's roster, the server's
|
||||
* own list, or the ones the user added for a server model. */
|
||||
export type VoicesSource = "local" | "server" | "saved"
|
||||
|
||||
/** The server that voices a podcast. */
|
||||
export type VoicedBy = { server: string; model: string }
|
||||
|
||||
/** The brief to review. `voices` is empty only for a server model with none
|
||||
* added yet; `languages` is what the brief may use. */
|
||||
export type OpenedBrief = {
|
||||
brief: PodcastBrief
|
||||
voices: Voice[]
|
||||
voices_source: VoicesSource
|
||||
voiced_by: VoicedBy | null
|
||||
languages: string[]
|
||||
}
|
||||
|
||||
export function readPodcastBrief(
|
||||
workspaceId: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ brief: PodcastBrief; voices: Voice[] }> {
|
||||
): Promise<OpenedBrief> {
|
||||
return requestJson(`/workspaces/${workspaceId}/studio/podcast/brief`, {
|
||||
signal,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState } from "react"
|
||||
import { afterEach, describe, expect, it } from "vitest"
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import { cleanup, render, screen, within } from "@testing-library/react"
|
||||
import userEvent from "@testing-library/user-event"
|
||||
|
||||
|
|
@ -28,9 +28,24 @@ function Harness({ initial = brief }: { initial?: PodcastBrief }) {
|
|||
return <Controlled initial={initial} />
|
||||
}
|
||||
|
||||
/** What the server sends beside a roster: the languages its voices speak. */
|
||||
const spokenBy = (roster: Voice[]) => [
|
||||
...new Set(roster.flatMap((voice) => voice.languages)),
|
||||
]
|
||||
|
||||
function Controlled({ initial }: { initial: PodcastBrief }) {
|
||||
const [value, setValue] = useState(initial)
|
||||
return <PodcastBriefForm brief={value} voices={voices} onChange={setValue} />
|
||||
return (
|
||||
<PodcastBriefForm
|
||||
brief={value}
|
||||
voices={voices}
|
||||
languages={spokenBy(voices)}
|
||||
voicesSource="local"
|
||||
voicedBy={null}
|
||||
onSetUpVoices={() => undefined}
|
||||
onChange={setValue}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(cleanup)
|
||||
|
|
@ -165,6 +180,10 @@ describe("podcast brief form", () => {
|
|||
<PodcastBriefForm
|
||||
brief={value}
|
||||
voices={multilingual}
|
||||
languages={spokenBy(multilingual)}
|
||||
voicesSource="local"
|
||||
voicedBy={null}
|
||||
onSetUpVoices={() => undefined}
|
||||
onChange={setValue}
|
||||
/>
|
||||
)
|
||||
|
|
@ -177,4 +196,145 @@ describe("podcast brief form", () => {
|
|||
expect(value(within(rows[0]).getByLabelText("Voice"))).toBe("M1")
|
||||
expect(value(within(rows[1]).getByLabelText("Voice"))).toBe("F1")
|
||||
})
|
||||
|
||||
it("lists voices whose gender nobody documents in one list", () => {
|
||||
// OpenAI names its voices and nothing else about them.
|
||||
const openai: Voice[] = [
|
||||
{ id: "alloy", label: "Alloy", gender: null, languages: ["en"] },
|
||||
{ id: "ash", label: "Ash", gender: null, languages: ["en"] },
|
||||
]
|
||||
render(
|
||||
<PodcastBriefForm
|
||||
brief={{
|
||||
...brief,
|
||||
language: "en",
|
||||
speakers: [{ name: "Host", role: "host", voice: "alloy" }],
|
||||
}}
|
||||
voices={openai}
|
||||
languages={["en"]}
|
||||
voicesSource="saved"
|
||||
voicedBy={{ server: "OpenAI", model: "tts-1" }}
|
||||
onSetUpVoices={() => undefined}
|
||||
onChange={() => undefined}
|
||||
/>
|
||||
)
|
||||
|
||||
const voice = screen.getByLabelText("Voice")
|
||||
expect(within(voice).queryAllByRole("group")).toEqual([])
|
||||
expect(
|
||||
within(voice)
|
||||
.getAllByRole("option")
|
||||
.map((option) => option.textContent)
|
||||
).toEqual(["Alloy", "Ash"])
|
||||
})
|
||||
|
||||
it("offers a server's listed voices in every language, as they state none", async () => {
|
||||
// Kokoro-FastAPI lists ids alone.
|
||||
const listed: Voice[] = [
|
||||
{ id: "af_heart", label: "af_heart", gender: null, languages: [] },
|
||||
{ id: "am_adam", label: "am_adam", gender: null, languages: [] },
|
||||
]
|
||||
const user = userEvent.setup()
|
||||
function Listed() {
|
||||
const [value, setValue] = useState<PodcastBrief>({
|
||||
...brief,
|
||||
language: "en",
|
||||
speakers: [{ name: "Host", role: "host", voice: "af_heart" }],
|
||||
})
|
||||
return (
|
||||
<PodcastBriefForm
|
||||
brief={value}
|
||||
voices={listed}
|
||||
languages={["en", "fr"]}
|
||||
voicesSource="server"
|
||||
voicedBy={{ server: "Kokoro", model: "kokoro" }}
|
||||
onSetUpVoices={() => undefined}
|
||||
onChange={setValue}
|
||||
/>
|
||||
)
|
||||
}
|
||||
render(<Listed />)
|
||||
|
||||
await user.selectOptions(screen.getByLabelText("Language"), "fr")
|
||||
|
||||
const voice = screen.getByLabelText("Voice")
|
||||
expect(value(voice)).toBe("af_heart")
|
||||
expect(
|
||||
within(voice)
|
||||
.getAllByRole("option")
|
||||
.map((option) => option.textContent)
|
||||
).toEqual(["af_heart", "am_adam"])
|
||||
expect(
|
||||
screen
|
||||
.getByRole("button", { name: "Add speaker" })
|
||||
.hasAttribute("disabled")
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it("says a server voices it, and reads like the local form otherwise", () => {
|
||||
render(
|
||||
<PodcastBriefForm
|
||||
brief={{
|
||||
...brief,
|
||||
language: "en",
|
||||
speakers: [{ name: "Host", role: "host", voice: "en_paul" }],
|
||||
}}
|
||||
voices={[
|
||||
{ id: "en_paul", label: "en_paul", gender: null, languages: [] },
|
||||
]}
|
||||
languages={["en", "fr"]}
|
||||
voicesSource="saved"
|
||||
voicedBy={{ server: "OpenRouter", model: "seed-audio-1-0" }}
|
||||
onSetUpVoices={() => undefined}
|
||||
onChange={() => undefined}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Voiced by seed-audio-1-0 on OpenRouter. Each line is billed by the provider."
|
||||
)
|
||||
).toBeTruthy()
|
||||
expect(screen.getByLabelText("Language")).toBeTruthy()
|
||||
expect(screen.getByText("Speakers")).toBeTruthy()
|
||||
expect(screen.queryByText("Script language")).toBeNull()
|
||||
expect(screen.queryByText("Your voices for this model")).toBeNull()
|
||||
expect(screen.queryByText("Listed by the server")).toBeNull()
|
||||
})
|
||||
|
||||
it("asks for voices before anything else when the server model has none", async () => {
|
||||
const user = userEvent.setup()
|
||||
const setUp = vi.fn()
|
||||
render(
|
||||
<PodcastBriefForm
|
||||
brief={{
|
||||
...brief,
|
||||
language: "en",
|
||||
speakers: [{ name: "Host", role: "host", voice: "" }],
|
||||
}}
|
||||
voices={[]}
|
||||
languages={["en"]}
|
||||
voicesSource="saved"
|
||||
voicedBy={{ server: "OpenRouter", model: "seed-audio-1-0" }}
|
||||
onSetUpVoices={setUp}
|
||||
onChange={() => undefined}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
"This model needs voices before it can voice a podcast. The server doesn’t list them, so add the ones you want."
|
||||
)
|
||||
).toBeTruthy()
|
||||
expect(screen.queryByRole("group", { name: /Speaker \d/ })).toBeNull()
|
||||
await user.click(screen.getByRole("button", { name: "Set up voices" }))
|
||||
expect(setUp).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it("says nothing about a server for a model on this computer", () => {
|
||||
render(<Harness />)
|
||||
|
||||
expect(screen.queryByText(/Voiced by/)).toBeNull()
|
||||
expect(screen.getByLabelText("Language")).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -15,12 +15,14 @@ import {
|
|||
type PodcastBrief,
|
||||
type PodcastSpeaker,
|
||||
type Voice,
|
||||
type VoicedBy,
|
||||
type VoicesSource,
|
||||
} from "./api"
|
||||
|
||||
type PodcastStyle = PodcastBrief["style"]
|
||||
type PodcastDuration = PodcastBrief["duration"]
|
||||
type PodcastRole = PodcastSpeaker["role"]
|
||||
type VoiceGender = Voice["gender"]
|
||||
type VoiceGender = NonNullable<Voice["gender"]>
|
||||
|
||||
const STYLE_LABELS: Record<PodcastStyle, () => string> = {
|
||||
conversational: () =>
|
||||
|
|
@ -132,25 +134,34 @@ const title = (word: string) => word[0].toUpperCase() + word.slice(1)
|
|||
export function PodcastBriefForm({
|
||||
brief,
|
||||
voices,
|
||||
languages: codes,
|
||||
voicesSource,
|
||||
voicedBy,
|
||||
onSetUpVoices,
|
||||
onChange,
|
||||
}: {
|
||||
brief: PodcastBrief
|
||||
voices: Voice[]
|
||||
languages: string[]
|
||||
voicesSource: VoicesSource
|
||||
/** The server that voices it; null for a model on this computer. */
|
||||
voicedBy: VoicedBy | null
|
||||
/** Opens where a server model's voices are added. */
|
||||
onSetUpVoices: () => void
|
||||
onChange: (brief: PodcastBrief) => void
|
||||
}) {
|
||||
const id = useId()
|
||||
// By the name shown, in the interface language: the model's order is its own.
|
||||
const languages = [...new Set(voices.flatMap((voice) => voice.languages))]
|
||||
const languages = codes
|
||||
.map((code) => ({
|
||||
code,
|
||||
name: intl.formatDisplayName(code, { type: "language" }) ?? code,
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, intl.locale))
|
||||
const spoken = voices.filter((voice) =>
|
||||
voice.languages.includes(brief.language)
|
||||
)
|
||||
const spoken = voices.filter((voice) => speaks(voice, brief.language))
|
||||
const taken = new Set(brief.speakers.map((speaker) => speaker.voice))
|
||||
const free = spoken.find((voice) => !taken.has(voice.id))
|
||||
const canAdd = free !== undefined
|
||||
|
||||
const update = (patch: Partial<PodcastBrief>) =>
|
||||
onChange({ ...brief, ...patch })
|
||||
|
|
@ -158,7 +169,7 @@ export function PodcastBriefForm({
|
|||
// A speaker keeps a voice that speaks the new language, as a Supertonic
|
||||
// voice speaks all of them; the rest take that language's free voices.
|
||||
const changeLanguage = (language: string) => {
|
||||
const next = voices.filter((voice) => voice.languages.includes(language))
|
||||
const next = voices.filter((voice) => speaks(voice, language))
|
||||
const kept = new Set(
|
||||
brief.speakers
|
||||
.map((speaker) => speaker.voice)
|
||||
|
|
@ -185,7 +196,7 @@ export function PodcastBriefForm({
|
|||
})
|
||||
|
||||
const addSpeaker = () => {
|
||||
if (!free) return
|
||||
if (!canAdd) return
|
||||
const slot = brief.speakers.length
|
||||
const role = ROLE_BY_SLOT[slot] ?? "guest"
|
||||
update({
|
||||
|
|
@ -194,7 +205,7 @@ export function PodcastBriefForm({
|
|||
{
|
||||
name: title(role === "cohost" ? "co-host" : role),
|
||||
role,
|
||||
voice: free.id,
|
||||
voice: free?.id ?? "",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
|
@ -203,8 +214,34 @@ export function PodcastBriefForm({
|
|||
const removeSpeaker = (index: number) =>
|
||||
update({ speakers: brief.speakers.filter((_, at) => at !== index) })
|
||||
|
||||
const server = voicedBy ? <VoicedByLine voicedBy={voicedBy} /> : null
|
||||
|
||||
if (voicesSource === "saved" && voices.length === 0) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{server}
|
||||
<div className="space-y-3 rounded-xl border border-dashed p-5 text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
{intl.formatMessage({
|
||||
id: "studio_podcast_brief_no_voices_body",
|
||||
defaultMessage:
|
||||
"This model needs voices before it can voice a podcast. The server doesn’t list them, so add the ones you want.",
|
||||
})}
|
||||
</p>
|
||||
<Button type="button" size="sm" onClick={onSetUpVoices}>
|
||||
{intl.formatMessage({
|
||||
id: "studio_podcast_brief_set_up_voices_button",
|
||||
defaultMessage: "Set up voices",
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{server}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field
|
||||
label={intl.formatMessage({
|
||||
|
|
@ -288,7 +325,7 @@ export function PodcastBriefForm({
|
|||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
disabled={!free || brief.speakers.length >= MAX_SPEAKERS}
|
||||
disabled={!canAdd || brief.speakers.length >= MAX_SPEAKERS}
|
||||
onClick={addSpeaker}
|
||||
>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
|
|
@ -357,19 +394,27 @@ export function PodcastBriefForm({
|
|||
return group.length ? (
|
||||
<optgroup key={gender} label={GENDER_LABELS[gender]()}>
|
||||
{group.map((voice) => (
|
||||
<option
|
||||
<VoiceOption
|
||||
key={voice.id}
|
||||
value={voice.id}
|
||||
voice={voice}
|
||||
disabled={
|
||||
taken.has(voice.id) && voice.id !== speaker.voice
|
||||
}
|
||||
>
|
||||
{voice.label}
|
||||
</option>
|
||||
/>
|
||||
))}
|
||||
</optgroup>
|
||||
) : null
|
||||
})}
|
||||
{/* A provider that never states a gender gets one plain list. */}
|
||||
{spoken
|
||||
.filter((voice) => voice.gender === null)
|
||||
.map((voice) => (
|
||||
<VoiceOption
|
||||
key={voice.id}
|
||||
voice={voice}
|
||||
disabled={taken.has(voice.id) && voice.id !== speaker.voice}
|
||||
/>
|
||||
))}
|
||||
</Select>
|
||||
{brief.speakers.length > 1 ? (
|
||||
<Button
|
||||
|
|
@ -399,6 +444,34 @@ export function PodcastBriefForm({
|
|||
)
|
||||
}
|
||||
|
||||
/** A voice that states no language, as a server's listed id, is not refused one. */
|
||||
function speaks(voice: Voice, language: string) {
|
||||
return voice.languages.length === 0 || voice.languages.includes(language)
|
||||
}
|
||||
|
||||
function VoicedByLine({ voicedBy }: { voicedBy: VoicedBy }) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{intl.formatMessage(
|
||||
{
|
||||
id: "studio_podcast_brief_voiced_by_body",
|
||||
defaultMessage:
|
||||
"Voiced by {model} on {server}. Each line is billed by the provider.",
|
||||
},
|
||||
{ model: voicedBy.model, server: voicedBy.server }
|
||||
)}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
function VoiceOption({ voice, disabled }: { voice: Voice; disabled: boolean }) {
|
||||
return (
|
||||
<option value={voice.id} disabled={disabled}>
|
||||
{voice.label}
|
||||
</option>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
htmlFor,
|
||||
|
|
|
|||
|
|
@ -33,8 +33,10 @@ const pendingArtifact = {
|
|||
|
||||
function StudioHarness({
|
||||
documents = [readyDocument],
|
||||
onSetUpVoices = () => undefined,
|
||||
}: {
|
||||
documents?: (typeof readyDocument)[]
|
||||
onSetUpVoices?: () => void
|
||||
}) {
|
||||
const studio = useStudio(1)
|
||||
// The panel is told what is selected; the page owns it. Mirror useSources,
|
||||
|
|
@ -69,6 +71,7 @@ function StudioHarness({
|
|||
isCreating={studio.isCreating}
|
||||
error={studio.error}
|
||||
onGenerate={studio.create}
|
||||
onSetUpVoices={onSetUpVoices}
|
||||
/>
|
||||
<ArtifactList
|
||||
workspaceId={1}
|
||||
|
|
@ -83,10 +86,13 @@ function StudioHarness({
|
|||
)
|
||||
}
|
||||
|
||||
function renderStudio(documents: (typeof readyDocument)[] = [readyDocument]) {
|
||||
function renderStudio(
|
||||
documents: (typeof readyDocument)[] = [readyDocument],
|
||||
onSetUpVoices?: () => void
|
||||
) {
|
||||
return render(
|
||||
<TooltipProvider>
|
||||
<StudioHarness documents={documents} />
|
||||
<StudioHarness documents={documents} onSetUpVoices={onSetUpVoices} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
|
@ -121,6 +127,7 @@ describe("studio panel", () => {
|
|||
isCreating={false}
|
||||
error={null}
|
||||
onGenerate={async () => false}
|
||||
onSetUpVoices={() => undefined}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
|
|
@ -269,7 +276,13 @@ describe("studio panel", () => {
|
|||
}
|
||||
if (path === "/workspaces/1/studio/podcast/brief") {
|
||||
await briefGate
|
||||
return Response.json({ brief, voices })
|
||||
return Response.json({
|
||||
brief,
|
||||
voices,
|
||||
voices_source: "local",
|
||||
voiced_by: null,
|
||||
languages: ["en-US"],
|
||||
})
|
||||
}
|
||||
if (path === "/workspaces/1/studio/jobs" && init?.method === "POST") {
|
||||
return Response.json(
|
||||
|
|
@ -416,6 +429,7 @@ describe("studio panel", () => {
|
|||
isCreating={false}
|
||||
error={null}
|
||||
onGenerate={async () => false}
|
||||
onSetUpVoices={() => undefined}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
|
|
@ -463,4 +477,50 @@ describe("studio panel", () => {
|
|||
)
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it("holds a podcast on a server model with no voices, and points to setting them up", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const path = String(input)
|
||||
if (path === "/workspaces/1/studio/formats") {
|
||||
return Response.json([
|
||||
{
|
||||
key: "podcast",
|
||||
label: "Podcast",
|
||||
requires_model_types: ["text_gen", "audio_gen"],
|
||||
available: true,
|
||||
unavailable_reason: null,
|
||||
},
|
||||
])
|
||||
}
|
||||
if (path === "/workspaces/1/studio/podcast/brief") {
|
||||
return Response.json({
|
||||
brief: {
|
||||
language: "en",
|
||||
style: "conversational",
|
||||
duration: "standard",
|
||||
speakers: [{ name: "Host", role: "host", voice: "" }],
|
||||
},
|
||||
voices: [],
|
||||
voices_source: "saved",
|
||||
voiced_by: { server: "OpenRouter", model: "seed-audio-1-0" },
|
||||
languages: ["en"],
|
||||
})
|
||||
}
|
||||
if (path === "/workspaces/1/artifacts") return Response.json([])
|
||||
return Response.json({ detail: "not found" }, { status: 404 })
|
||||
})
|
||||
)
|
||||
const setUp = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderStudio([readyDocument], setUp)
|
||||
await user.click(await screen.findByRole("button", { name: "Podcast" }))
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Set up voices" })
|
||||
)
|
||||
expect(setUp).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -141,6 +141,7 @@ function Composer({
|
|||
onToggleAll,
|
||||
isCreating,
|
||||
onGenerate,
|
||||
onSetUpVoices,
|
||||
}: {
|
||||
workspaceId: number
|
||||
format: string
|
||||
|
|
@ -150,6 +151,7 @@ function Composer({
|
|||
onToggleAll: () => void
|
||||
isCreating: boolean
|
||||
onGenerate: (job: StudioJobCreate) => void
|
||||
onSetUpVoices: () => void
|
||||
}) {
|
||||
const ready = documents.filter((document) => document.status === "ready")
|
||||
const selected = new Set(selectedDocumentIds)
|
||||
|
|
@ -161,7 +163,12 @@ function Composer({
|
|||
const toggle = (id: number) => onSelectionChange(id, !selected.has(id))
|
||||
|
||||
// A podcast is generated from its reviewed brief, so it waits for the brief.
|
||||
const briefReady = format !== "podcast" || podcast.brief != null
|
||||
// A server model with no voices yet can voice nothing: set them up first.
|
||||
const needsVoices =
|
||||
podcast.opened?.voices_source === "saved" &&
|
||||
podcast.opened.voices.length === 0
|
||||
const briefReady =
|
||||
format !== "podcast" || (podcast.brief != null && !needsVoices)
|
||||
const canGenerate = selected.size > 0 && !isCreating && briefReady
|
||||
|
||||
return (
|
||||
|
|
@ -177,10 +184,14 @@ function Composer({
|
|||
>
|
||||
<div className="space-y-3">
|
||||
{format === "podcast" ? (
|
||||
podcast.brief ? (
|
||||
podcast.brief && podcast.opened ? (
|
||||
<PodcastBriefForm
|
||||
brief={podcast.brief}
|
||||
voices={podcast.voices}
|
||||
voices={podcast.opened.voices}
|
||||
languages={podcast.opened.languages}
|
||||
voicesSource={podcast.opened.voices_source}
|
||||
voicedBy={podcast.opened.voiced_by}
|
||||
onSetUpVoices={onSetUpVoices}
|
||||
onChange={podcast.setBrief}
|
||||
/>
|
||||
) : (
|
||||
|
|
@ -404,6 +415,7 @@ export function StudioPanel({
|
|||
isCreating,
|
||||
error,
|
||||
onGenerate,
|
||||
onSetUpVoices,
|
||||
}: {
|
||||
workspaceId: number
|
||||
documents: WorkspaceDocument[]
|
||||
|
|
@ -414,6 +426,8 @@ export function StudioPanel({
|
|||
isCreating: boolean
|
||||
error: string | null
|
||||
onGenerate: (job: StudioJobCreate) => Promise<boolean>
|
||||
/** Opens Settings where a server audio model's voices are added. */
|
||||
onSetUpVoices: () => void
|
||||
}) {
|
||||
const [format, setFormat] = useState<string | null>(null)
|
||||
const [formatOpen, setFormatOpen] = useState(false)
|
||||
|
|
@ -486,6 +500,10 @@ export function StudioPanel({
|
|||
if (created) setFormatOpen(false)
|
||||
})
|
||||
}}
|
||||
onSetUpVoices={() => {
|
||||
setFormatOpen(false)
|
||||
onSetUpVoices()
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -2,22 +2,23 @@ import { useEffect, useState } from "react"
|
|||
|
||||
import { messageFrom } from "./use-studio"
|
||||
|
||||
import { readPodcastBrief, type PodcastBrief, type Voice } from "./api"
|
||||
import { readPodcastBrief, type OpenedBrief, type PodcastBrief } from "./api"
|
||||
|
||||
/** The brief the server proposes, held for the user to edit before generating.
|
||||
* Pass null for other formats: nothing loads. */
|
||||
export function usePodcastBrief(workspaceId: number | null) {
|
||||
const [brief, setBrief] = useState<PodcastBrief | null>(null)
|
||||
const [voices, setVoices] = useState<Voice[]>([])
|
||||
// What the brief was opened with, beside the brief the user edits.
|
||||
const [opened, setOpened] = useState<Omit<OpenedBrief, "brief"> | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (workspaceId == null) return
|
||||
const controller = new AbortController()
|
||||
readPodcastBrief(workspaceId, controller.signal)
|
||||
.then((opened) => {
|
||||
setBrief(opened.brief)
|
||||
setVoices(opened.voices)
|
||||
.then(({ brief, ...rest }) => {
|
||||
setBrief(brief)
|
||||
setOpened(rest)
|
||||
})
|
||||
.catch((cause) => {
|
||||
if (!controller.signal.aborted) setError(messageFrom(cause))
|
||||
|
|
@ -25,5 +26,5 @@ export function usePodcastBrief(workspaceId: number | null) {
|
|||
return () => controller.abort()
|
||||
}, [workspaceId])
|
||||
|
||||
return { brief, voices, error, setBrief }
|
||||
return { brief, opened, error, setBrief }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -417,6 +417,18 @@
|
|||
"models_server_models_vision_label": "Vision",
|
||||
"models_server_picker_load_error": "Server konnten nicht geladen werden",
|
||||
"models_server_picker_load_error_title": "Server konnten nicht geladen werden",
|
||||
"models_server_voices_add_button": "Hinzufügen und testen",
|
||||
"models_server_voices_clip_aria": "{voice}, wie der Server sie spricht",
|
||||
"models_server_voices_listed_body": "Vom Server aufgelistet",
|
||||
"models_server_voices_page_link": "Stimmen dieses Modells ansehen",
|
||||
"models_server_voices_play_aria": "{voice} abspielen",
|
||||
"models_server_voices_remove_aria": "{voice} entfernen",
|
||||
"models_server_voices_request_error": "Die Anfrage ist fehlgeschlagen",
|
||||
"models_server_voices_saved_body": "Dieser Server listet seine Stimmen nicht auf. Füge die gewünschten IDs hinzu; jede wird einmal abgespielt, bevor sie gespeichert wird. Ein Podcast braucht eine pro Sprecher.",
|
||||
"models_server_voices_title": "Stimmen für dieses Modell",
|
||||
"models_server_voices_trial_body": "Hallo. So werden deine Podcasts klingen.",
|
||||
"models_server_voices_voice_aria": "Stimmen-ID",
|
||||
"models_server_voices_voice_placeholder": "Stimmen-ID",
|
||||
"models_try_dialog_cancel_button": "Abbrechen",
|
||||
"models_try_dialog_chat_confirmed_body": "Chat-Unterstützung ist bestätigt. Der Test sendet einen kurzen Prompt und kann Kosten verursachen.",
|
||||
"models_try_dialog_chat_unconfirmed_body": "Dieser Endpunkt veröffentlicht keine Fähigkeiten, daher ist Chat-Unterstützung nicht bestätigt. Der Test sendet einen kurzen Prompt und kann Kosten verursachen.",
|
||||
|
|
@ -424,8 +436,12 @@
|
|||
"models_try_dialog_image_unconfirmed_body": "Dieser Endpunkt veröffentlicht keine Fähigkeiten, daher ist Bildunterstützung nicht bestätigt. Er muss /images/generations oder /images unterstützen. Der Test führt eine echte Inferenz aus und kann Kosten verursachen.",
|
||||
"models_try_dialog_preview_aria": "Testbild von {model}",
|
||||
"models_try_dialog_request_error": "Die Anfrage ist fehlgeschlagen",
|
||||
"models_try_dialog_speech_confirmed_body": "Sprachunterstützung ist bestätigt. Der Test spricht eine kurze Zeile und kann Kosten verursachen.",
|
||||
"models_try_dialog_speech_preview_aria": "Testaufnahme von {model}",
|
||||
"models_try_dialog_speech_unconfirmed_body": "Dieser Endpunkt veröffentlicht keine Fähigkeiten, daher ist Sprachunterstützung nicht bestätigt. Er muss /audio/speech unterstützen. Der Test spricht eine kurze Zeile und kann Kosten verursachen.",
|
||||
"models_try_dialog_test_chat_button": "Chat testen",
|
||||
"models_try_dialog_test_image_button": "Bild testen",
|
||||
"models_try_dialog_test_speech_button": "Stimme testen",
|
||||
"models_try_dialog_title": "{slot, select, text_gen {{model} für Chat verwenden?} image_gen {{model} für Bilder verwenden?} image_edit {{model} für Bildbearbeitung verwenden?} video_gen {{model} für Video verwenden?} audio_gen {{model} für Audio verwenden?} other {{model} verwenden?}}",
|
||||
"models_try_dialog_unlisted_body": "SurfSense konnte dieses Modell im Live-Katalog nicht bestätigen. Fahre nur fort, wenn die genaue Modell-ID stimmt.",
|
||||
"models_try_dialog_unlisted_cancel_button": "Abbrechen",
|
||||
|
|
@ -433,22 +449,26 @@
|
|||
"models_try_dialog_unlisted_title": "Nicht gelistetes Modell verwenden?",
|
||||
"models_try_dialog_use_tested_button": "Dieses Modell verwenden",
|
||||
"models_try_dialog_use_untested_button": "Ohne Test verwenden",
|
||||
"models_try_dialog_voice_aria": "Stimme",
|
||||
"models_try_dialog_voice_body": "Leer lassen, um die Standardstimme des Servers zu nutzen, falls er eine hat.",
|
||||
"models_try_dialog_voice_placeholder": "Stimmen-ID (optional)",
|
||||
"models_try_dialog_voice_trial_body": "Hallo. So werden deine Podcasts klingen.",
|
||||
"models_your_models_vision_label": "Vision",
|
||||
"onboarding_audio_step_body": "Erstellt Podcasts für dich. {servers, select, yes {Führe eines auf diesem Computer aus oder nutze eines von einem Server.} other {Führe eines auf diesem Computer aus.}}",
|
||||
"onboarding_audio_step_no_local_empty": "Audiomodelle können auf diesem Computer nicht laufen.{servers, select, yes { Nutze stattdessen einen Server.} other {}}",
|
||||
"onboarding_audio_step_body": "Erstellt Podcasts für dich. Führe eines auf diesem Computer aus oder nutze eines von einem Server.",
|
||||
"onboarding_audio_step_no_local_empty": "Audiomodelle können auf diesem Computer nicht laufen. Nutze stattdessen einen Server.",
|
||||
"onboarding_audio_step_title": "Audiomodell wählen",
|
||||
"onboarding_chat_step_body": "Antwortet dir im Chat. {servers, select, yes {Führe eines auf diesem Computer aus, damit deine Chats privat bleiben, oder nutze eines von einem Server.} other {Führe eines auf diesem Computer aus, damit deine Chats privat bleiben.}}",
|
||||
"onboarding_chat_step_no_local_empty": "Kein getestetes Modell kann auf diesem Computer laufen.{servers, select, yes { Nutze stattdessen einen Server.} other {}}",
|
||||
"onboarding_chat_step_body": "Antwortet dir im Chat. Führe eines auf diesem Computer aus, damit deine Chats privat bleiben, oder nutze eines von einem Server.",
|
||||
"onboarding_chat_step_no_local_empty": "Kein getestetes Modell kann auf diesem Computer laufen. Nutze stattdessen einen Server.",
|
||||
"onboarding_chat_step_title": "Modell für Texterzeugung wählen",
|
||||
"onboarding_finish_error": "Einrichtung konnte nicht abgeschlossen werden",
|
||||
"onboarding_finish_no_chat_model_error": "Wähle zuerst ein Chatmodell",
|
||||
"onboarding_hugging_face_search_hide_button": "Hugging-Face-Suche ausblenden",
|
||||
"onboarding_hugging_face_search_show_button": "Nicht dabei? Auf Hugging Face suchen",
|
||||
"onboarding_image_edit_step_body": "Bearbeitet Bilder für dich. {servers, select, yes {Führe eines auf diesem Computer aus oder nutze eines von einem Server.} other {Führe eines auf diesem Computer aus.}}",
|
||||
"onboarding_image_edit_step_no_local_empty": "Bildbearbeitungsmodelle können auf diesem Computer nicht laufen.{servers, select, yes { Nutze stattdessen einen Server.} other {}}",
|
||||
"onboarding_image_edit_step_body": "Bearbeitet Bilder für dich. Führe eines auf diesem Computer aus oder nutze eines von einem Server.",
|
||||
"onboarding_image_edit_step_no_local_empty": "Bildbearbeitungsmodelle können auf diesem Computer nicht laufen. Nutze stattdessen einen Server.",
|
||||
"onboarding_image_edit_step_title": "Modell für Bildbearbeitung wählen",
|
||||
"onboarding_image_step_body": "Erstellt Bilder für dich. {servers, select, yes {Führe eines auf diesem Computer aus oder nutze eines von einem Server.} other {Führe eines auf diesem Computer aus.}}",
|
||||
"onboarding_image_step_no_local_empty": "Bildmodelle können auf diesem Computer nicht laufen.{servers, select, yes { Nutze stattdessen einen Server.} other {}}",
|
||||
"onboarding_image_step_body": "Erstellt Bilder für dich. Führe eines auf diesem Computer aus oder nutze eines von einem Server.",
|
||||
"onboarding_image_step_no_local_empty": "Bildmodelle können auf diesem Computer nicht laufen. Nutze stattdessen einen Server.",
|
||||
"onboarding_image_step_title": "Modell für Bilderzeugung wählen",
|
||||
"onboarding_model_list_aria": "Modelle für diesen Computer",
|
||||
"onboarding_model_list_delete_aria": "{name} löschen",
|
||||
|
|
@ -482,8 +502,8 @@
|
|||
"onboarding_server_path_connect_another_button": "Weiteren Server verbinden",
|
||||
"onboarding_server_path_connect_button": "Server verbinden",
|
||||
"onboarding_server_path_empty": "Noch keine Server verbunden.",
|
||||
"onboarding_video_step_body": "Erstellt kurze Videoclips. {servers, select, yes {Führe eines auf diesem Computer aus oder nutze eines von einem Server.} other {Führe eines auf diesem Computer aus.}}",
|
||||
"onboarding_video_step_no_local_empty": "Videomodelle können auf diesem Computer nicht laufen.{servers, select, yes { Nutze stattdessen einen Server.} other {}}",
|
||||
"onboarding_video_step_body": "Erstellt kurze Videoclips. Führe eines auf diesem Computer aus oder nutze eines von einem Server.",
|
||||
"onboarding_video_step_no_local_empty": "Videomodelle können auf diesem Computer nicht laufen. Nutze stattdessen einen Server.",
|
||||
"onboarding_video_step_title": "Modell für Videoerzeugung wählen",
|
||||
"onboarding_welcome_body": "Ein privates Recherche-Notizbuch, das komplett auf deinem eigenen Rechner läuft. Deine Dokumente, deine Modellschlüssel, keine Cloud, kein Konto.",
|
||||
"onboarding_welcome_start_button": "Einrichtung starten",
|
||||
|
|
@ -704,12 +724,14 @@
|
|||
"studio_podcast_brief_length_short_label": "Kurz",
|
||||
"studio_podcast_brief_length_standard_body": "~8 Min.",
|
||||
"studio_podcast_brief_length_standard_label": "Standard",
|
||||
"studio_podcast_brief_no_voices_body": "Dieses Modell braucht Stimmen, bevor es einen Podcast sprechen kann. Der Server listet keine auf, also füge die gewünschten hinzu.",
|
||||
"studio_podcast_brief_remove_speaker_aria": "Sprecher {number, number} entfernen",
|
||||
"studio_podcast_brief_role_cohost_label": "Co-Moderation",
|
||||
"studio_podcast_brief_role_expert_label": "Fachperson",
|
||||
"studio_podcast_brief_role_guest_label": "Gast",
|
||||
"studio_podcast_brief_role_host_label": "Moderation",
|
||||
"studio_podcast_brief_role_narrator_label": "Erzählstimme",
|
||||
"studio_podcast_brief_set_up_voices_button": "Stimmen einrichten",
|
||||
"studio_podcast_brief_speaker_aria": "Sprecher {number, number}",
|
||||
"studio_podcast_brief_speaker_name_aria": "Name",
|
||||
"studio_podcast_brief_speaker_role_aria": "Rolle",
|
||||
|
|
@ -723,6 +745,7 @@
|
|||
"studio_podcast_brief_style_narrative_label": "Erzählung",
|
||||
"studio_podcast_brief_voice_female_label": "Weiblich",
|
||||
"studio_podcast_brief_voice_male_label": "Männlich",
|
||||
"studio_podcast_brief_voiced_by_body": "Gesprochen von {model} auf {server}. Der Anbieter berechnet jede Zeile.",
|
||||
"studio_podcast_viewer_transcript_title": "Transkript",
|
||||
"studio_pptx_viewer_error_title": "Präsentation konnte nicht geladen werden",
|
||||
"studio_pptx_viewer_load_error": "Der Server hat beim Abrufen der Präsentation {status} zurückgegeben",
|
||||
|
|
|
|||
|
|
@ -417,6 +417,18 @@
|
|||
"models_server_models_vision_label": "Vision",
|
||||
"models_server_picker_load_error": "Could not load servers",
|
||||
"models_server_picker_load_error_title": "Could not load servers",
|
||||
"models_server_voices_add_button": "Add and test",
|
||||
"models_server_voices_clip_aria": "{voice}, as the server voices it",
|
||||
"models_server_voices_listed_body": "Listed by the server",
|
||||
"models_server_voices_page_link": "See this model’s voices",
|
||||
"models_server_voices_play_aria": "Play {voice}",
|
||||
"models_server_voices_remove_aria": "Remove {voice}",
|
||||
"models_server_voices_request_error": "The request failed",
|
||||
"models_server_voices_saved_body": "This server doesn’t list its voices. Add the IDs you want; each is played once before it’s kept. A podcast needs one per speaker.",
|
||||
"models_server_voices_title": "Voices for this model",
|
||||
"models_server_voices_trial_body": "Hello. This is how your podcasts will sound.",
|
||||
"models_server_voices_voice_aria": "Voice ID",
|
||||
"models_server_voices_voice_placeholder": "Voice ID",
|
||||
"models_try_dialog_cancel_button": "Cancel",
|
||||
"models_try_dialog_chat_confirmed_body": "Chat support is confirmed. Testing sends one short prompt and may cost money.",
|
||||
"models_try_dialog_chat_unconfirmed_body": "This endpoint does not publish capabilities, so chat support is unconfirmed. Testing sends one short prompt and may cost money.",
|
||||
|
|
@ -424,8 +436,12 @@
|
|||
"models_try_dialog_image_unconfirmed_body": "This endpoint does not publish capabilities, so image support is unconfirmed. It must implement /images/generations or /images. Testing runs real inference and may cost money.",
|
||||
"models_try_dialog_preview_aria": "Test generated by {model}",
|
||||
"models_try_dialog_request_error": "The request failed",
|
||||
"models_try_dialog_speech_confirmed_body": "Speech support is confirmed. Testing voices one short line and may cost money.",
|
||||
"models_try_dialog_speech_preview_aria": "Test voiced by {model}",
|
||||
"models_try_dialog_speech_unconfirmed_body": "This endpoint does not publish capabilities, so speech support is unconfirmed. It must implement /audio/speech. Testing voices one short line and may cost money.",
|
||||
"models_try_dialog_test_chat_button": "Test chat",
|
||||
"models_try_dialog_test_image_button": "Test image",
|
||||
"models_try_dialog_test_speech_button": "Test voice",
|
||||
"models_try_dialog_title": "{slot, select, text_gen {Use {model} for chat?} image_gen {Use {model} for image?} image_edit {Use {model} for image editing?} video_gen {Use {model} for video?} audio_gen {Use {model} for audio?} other {Use {model}?}}",
|
||||
"models_try_dialog_unlisted_body": "SurfSense could not confirm this model in the live catalogue. Continue only if the exact model ID is correct.",
|
||||
"models_try_dialog_unlisted_cancel_button": "Cancel",
|
||||
|
|
@ -433,22 +449,26 @@
|
|||
"models_try_dialog_unlisted_title": "Use an unlisted model?",
|
||||
"models_try_dialog_use_tested_button": "Use this model",
|
||||
"models_try_dialog_use_untested_button": "Use without testing",
|
||||
"models_try_dialog_voice_aria": "Voice",
|
||||
"models_try_dialog_voice_body": "Empty uses the server’s default voice, if it has one.",
|
||||
"models_try_dialog_voice_placeholder": "Voice ID (optional)",
|
||||
"models_try_dialog_voice_trial_body": "Hello. This is how your podcasts will sound.",
|
||||
"models_your_models_vision_label": "Vision",
|
||||
"onboarding_audio_step_body": "Creates podcasts for you. {servers, select, yes {Run one on this computer, or use one from a server.} other {Run one on this computer.}}",
|
||||
"onboarding_audio_step_no_local_empty": "Audio models cannot run on this computer.{servers, select, yes { Use a server instead.} other {}}",
|
||||
"onboarding_audio_step_body": "Creates podcasts for you. Run one on this computer, or use one from a server.",
|
||||
"onboarding_audio_step_no_local_empty": "Audio models cannot run on this computer. Use a server instead.",
|
||||
"onboarding_audio_step_title": "Choose an audio model",
|
||||
"onboarding_chat_step_body": "Answers you in chat. {servers, select, yes {Run one on this computer so your chats stay private, or use one from a server.} other {Run one on this computer so your chats stay private.}}",
|
||||
"onboarding_chat_step_no_local_empty": "No tested model can run on this computer.{servers, select, yes { Use a server instead.} other {}}",
|
||||
"onboarding_chat_step_body": "Answers you in chat. Run one on this computer so your chats stay private, or use one from a server.",
|
||||
"onboarding_chat_step_no_local_empty": "No tested model can run on this computer. Use a server instead.",
|
||||
"onboarding_chat_step_title": "Choose a text generation model",
|
||||
"onboarding_finish_error": "Could not finish setup",
|
||||
"onboarding_finish_no_chat_model_error": "Choose a chat model first",
|
||||
"onboarding_hugging_face_search_hide_button": "Hide Hugging Face search",
|
||||
"onboarding_hugging_face_search_show_button": "Not listed? Search Hugging Face",
|
||||
"onboarding_image_edit_step_body": "Edits images for you. {servers, select, yes {Run one on this computer, or use one from a server.} other {Run one on this computer.}}",
|
||||
"onboarding_image_edit_step_no_local_empty": "Image editing models cannot run on this computer.{servers, select, yes { Use a server instead.} other {}}",
|
||||
"onboarding_image_edit_step_body": "Edits images for you. Run one on this computer, or use one from a server.",
|
||||
"onboarding_image_edit_step_no_local_empty": "Image editing models cannot run on this computer. Use a server instead.",
|
||||
"onboarding_image_edit_step_title": "Choose an image editing model",
|
||||
"onboarding_image_step_body": "Creates images for you. {servers, select, yes {Run one on this computer, or use one from a server.} other {Run one on this computer.}}",
|
||||
"onboarding_image_step_no_local_empty": "Image models cannot run on this computer.{servers, select, yes { Use a server instead.} other {}}",
|
||||
"onboarding_image_step_body": "Creates images for you. Run one on this computer, or use one from a server.",
|
||||
"onboarding_image_step_no_local_empty": "Image models cannot run on this computer. Use a server instead.",
|
||||
"onboarding_image_step_title": "Choose an image generation model",
|
||||
"onboarding_model_list_aria": "Models for this computer",
|
||||
"onboarding_model_list_delete_aria": "Delete {name}",
|
||||
|
|
@ -482,8 +502,8 @@
|
|||
"onboarding_server_path_connect_another_button": "Connect another server",
|
||||
"onboarding_server_path_connect_button": "Connect a server",
|
||||
"onboarding_server_path_empty": "No servers connected yet.",
|
||||
"onboarding_video_step_body": "Makes short video clips. {servers, select, yes {Run one on this computer, or use one from a server.} other {Run one on this computer.}}",
|
||||
"onboarding_video_step_no_local_empty": "Video models cannot run on this computer.{servers, select, yes { Use a server instead.} other {}}",
|
||||
"onboarding_video_step_body": "Makes short video clips. Run one on this computer, or use one from a server.",
|
||||
"onboarding_video_step_no_local_empty": "Video models cannot run on this computer. Use a server instead.",
|
||||
"onboarding_video_step_title": "Choose a video generation model",
|
||||
"onboarding_welcome_body": "A private research notebook that runs entirely on your own machine. Your documents, your model keys, no cloud, no account.",
|
||||
"onboarding_welcome_start_button": "Start setting up",
|
||||
|
|
@ -704,12 +724,14 @@
|
|||
"studio_podcast_brief_length_short_label": "Short",
|
||||
"studio_podcast_brief_length_standard_body": "~8 min",
|
||||
"studio_podcast_brief_length_standard_label": "Standard",
|
||||
"studio_podcast_brief_no_voices_body": "This model needs voices before it can voice a podcast. The server doesn’t list them, so add the ones you want.",
|
||||
"studio_podcast_brief_remove_speaker_aria": "Remove speaker {number, number}",
|
||||
"studio_podcast_brief_role_cohost_label": "Co-host",
|
||||
"studio_podcast_brief_role_expert_label": "Expert",
|
||||
"studio_podcast_brief_role_guest_label": "Guest",
|
||||
"studio_podcast_brief_role_host_label": "Host",
|
||||
"studio_podcast_brief_role_narrator_label": "Narrator",
|
||||
"studio_podcast_brief_set_up_voices_button": "Set up voices",
|
||||
"studio_podcast_brief_speaker_aria": "Speaker {number, number}",
|
||||
"studio_podcast_brief_speaker_name_aria": "Name",
|
||||
"studio_podcast_brief_speaker_role_aria": "Role",
|
||||
|
|
@ -723,6 +745,7 @@
|
|||
"studio_podcast_brief_style_narrative_label": "Narrative",
|
||||
"studio_podcast_brief_voice_female_label": "Female",
|
||||
"studio_podcast_brief_voice_male_label": "Male",
|
||||
"studio_podcast_brief_voiced_by_body": "Voiced by {model} on {server}. Each line is billed by the provider.",
|
||||
"studio_podcast_viewer_transcript_title": "Transcript",
|
||||
"studio_pptx_viewer_error_title": "Failed to load presentation",
|
||||
"studio_pptx_viewer_load_error": "Server returned {status} while retrieving the presentation",
|
||||
|
|
|
|||
|
|
@ -417,6 +417,18 @@
|
|||
"models_server_models_vision_label": "Visión",
|
||||
"models_server_picker_load_error": "No se pudieron cargar los servidores",
|
||||
"models_server_picker_load_error_title": "No se pudieron cargar los servidores",
|
||||
"models_server_voices_add_button": "Añadir y probar",
|
||||
"models_server_voices_clip_aria": "{voice}, tal como la pronuncia el servidor",
|
||||
"models_server_voices_listed_body": "Lista del servidor",
|
||||
"models_server_voices_page_link": "Ver las voces de este modelo",
|
||||
"models_server_voices_play_aria": "Reproducir {voice}",
|
||||
"models_server_voices_remove_aria": "Quitar {voice}",
|
||||
"models_server_voices_request_error": "La solicitud falló",
|
||||
"models_server_voices_saved_body": "Este servidor no lista sus voces. Añade los ID que quieras; cada uno se reproduce una vez antes de guardarse. Un podcast necesita uno por participante.",
|
||||
"models_server_voices_title": "Voces de este modelo",
|
||||
"models_server_voices_trial_body": "Hola. Así sonarán tus podcasts.",
|
||||
"models_server_voices_voice_aria": "ID de voz",
|
||||
"models_server_voices_voice_placeholder": "ID de voz",
|
||||
"models_try_dialog_cancel_button": "Cancelar",
|
||||
"models_try_dialog_chat_confirmed_body": "La compatibilidad con chat está confirmada. La prueba envía un prompt corto y puede costar dinero.",
|
||||
"models_try_dialog_chat_unconfirmed_body": "Este endpoint no publica sus capacidades, así que la compatibilidad con chat no está confirmada. La prueba envía un prompt corto y puede costar dinero.",
|
||||
|
|
@ -424,8 +436,12 @@
|
|||
"models_try_dialog_image_unconfirmed_body": "Este endpoint no publica sus capacidades, así que la compatibilidad con imagen no está confirmada. Debe implementar /images/generations o /images. La prueba ejecuta una inferencia real y puede costar dinero.",
|
||||
"models_try_dialog_preview_aria": "Prueba generada por {model}",
|
||||
"models_try_dialog_request_error": "La solicitud falló",
|
||||
"models_try_dialog_speech_confirmed_body": "La compatibilidad con voz está confirmada. La prueba lee en voz alta una frase corta y puede costar dinero.",
|
||||
"models_try_dialog_speech_preview_aria": "Prueba leída por {model}",
|
||||
"models_try_dialog_speech_unconfirmed_body": "Este endpoint no publica sus capacidades, así que la compatibilidad con voz no está confirmada. Debe implementar /audio/speech. La prueba lee en voz alta una frase corta y puede costar dinero.",
|
||||
"models_try_dialog_test_chat_button": "Probar chat",
|
||||
"models_try_dialog_test_image_button": "Probar imagen",
|
||||
"models_try_dialog_test_speech_button": "Probar voz",
|
||||
"models_try_dialog_title": "{slot, select, text_gen {¿Usar {model} para chat?} image_gen {¿Usar {model} para imagen?} image_edit {¿Usar {model} para edición de imágenes?} video_gen {¿Usar {model} para video?} audio_gen {¿Usar {model} para audio?} other {¿Usar {model}?}}",
|
||||
"models_try_dialog_unlisted_body": "SurfSense no pudo confirmar este modelo en el catálogo en vivo. Continúa solo si el ID exacto del modelo es correcto.",
|
||||
"models_try_dialog_unlisted_cancel_button": "Cancelar",
|
||||
|
|
@ -433,22 +449,26 @@
|
|||
"models_try_dialog_unlisted_title": "¿Usar un modelo no listado?",
|
||||
"models_try_dialog_use_tested_button": "Usar este modelo",
|
||||
"models_try_dialog_use_untested_button": "Usar sin probar",
|
||||
"models_try_dialog_voice_aria": "Voz",
|
||||
"models_try_dialog_voice_body": "Si lo dejas vacío, se usa la voz predeterminada del servidor, si tiene una.",
|
||||
"models_try_dialog_voice_placeholder": "ID de voz (opcional)",
|
||||
"models_try_dialog_voice_trial_body": "Hola. Así sonarán tus podcasts.",
|
||||
"models_your_models_vision_label": "Visión",
|
||||
"onboarding_audio_step_body": "Crea podcasts para ti. {servers, select, yes {Ejecuta uno en este equipo o usa uno de un servidor.} other {Ejecuta uno en este equipo.}}",
|
||||
"onboarding_audio_step_no_local_empty": "Los modelos de audio no pueden ejecutarse en este equipo.{servers, select, yes { Usa un servidor en su lugar.} other {}}",
|
||||
"onboarding_audio_step_body": "Crea podcasts para ti. Ejecuta uno en este equipo o usa uno de un servidor.",
|
||||
"onboarding_audio_step_no_local_empty": "Los modelos de audio no pueden ejecutarse en este equipo. Usa un servidor en su lugar.",
|
||||
"onboarding_audio_step_title": "Elige un modelo de audio",
|
||||
"onboarding_chat_step_body": "Te responde en el chat. {servers, select, yes {Ejecuta uno en este equipo para que tus chats sigan siendo privados, o usa uno de un servidor.} other {Ejecuta uno en este equipo para que tus chats sigan siendo privados.}}",
|
||||
"onboarding_chat_step_no_local_empty": "Ningún modelo probado puede ejecutarse en este equipo.{servers, select, yes { Usa un servidor en su lugar.} other {}}",
|
||||
"onboarding_chat_step_body": "Te responde en el chat. Ejecuta uno en este equipo para que tus chats sigan siendo privados, o usa uno de un servidor.",
|
||||
"onboarding_chat_step_no_local_empty": "Ningún modelo probado puede ejecutarse en este equipo. Usa un servidor en su lugar.",
|
||||
"onboarding_chat_step_title": "Elige un modelo de generación de texto",
|
||||
"onboarding_finish_error": "No se pudo completar la configuración",
|
||||
"onboarding_finish_no_chat_model_error": "Elige primero un modelo de chat",
|
||||
"onboarding_hugging_face_search_hide_button": "Ocultar la búsqueda en Hugging Face",
|
||||
"onboarding_hugging_face_search_show_button": "¿No está en la lista? Busca en Hugging Face",
|
||||
"onboarding_image_edit_step_body": "Edita imágenes para ti. {servers, select, yes {Ejecuta uno en este equipo o usa uno de un servidor.} other {Ejecuta uno en este equipo.}}",
|
||||
"onboarding_image_edit_step_no_local_empty": "Los modelos de edición de imágenes no pueden ejecutarse en este equipo.{servers, select, yes { Usa un servidor en su lugar.} other {}}",
|
||||
"onboarding_image_edit_step_body": "Edita imágenes para ti. Ejecuta uno en este equipo o usa uno de un servidor.",
|
||||
"onboarding_image_edit_step_no_local_empty": "Los modelos de edición de imágenes no pueden ejecutarse en este equipo. Usa un servidor en su lugar.",
|
||||
"onboarding_image_edit_step_title": "Elige un modelo de edición de imágenes",
|
||||
"onboarding_image_step_body": "Crea imágenes para ti. {servers, select, yes {Ejecuta uno en este equipo o usa uno de un servidor.} other {Ejecuta uno en este equipo.}}",
|
||||
"onboarding_image_step_no_local_empty": "Los modelos de imagen no pueden ejecutarse en este equipo.{servers, select, yes { Usa un servidor en su lugar.} other {}}",
|
||||
"onboarding_image_step_body": "Crea imágenes para ti. Ejecuta uno en este equipo o usa uno de un servidor.",
|
||||
"onboarding_image_step_no_local_empty": "Los modelos de imagen no pueden ejecutarse en este equipo. Usa un servidor en su lugar.",
|
||||
"onboarding_image_step_title": "Elige un modelo de generación de imágenes",
|
||||
"onboarding_model_list_aria": "Modelos para este equipo",
|
||||
"onboarding_model_list_delete_aria": "Eliminar {name}",
|
||||
|
|
@ -482,8 +502,8 @@
|
|||
"onboarding_server_path_connect_another_button": "Conectar otro servidor",
|
||||
"onboarding_server_path_connect_button": "Conectar un servidor",
|
||||
"onboarding_server_path_empty": "Todavía no hay servidores conectados.",
|
||||
"onboarding_video_step_body": "Crea videoclips cortos. {servers, select, yes {Ejecuta uno en este equipo o usa uno de un servidor.} other {Ejecuta uno en este equipo.}}",
|
||||
"onboarding_video_step_no_local_empty": "Los modelos de video no pueden ejecutarse en este equipo.{servers, select, yes { Usa un servidor en su lugar.} other {}}",
|
||||
"onboarding_video_step_body": "Crea videoclips cortos. Ejecuta uno en este equipo o usa uno de un servidor.",
|
||||
"onboarding_video_step_no_local_empty": "Los modelos de video no pueden ejecutarse en este equipo. Usa un servidor en su lugar.",
|
||||
"onboarding_video_step_title": "Elige un modelo de generación de video",
|
||||
"onboarding_welcome_body": "Un cuaderno de investigación privado que funciona por completo en tu propia máquina. Tus documentos, tus claves de modelos, sin nube y sin cuenta.",
|
||||
"onboarding_welcome_start_button": "Empezar la configuración",
|
||||
|
|
@ -704,12 +724,14 @@
|
|||
"studio_podcast_brief_length_short_label": "Corta",
|
||||
"studio_podcast_brief_length_standard_body": "~8 min",
|
||||
"studio_podcast_brief_length_standard_label": "Estándar",
|
||||
"studio_podcast_brief_no_voices_body": "Este modelo necesita voces antes de poder narrar un podcast. El servidor no las lista, así que añade las que quieras.",
|
||||
"studio_podcast_brief_remove_speaker_aria": "Quitar el participante {number, number}",
|
||||
"studio_podcast_brief_role_cohost_label": "Copresentador",
|
||||
"studio_podcast_brief_role_expert_label": "Experto",
|
||||
"studio_podcast_brief_role_guest_label": "Invitado",
|
||||
"studio_podcast_brief_role_host_label": "Presentador",
|
||||
"studio_podcast_brief_role_narrator_label": "Narrador",
|
||||
"studio_podcast_brief_set_up_voices_button": "Configurar voces",
|
||||
"studio_podcast_brief_speaker_aria": "Participante {number, number}",
|
||||
"studio_podcast_brief_speaker_name_aria": "Nombre",
|
||||
"studio_podcast_brief_speaker_role_aria": "Rol",
|
||||
|
|
@ -723,6 +745,7 @@
|
|||
"studio_podcast_brief_style_narrative_label": "Narrativo",
|
||||
"studio_podcast_brief_voice_female_label": "Femenina",
|
||||
"studio_podcast_brief_voice_male_label": "Masculina",
|
||||
"studio_podcast_brief_voiced_by_body": "Narrado por {model} en {server}. El proveedor cobra cada línea.",
|
||||
"studio_podcast_viewer_transcript_title": "Transcripción",
|
||||
"studio_pptx_viewer_error_title": "No se pudo cargar la presentación",
|
||||
"studio_pptx_viewer_load_error": "El servidor devolvió {status} al recuperar la presentación",
|
||||
|
|
|
|||
|
|
@ -417,6 +417,18 @@
|
|||
"models_server_models_vision_label": "Vision",
|
||||
"models_server_picker_load_error": "Impossible de charger les serveurs",
|
||||
"models_server_picker_load_error_title": "Impossible de charger les serveurs",
|
||||
"models_server_voices_add_button": "Ajouter et tester",
|
||||
"models_server_voices_clip_aria": "{voice}, telle que le serveur la prononce",
|
||||
"models_server_voices_listed_body": "Listées par le serveur",
|
||||
"models_server_voices_page_link": "Voir les voix de ce modèle",
|
||||
"models_server_voices_play_aria": "Écouter {voice}",
|
||||
"models_server_voices_remove_aria": "Retirer {voice}",
|
||||
"models_server_voices_request_error": "La requête a échoué",
|
||||
"models_server_voices_saved_body": "Ce serveur ne liste pas ses voix. Ajoutez les ID souhaités ; chacun est lu une fois avant d’être enregistré. Un podcast en demande un par intervenant.",
|
||||
"models_server_voices_title": "Voix de ce modèle",
|
||||
"models_server_voices_trial_body": "Bonjour. Voici comment vos podcasts sonneront.",
|
||||
"models_server_voices_voice_aria": "ID de voix",
|
||||
"models_server_voices_voice_placeholder": "ID de voix",
|
||||
"models_try_dialog_cancel_button": "Annuler",
|
||||
"models_try_dialog_chat_confirmed_body": "La prise en charge du chat est confirmée. Le test envoie un prompt court et peut être facturé.",
|
||||
"models_try_dialog_chat_unconfirmed_body": "Cet endpoint ne publie pas ses capacités, la prise en charge du chat n’est donc pas confirmée. Le test envoie un prompt court et peut être facturé.",
|
||||
|
|
@ -424,8 +436,12 @@
|
|||
"models_try_dialog_image_unconfirmed_body": "Cet endpoint ne publie pas ses capacités, la prise en charge de l’image n’est donc pas confirmée. Il doit implémenter /images/generations ou /images. Le test lance une véritable inférence et peut être facturé.",
|
||||
"models_try_dialog_preview_aria": "Test généré par {model}",
|
||||
"models_try_dialog_request_error": "La requête a échoué",
|
||||
"models_try_dialog_speech_confirmed_body": "La prise en charge de la voix est confirmée. Le test lit une courte phrase à voix haute et peut être facturé.",
|
||||
"models_try_dialog_speech_preview_aria": "Test lu par {model}",
|
||||
"models_try_dialog_speech_unconfirmed_body": "Cet endpoint ne publie pas ses capacités, la prise en charge de la voix n’est donc pas confirmée. Il doit implémenter /audio/speech. Le test lit une courte phrase à voix haute et peut être facturé.",
|
||||
"models_try_dialog_test_chat_button": "Tester le chat",
|
||||
"models_try_dialog_test_image_button": "Tester l’image",
|
||||
"models_try_dialog_test_speech_button": "Tester la voix",
|
||||
"models_try_dialog_title": "{slot, select, text_gen {Utiliser {model} pour le chat ?} image_gen {Utiliser {model} pour l’image ?} image_edit {Utiliser {model} pour la modification d’images ?} video_gen {Utiliser {model} pour la vidéo ?} audio_gen {Utiliser {model} pour l’audio ?} other {Utiliser {model} ?}}",
|
||||
"models_try_dialog_unlisted_body": "SurfSense n’a pas pu confirmer ce modèle dans le catalogue en direct. Ne continuez que si l’ID de modèle exact est correct.",
|
||||
"models_try_dialog_unlisted_cancel_button": "Annuler",
|
||||
|
|
@ -433,22 +449,26 @@
|
|||
"models_try_dialog_unlisted_title": "Utiliser un modèle non répertorié ?",
|
||||
"models_try_dialog_use_tested_button": "Utiliser ce modèle",
|
||||
"models_try_dialog_use_untested_button": "Utiliser sans tester",
|
||||
"models_try_dialog_voice_aria": "Voix",
|
||||
"models_try_dialog_voice_body": "Vide, la voix par défaut du serveur est utilisée, s’il en a une.",
|
||||
"models_try_dialog_voice_placeholder": "ID de voix (facultatif)",
|
||||
"models_try_dialog_voice_trial_body": "Bonjour. Voici comment vos podcasts sonneront.",
|
||||
"models_your_models_vision_label": "Vision",
|
||||
"onboarding_audio_step_body": "Crée vos podcasts. {servers, select, yes {Exécutez-en un sur cet ordinateur ou utilisez-en un depuis un serveur.} other {Exécutez-en un sur cet ordinateur.}}",
|
||||
"onboarding_audio_step_no_local_empty": "Les modèles audio ne peuvent pas fonctionner sur cet ordinateur.{servers, select, yes { Utilisez plutôt un serveur.} other {}}",
|
||||
"onboarding_audio_step_body": "Crée vos podcasts. Exécutez-en un sur cet ordinateur ou utilisez-en un depuis un serveur.",
|
||||
"onboarding_audio_step_no_local_empty": "Les modèles audio ne peuvent pas fonctionner sur cet ordinateur. Utilisez plutôt un serveur.",
|
||||
"onboarding_audio_step_title": "Choisissez un modèle audio",
|
||||
"onboarding_chat_step_body": "Vous répond dans le chat. {servers, select, yes {Exécutez-en un sur cet ordinateur pour que vos chats restent privés, ou utilisez-en un depuis un serveur.} other {Exécutez-en un sur cet ordinateur pour que vos chats restent privés.}}",
|
||||
"onboarding_chat_step_no_local_empty": "Aucun modèle testé ne peut fonctionner sur cet ordinateur.{servers, select, yes { Utilisez plutôt un serveur.} other {}}",
|
||||
"onboarding_chat_step_body": "Vous répond dans le chat. Exécutez-en un sur cet ordinateur pour que vos chats restent privés, ou utilisez-en un depuis un serveur.",
|
||||
"onboarding_chat_step_no_local_empty": "Aucun modèle testé ne peut fonctionner sur cet ordinateur. Utilisez plutôt un serveur.",
|
||||
"onboarding_chat_step_title": "Choisissez un modèle de génération de texte",
|
||||
"onboarding_finish_error": "Impossible de terminer la configuration",
|
||||
"onboarding_finish_no_chat_model_error": "Choisissez d’abord un modèle de chat",
|
||||
"onboarding_hugging_face_search_hide_button": "Masquer la recherche Hugging Face",
|
||||
"onboarding_hugging_face_search_show_button": "Absent de la liste ? Rechercher sur Hugging Face",
|
||||
"onboarding_image_edit_step_body": "Modifie vos images. {servers, select, yes {Exécutez-en un sur cet ordinateur ou utilisez-en un depuis un serveur.} other {Exécutez-en un sur cet ordinateur.}}",
|
||||
"onboarding_image_edit_step_no_local_empty": "Les modèles de modification d’images ne peuvent pas fonctionner sur cet ordinateur.{servers, select, yes { Utilisez plutôt un serveur.} other {}}",
|
||||
"onboarding_image_edit_step_body": "Modifie vos images. Exécutez-en un sur cet ordinateur ou utilisez-en un depuis un serveur.",
|
||||
"onboarding_image_edit_step_no_local_empty": "Les modèles de modification d’images ne peuvent pas fonctionner sur cet ordinateur. Utilisez plutôt un serveur.",
|
||||
"onboarding_image_edit_step_title": "Choisissez un modèle de modification d’images",
|
||||
"onboarding_image_step_body": "Crée vos images. {servers, select, yes {Exécutez-en un sur cet ordinateur ou utilisez-en un depuis un serveur.} other {Exécutez-en un sur cet ordinateur.}}",
|
||||
"onboarding_image_step_no_local_empty": "Les modèles d’image ne peuvent pas fonctionner sur cet ordinateur.{servers, select, yes { Utilisez plutôt un serveur.} other {}}",
|
||||
"onboarding_image_step_body": "Crée vos images. Exécutez-en un sur cet ordinateur ou utilisez-en un depuis un serveur.",
|
||||
"onboarding_image_step_no_local_empty": "Les modèles d’image ne peuvent pas fonctionner sur cet ordinateur. Utilisez plutôt un serveur.",
|
||||
"onboarding_image_step_title": "Choisissez un modèle de génération d’images",
|
||||
"onboarding_model_list_aria": "Modèles pour cet ordinateur",
|
||||
"onboarding_model_list_delete_aria": "Supprimer {name}",
|
||||
|
|
@ -482,8 +502,8 @@
|
|||
"onboarding_server_path_connect_another_button": "Connecter un autre serveur",
|
||||
"onboarding_server_path_connect_button": "Connecter un serveur",
|
||||
"onboarding_server_path_empty": "Aucun serveur connecté pour l’instant.",
|
||||
"onboarding_video_step_body": "Crée de courts clips vidéo. {servers, select, yes {Exécutez-en un sur cet ordinateur ou utilisez-en un depuis un serveur.} other {Exécutez-en un sur cet ordinateur.}}",
|
||||
"onboarding_video_step_no_local_empty": "Les modèles vidéo ne peuvent pas fonctionner sur cet ordinateur.{servers, select, yes { Utilisez plutôt un serveur.} other {}}",
|
||||
"onboarding_video_step_body": "Crée de courts clips vidéo. Exécutez-en un sur cet ordinateur ou utilisez-en un depuis un serveur.",
|
||||
"onboarding_video_step_no_local_empty": "Les modèles vidéo ne peuvent pas fonctionner sur cet ordinateur. Utilisez plutôt un serveur.",
|
||||
"onboarding_video_step_title": "Choisissez un modèle de génération de vidéos",
|
||||
"onboarding_welcome_body": "Un carnet de recherche privé qui fonctionne entièrement sur votre propre machine. Vos documents, vos clés de modèle, sans cloud, sans compte.",
|
||||
"onboarding_welcome_start_button": "Commencer la configuration",
|
||||
|
|
@ -704,12 +724,14 @@
|
|||
"studio_podcast_brief_length_short_label": "Court",
|
||||
"studio_podcast_brief_length_standard_body": "~8 min",
|
||||
"studio_podcast_brief_length_standard_label": "Standard",
|
||||
"studio_podcast_brief_no_voices_body": "Ce modèle a besoin de voix avant de pouvoir lire un podcast. Le serveur ne les liste pas : ajoutez celles que vous voulez.",
|
||||
"studio_podcast_brief_remove_speaker_aria": "Supprimer l’intervenant {number, number}",
|
||||
"studio_podcast_brief_role_cohost_label": "Coanimateur",
|
||||
"studio_podcast_brief_role_expert_label": "Expert",
|
||||
"studio_podcast_brief_role_guest_label": "Invité",
|
||||
"studio_podcast_brief_role_host_label": "Animateur",
|
||||
"studio_podcast_brief_role_narrator_label": "Narrateur",
|
||||
"studio_podcast_brief_set_up_voices_button": "Configurer les voix",
|
||||
"studio_podcast_brief_speaker_aria": "Intervenant {number, number}",
|
||||
"studio_podcast_brief_speaker_name_aria": "Nom",
|
||||
"studio_podcast_brief_speaker_role_aria": "Rôle",
|
||||
|
|
@ -723,6 +745,7 @@
|
|||
"studio_podcast_brief_style_narrative_label": "Récit",
|
||||
"studio_podcast_brief_voice_female_label": "Féminine",
|
||||
"studio_podcast_brief_voice_male_label": "Masculine",
|
||||
"studio_podcast_brief_voiced_by_body": "Lu par {model} sur {server}. Chaque ligne est facturée par le fournisseur.",
|
||||
"studio_podcast_viewer_transcript_title": "Transcription",
|
||||
"studio_pptx_viewer_error_title": "Impossible de charger la présentation",
|
||||
"studio_pptx_viewer_load_error": "Le serveur a renvoyé {status} lors de la récupération de la présentation",
|
||||
|
|
|
|||
|
|
@ -417,6 +417,18 @@
|
|||
"models_server_models_vision_label": "विज़न",
|
||||
"models_server_picker_load_error": "सर्वर लोड नहीं हो सके",
|
||||
"models_server_picker_load_error_title": "सर्वर लोड नहीं हो सके",
|
||||
"models_server_voices_add_button": "जोड़ें और जांचें",
|
||||
"models_server_voices_clip_aria": "सर्वर की आवाज़ में {voice}",
|
||||
"models_server_voices_listed_body": "सर्वर की सूची",
|
||||
"models_server_voices_page_link": "इस मॉडल की आवाज़ें देखें",
|
||||
"models_server_voices_play_aria": "{voice} चलाएं",
|
||||
"models_server_voices_remove_aria": "{voice} हटाएं",
|
||||
"models_server_voices_request_error": "अनुरोध विफल रहा",
|
||||
"models_server_voices_saved_body": "यह सर्वर अपनी आवाज़ों की सूची नहीं देता। जो आवाज़ ID चाहिए, जोड़ें; सहेजने से पहले हर एक को एक बार चलाया जाता है। पॉडकास्ट में हर वक्ता के लिए एक चाहिए।",
|
||||
"models_server_voices_title": "इस मॉडल की आवाज़ें",
|
||||
"models_server_voices_trial_body": "नमस्ते। आपके पॉडकास्ट ऐसे सुनाई देंगे।",
|
||||
"models_server_voices_voice_aria": "आवाज़ ID",
|
||||
"models_server_voices_voice_placeholder": "आवाज़ ID",
|
||||
"models_try_dialog_cancel_button": "रद्द करें",
|
||||
"models_try_dialog_chat_confirmed_body": "चैट सपोर्ट की पुष्टि हो चुकी है। जांच में एक छोटा प्रॉम्प्ट भेजा जाता है, जिसका शुल्क लग सकता है।",
|
||||
"models_try_dialog_chat_unconfirmed_body": "यह एंडपॉइंट अपनी क्षमताएं प्रकाशित नहीं करता, इसलिए चैट सपोर्ट की पुष्टि नहीं हुई है। जांच में एक छोटा प्रॉम्प्ट भेजा जाता है, जिसका शुल्क लग सकता है।",
|
||||
|
|
@ -424,8 +436,12 @@
|
|||
"models_try_dialog_image_unconfirmed_body": "यह एंडपॉइंट अपनी क्षमताएं प्रकाशित नहीं करता, इसलिए इमेज सपोर्ट की पुष्टि नहीं हुई है। इसे /images/generations या /images लागू करना ज़रूरी है। जांच में असली इन्फ़रेंस चलता है, जिसका शुल्क लग सकता है।",
|
||||
"models_try_dialog_preview_aria": "{model} द्वारा बनाया गया टेस्ट",
|
||||
"models_try_dialog_request_error": "अनुरोध विफल रहा",
|
||||
"models_try_dialog_speech_confirmed_body": "वॉइस सपोर्ट की पुष्टि हो चुकी है। जांच में एक छोटी पंक्ति बोली जाती है, जिसका शुल्क लग सकता है।",
|
||||
"models_try_dialog_speech_preview_aria": "{model} द्वारा बोला गया टेस्ट",
|
||||
"models_try_dialog_speech_unconfirmed_body": "यह एंडपॉइंट अपनी क्षमताएं प्रकाशित नहीं करता, इसलिए वॉइस सपोर्ट की पुष्टि नहीं हुई है। इसे /audio/speech लागू करना ज़रूरी है। जांच में एक छोटी पंक्ति बोली जाती है, जिसका शुल्क लग सकता है।",
|
||||
"models_try_dialog_test_chat_button": "चैट जांचें",
|
||||
"models_try_dialog_test_image_button": "इमेज जांचें",
|
||||
"models_try_dialog_test_speech_button": "वॉइस जांचें",
|
||||
"models_try_dialog_title": "{slot, select, text_gen {{model} को चैट के लिए इस्तेमाल करें?} image_gen {{model} को इमेज के लिए इस्तेमाल करें?} image_edit {{model} को इमेज एडिटिंग के लिए इस्तेमाल करें?} video_gen {{model} को वीडियो के लिए इस्तेमाल करें?} audio_gen {{model} को ऑडियो के लिए इस्तेमाल करें?} other {{model} इस्तेमाल करें?}}",
|
||||
"models_try_dialog_unlisted_body": "SurfSense लाइव कैटलॉग में इस मॉडल की पुष्टि नहीं कर सका। तभी आगे बढ़ें जब सटीक मॉडल ID सही हो।",
|
||||
"models_try_dialog_unlisted_cancel_button": "रद्द करें",
|
||||
|
|
@ -433,22 +449,26 @@
|
|||
"models_try_dialog_unlisted_title": "असूचीबद्ध मॉडल इस्तेमाल करें?",
|
||||
"models_try_dialog_use_tested_button": "यह मॉडल इस्तेमाल करें",
|
||||
"models_try_dialog_use_untested_button": "बिना जांचे इस्तेमाल करें",
|
||||
"models_try_dialog_voice_aria": "आवाज़",
|
||||
"models_try_dialog_voice_body": "खाली छोड़ने पर सर्वर की डिफ़ॉल्ट आवाज़ इस्तेमाल होती है, अगर उसके पास है।",
|
||||
"models_try_dialog_voice_placeholder": "आवाज़ ID (वैकल्पिक)",
|
||||
"models_try_dialog_voice_trial_body": "नमस्ते। आपके पॉडकास्ट ऐसे सुनाई देंगे।",
|
||||
"models_your_models_vision_label": "विज़न",
|
||||
"onboarding_audio_step_body": "आपके लिए पॉडकास्ट बनाता है। {servers, select, yes {इसे इस कंप्यूटर पर चलाएं, या किसी सर्वर से इस्तेमाल करें।} other {इसे इस कंप्यूटर पर चलाएं।}}",
|
||||
"onboarding_audio_step_no_local_empty": "ऑडियो मॉडल इस कंप्यूटर पर नहीं चल सकते।{servers, select, yes { इसके बजाय कोई सर्वर इस्तेमाल करें।} other {}}",
|
||||
"onboarding_audio_step_body": "आपके लिए पॉडकास्ट बनाता है। इसे इस कंप्यूटर पर चलाएं, या किसी सर्वर से इस्तेमाल करें।",
|
||||
"onboarding_audio_step_no_local_empty": "ऑडियो मॉडल इस कंप्यूटर पर नहीं चल सकते। इसके बजाय कोई सर्वर इस्तेमाल करें।",
|
||||
"onboarding_audio_step_title": "ऑडियो मॉडल चुनें",
|
||||
"onboarding_chat_step_body": "चैट में आपको जवाब देता है। {servers, select, yes {इसे इस कंप्यूटर पर चलाएं ताकि आपकी चैट निजी रहें, या किसी सर्वर से इस्तेमाल करें।} other {इसे इस कंप्यूटर पर चलाएं ताकि आपकी चैट निजी रहें।}}",
|
||||
"onboarding_chat_step_no_local_empty": "इस कंप्यूटर पर कोई भी जांचा गया मॉडल नहीं चल सकता।{servers, select, yes { इसके बजाय कोई सर्वर इस्तेमाल करें।} other {}}",
|
||||
"onboarding_chat_step_body": "चैट में आपको जवाब देता है। इसे इस कंप्यूटर पर चलाएं ताकि आपकी चैट निजी रहें, या किसी सर्वर से इस्तेमाल करें।",
|
||||
"onboarding_chat_step_no_local_empty": "इस कंप्यूटर पर कोई भी जांचा गया मॉडल नहीं चल सकता। इसके बजाय कोई सर्वर इस्तेमाल करें।",
|
||||
"onboarding_chat_step_title": "टेक्स्ट जनरेशन मॉडल चुनें",
|
||||
"onboarding_finish_error": "सेटअप पूरा नहीं हो सका",
|
||||
"onboarding_finish_no_chat_model_error": "पहले चैट मॉडल चुनें",
|
||||
"onboarding_hugging_face_search_hide_button": "Hugging Face खोज छिपाएं",
|
||||
"onboarding_hugging_face_search_show_button": "सूची में नहीं है? Hugging Face पर खोजें",
|
||||
"onboarding_image_edit_step_body": "आपके लिए इमेज एडिट करता है। {servers, select, yes {इसे इस कंप्यूटर पर चलाएं, या किसी सर्वर से इस्तेमाल करें।} other {इसे इस कंप्यूटर पर चलाएं।}}",
|
||||
"onboarding_image_edit_step_no_local_empty": "इमेज एडिटिंग मॉडल इस कंप्यूटर पर नहीं चल सकते।{servers, select, yes { इसके बजाय कोई सर्वर इस्तेमाल करें।} other {}}",
|
||||
"onboarding_image_edit_step_body": "आपके लिए इमेज एडिट करता है। इसे इस कंप्यूटर पर चलाएं, या किसी सर्वर से इस्तेमाल करें।",
|
||||
"onboarding_image_edit_step_no_local_empty": "इमेज एडिटिंग मॉडल इस कंप्यूटर पर नहीं चल सकते। इसके बजाय कोई सर्वर इस्तेमाल करें।",
|
||||
"onboarding_image_edit_step_title": "इमेज एडिटिंग मॉडल चुनें",
|
||||
"onboarding_image_step_body": "आपके लिए इमेज बनाता है। {servers, select, yes {इसे इस कंप्यूटर पर चलाएं, या किसी सर्वर से इस्तेमाल करें।} other {इसे इस कंप्यूटर पर चलाएं।}}",
|
||||
"onboarding_image_step_no_local_empty": "इमेज मॉडल इस कंप्यूटर पर नहीं चल सकते।{servers, select, yes { इसके बजाय कोई सर्वर इस्तेमाल करें।} other {}}",
|
||||
"onboarding_image_step_body": "आपके लिए इमेज बनाता है। इसे इस कंप्यूटर पर चलाएं, या किसी सर्वर से इस्तेमाल करें।",
|
||||
"onboarding_image_step_no_local_empty": "इमेज मॉडल इस कंप्यूटर पर नहीं चल सकते। इसके बजाय कोई सर्वर इस्तेमाल करें।",
|
||||
"onboarding_image_step_title": "इमेज जनरेशन मॉडल चुनें",
|
||||
"onboarding_model_list_aria": "इस कंप्यूटर के लिए मॉडल",
|
||||
"onboarding_model_list_delete_aria": "{name} हटाएं",
|
||||
|
|
@ -482,8 +502,8 @@
|
|||
"onboarding_server_path_connect_another_button": "दूसरा सर्वर कनेक्ट करें",
|
||||
"onboarding_server_path_connect_button": "सर्वर कनेक्ट करें",
|
||||
"onboarding_server_path_empty": "अभी तक कोई सर्वर कनेक्ट नहीं है।",
|
||||
"onboarding_video_step_body": "छोटे वीडियो क्लिप बनाता है। {servers, select, yes {इसे इस कंप्यूटर पर चलाएं, या किसी सर्वर से इस्तेमाल करें।} other {इसे इस कंप्यूटर पर चलाएं।}}",
|
||||
"onboarding_video_step_no_local_empty": "वीडियो मॉडल इस कंप्यूटर पर नहीं चल सकते।{servers, select, yes { इसके बजाय कोई सर्वर इस्तेमाल करें।} other {}}",
|
||||
"onboarding_video_step_body": "छोटे वीडियो क्लिप बनाता है। इसे इस कंप्यूटर पर चलाएं, या किसी सर्वर से इस्तेमाल करें।",
|
||||
"onboarding_video_step_no_local_empty": "वीडियो मॉडल इस कंप्यूटर पर नहीं चल सकते। इसके बजाय कोई सर्वर इस्तेमाल करें।",
|
||||
"onboarding_video_step_title": "वीडियो जनरेशन मॉडल चुनें",
|
||||
"onboarding_welcome_body": "एक निजी रिसर्च नोटबुक जो पूरी तरह आपकी अपनी मशीन पर चलती है। आपके दस्तावेज़, आपकी मॉडल कुंजियां, न कोई क्लाउड, न कोई खाता।",
|
||||
"onboarding_welcome_start_button": "सेटअप शुरू करें",
|
||||
|
|
@ -704,12 +724,14 @@
|
|||
"studio_podcast_brief_length_short_label": "छोटा",
|
||||
"studio_podcast_brief_length_standard_body": "~8 मिनट",
|
||||
"studio_podcast_brief_length_standard_label": "मानक",
|
||||
"studio_podcast_brief_no_voices_body": "पॉडकास्ट पढ़ने से पहले इस मॉडल को आवाज़ें चाहिए। सर्वर उनकी सूची नहीं देता, इसलिए जो चाहिए, जोड़ें।",
|
||||
"studio_podcast_brief_remove_speaker_aria": "स्पीकर {number, number} हटाएं",
|
||||
"studio_podcast_brief_role_cohost_label": "को-होस्ट",
|
||||
"studio_podcast_brief_role_expert_label": "विशेषज्ञ",
|
||||
"studio_podcast_brief_role_guest_label": "मेहमान",
|
||||
"studio_podcast_brief_role_host_label": "होस्ट",
|
||||
"studio_podcast_brief_role_narrator_label": "नैरेटर",
|
||||
"studio_podcast_brief_set_up_voices_button": "आवाज़ें सेट करें",
|
||||
"studio_podcast_brief_speaker_aria": "स्पीकर {number, number}",
|
||||
"studio_podcast_brief_speaker_name_aria": "नाम",
|
||||
"studio_podcast_brief_speaker_role_aria": "भूमिका",
|
||||
|
|
@ -723,6 +745,7 @@
|
|||
"studio_podcast_brief_style_narrative_label": "कथात्मक",
|
||||
"studio_podcast_brief_voice_female_label": "महिला",
|
||||
"studio_podcast_brief_voice_male_label": "पुरुष",
|
||||
"studio_podcast_brief_voiced_by_body": "{server} पर {model} पढ़ता है। प्रोवाइडर हर पंक्ति का शुल्क लेता है।",
|
||||
"studio_podcast_viewer_transcript_title": "ट्रांसक्रिप्ट",
|
||||
"studio_pptx_viewer_error_title": "प्रेज़ेंटेशन लोड नहीं हो सका",
|
||||
"studio_pptx_viewer_load_error": "प्रेज़ेंटेशन लाते समय सर्वर ने {status} लौटाया",
|
||||
|
|
|
|||
|
|
@ -417,6 +417,18 @@
|
|||
"models_server_models_vision_label": "画像認識",
|
||||
"models_server_picker_load_error": "サーバーを読み込めませんでした",
|
||||
"models_server_picker_load_error_title": "サーバーを読み込めませんでした",
|
||||
"models_server_voices_add_button": "追加してテスト",
|
||||
"models_server_voices_clip_aria": "サーバーが読み上げた{voice}",
|
||||
"models_server_voices_listed_body": "サーバーが公開している声",
|
||||
"models_server_voices_page_link": "このモデルの声を見る",
|
||||
"models_server_voices_play_aria": "{voice}を再生",
|
||||
"models_server_voices_remove_aria": "{voice}を削除",
|
||||
"models_server_voices_request_error": "リクエストに失敗しました",
|
||||
"models_server_voices_saved_body": "このサーバーは声の一覧を公開していません。使いたい声のIDを追加してください。保存する前に一度再生して確認します。ポッドキャストには話者ごとに1つ必要です。",
|
||||
"models_server_voices_title": "このモデルの声",
|
||||
"models_server_voices_trial_body": "こんにちは。ポッドキャストはこのように聞こえます。",
|
||||
"models_server_voices_voice_aria": "声のID",
|
||||
"models_server_voices_voice_placeholder": "声のID",
|
||||
"models_try_dialog_cancel_button": "キャンセル",
|
||||
"models_try_dialog_chat_confirmed_body": "チャットへの対応は確認済みです。テストでは短いプロンプトを1回送信するため、料金が発生する場合があります。",
|
||||
"models_try_dialog_chat_unconfirmed_body": "このエンドポイントは機能を公開していないため、チャットへの対応は未確認です。テストでは短いプロンプトを1回送信するため、料金が発生する場合があります。",
|
||||
|
|
@ -424,8 +436,12 @@
|
|||
"models_try_dialog_image_unconfirmed_body": "このエンドポイントは機能を公開していないため、画像への対応は未確認です。/images/generationsまたは/imagesを実装している必要があります。テストでは実際に推論を行うため、料金が発生する場合があります。",
|
||||
"models_try_dialog_preview_aria": "{model}が生成したテスト画像",
|
||||
"models_try_dialog_request_error": "リクエストに失敗しました",
|
||||
"models_try_dialog_speech_confirmed_body": "音声合成への対応は確認済みです。テストでは短い一文を読み上げるため、料金が発生する場合があります。",
|
||||
"models_try_dialog_speech_preview_aria": "{model}が読み上げたテスト音声",
|
||||
"models_try_dialog_speech_unconfirmed_body": "このエンドポイントは機能を公開していないため、音声合成への対応は未確認です。/audio/speechを実装している必要があります。テストでは短い一文を読み上げるため、料金が発生する場合があります。",
|
||||
"models_try_dialog_test_chat_button": "チャットをテスト",
|
||||
"models_try_dialog_test_image_button": "画像をテスト",
|
||||
"models_try_dialog_test_speech_button": "音声をテスト",
|
||||
"models_try_dialog_title": "{slot, select, text_gen {{model}をチャットに使用しますか?} image_gen {{model}を画像に使用しますか?} image_edit {{model}を画像編集に使用しますか?} video_gen {{model}を動画に使用しますか?} audio_gen {{model}を音声に使用しますか?} other {{model}を使用しますか?}}",
|
||||
"models_try_dialog_unlisted_body": "SurfSenseはこのモデルを最新のカタログで確認できませんでした。モデルIDが正確な場合のみ続行してください。",
|
||||
"models_try_dialog_unlisted_cancel_button": "キャンセル",
|
||||
|
|
@ -433,22 +449,26 @@
|
|||
"models_try_dialog_unlisted_title": "一覧にないモデルを使用しますか?",
|
||||
"models_try_dialog_use_tested_button": "このモデルを使用",
|
||||
"models_try_dialog_use_untested_button": "テストせずに使用",
|
||||
"models_try_dialog_voice_aria": "声",
|
||||
"models_try_dialog_voice_body": "空欄の場合、サーバーに既定の声があればそれを使います。",
|
||||
"models_try_dialog_voice_placeholder": "声のID(任意)",
|
||||
"models_try_dialog_voice_trial_body": "こんにちは。ポッドキャストはこのように聞こえます。",
|
||||
"models_your_models_vision_label": "画像認識",
|
||||
"onboarding_audio_step_body": "ポッドキャストを作成します。{servers, select, yes {このコンピューターで実行するか、サーバー上のモデルを使います。} other {このコンピューターで実行します。}}",
|
||||
"onboarding_audio_step_no_local_empty": "このコンピューターでは音声モデルを実行できません。{servers, select, yes {代わりにサーバーを使ってください。} other {}}",
|
||||
"onboarding_audio_step_body": "ポッドキャストを作成します。このコンピューターで実行するか、サーバー上のモデルを使います。",
|
||||
"onboarding_audio_step_no_local_empty": "このコンピューターでは音声モデルを実行できません。代わりにサーバーを使ってください。",
|
||||
"onboarding_audio_step_title": "音声モデルを選択",
|
||||
"onboarding_chat_step_body": "チャットで回答します。{servers, select, yes {このコンピューターで実行すればチャットはプライベートなまま保たれます。サーバー上のモデルを使うこともできます。} other {このコンピューターで実行すればチャットはプライベートなまま保たれます。}}",
|
||||
"onboarding_chat_step_no_local_empty": "このコンピューターで実行できる検証済みのモデルはありません。{servers, select, yes {代わりにサーバーを使ってください。} other {}}",
|
||||
"onboarding_chat_step_body": "チャットで回答します。このコンピューターで実行すればチャットはプライベートなまま保たれます。サーバー上のモデルを使うこともできます。",
|
||||
"onboarding_chat_step_no_local_empty": "このコンピューターで実行できる検証済みのモデルはありません。代わりにサーバーを使ってください。",
|
||||
"onboarding_chat_step_title": "テキスト生成モデルを選択",
|
||||
"onboarding_finish_error": "セットアップを完了できませんでした",
|
||||
"onboarding_finish_no_chat_model_error": "先にチャットモデルを選んでください",
|
||||
"onboarding_hugging_face_search_hide_button": "Hugging Faceの検索を閉じる",
|
||||
"onboarding_hugging_face_search_show_button": "見つからない場合はHugging Faceを検索",
|
||||
"onboarding_image_edit_step_body": "画像を編集します。{servers, select, yes {このコンピューターで実行するか、サーバー上のモデルを使います。} other {このコンピューターで実行します。}}",
|
||||
"onboarding_image_edit_step_no_local_empty": "このコンピューターでは画像編集モデルを実行できません。{servers, select, yes {代わりにサーバーを使ってください。} other {}}",
|
||||
"onboarding_image_edit_step_body": "画像を編集します。このコンピューターで実行するか、サーバー上のモデルを使います。",
|
||||
"onboarding_image_edit_step_no_local_empty": "このコンピューターでは画像編集モデルを実行できません。代わりにサーバーを使ってください。",
|
||||
"onboarding_image_edit_step_title": "画像編集モデルを選択",
|
||||
"onboarding_image_step_body": "画像を作成します。{servers, select, yes {このコンピューターで実行するか、サーバー上のモデルを使います。} other {このコンピューターで実行します。}}",
|
||||
"onboarding_image_step_no_local_empty": "このコンピューターでは画像モデルを実行できません。{servers, select, yes {代わりにサーバーを使ってください。} other {}}",
|
||||
"onboarding_image_step_body": "画像を作成します。このコンピューターで実行するか、サーバー上のモデルを使います。",
|
||||
"onboarding_image_step_no_local_empty": "このコンピューターでは画像モデルを実行できません。代わりにサーバーを使ってください。",
|
||||
"onboarding_image_step_title": "画像生成モデルを選択",
|
||||
"onboarding_model_list_aria": "このコンピューター向けのモデル",
|
||||
"onboarding_model_list_delete_aria": "{name}を削除",
|
||||
|
|
@ -482,8 +502,8 @@
|
|||
"onboarding_server_path_connect_another_button": "別のサーバーを接続",
|
||||
"onboarding_server_path_connect_button": "サーバーを接続",
|
||||
"onboarding_server_path_empty": "接続済みのサーバーはまだありません。",
|
||||
"onboarding_video_step_body": "短い動画クリップを作成します。{servers, select, yes {このコンピューターで実行するか、サーバー上のモデルを使います。} other {このコンピューターで実行します。}}",
|
||||
"onboarding_video_step_no_local_empty": "このコンピューターでは動画モデルを実行できません。{servers, select, yes {代わりにサーバーを使ってください。} other {}}",
|
||||
"onboarding_video_step_body": "短い動画クリップを作成します。このコンピューターで実行するか、サーバー上のモデルを使います。",
|
||||
"onboarding_video_step_no_local_empty": "このコンピューターでは動画モデルを実行できません。代わりにサーバーを使ってください。",
|
||||
"onboarding_video_step_title": "動画生成モデルを選択",
|
||||
"onboarding_welcome_body": "完全に自分のマシン上で動作するプライベートなリサーチノートブックです。ドキュメントもモデルのキーも手元に。クラウドもアカウントも不要です。",
|
||||
"onboarding_welcome_start_button": "セットアップを始める",
|
||||
|
|
@ -704,12 +724,14 @@
|
|||
"studio_podcast_brief_length_short_label": "短め",
|
||||
"studio_podcast_brief_length_standard_body": "約8分",
|
||||
"studio_podcast_brief_length_standard_label": "標準",
|
||||
"studio_podcast_brief_no_voices_body": "ポッドキャストを読み上げるには、このモデルに声が必要です。サーバーは声の一覧を公開していないため、使いたい声を追加してください。",
|
||||
"studio_podcast_brief_remove_speaker_aria": "話者{number, number}を削除",
|
||||
"studio_podcast_brief_role_cohost_label": "共同ホスト",
|
||||
"studio_podcast_brief_role_expert_label": "専門家",
|
||||
"studio_podcast_brief_role_guest_label": "ゲスト",
|
||||
"studio_podcast_brief_role_host_label": "ホスト",
|
||||
"studio_podcast_brief_role_narrator_label": "ナレーター",
|
||||
"studio_podcast_brief_set_up_voices_button": "声を設定",
|
||||
"studio_podcast_brief_speaker_aria": "話者{number, number}",
|
||||
"studio_podcast_brief_speaker_name_aria": "名前",
|
||||
"studio_podcast_brief_speaker_role_aria": "役割",
|
||||
|
|
@ -723,6 +745,7 @@
|
|||
"studio_podcast_brief_style_narrative_label": "ナラティブ",
|
||||
"studio_podcast_brief_voice_female_label": "女性",
|
||||
"studio_podcast_brief_voice_male_label": "男性",
|
||||
"studio_podcast_brief_voiced_by_body": "{server}の{model}が読み上げます。行ごとにプロバイダーから課金されます。",
|
||||
"studio_podcast_viewer_transcript_title": "文字起こし",
|
||||
"studio_pptx_viewer_error_title": "プレゼンテーションを読み込めませんでした",
|
||||
"studio_pptx_viewer_load_error": "プレゼンテーションの取得中にサーバーが{status}を返しました",
|
||||
|
|
|
|||
|
|
@ -417,6 +417,18 @@
|
|||
"models_server_models_vision_label": "이미지 인식",
|
||||
"models_server_picker_load_error": "서버를 불러오지 못했습니다",
|
||||
"models_server_picker_load_error_title": "서버를 불러오지 못했습니다",
|
||||
"models_server_voices_add_button": "추가하고 테스트",
|
||||
"models_server_voices_clip_aria": "서버가 읽은 {voice}",
|
||||
"models_server_voices_listed_body": "서버가 제공한 목록",
|
||||
"models_server_voices_page_link": "이 모델의 목소리 보기",
|
||||
"models_server_voices_play_aria": "{voice} 재생",
|
||||
"models_server_voices_remove_aria": "{voice} 삭제",
|
||||
"models_server_voices_request_error": "요청에 실패했습니다",
|
||||
"models_server_voices_saved_body": "이 서버는 목소리 목록을 제공하지 않습니다. 원하는 ID를 추가하세요. 저장하기 전에 한 번 재생해 확인합니다. 팟캐스트에는 화자마다 하나씩 필요합니다.",
|
||||
"models_server_voices_title": "이 모델의 목소리",
|
||||
"models_server_voices_trial_body": "안녕하세요. 팟캐스트는 이렇게 들립니다.",
|
||||
"models_server_voices_voice_aria": "목소리 ID",
|
||||
"models_server_voices_voice_placeholder": "목소리 ID",
|
||||
"models_try_dialog_cancel_button": "취소",
|
||||
"models_try_dialog_chat_confirmed_body": "채팅 지원이 확인되었습니다. 테스트는 짧은 프롬프트를 한 번 전송하며 비용이 발생할 수 있습니다.",
|
||||
"models_try_dialog_chat_unconfirmed_body": "이 엔드포인트는 기능을 공개하지 않으므로 채팅 지원 여부가 확인되지 않았습니다. 테스트는 짧은 프롬프트를 한 번 전송하며 비용이 발생할 수 있습니다.",
|
||||
|
|
@ -424,8 +436,12 @@
|
|||
"models_try_dialog_image_unconfirmed_body": "이 엔드포인트는 기능을 공개하지 않으므로 이미지 지원 여부가 확인되지 않았습니다. /images/generations 또는 /images를 구현해야 합니다. 테스트는 실제 추론을 실행하며 비용이 발생할 수 있습니다.",
|
||||
"models_try_dialog_preview_aria": "{model}이(가) 생성한 테스트",
|
||||
"models_try_dialog_request_error": "요청이 실패했습니다",
|
||||
"models_try_dialog_speech_confirmed_body": "음성 지원이 확인되었습니다. 테스트는 짧은 문장 하나를 읽으며 비용이 발생할 수 있습니다.",
|
||||
"models_try_dialog_speech_preview_aria": "{model}이(가) 읽은 테스트",
|
||||
"models_try_dialog_speech_unconfirmed_body": "이 엔드포인트는 기능을 공개하지 않으므로 음성 지원 여부가 확인되지 않았습니다. /audio/speech를 구현해야 합니다. 테스트는 짧은 문장 하나를 읽으며 비용이 발생할 수 있습니다.",
|
||||
"models_try_dialog_test_chat_button": "채팅 테스트",
|
||||
"models_try_dialog_test_image_button": "이미지 테스트",
|
||||
"models_try_dialog_test_speech_button": "음성 테스트",
|
||||
"models_try_dialog_title": "{slot, select, text_gen {{model}을(를) 채팅에 사용하시겠습니까?} image_gen {{model}을(를) 이미지에 사용하시겠습니까?} image_edit {{model}을(를) 이미지 편집에 사용하시겠습니까?} video_gen {{model}을(를) 동영상에 사용하시겠습니까?} audio_gen {{model}을(를) 음성에 사용하시겠습니까?} other {{model}을(를) 사용하시겠습니까?}}",
|
||||
"models_try_dialog_unlisted_body": "SurfSense가 최신 카탈로그에서 이 모델을 확인하지 못했습니다. 정확한 모델 ID가 맞는 경우에만 계속하세요.",
|
||||
"models_try_dialog_unlisted_cancel_button": "취소",
|
||||
|
|
@ -433,22 +449,26 @@
|
|||
"models_try_dialog_unlisted_title": "목록에 없는 모델을 사용하시겠습니까?",
|
||||
"models_try_dialog_use_tested_button": "이 모델 사용",
|
||||
"models_try_dialog_use_untested_button": "테스트 없이 사용",
|
||||
"models_try_dialog_voice_aria": "목소리",
|
||||
"models_try_dialog_voice_body": "비워 두면 서버에 기본 목소리가 있을 경우 그 목소리를 사용합니다.",
|
||||
"models_try_dialog_voice_placeholder": "목소리 ID(선택)",
|
||||
"models_try_dialog_voice_trial_body": "안녕하세요. 팟캐스트는 이렇게 들립니다.",
|
||||
"models_your_models_vision_label": "이미지 인식",
|
||||
"onboarding_audio_step_body": "팟캐스트를 만들어 줍니다. {servers, select, yes {이 컴퓨터에서 실행하거나, 서버의 모델을 사용하세요.} other {이 컴퓨터에서 실행하세요.}}",
|
||||
"onboarding_audio_step_no_local_empty": "이 컴퓨터에서는 음성 모델을 실행할 수 없습니다.{servers, select, yes { 대신 서버를 사용하세요.} other {}}",
|
||||
"onboarding_audio_step_body": "팟캐스트를 만들어 줍니다. 이 컴퓨터에서 실행하거나, 서버의 모델을 사용하세요.",
|
||||
"onboarding_audio_step_no_local_empty": "이 컴퓨터에서는 음성 모델을 실행할 수 없습니다. 대신 서버를 사용하세요.",
|
||||
"onboarding_audio_step_title": "음성 모델 선택",
|
||||
"onboarding_chat_step_body": "채팅에서 답변합니다. {servers, select, yes {이 컴퓨터에서 실행하면 채팅이 비공개로 유지되며, 서버의 모델을 사용할 수도 있습니다.} other {이 컴퓨터에서 실행하면 채팅이 비공개로 유지됩니다.}}",
|
||||
"onboarding_chat_step_no_local_empty": "이 컴퓨터에서 실행할 수 있는 테스트된 모델이 없습니다.{servers, select, yes { 대신 서버를 사용하세요.} other {}}",
|
||||
"onboarding_chat_step_body": "채팅에서 답변합니다. 이 컴퓨터에서 실행하면 채팅이 비공개로 유지되며, 서버의 모델을 사용할 수도 있습니다.",
|
||||
"onboarding_chat_step_no_local_empty": "이 컴퓨터에서 실행할 수 있는 테스트된 모델이 없습니다. 대신 서버를 사용하세요.",
|
||||
"onboarding_chat_step_title": "텍스트 생성 모델 선택",
|
||||
"onboarding_finish_error": "설정을 완료하지 못했습니다",
|
||||
"onboarding_finish_no_chat_model_error": "먼저 채팅 모델을 선택하세요",
|
||||
"onboarding_hugging_face_search_hide_button": "Hugging Face 검색 숨기기",
|
||||
"onboarding_hugging_face_search_show_button": "목록에 없나요? Hugging Face 검색",
|
||||
"onboarding_image_edit_step_body": "이미지를 편집해 줍니다. {servers, select, yes {이 컴퓨터에서 실행하거나, 서버의 모델을 사용하세요.} other {이 컴퓨터에서 실행하세요.}}",
|
||||
"onboarding_image_edit_step_no_local_empty": "이 컴퓨터에서는 이미지 편집 모델을 실행할 수 없습니다.{servers, select, yes { 대신 서버를 사용하세요.} other {}}",
|
||||
"onboarding_image_edit_step_body": "이미지를 편집해 줍니다. 이 컴퓨터에서 실행하거나, 서버의 모델을 사용하세요.",
|
||||
"onboarding_image_edit_step_no_local_empty": "이 컴퓨터에서는 이미지 편집 모델을 실행할 수 없습니다. 대신 서버를 사용하세요.",
|
||||
"onboarding_image_edit_step_title": "이미지 편집 모델 선택",
|
||||
"onboarding_image_step_body": "이미지를 만들어 줍니다. {servers, select, yes {이 컴퓨터에서 실행하거나, 서버의 모델을 사용하세요.} other {이 컴퓨터에서 실행하세요.}}",
|
||||
"onboarding_image_step_no_local_empty": "이 컴퓨터에서는 이미지 모델을 실행할 수 없습니다.{servers, select, yes { 대신 서버를 사용하세요.} other {}}",
|
||||
"onboarding_image_step_body": "이미지를 만들어 줍니다. 이 컴퓨터에서 실행하거나, 서버의 모델을 사용하세요.",
|
||||
"onboarding_image_step_no_local_empty": "이 컴퓨터에서는 이미지 모델을 실행할 수 없습니다. 대신 서버를 사용하세요.",
|
||||
"onboarding_image_step_title": "이미지 생성 모델 선택",
|
||||
"onboarding_model_list_aria": "이 컴퓨터용 모델",
|
||||
"onboarding_model_list_delete_aria": "{name} 삭제",
|
||||
|
|
@ -482,8 +502,8 @@
|
|||
"onboarding_server_path_connect_another_button": "다른 서버 연결",
|
||||
"onboarding_server_path_connect_button": "서버 연결",
|
||||
"onboarding_server_path_empty": "아직 연결된 서버가 없습니다.",
|
||||
"onboarding_video_step_body": "짧은 동영상 클립을 만들어 줍니다. {servers, select, yes {이 컴퓨터에서 실행하거나, 서버의 모델을 사용하세요.} other {이 컴퓨터에서 실행하세요.}}",
|
||||
"onboarding_video_step_no_local_empty": "이 컴퓨터에서는 동영상 모델을 실행할 수 없습니다.{servers, select, yes { 대신 서버를 사용하세요.} other {}}",
|
||||
"onboarding_video_step_body": "짧은 동영상 클립을 만들어 줍니다. 이 컴퓨터에서 실행하거나, 서버의 모델을 사용하세요.",
|
||||
"onboarding_video_step_no_local_empty": "이 컴퓨터에서는 동영상 모델을 실행할 수 없습니다. 대신 서버를 사용하세요.",
|
||||
"onboarding_video_step_title": "동영상 생성 모델 선택",
|
||||
"onboarding_welcome_body": "전적으로 내 컴퓨터에서 실행되는 비공개 리서치 노트북입니다. 문서도 모델 키도 내 것이며, 클라우드도 계정도 없습니다.",
|
||||
"onboarding_welcome_start_button": "설정 시작",
|
||||
|
|
@ -704,12 +724,14 @@
|
|||
"studio_podcast_brief_length_short_label": "짧게",
|
||||
"studio_podcast_brief_length_standard_body": "약 8분",
|
||||
"studio_podcast_brief_length_standard_label": "표준",
|
||||
"studio_podcast_brief_no_voices_body": "팟캐스트를 읽으려면 이 모델에 목소리가 필요합니다. 서버가 목록을 제공하지 않으므로 원하는 목소리를 추가하세요.",
|
||||
"studio_podcast_brief_remove_speaker_aria": "화자 {number, number} 제거",
|
||||
"studio_podcast_brief_role_cohost_label": "공동 진행자",
|
||||
"studio_podcast_brief_role_expert_label": "전문가",
|
||||
"studio_podcast_brief_role_guest_label": "게스트",
|
||||
"studio_podcast_brief_role_host_label": "진행자",
|
||||
"studio_podcast_brief_role_narrator_label": "내레이터",
|
||||
"studio_podcast_brief_set_up_voices_button": "목소리 설정",
|
||||
"studio_podcast_brief_speaker_aria": "화자 {number, number}",
|
||||
"studio_podcast_brief_speaker_name_aria": "이름",
|
||||
"studio_podcast_brief_speaker_role_aria": "역할",
|
||||
|
|
@ -723,6 +745,7 @@
|
|||
"studio_podcast_brief_style_narrative_label": "내러티브",
|
||||
"studio_podcast_brief_voice_female_label": "여성",
|
||||
"studio_podcast_brief_voice_male_label": "남성",
|
||||
"studio_podcast_brief_voiced_by_body": "{server}의 {model}이(가) 읽습니다. 줄마다 제공업체가 요금을 부과합니다.",
|
||||
"studio_podcast_viewer_transcript_title": "스크립트",
|
||||
"studio_pptx_viewer_error_title": "프레젠테이션을 불러오지 못했습니다",
|
||||
"studio_pptx_viewer_load_error": "프레젠테이션을 가져오는 중 서버가 {status}을(를) 반환했습니다",
|
||||
|
|
|
|||
|
|
@ -417,6 +417,18 @@
|
|||
"models_server_models_vision_label": "Visão",
|
||||
"models_server_picker_load_error": "Não foi possível carregar os servidores",
|
||||
"models_server_picker_load_error_title": "Não foi possível carregar os servidores",
|
||||
"models_server_voices_add_button": "Adicionar e testar",
|
||||
"models_server_voices_clip_aria": "{voice}, como o servidor a pronuncia",
|
||||
"models_server_voices_listed_body": "Listadas pelo servidor",
|
||||
"models_server_voices_page_link": "Ver as vozes deste modelo",
|
||||
"models_server_voices_play_aria": "Ouvir {voice}",
|
||||
"models_server_voices_remove_aria": "Remover {voice}",
|
||||
"models_server_voices_request_error": "A solicitação falhou",
|
||||
"models_server_voices_saved_body": "Este servidor não lista as vozes dele. Adicione os IDs que quiser; cada um é tocado uma vez antes de ser salvo. Um podcast precisa de um por participante.",
|
||||
"models_server_voices_title": "Vozes deste modelo",
|
||||
"models_server_voices_trial_body": "Olá. É assim que seus podcasts vão soar.",
|
||||
"models_server_voices_voice_aria": "ID de voz",
|
||||
"models_server_voices_voice_placeholder": "ID de voz",
|
||||
"models_try_dialog_cancel_button": "Cancelar",
|
||||
"models_try_dialog_chat_confirmed_body": "O suporte a chat está confirmado. O teste envia um prompt curto e pode gerar custos.",
|
||||
"models_try_dialog_chat_unconfirmed_body": "Este endpoint não publica suas capacidades, então o suporte a chat não está confirmado. O teste envia um prompt curto e pode gerar custos.",
|
||||
|
|
@ -424,8 +436,12 @@
|
|||
"models_try_dialog_image_unconfirmed_body": "Este endpoint não publica suas capacidades, então o suporte a imagem não está confirmado. Ele precisa implementar /images/generations ou /images. O teste executa uma inferência real e pode gerar custos.",
|
||||
"models_try_dialog_preview_aria": "Teste gerado por {model}",
|
||||
"models_try_dialog_request_error": "A solicitação falhou",
|
||||
"models_try_dialog_speech_confirmed_body": "O suporte a voz está confirmado. O teste lê uma frase curta em voz alta e pode gerar custos.",
|
||||
"models_try_dialog_speech_preview_aria": "Teste lido por {model}",
|
||||
"models_try_dialog_speech_unconfirmed_body": "Este endpoint não publica suas capacidades, então o suporte a voz não está confirmado. Ele precisa implementar /audio/speech. O teste lê uma frase curta em voz alta e pode gerar custos.",
|
||||
"models_try_dialog_test_chat_button": "Testar chat",
|
||||
"models_try_dialog_test_image_button": "Testar imagem",
|
||||
"models_try_dialog_test_speech_button": "Testar voz",
|
||||
"models_try_dialog_title": "{slot, select, text_gen {Usar {model} para chat?} image_gen {Usar {model} para imagem?} image_edit {Usar {model} para edição de imagem?} video_gen {Usar {model} para vídeo?} audio_gen {Usar {model} para áudio?} other {Usar {model}?}}",
|
||||
"models_try_dialog_unlisted_body": "O SurfSense não conseguiu confirmar este modelo no catálogo em tempo real. Continue apenas se o ID exato do modelo estiver correto.",
|
||||
"models_try_dialog_unlisted_cancel_button": "Cancelar",
|
||||
|
|
@ -433,22 +449,26 @@
|
|||
"models_try_dialog_unlisted_title": "Usar um modelo não listado?",
|
||||
"models_try_dialog_use_tested_button": "Usar este modelo",
|
||||
"models_try_dialog_use_untested_button": "Usar sem testar",
|
||||
"models_try_dialog_voice_aria": "Voz",
|
||||
"models_try_dialog_voice_body": "Vazio usa a voz padrão do servidor, se ele tiver uma.",
|
||||
"models_try_dialog_voice_placeholder": "ID de voz (opcional)",
|
||||
"models_try_dialog_voice_trial_body": "Olá. É assim que seus podcasts vão soar.",
|
||||
"models_your_models_vision_label": "Visão",
|
||||
"onboarding_audio_step_body": "Cria podcasts para você. {servers, select, yes {Execute um neste computador ou use um de um servidor.} other {Execute um neste computador.}}",
|
||||
"onboarding_audio_step_no_local_empty": "Modelos de áudio não podem ser executados neste computador.{servers, select, yes { Em vez disso, use um servidor.} other {}}",
|
||||
"onboarding_audio_step_body": "Cria podcasts para você. Execute um neste computador ou use um de um servidor.",
|
||||
"onboarding_audio_step_no_local_empty": "Modelos de áudio não podem ser executados neste computador. Em vez disso, use um servidor.",
|
||||
"onboarding_audio_step_title": "Escolha um modelo de áudio",
|
||||
"onboarding_chat_step_body": "Responde a você no chat. {servers, select, yes {Execute um neste computador para que seus chats fiquem privados, ou use um de um servidor.} other {Execute um neste computador para que seus chats fiquem privados.}}",
|
||||
"onboarding_chat_step_no_local_empty": "Nenhum modelo testado pode ser executado neste computador.{servers, select, yes { Em vez disso, use um servidor.} other {}}",
|
||||
"onboarding_chat_step_body": "Responde a você no chat. Execute um neste computador para que seus chats fiquem privados, ou use um de um servidor.",
|
||||
"onboarding_chat_step_no_local_empty": "Nenhum modelo testado pode ser executado neste computador. Em vez disso, use um servidor.",
|
||||
"onboarding_chat_step_title": "Escolha um modelo de geração de texto",
|
||||
"onboarding_finish_error": "Não foi possível concluir a configuração",
|
||||
"onboarding_finish_no_chat_model_error": "Escolha primeiro um modelo de chat",
|
||||
"onboarding_hugging_face_search_hide_button": "Ocultar a busca no Hugging Face",
|
||||
"onboarding_hugging_face_search_show_button": "Não está na lista? Busque no Hugging Face",
|
||||
"onboarding_image_edit_step_body": "Edita imagens para você. {servers, select, yes {Execute um neste computador ou use um de um servidor.} other {Execute um neste computador.}}",
|
||||
"onboarding_image_edit_step_no_local_empty": "Modelos de edição de imagem não podem ser executados neste computador.{servers, select, yes { Em vez disso, use um servidor.} other {}}",
|
||||
"onboarding_image_edit_step_body": "Edita imagens para você. Execute um neste computador ou use um de um servidor.",
|
||||
"onboarding_image_edit_step_no_local_empty": "Modelos de edição de imagem não podem ser executados neste computador. Em vez disso, use um servidor.",
|
||||
"onboarding_image_edit_step_title": "Escolha um modelo de edição de imagem",
|
||||
"onboarding_image_step_body": "Cria imagens para você. {servers, select, yes {Execute um neste computador ou use um de um servidor.} other {Execute um neste computador.}}",
|
||||
"onboarding_image_step_no_local_empty": "Modelos de imagem não podem ser executados neste computador.{servers, select, yes { Em vez disso, use um servidor.} other {}}",
|
||||
"onboarding_image_step_body": "Cria imagens para você. Execute um neste computador ou use um de um servidor.",
|
||||
"onboarding_image_step_no_local_empty": "Modelos de imagem não podem ser executados neste computador. Em vez disso, use um servidor.",
|
||||
"onboarding_image_step_title": "Escolha um modelo de geração de imagem",
|
||||
"onboarding_model_list_aria": "Modelos para este computador",
|
||||
"onboarding_model_list_delete_aria": "Excluir {name}",
|
||||
|
|
@ -482,8 +502,8 @@
|
|||
"onboarding_server_path_connect_another_button": "Conectar outro servidor",
|
||||
"onboarding_server_path_connect_button": "Conectar um servidor",
|
||||
"onboarding_server_path_empty": "Nenhum servidor conectado ainda.",
|
||||
"onboarding_video_step_body": "Cria clipes de vídeo curtos. {servers, select, yes {Execute um neste computador ou use um de um servidor.} other {Execute um neste computador.}}",
|
||||
"onboarding_video_step_no_local_empty": "Modelos de vídeo não podem ser executados neste computador.{servers, select, yes { Em vez disso, use um servidor.} other {}}",
|
||||
"onboarding_video_step_body": "Cria clipes de vídeo curtos. Execute um neste computador ou use um de um servidor.",
|
||||
"onboarding_video_step_no_local_empty": "Modelos de vídeo não podem ser executados neste computador. Em vez disso, use um servidor.",
|
||||
"onboarding_video_step_title": "Escolha um modelo de geração de vídeo",
|
||||
"onboarding_welcome_body": "Um caderno de pesquisa privado que roda inteiramente na sua própria máquina. Seus documentos, suas chaves de modelo, sem nuvem, sem conta.",
|
||||
"onboarding_welcome_start_button": "Começar a configurar",
|
||||
|
|
@ -704,12 +724,14 @@
|
|||
"studio_podcast_brief_length_short_label": "Curto",
|
||||
"studio_podcast_brief_length_standard_body": "~8 min",
|
||||
"studio_podcast_brief_length_standard_label": "Padrão",
|
||||
"studio_podcast_brief_no_voices_body": "Este modelo precisa de vozes antes de narrar um podcast. O servidor não as lista, então adicione as que quiser.",
|
||||
"studio_podcast_brief_remove_speaker_aria": "Remover o locutor {number, number}",
|
||||
"studio_podcast_brief_role_cohost_label": "Coapresentador",
|
||||
"studio_podcast_brief_role_expert_label": "Especialista",
|
||||
"studio_podcast_brief_role_guest_label": "Convidado",
|
||||
"studio_podcast_brief_role_host_label": "Apresentador",
|
||||
"studio_podcast_brief_role_narrator_label": "Narrador",
|
||||
"studio_podcast_brief_set_up_voices_button": "Configurar vozes",
|
||||
"studio_podcast_brief_speaker_aria": "Locutor {number, number}",
|
||||
"studio_podcast_brief_speaker_name_aria": "Nome",
|
||||
"studio_podcast_brief_speaker_role_aria": "Papel",
|
||||
|
|
@ -723,6 +745,7 @@
|
|||
"studio_podcast_brief_style_narrative_label": "Narrativa",
|
||||
"studio_podcast_brief_voice_female_label": "Feminina",
|
||||
"studio_podcast_brief_voice_male_label": "Masculina",
|
||||
"studio_podcast_brief_voiced_by_body": "Narrado por {model} em {server}. Cada fala é cobrada pelo provedor.",
|
||||
"studio_podcast_viewer_transcript_title": "Transcrição",
|
||||
"studio_pptx_viewer_error_title": "Não foi possível carregar a apresentação",
|
||||
"studio_pptx_viewer_load_error": "O servidor retornou {status} ao obter a apresentação",
|
||||
|
|
|
|||
|
|
@ -417,6 +417,18 @@
|
|||
"models_server_models_vision_label": "Изображения",
|
||||
"models_server_picker_load_error": "Не удалось загрузить серверы",
|
||||
"models_server_picker_load_error_title": "Не удалось загрузить серверы",
|
||||
"models_server_voices_add_button": "Добавить и проверить",
|
||||
"models_server_voices_clip_aria": "{voice} в озвучке сервера",
|
||||
"models_server_voices_listed_body": "Список сервера",
|
||||
"models_server_voices_page_link": "Голоса этой модели",
|
||||
"models_server_voices_play_aria": "Прослушать {voice}",
|
||||
"models_server_voices_remove_aria": "Удалить {voice}",
|
||||
"models_server_voices_request_error": "Не удалось выполнить запрос",
|
||||
"models_server_voices_saved_body": "Этот сервер не сообщает список голосов. Добавьте нужные ID: каждый будет один раз озвучен перед сохранением. Для подкаста нужен свой голос на каждого участника.",
|
||||
"models_server_voices_title": "Голоса для этой модели",
|
||||
"models_server_voices_trial_body": "Здравствуйте. Так будут звучать ваши подкасты.",
|
||||
"models_server_voices_voice_aria": "ID голоса",
|
||||
"models_server_voices_voice_placeholder": "ID голоса",
|
||||
"models_try_dialog_cancel_button": "Отмена",
|
||||
"models_try_dialog_chat_confirmed_body": "Поддержка чата подтверждена. При тесте отправляется один короткий промпт, это может стоить денег.",
|
||||
"models_try_dialog_chat_unconfirmed_body": "Эта конечная точка не сообщает о своих возможностях, поэтому поддержка чата не подтверждена. При тесте отправляется один короткий промпт, это может стоить денег.",
|
||||
|
|
@ -424,8 +436,12 @@
|
|||
"models_try_dialog_image_unconfirmed_body": "Эта конечная точка не сообщает о своих возможностях, поэтому поддержка изображений не подтверждена. Она должна реализовывать /images/generations или /images. Тест выполняет настоящий инференс, это может стоить денег.",
|
||||
"models_try_dialog_preview_aria": "Тест, созданный моделью {model}",
|
||||
"models_try_dialog_request_error": "Запрос не выполнен",
|
||||
"models_try_dialog_speech_confirmed_body": "Поддержка речи подтверждена. Тест озвучивает одну короткую фразу, это может стоить денег.",
|
||||
"models_try_dialog_speech_preview_aria": "Тест, озвученный моделью {model}",
|
||||
"models_try_dialog_speech_unconfirmed_body": "Эта конечная точка не сообщает о своих возможностях, поэтому поддержка речи не подтверждена. Она должна реализовывать /audio/speech. Тест озвучивает одну короткую фразу, это может стоить денег.",
|
||||
"models_try_dialog_test_chat_button": "Тест чата",
|
||||
"models_try_dialog_test_image_button": "Тест изображения",
|
||||
"models_try_dialog_test_speech_button": "Тест голоса",
|
||||
"models_try_dialog_title": "{slot, select, text_gen {Использовать {model} для чата?} image_gen {Использовать {model} для изображений?} image_edit {Использовать {model} для редактирования изображений?} video_gen {Использовать {model} для видео?} audio_gen {Использовать {model} для аудио?} other {Использовать {model}?}}",
|
||||
"models_try_dialog_unlisted_body": "SurfSense не смог подтвердить эту модель в актуальном каталоге. Продолжайте, только если точный идентификатор модели верен.",
|
||||
"models_try_dialog_unlisted_cancel_button": "Отмена",
|
||||
|
|
@ -433,22 +449,26 @@
|
|||
"models_try_dialog_unlisted_title": "Использовать модель не из списка?",
|
||||
"models_try_dialog_use_tested_button": "Использовать эту модель",
|
||||
"models_try_dialog_use_untested_button": "Использовать без теста",
|
||||
"models_try_dialog_voice_aria": "Голос",
|
||||
"models_try_dialog_voice_body": "Если оставить пустым, сервер использует голос по умолчанию, если он есть.",
|
||||
"models_try_dialog_voice_placeholder": "ID голоса (необязательно)",
|
||||
"models_try_dialog_voice_trial_body": "Здравствуйте. Так будут звучать ваши подкасты.",
|
||||
"models_your_models_vision_label": "Изображения",
|
||||
"onboarding_audio_step_body": "Создаёт для вас подкасты. {servers, select, yes {Запустите её на этом компьютере или используйте модель с сервера.} other {Запустите её на этом компьютере.}}",
|
||||
"onboarding_audio_step_no_local_empty": "Модели для аудио не запускаются на этом компьютере.{servers, select, yes { Используйте вместо них сервер.} other {}}",
|
||||
"onboarding_audio_step_body": "Создаёт для вас подкасты. Запустите её на этом компьютере или используйте модель с сервера.",
|
||||
"onboarding_audio_step_no_local_empty": "Модели для аудио не запускаются на этом компьютере. Используйте вместо них сервер.",
|
||||
"onboarding_audio_step_title": "Выберите модель для аудио",
|
||||
"onboarding_chat_step_body": "Отвечает вам в чате. {servers, select, yes {Запустите её на этом компьютере, чтобы чаты оставались приватными, или используйте модель с сервера.} other {Запустите её на этом компьютере, чтобы чаты оставались приватными.}}",
|
||||
"onboarding_chat_step_no_local_empty": "Ни одна проверенная модель не запускается на этом компьютере.{servers, select, yes { Используйте вместо неё сервер.} other {}}",
|
||||
"onboarding_chat_step_body": "Отвечает вам в чате. Запустите её на этом компьютере, чтобы чаты оставались приватными, или используйте модель с сервера.",
|
||||
"onboarding_chat_step_no_local_empty": "Ни одна проверенная модель не запускается на этом компьютере. Используйте вместо неё сервер.",
|
||||
"onboarding_chat_step_title": "Выберите модель генерации текста",
|
||||
"onboarding_finish_error": "Не удалось завершить настройку",
|
||||
"onboarding_finish_no_chat_model_error": "Сначала выберите модель для чата",
|
||||
"onboarding_hugging_face_search_hide_button": "Скрыть поиск по Hugging Face",
|
||||
"onboarding_hugging_face_search_show_button": "Нет в списке? Поищите на Hugging Face",
|
||||
"onboarding_image_edit_step_body": "Редактирует для вас изображения. {servers, select, yes {Запустите её на этом компьютере или используйте модель с сервера.} other {Запустите её на этом компьютере.}}",
|
||||
"onboarding_image_edit_step_no_local_empty": "Модели для редактирования изображений не запускаются на этом компьютере.{servers, select, yes { Используйте вместо них сервер.} other {}}",
|
||||
"onboarding_image_edit_step_body": "Редактирует для вас изображения. Запустите её на этом компьютере или используйте модель с сервера.",
|
||||
"onboarding_image_edit_step_no_local_empty": "Модели для редактирования изображений не запускаются на этом компьютере. Используйте вместо них сервер.",
|
||||
"onboarding_image_edit_step_title": "Выберите модель редактирования изображений",
|
||||
"onboarding_image_step_body": "Создаёт для вас изображения. {servers, select, yes {Запустите её на этом компьютере или используйте модель с сервера.} other {Запустите её на этом компьютере.}}",
|
||||
"onboarding_image_step_no_local_empty": "Модели для изображений не запускаются на этом компьютере.{servers, select, yes { Используйте вместо них сервер.} other {}}",
|
||||
"onboarding_image_step_body": "Создаёт для вас изображения. Запустите её на этом компьютере или используйте модель с сервера.",
|
||||
"onboarding_image_step_no_local_empty": "Модели для изображений не запускаются на этом компьютере. Используйте вместо них сервер.",
|
||||
"onboarding_image_step_title": "Выберите модель генерации изображений",
|
||||
"onboarding_model_list_aria": "Модели для этого компьютера",
|
||||
"onboarding_model_list_delete_aria": "Удалить {name}",
|
||||
|
|
@ -482,8 +502,8 @@
|
|||
"onboarding_server_path_connect_another_button": "Подключить другой сервер",
|
||||
"onboarding_server_path_connect_button": "Подключить сервер",
|
||||
"onboarding_server_path_empty": "Пока не подключено ни одного сервера.",
|
||||
"onboarding_video_step_body": "Создаёт короткие видеоклипы. {servers, select, yes {Запустите её на этом компьютере или используйте модель с сервера.} other {Запустите её на этом компьютере.}}",
|
||||
"onboarding_video_step_no_local_empty": "Модели для видео не запускаются на этом компьютере.{servers, select, yes { Используйте вместо них сервер.} other {}}",
|
||||
"onboarding_video_step_body": "Создаёт короткие видеоклипы. Запустите её на этом компьютере или используйте модель с сервера.",
|
||||
"onboarding_video_step_no_local_empty": "Модели для видео не запускаются на этом компьютере. Используйте вместо них сервер.",
|
||||
"onboarding_video_step_title": "Выберите модель генерации видео",
|
||||
"onboarding_welcome_body": "Личный исследовательский блокнот, который работает целиком на вашей машине. Ваши документы, ваши ключи моделей, без облака и без аккаунта.",
|
||||
"onboarding_welcome_start_button": "Начать настройку",
|
||||
|
|
@ -704,12 +724,14 @@
|
|||
"studio_podcast_brief_length_short_label": "Короткий",
|
||||
"studio_podcast_brief_length_standard_body": "~8 мин",
|
||||
"studio_podcast_brief_length_standard_label": "Стандартный",
|
||||
"studio_podcast_brief_no_voices_body": "Чтобы озвучить подкаст, этой модели нужны голоса. Сервер их не перечисляет, поэтому добавьте нужные.",
|
||||
"studio_podcast_brief_remove_speaker_aria": "Удалить спикера {number, number}",
|
||||
"studio_podcast_brief_role_cohost_label": "Соведущий",
|
||||
"studio_podcast_brief_role_expert_label": "Эксперт",
|
||||
"studio_podcast_brief_role_guest_label": "Гость",
|
||||
"studio_podcast_brief_role_host_label": "Ведущий",
|
||||
"studio_podcast_brief_role_narrator_label": "Рассказчик",
|
||||
"studio_podcast_brief_set_up_voices_button": "Настроить голоса",
|
||||
"studio_podcast_brief_speaker_aria": "Спикер {number, number}",
|
||||
"studio_podcast_brief_speaker_name_aria": "Имя",
|
||||
"studio_podcast_brief_speaker_role_aria": "Роль",
|
||||
|
|
@ -723,6 +745,7 @@
|
|||
"studio_podcast_brief_style_narrative_label": "Повествование",
|
||||
"studio_podcast_brief_voice_female_label": "Женские",
|
||||
"studio_podcast_brief_voice_male_label": "Мужские",
|
||||
"studio_podcast_brief_voiced_by_body": "Озвучивает {model} на сервере {server}. Провайдер берёт плату за каждую реплику.",
|
||||
"studio_podcast_viewer_transcript_title": "Расшифровка",
|
||||
"studio_pptx_viewer_error_title": "Не удалось загрузить презентацию",
|
||||
"studio_pptx_viewer_load_error": "При получении презентации сервер вернул {status}",
|
||||
|
|
|
|||
|
|
@ -417,6 +417,18 @@
|
|||
"models_server_models_vision_label": "图像识别",
|
||||
"models_server_picker_load_error": "无法加载服务器",
|
||||
"models_server_picker_load_error_title": "无法加载服务器",
|
||||
"models_server_voices_add_button": "添加并测试",
|
||||
"models_server_voices_clip_aria": "服务器朗读的 {voice}",
|
||||
"models_server_voices_listed_body": "服务器列出的音色",
|
||||
"models_server_voices_page_link": "查看此模型的音色",
|
||||
"models_server_voices_play_aria": "播放 {voice}",
|
||||
"models_server_voices_remove_aria": "移除 {voice}",
|
||||
"models_server_voices_request_error": "请求失败",
|
||||
"models_server_voices_saved_body": "此服务器不列出音色。添加你想要的音色 ID,每个都会先播放一次再保存。播客的每位说话人需要一个。",
|
||||
"models_server_voices_title": "此模型的音色",
|
||||
"models_server_voices_trial_body": "你好。你的播客听起来会是这样。",
|
||||
"models_server_voices_voice_aria": "音色 ID",
|
||||
"models_server_voices_voice_placeholder": "音色 ID",
|
||||
"models_try_dialog_cancel_button": "取消",
|
||||
"models_try_dialog_chat_confirmed_body": "已确认支持对话。测试会发送一条简短的提示词,可能产生费用。",
|
||||
"models_try_dialog_chat_unconfirmed_body": "这个端点没有公布能力,因此是否支持对话尚未确认。测试会发送一条简短的提示词,可能产生费用。",
|
||||
|
|
@ -424,8 +436,12 @@
|
|||
"models_try_dialog_image_unconfirmed_body": "这个端点没有公布能力,因此是否支持图像尚未确认。它必须实现 /images/generations 或 /images。测试会真实执行推理,可能产生费用。",
|
||||
"models_try_dialog_preview_aria": "{model} 生成的测试图像",
|
||||
"models_try_dialog_request_error": "请求失败",
|
||||
"models_try_dialog_speech_confirmed_body": "已确认支持语音。测试会朗读一句简短的话,可能产生费用。",
|
||||
"models_try_dialog_speech_preview_aria": "{model} 朗读的测试语音",
|
||||
"models_try_dialog_speech_unconfirmed_body": "这个端点没有公布能力,因此是否支持语音尚未确认。它必须实现 /audio/speech。测试会朗读一句简短的话,可能产生费用。",
|
||||
"models_try_dialog_test_chat_button": "测试对话",
|
||||
"models_try_dialog_test_image_button": "测试图像",
|
||||
"models_try_dialog_test_speech_button": "测试语音",
|
||||
"models_try_dialog_title": "{slot, select, text_gen {把 {model} 用于对话吗?} image_gen {把 {model} 用于图像吗?} image_edit {把 {model} 用于图像编辑吗?} video_gen {把 {model} 用于视频吗?} audio_gen {把 {model} 用于语音吗?} other {使用 {model} 吗?}}",
|
||||
"models_try_dialog_unlisted_body": "SurfSense 无法在实时目录中确认这个模型。只有在模型 ID 完全正确时才继续。",
|
||||
"models_try_dialog_unlisted_cancel_button": "取消",
|
||||
|
|
@ -433,22 +449,26 @@
|
|||
"models_try_dialog_unlisted_title": "使用未列出的模型吗?",
|
||||
"models_try_dialog_use_tested_button": "使用这个模型",
|
||||
"models_try_dialog_use_untested_button": "不测试直接使用",
|
||||
"models_try_dialog_voice_aria": "音色",
|
||||
"models_try_dialog_voice_body": "留空则使用服务器的默认音色(如果有)。",
|
||||
"models_try_dialog_voice_placeholder": "音色 ID(可选)",
|
||||
"models_try_dialog_voice_trial_body": "你好。你的播客听起来会是这样。",
|
||||
"models_your_models_vision_label": "图像识别",
|
||||
"onboarding_audio_step_body": "为你制作播客。{servers, select, yes {可以在这台电脑上运行一个,也可以使用服务器上的。} other {可以在这台电脑上运行一个。}}",
|
||||
"onboarding_audio_step_no_local_empty": "这台电脑无法运行语音模型。{servers, select, yes {请改用服务器。} other {}}",
|
||||
"onboarding_audio_step_body": "为你制作播客。可以在这台电脑上运行一个,也可以使用服务器上的。",
|
||||
"onboarding_audio_step_no_local_empty": "这台电脑无法运行语音模型。请改用服务器。",
|
||||
"onboarding_audio_step_title": "选择语音模型",
|
||||
"onboarding_chat_step_body": "在对话中回答你。{servers, select, yes {在这台电脑上运行可以让你的对话保持私密,也可以使用服务器上的。} other {在这台电脑上运行可以让你的对话保持私密。}}",
|
||||
"onboarding_chat_step_no_local_empty": "这台电脑无法运行任何经过测试的模型。{servers, select, yes {请改用服务器。} other {}}",
|
||||
"onboarding_chat_step_body": "在对话中回答你。在这台电脑上运行可以让你的对话保持私密,也可以使用服务器上的。",
|
||||
"onboarding_chat_step_no_local_empty": "这台电脑无法运行任何经过测试的模型。请改用服务器。",
|
||||
"onboarding_chat_step_title": "选择文本生成模型",
|
||||
"onboarding_finish_error": "无法完成设置",
|
||||
"onboarding_finish_no_chat_model_error": "请先选择对话模型",
|
||||
"onboarding_hugging_face_search_hide_button": "隐藏 Hugging Face 搜索",
|
||||
"onboarding_hugging_face_search_show_button": "没有列出?搜索 Hugging Face",
|
||||
"onboarding_image_edit_step_body": "为你编辑图像。{servers, select, yes {可以在这台电脑上运行一个,也可以使用服务器上的。} other {可以在这台电脑上运行一个。}}",
|
||||
"onboarding_image_edit_step_no_local_empty": "这台电脑无法运行图像编辑模型。{servers, select, yes {请改用服务器。} other {}}",
|
||||
"onboarding_image_edit_step_body": "为你编辑图像。可以在这台电脑上运行一个,也可以使用服务器上的。",
|
||||
"onboarding_image_edit_step_no_local_empty": "这台电脑无法运行图像编辑模型。请改用服务器。",
|
||||
"onboarding_image_edit_step_title": "选择图像编辑模型",
|
||||
"onboarding_image_step_body": "为你生成图像。{servers, select, yes {可以在这台电脑上运行一个,也可以使用服务器上的。} other {可以在这台电脑上运行一个。}}",
|
||||
"onboarding_image_step_no_local_empty": "这台电脑无法运行图像模型。{servers, select, yes {请改用服务器。} other {}}",
|
||||
"onboarding_image_step_body": "为你生成图像。可以在这台电脑上运行一个,也可以使用服务器上的。",
|
||||
"onboarding_image_step_no_local_empty": "这台电脑无法运行图像模型。请改用服务器。",
|
||||
"onboarding_image_step_title": "选择图像生成模型",
|
||||
"onboarding_model_list_aria": "适用于这台电脑的模型",
|
||||
"onboarding_model_list_delete_aria": "删除 {name}",
|
||||
|
|
@ -482,8 +502,8 @@
|
|||
"onboarding_server_path_connect_another_button": "连接另一台服务器",
|
||||
"onboarding_server_path_connect_button": "连接服务器",
|
||||
"onboarding_server_path_empty": "还没有连接任何服务器。",
|
||||
"onboarding_video_step_body": "生成短视频片段。{servers, select, yes {可以在这台电脑上运行一个,也可以使用服务器上的。} other {可以在这台电脑上运行一个。}}",
|
||||
"onboarding_video_step_no_local_empty": "这台电脑无法运行视频模型。{servers, select, yes {请改用服务器。} other {}}",
|
||||
"onboarding_video_step_body": "生成短视频片段。可以在这台电脑上运行一个,也可以使用服务器上的。",
|
||||
"onboarding_video_step_no_local_empty": "这台电脑无法运行视频模型。请改用服务器。",
|
||||
"onboarding_video_step_title": "选择视频生成模型",
|
||||
"onboarding_welcome_body": "完全在你自己的机器上运行的私密研究笔记本。你的文档,你的模型密钥,没有云端,不用账户。",
|
||||
"onboarding_welcome_start_button": "开始设置",
|
||||
|
|
@ -704,12 +724,14 @@
|
|||
"studio_podcast_brief_length_short_label": "短",
|
||||
"studio_podcast_brief_length_standard_body": "约 8 分钟",
|
||||
"studio_podcast_brief_length_standard_label": "标准",
|
||||
"studio_podcast_brief_no_voices_body": "此模型需要音色才能朗读播客。服务器不列出音色,请添加你想要的。",
|
||||
"studio_podcast_brief_remove_speaker_aria": "移除说话人 {number, number}",
|
||||
"studio_podcast_brief_role_cohost_label": "联合主持人",
|
||||
"studio_podcast_brief_role_expert_label": "专家",
|
||||
"studio_podcast_brief_role_guest_label": "嘉宾",
|
||||
"studio_podcast_brief_role_host_label": "主持人",
|
||||
"studio_podcast_brief_role_narrator_label": "旁白",
|
||||
"studio_podcast_brief_set_up_voices_button": "设置音色",
|
||||
"studio_podcast_brief_speaker_aria": "说话人 {number, number}",
|
||||
"studio_podcast_brief_speaker_name_aria": "姓名",
|
||||
"studio_podcast_brief_speaker_role_aria": "角色",
|
||||
|
|
@ -723,6 +745,7 @@
|
|||
"studio_podcast_brief_style_narrative_label": "叙事",
|
||||
"studio_podcast_brief_voice_female_label": "女声",
|
||||
"studio_podcast_brief_voice_male_label": "男声",
|
||||
"studio_podcast_brief_voiced_by_body": "由 {server} 上的 {model} 朗读。提供商按每一句计费。",
|
||||
"studio_podcast_viewer_transcript_title": "文字稿",
|
||||
"studio_pptx_viewer_error_title": "无法加载演示文稿",
|
||||
"studio_pptx_viewer_load_error": "获取演示文稿时服务器返回 {status}",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue