dsh-tts-service
English | 简体中文
Multi-provider text-to-speech (TTS) service for DSH: Doubao TTS 2.0, Alibaba Bailian and OpenAI-compatible providers register independently and the user picks the voice; the agent can speak with per-segment tone, and other plugins reuse the same service.
One package, several plugin entries that you switch on and off separately on the DSH plugin page:
| Entry id | What it does | On by default |
|---|---|---|
tts-service |
The service ctx.ttsService: provider registry, selection (provider, voice per provider, language), synthesis, voice listing. |
yes |
tts-doubao |
Provider: Doubao (Volcengine) TTS 2.0. | no |
tts-bailian |
Provider: Alibaba Bailian / Model Studio (CosyVoice, Qwen-Audio-TTS, Qwen3-TTS). | no |
tts-openai-compatible |
Provider: any endpoint compatible with POST /audio/speech. |
no |
tts-say |
Agent tools tts_service_say, tts_service_voices and tts_service_select, plus the output registry ctx.ttsSay. |
yes |
tts-speaker-windows |
Output: play on the PC speakers when you are at the computer (Windows only; registers nothing elsewhere). | yes |
There is no default provider and no default voice. Until you pick one, tts_service_say fails with
VOICE_NOT_SELECTED and tells the agent where to pick it.
This version synthesizes whole clips (no streaming).
Requirements
- DSH
>=0.2.1-rc.3 <0.3.0-0(prereleases included). Tests run against the 0.2.1-rc.5 packages. - Node.js 22 or newer (the one that ships with DSH is fine).
- An account and API key for at least one provider (see below). Synthesis is billed by the provider.
- For the speaker output: Windows with Windows PowerShell (
powershell.exe, present by default).
Install
dsh plugin add @loveandbeepbrother/dsh-tts-service
or from GitHub: dsh plugin add github:loveandbeepbrother/dsh-tts-service.
Quick start
- On the plugin page, switch on one provider entry (
tts-doubao,tts-bailianortts-openai-compatible) and fill in its form. - Store its API key in DSH's credential store (see Keys).
- Pick a voice: ask the agent "list the available voices" and then "use voice X", or set it yourself in the settings
of Text-to-Speech Service (
tts-service):provider= the provider id (e.g.doubao), and an entry invoices: provider id → voice id. See Choosing a voice. - Ask the agent to say something.
Keys
Configuration never contains secret values, only key names. The value comes from DSH's credential service
(ctx.credentials, the key store on the settings page) or, when no credential service is loaded, from the environment
DSH was launched with. Keys are read on every request, so a rotated key applies immediately. The settings page shows
only whether a key is configured.
| Entry | Config field | Default key name | Used for |
|---|---|---|---|
tts-doubao |
apiKeyEnv |
VOLCENGINE_TTS_API_KEY |
Synthesis (an API key from the Volcengine console, "API Key 管理"). |
tts-doubao |
accessKeyEnv, secretKeyEnv |
VOLCENGINE_ACCESS_KEY, VOLCENGINE_SECRET_KEY |
Optional: your Volcengine account AK/SK, only to list the complete voice catalogue. |
tts-bailian |
apiKeyEnv |
DASHSCOPE_API_KEY |
Synthesis and listing cloned voices (same name as the official DashScope SDK). |
tts-openai-compatible |
apiKeyEnv |
OPENAI_COMPATIBLE_TTS_API_KEY |
Sent as Authorization: Bearer. Leave the field empty for local endpoints that need no key. |
You can rename any of them, e.g. to keep keys for two endpoints apart.
Choosing a voice
The selection lives in the tts-service settings and is saved to your profile without a restart:
| Field | Meaning |
|---|---|
provider |
Default provider id. Empty means none. |
voices |
Default voice per provider: provider id → voice id. Voice ids only make sense within their provider, so switching from Doubao to Bailian and back keeps both choices. |
language |
Default language code, auto by default. Must be one the provider declares. |
There are three ways to set it; all of them write the same settings and take effect immediately:
- Settings page: edit
providerandvoicesin the Text-to-Speech Service settings. - Ask the agent: "which voices are there?" (
tts_service_voices), then "switch to …" (tts_service_select). The agent's reply states the old and new voice and what to enter to change it back; you can also change it back on the settings page. - From a plugin:
await ctx.ttsService.configure({ provider: 'doubao', voices: { doubao: 'voice-id' } }).
tts_service_voices (or ctx.ttsService.voices(id) for plugins) lists voices with their languages and whether they
follow tone descriptions. Lists that cannot be complete say so; you can always type a voice id by hand.
Errors are explicit: a provider that is not registered, a language it does not support, text longer than its
maxChars, or a format it cannot produce all fail. The service never silently switches to another provider.
Providers
Doubao TTS 2.0 (tts-doubao, provider id doubao)
| Field | Default | Notes |
|---|---|---|
providerId |
doubao |
Change it if you run two copies. |
resourceId |
seed-tts-2.0 |
Sent as X-Api-Resource-Id. |
apiKeyEnv, accessKeyEnv, secretKeyEnv |
see Keys | |
sampleRate |
24000 |
16-bit mono PCM. |
maxChars |
300 |
Per request. |
timeoutMs |
60000 |
- Tone descriptions and the "context" (the user's last sentence) are combined into one voice instruction and sent as
additions.context_texts. Only Doubao 2.0 system voices (ids containinguranus) follow it; cloned voices do not. - Voice listing: without AK/SK you get a static table of 2.0 system voices transcribed from the
official voice list, marked incomplete. With AK/SK the plugin calls
the official
ListSpeakersAPI (signed with HMAC-SHA256, servicespeech_saas_prod, regioncn-beijing). - Languages:
auto,zh,en,ja(sent asexplicit_language).
Alibaba Bailian (tts-bailian, provider id bailian)
| Field | Default | Notes |
|---|---|---|
model |
cosyvoice-v3-flash |
e.g. cosyvoice-v3-flash, cosyvoice-v3-plus, cosyvoice-v2, cosyvoice-v3.5-flash, qwen-audio-3.1-tts-flash, qwen3-tts-flash. Voices only work with their own model. |
region |
cn-beijing |
cn-beijing, ap-southeast-1, cn-hongkong, us-east-1, eu-central-1, ap-northeast-1. Keys, workspaces and models are per region. |
workspaceId |
— | The first label of your API host (e.g. llm-xxx). When set, the workspace domain {workspaceId}.{region}.maas.aliyuncs.com is used; otherwise the older DashScope domain. Required for eu-central-1 and ap-northeast-1. Alibaba Cloud says the DashScope domain gets no new features after 2026-09-30, so setting it is recommended. |
apiKeyEnv |
DASHSCOPE_API_KEY |
|
sampleRate |
24000 |
CosyVoice / Qwen-Audio-TTS; Qwen3-TTS is fixed at 24000. |
maxChars |
600 for Qwen3-TTS, 2000 otherwise | |
timeoutMs |
60000 |
Covers the synthesis call and the audio download. |
CosyVoice and Qwen-Audio-TTS use
/api/v1/services/audio/tts/SpeechSynthesizer; Qwen3-TTS uses/api/v1/services/aigc/multimodal-generation/generation. Both return a 24-hour audio URL, which the plugin downloads.Tone descriptions (
instruction), per the official docs:Model Follows free-form tone descriptions qwen-audio-*yes, system, cloned and designed voices (a few English voices excepted) cosyvoice-v3.5-*yes (this model only has cloned and designed voices) cosyvoice-v3-flashcloned and designed voices only; system voices accept only fixed instruction templates cosyvoice-v3-plus,cosyvoice-v2no qwen3-tts-instruct-*yes (sent as instructions)qwen3-tts-flashno CosyVoice instructions are limited to 100 characters (CJK characters count as two).
Voice listing: system voices from a static table transcribed from the official CosyVoice, Qwen-Audio-TTS and Qwen3-TTS lists; your cloned and designed voices for the configured model come from the official
voice-enrollmentlist_voiceAPI.
OpenAI-compatible endpoints (tts-openai-compatible, provider id openai-compatible)
No vendor, URL or voice is preset. Fill in:
| Field | Default | Notes |
|---|---|---|
name |
OpenAI 兼容接口 |
Display name. |
baseUrl |
required | e.g. https://example.com/v1; the plugin calls {baseUrl}/audio/speech. |
model |
required | |
apiKeyEnv |
OPENAI_COMPATIBLE_TTS_API_KEY |
Empty: no Authorization header. |
instructions |
false |
Set to true if the endpoint accepts the instructions parameter. |
voices |
[] |
Hand-written voice table, used when GET {baseUrl}/audio/voices is not available. Each entry can set instructions: false. |
languages |
["auto"] |
Only used to validate the selected language; not sent. |
maxChars |
4096 |
|
timeoutMs |
60000 |
The plugin always requests response_format: "wav" and needs 16-bit PCM WAV back. To use two endpoints, add a second
row to your profile with another providerId.
This is not "the OpenAI integration". Note that OpenAI's own three speech models are scheduled to be retired on 2027-01-06; check the vendor's current model list before pointing this entry at it.
The tools
tts_service_say
| Parameter | Description |
|---|---|
segments |
1–12 segments, spoken in order. Each has text and an optional tone (one short natural-language description, at most 40 characters). |
context |
Optional: the user's last sentence (at most 60 characters). Providers that support it use it to pick the tone. |
target |
auto (default) or the id of one output. |
Each segment is synthesized separately as 16-bit PCM, joined with 0.25 s of silence into one WAV file and handed to an
output. The result (a JSON string) has route (the output id, or kept), file, seconds, detail, the outputs that
were unavailable and why, and tone: when a segment's tone was not applied it says so, so the agent can stop writing
tones for that voice.
tts-say settings:
| Field | Default | Description |
|---|---|---|
outputs |
["speaker-windows"] |
Outputs tried in order for target: "auto"; the first available one is used. |
maxChars |
300 |
Total characters per call. |
retentionDays |
0 |
Keep audio of the last N days (today included); 0 keeps everything. |
Audio files are stored at <DSH home>/tts-say/audio/<YYYY-MM-DD>/<HHMMSS>-<id>.wav (local date) and are not deleted
automatically unless you set retentionDays. <DSH home> is ~/.dsh unless you set DSH_HOME.
tts_service_voices
Lists the current selection and, per provider, its voices. Optional parameters: provider, language, search
(substring of id, name or description) and refresh. At most 60 voices per provider are shown. Pressing stop ends it
right away, even while a provider is still answering.
tts_service_select
Changes the voice for the user. The tool description tells the agent to use it only when you ask to change the voice or explicitly agree to one; because that is only an instruction to the model, the change is also made visible and reversible in the result itself.
| Parameter | Description |
|---|---|
voice |
Required: the voice id. |
provider |
Optional: the provider id; defaults to the current default provider (an error asks for it when there is none). |
Before saving it checks the provider's voice list. If the list is complete and the id is not in it, nothing is saved
and the agent is told to look it up with tts_service_voices. If the list is incomplete or cannot be fetched, the id
is saved as typed, with a note that it is not in the known list. The result always states the selection before and
after (with voice names when known), that it is already in effect, that you can see and change it in the
Text-to-Speech Service settings, and the exact values that restore the previous state.
Outputs
speaker-windows counts as available when there was keyboard or mouse input in the last presentMinutes (default 5)
and the screen is not locked (Windows PowerShell: GetLastInputInfo, LogonUI.exe). Playback uses
System.Media.SoundPlayer, one clip at a time; the tool does not wait for it to finish.
Write your own provider
A provider is a plugin that injects ttsService and registers an object implementing TtsProvider (types in
index.d.ts):
export const name = 'my-tts'
export const inject = ['ttsService']
export function apply(ctx) {
ctx.effect(() => ctx.ttsService.register({
info: {
id: 'my-tts', name: 'My TTS', location: 'cloud',
languages: ['auto', 'en'], formats: ['pcm16', 'wav'],
instructions: false, context: false, maxChars: 1000, voiceListing: true,
},
async listVoices(query, signal) {
return { voices: [{ id: 'v1', name: 'Voice 1', languages: ['en'] }], complete: true }
},
async synthesize(input, signal) {
// input: { text, voice, language, instructions?, context?, formats }
// Pick the first of input.formats you can produce; honour `signal`.
const pcm = await callMyApi(input, signal)
return { audio: pcm, format: 'pcm16', sampleRate: 24000, channels: 1, seconds: pcm.length / 48000, instructionsApplied: false }
},
}))
}
registerthrows if the id is taken and returns an async disposer;ctx.effectcalls it when your plugin unloads. Unregistering rejects new requests, aborts thesignalof running ones and waits for them.- Report
instructionsApplied: trueonly when the tone description was actually used. - The service does not convert formats. The bundled tool asks for
pcm16.
Calling the service from another plugin: inject = ['ttsService'], then
const audio = await ctx.ttsService.synthesize(ctx.ttsService.resolve({ text, formats: ['wav'] }), signal).
Write your own output
An output is a plugin that injects ttsSay and registers a VoiceOutput, for example to send the clip to a chat app:
export const name = 'my-voice-output'
export const inject = ['ttsSay']
export function apply(ctx) {
ctx.effect(() => ctx.ttsSay.registerOutput({
id: 'my-chat',
name: 'My chat app',
async available(session) {
return isBound(session.id) ? { yes: true } : { yes: false, why: 'no chat bound to this session' }
},
async deliver(session, file, { seconds }) {
await sendVoice(session.id, file) // file: absolute path of a 16-bit PCM WAV
return { ok: true, detail: 'sent to my chat app' }
},
}))
}
Then add its id to outputs in the tts-say settings, e.g. ["speaker-windows", "my-chat"].
Tested combinations
Real synthesis was checked on 2026-11-04 with these combinations, all returning pcm16 at 24 kHz:
| Provider | Model / resource | Voice | Note |
|---|---|---|---|
| Doubao | seed-tts-2.0 |
ICL_uranus_zh_female_wenrounvshen_tob |
tone description applied |
| Alibaba Bailian | cosyvoice-v3-flash |
system voice | |
| Alibaba Bailian | qwen3-tts-flash |
system voice | |
| Alibaba Bailian | qwen-audio-3.1-tts-flash |
system voice |
Known limitations
These can only be confirmed by real use; reports are welcome.
- Bailian
qwen3-tts-instruct-*: whereinstructionsgoes in the HTTP body (input.instructions) is inferred from the official SDK example, not from the HTTP reference. - Bailian without
workspaceId: whether the old DashScope domain still serves newer models after 2026-09-30 is not confirmed. SetworkspaceIdif a model is rejected. - Per-request character limits are conservative guesses where the docs give none: Doubao 300, Bailian
SpeechSynthesizer (CosyVoice, Qwen-Audio-TTS) 2000. Both can be changed with
maxChars. - Doubao: the accepted
explicit_languagevalues (zh-cn,en,jaare sent) and the format of the language field inListSpeakersresponses are not confirmed. - The static voice tables are transcribed from the official pages and may be incomplete or contain mistakes, so
lists built from them (Doubao without AK/SK, all Bailian lists) are always marked incomplete and
tts_service_selectaccepts ids that are not in them. You can always type a voice id by hand.
Pricing and terms
Every provider bills you directly under its own terms; this plugin adds no fees.
- Doubao / Volcengine: billing overview, Volcengine Terms of Service.
- Alibaba Bailian (Model Studio): model pricing, agreements.
- OpenAI-compatible endpoints: whatever the endpoint's operator charges and requires.
Privacy
The text, tone descriptions and context are sent to the provider you selected. Doubao requests go to
openspeech.bytedance.com (and open.volcengineapi.com for listing); Bailian requests go to your region's DashScope
or workspace domain, and the audio is downloaded from the URL it returns; OpenAI-compatible requests go to your
baseUrl. Nothing else leaves the machine unless an output sends the audio.
When DSH ships its own textToSpeech service
DSH's speech-recognition docs list speech synthesis as future work. If it ships, it will almost certainly be called
ctx.textToSpeech. Cordis service names are global, so this package uses ctx.ttsService and both can run side by
side. We will then publish a major version that:
- registers the three providers on the upstream service, keeping capabilities the upstream provider interface lacks (tone descriptions, context, voice listing) as extensions until upstream adds them;
- makes the tools and outputs call the upstream service;
- retires
ctx.ttsServicewith no forwarding layer. Your selection moves over as follows, and a one-time migration script will be provided:
Here (tts-service settings and provider entries) |
Upstream |
|---|---|
provider |
the upstream default provider |
voices (per provider) |
the upstream per-provider voice setting |
language |
the upstream language |
each provider's apiKeyEnv (and Doubao's AK/SK names) |
the same key names; stored values in the credential store need no change |
The interface here deliberately mirrors the upstream speech-recognition service, so provider implementations should change very little.
Development
npm install
npm test
Tests use node --test. Every network call goes through a fake fetch, and PowerShell is stubbed, so the tests make
no network requests and play no sound. The Volcengine signature is checked against the worked examples in the official
signing guide.
License
MIT © 2026 loveandbeepbrother
DSH is developed by DeepSeek. This plugin is an independent project and is not affiliated with DeepSeek, Volcengine, Alibaba Cloud or OpenAI.
No comments yet. Be the first to write one.