Skip to content

Commit b114a36

Browse files
authored
Merge pull request #5171 from pipecat-ai/evals-speech-language
evals: support setting language on built-in speech/transcription services
2 parents c49bf69 + 72ac82c commit b114a36

15 files changed

Lines changed: 412 additions & 22 deletions

File tree

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,4 +71,6 @@ htmlcov/
7171
whisker_setup.py
7272

7373
# MoQ transport
74-
*.pem
74+
*.pem
75+
# Release-eval run artifacts (logs and conversation recordings)
76+
scripts/release-evals/test-runs/

changelog/5171.added.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Added an optional `language` key to the eval harness's built-in `user.speech:` and `judge.transcription:` blocks. Each built-in speech service builder (`kokoro`, `cartesia`, `whisper`, `moonshine`) now forwards `language` (a code like `zh` or a `Language`) into the service settings, so non-English audio evals can synthesize user turns and transcribe bot audio in the right language without the `factory:` escape hatch. Omitting `language` is unchanged; the TTS audio cache key now includes the language so English and non-English renders of the same text don't collide.

changelog/5171.fixed.2.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
- Fixed `WhisperSTTService` silently transcribing in English when its model can't handle the configured language. The English-only models — every `.en` one, including the default `distil-medium.en` — accept any language and transcribe as English regardless, so `Settings(language=Language.ES)` produced fluent-looking English rather than an error. Constructing such a pairing now raises a `ValueError` naming the model and its supported languages; a mid-call switch via `STTUpdateSettingsFrame` reports a non-fatal `ErrorFrame` instead, leaving the pipeline running.
2+
3+
⚠️ Code that set a non-English `language` on an English-only model was getting English transcripts and now raises at construction. Use a multilingual model (e.g. `large-v3-turbo`) or drop the `language`.

changelog/5171.fixed.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Fixed `KokoroTTSService` failing to synthesize French and Mandarin. kokoro-onnx phonemizes through espeak-ng, which has no `zh` and no bare `fr` voice, so both raised `language "..." is not supported by the espeak backend` at synthesis time. Mandarin (including the `zh-CN`/`zh-HK`/`zh-TW` variants) now maps to `cmn` and French to `fr-fr`, with `fr-be`, `fr-ch` and `pt-br` mapped to the regional espeak-ng voices they have.

scripts/release-evals/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@ The harness runs the judge, the user's voice, and the bot-speech transcriber
3535
[Moonshine](https://github.com/moonshine-ai/moonshine) (Whisper is available
3636
as an alternative via the scenario's `transcription:` block). All run from
3737
local ONNX/model files that download once on first use (cached under
38-
`~/.cache/pipecat/evals/tts`). No keys, no per-run cost.
38+
`~/.cache/pipecat/evals/tts`). No keys, no per-run cost. Non-English
39+
transcription needs a multilingual model, which the English-only defaults
40+
aren't — `language_switch_audio` pulls Whisper's `tiny` (75MB).
3941
- **Each bot's own credentials.** A bot is a real example, so it needs the same
4042
service API keys it normally would, in your `.env` (e.g. `$OPENAI_API_KEY`,
4143
`$CARTESIA_API_KEY`, `$DEEPGRAM_API_KEY`, ...). A bot whose keys are missing

scripts/release-evals/manifest.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ suite:
4343
- interruption_audio
4444
- interruption_text
4545
- language_switch
46+
- language_switch_audio
4647
- bot: voice/voice-cartesia-http.py
4748
scenarios: [capital_question]
4849
- bot: voice/voice-cartesia-turns.py
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
name: language_switch_audio
2+
3+
# Audio modality: the user asks (in English) for Spanish replies, and the judge
4+
# evaluates the transcription of the bot's actual Spanish speech — the end-to-end
5+
# check that a non-English answer survives the bot's TTS and the harness's STT.
6+
# The text-modality `language_switch` covers the same instruction, but judges the
7+
# LLM's text and so never exercises either audio path.
8+
#
9+
# One transcriber serves the whole run, and this one is Spanish. The bot's
10+
# English greeting is therefore asserted on `tts_response` (the text the TTS
11+
# reports speaking) rather than `response` (the transcription), which would be
12+
# English decoded as Spanish. Only the post-switch turn asserts on `response`.
13+
14+
user: !include user_audio.yaml
15+
16+
judge:
17+
modality: audio
18+
eval:
19+
service: ollama
20+
model: gemma2:9b
21+
transcription:
22+
# Whisper rather than the default Moonshine: Moonshine's Spanish model
23+
# returns nothing on the bot's synthesized Spanish. `tiny` because Whisper's
24+
# own default model is English-only, and it is the smallest multilingual one
25+
# (75MB) that transcribes this reply cleanly.
26+
service: whisper
27+
model: tiny
28+
language: es
29+
30+
turns:
31+
# Wait for the bot's on-connect greeting before speaking (avoids barging into it).
32+
- expect:
33+
- event: tts_response
34+
eval: "the bot opens the conversation in some way (a greeting, an introduction, an offer to help, or a question to get the user started)"
35+
36+
- user: "From now on, reply only in Spanish. Where is La Sagrada Familia?"
37+
expect:
38+
- event: response
39+
eval: "the response is written in Spanish and says that La Sagrada Familia is in Barcelona"

src/pipecat/evals/scenario.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,8 @@
107107
modality: audio # audio | text (default text)
108108
speech: # required when modality is audio
109109
service: kokoro # local TTS that synthesizes the user turns
110-
voice: af_heart
110+
voice: af_heart # voices are language-specific
111+
language: en # optional; must match the voice
111112
sample_rate: 16000 # optional
112113
113114
``audio`` streams synthesized user audio to the bot (exercising its
@@ -122,6 +123,7 @@
122123
transcription: # required when modality is audio
123124
service: moonshine # STT for the bot's audio (or whisper)
124125
model: small-streaming # optional
126+
language: en # optional; the language the bot speaks
125127
padding_secs: 0 # optional; silence padded around the
126128
# segment (default: 2)
127129
@@ -351,14 +353,17 @@ class EvalScenario:
351353
greeting is silent. True (audio): the bot speaks, and the judge
352354
evaluates the transcription of its actual audio.
353355
transcriber: Parsed from the ``judge.transcription:`` block; the STT
354-
config (``service`` defaults to ``moonshine``, plus ``model``) used to
355-
transcribe the bot's audio for the ``response`` event (``None`` in
356-
text modality).
356+
config (``service`` defaults to ``moonshine``, plus ``model`` and an
357+
optional ``language`` code) used to transcribe the bot's audio for the
358+
``response`` event (``None`` in text modality). Set ``language`` when
359+
the bot speaks a non-English language so the STT doesn't default to
360+
English.
357361
user_audio: TTS config the harness uses to generate user audio. When
358362
present, the harness streams RTVI ``raw-audio`` (not ``send-text``)
359363
to the bot, exercising its STT for real. Mapping with ``service``,
360-
``voice``, and optional ``model`` / ``sample_rate`` /
361-
``api_key``. Omit for text-only evals (default).
364+
``voice``, and optional ``model`` / ``language`` / ``sample_rate`` /
365+
``api_key``. Set ``language`` (a code like ``zh``) to synthesize
366+
non-English user turns. Omit for text-only evals (default).
362367
trigger_disconnect: Whether the harness fires the bot's
363368
``on_client_disconnected`` handler when this scenario's connection
364369
ends. Bots often cancel their pipeline there, so this is False by

src/pipecat/evals/services.py

Lines changed: 109 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,45 @@
1818
from typing import Any
1919

2020
from pipecat.services.llm_service import LLMService
21-
from pipecat.services.settings import NOT_GIVEN
21+
from pipecat.services.settings import NOT_GIVEN, _NotGiven
2222
from pipecat.services.stt_service import STTService
2323
from pipecat.services.tts_service import TTSService
24+
from pipecat.transcriptions.language import Language
25+
26+
27+
def _cfg_language(cfg: dict) -> Language | _NotGiven:
28+
"""Coerce a config's optional ``language`` value to a :class:`Language`.
29+
30+
``Language`` is a ``StrEnum``, so both a code string (e.g. ``"zh"``) and a
31+
``Language`` are accepted. Each concrete service maps the ``Language`` to its
32+
own provider code internally (via ``resolve_language``), so the eval layer
33+
only needs to hand off a ``Language``.
34+
35+
Args:
36+
cfg: A ``user.speech`` or ``judge.transcription`` config mapping.
37+
38+
Returns:
39+
The resolved ``Language``, or ``NOT_GIVEN`` when ``language`` is absent,
40+
which leaves the service's own default in place.
41+
42+
Raises:
43+
ValueError: If ``language`` is set to a value that is not a recognized
44+
language code.
45+
"""
46+
value = cfg.get("language")
47+
if value is None:
48+
return NOT_GIVEN
49+
if isinstance(value, str):
50+
value = value.strip()
51+
if not value:
52+
return NOT_GIVEN
53+
try:
54+
return Language(value)
55+
except ValueError as e:
56+
raise ValueError(
57+
f"Unknown language {value!r} in eval speech/transcription config; "
58+
"expected a language code like 'zh' or a Language value."
59+
) from e
2460

2561

2662
def kokoro_service(voice_cfg: dict, sample_rate: int) -> TTSService:
@@ -29,17 +65,46 @@ def kokoro_service(voice_cfg: dict, sample_rate: int) -> TTSService:
2965
Kokoro runs an ONNX model locally (no API key, no per-run cost), so the eval
3066
suite synthesizes user audio for free. The model files are downloaded once
3167
on first use and cached under ``~/.cache/kokoro-onnx``.
68+
69+
Args:
70+
voice_cfg: The ``user.speech`` config mapping:
71+
72+
- ``voice``: Kokoro voice id (e.g. ``af_heart``).
73+
- ``language``: Optional language code (e.g. ``zh``) or ``Language``.
74+
When omitted, Kokoro keeps its own default (English). Voices are
75+
language-specific, so a non-English language needs a matching voice
76+
— ``af_heart`` speaks US English whatever the language is set to.
77+
78+
sample_rate: Sample rate for the synthesized audio.
3279
"""
3380
from pipecat.services.kokoro.tts import KokoroTTSService
3481

3582
return KokoroTTSService(
36-
settings=KokoroTTSService.Settings(voice=str(voice_cfg.get("voice", ""))),
83+
settings=KokoroTTSService.Settings(
84+
voice=str(voice_cfg.get("voice", "")),
85+
language=_cfg_language(voice_cfg),
86+
),
3787
sample_rate=sample_rate,
3888
)
3989

4090

4191
def cartesia_service(voice_cfg: dict, sample_rate: int) -> TTSService:
42-
"""Build a Cartesia TTS service from the ``user_audio`` config."""
92+
"""Build a Cartesia TTS service from the ``user_audio`` config.
93+
94+
Args:
95+
voice_cfg: The ``user.speech`` config mapping:
96+
97+
- ``voice``: Cartesia voice id.
98+
- ``model``: Optional model (defaults to ``sonic-2``).
99+
- ``api_key``: Optional key (falls back to ``$CARTESIA_API_KEY``).
100+
- ``language``: Optional language code (e.g. ``zh``) or ``Language``.
101+
When omitted, Cartesia keeps its own default (English).
102+
103+
sample_rate: Sample rate for the synthesized audio.
104+
105+
Raises:
106+
RuntimeError: If no API key is given in the config or the environment.
107+
"""
43108
from pipecat.services.cartesia.tts import CartesiaHttpTTSService
44109

45110
# Prefer an explicit api_key in the config; fall back to the env var so
@@ -55,6 +120,7 @@ def cartesia_service(voice_cfg: dict, sample_rate: int) -> TTSService:
55120
settings=CartesiaHttpTTSService.Settings(
56121
voice=str(voice_cfg.get("voice", "")),
57122
model=voice_cfg.get("model") or "sonic-2",
123+
language=_cfg_language(voice_cfg),
58124
),
59125
sample_rate=sample_rate,
60126
)
@@ -77,6 +143,23 @@ def whisper_service(config: dict) -> STTService:
77143
whose ``no_speech_prob`` jitters across ~0.4-0.6 run to run (a dropped segment
78144
yields no ``TranscriptionFrame``, so the harness then waits out the whole
79145
transcription timeout). Disable the filter with a permissive threshold.
146+
147+
Args:
148+
config: The ``judge.transcription`` config mapping:
149+
150+
- ``device``: ``cpu`` (default) or ``cuda``.
151+
- ``compute_type``: Whisper compute type (``int8`` on CPU).
152+
- ``model``: Optional Whisper model (left unset to use Whisper's own).
153+
Whisper's default is English-only, as is every ``.en`` model, so a
154+
non-English ``language`` needs a multilingual model here (e.g.
155+
``large-v3-turbo``).
156+
- ``language``: Optional language code (e.g. ``es``) or ``Language``.
157+
When omitted, Whisper keeps its own default (English) — it does not
158+
auto-detect, so a non-English bot needs this set.
159+
160+
Raises:
161+
ValueError: If ``language`` names a language the chosen model can't
162+
transcribe (raised by :class:`~pipecat.services.whisper.stt.WhisperSTTService`).
80163
"""
81164
from pipecat.services.whisper.stt import WhisperSTTService
82165

@@ -91,6 +174,7 @@ def whisper_service(config: dict) -> STTService:
91174
settings=WhisperSTTService.Settings(
92175
no_speech_prob=1.0,
93176
model=config.get("model", NOT_GIVEN),
177+
language=_cfg_language(config),
94178
),
95179
)
96180

@@ -100,14 +184,32 @@ def moonshine_service(config: dict) -> STTService:
100184
101185
Moonshine runs on the CPU via ONNX Runtime (no GPU, no API key) and is small
102186
and fast. On the short, isolated bot-answer segments the harness transcribes,
103-
it tends to keep the answer where Whisper sometimes drops it. ``model`` selects
104-
the architecture (a :class:`~pipecat.services.moonshine.stt.Model` value or
105-
string; default ``Model.SMALL_STREAMING``).
187+
it tends to keep the answer where Whisper sometimes drops it.
188+
189+
Args:
190+
config: The ``judge.transcription`` config mapping:
191+
192+
- ``model``: Optional architecture, as a
193+
:class:`~pipecat.services.moonshine.stt.Model` or the equivalent
194+
string (default ``Model.SMALL_STREAMING``). Only ``base`` has
195+
non-English models, so a non-English ``language`` needs it, and
196+
raises at construction naming the models it does have if the
197+
pairing has none.
198+
- ``language``: Optional language code (e.g. ``es``) or ``Language``.
199+
When omitted, Moonshine keeps its own default (English).
200+
201+
Prefer :func:`whisper_service` for a non-English bot: Moonshine's non-English
202+
models transcribe synthesized speech unreliably, returning an empty transcript
203+
or dropping the tail of an utterance, and an empty transcript is
204+
indistinguishable from a bot that said nothing.
106205
"""
107206
from pipecat.services.moonshine.stt import Model, MoonshineSTTService
108207

109208
return MoonshineSTTService(
110-
settings=MoonshineSTTService.Settings(model=config.get("model") or Model.SMALL_STREAMING),
209+
settings=MoonshineSTTService.Settings(
210+
model=config.get("model") or Model.SMALL_STREAMING,
211+
language=_cfg_language(config),
212+
),
111213
)
112214

113215

src/pipecat/evals/speech.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,14 +66,15 @@ def tts_sample_rate(voice_cfg: dict) -> int:
6666
def tts_cache_key(voice_cfg: dict) -> str:
6767
"""A stable identity for a ``user_audio`` config, for caching synthesized audio.
6868
69-
Covers the audio's semantic identity (service, voice, model) but not the
70-
sample rate, so different rates reuse the same slot (a mismatch just triggers
71-
regeneration in :meth:`EvalSpeech.generate`).
69+
Covers the audio's semantic identity (service, voice, model, language) but not
70+
the sample rate, so different rates reuse the same slot (a mismatch just
71+
triggers regeneration in :meth:`EvalSpeech.generate`).
7272
"""
7373
service = str(voice_cfg.get("service", "")).lower()
7474
voice = str(voice_cfg.get("voice", ""))
7575
model = str(voice_cfg.get("model", ""))
76-
return "\x00".join((service, voice, model))
76+
language = str(voice_cfg.get("language") or "")
77+
return "\x00".join((service, voice, model, language))
7778

7879

7980
class EvalSpeech:

0 commit comments

Comments
 (0)