Skip to content

Commit b343b51

Browse files
authored
Merge pull request #4413 from teleniasoftware/google-stt-speech-adaptation
Add Google STT adaptation support
2 parents 0db3c9a + 041a49f commit b343b51

3 files changed

Lines changed: 273 additions & 23 deletions

File tree

changelog/4413.added.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Added Google Speech-to-Text v2 `adaptation` support to `GoogleSTTService`, so recognition can be biased toward domain terms using inline or referenced phrase sets. Configurable at construction and updatable at runtime.

src/pipecat/services/google/stt.py

Lines changed: 137 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
from typing import Any
2828

2929
from loguru import logger
30-
from pydantic import BaseModel, Field, field_validator
30+
from pydantic import BaseModel, ConfigDict, Field, field_validator
3131

3232
from pipecat.frames.frames import (
3333
CancelFrame,
@@ -37,7 +37,7 @@
3737
StartFrame,
3838
TranscriptionFrame,
3939
)
40-
from pipecat.services.settings import NOT_GIVEN, STTSettings, _NotGiven, assert_given
40+
from pipecat.services.settings import NOT_GIVEN, STTSettings, _NotGiven, assert_given, is_given
4141
from pipecat.services.stt_latency import GOOGLE_TTFS_P99
4242
from pipecat.services.stt_service import STTService
4343
from pipecat.transcriptions.language import Language, resolve_language
@@ -363,6 +363,69 @@ def language_to_google_stt_language(language: Language) -> str:
363363
return resolve_language(language, LANGUAGE_MAP, use_base_code=False)
364364

365365

366+
def _model_supports_adaptation(model: str | None) -> bool:
367+
"""Return whether a Google STT v2 model accepts a SpeechAdaptation config.
368+
369+
The telephony model rejects it with ``Recognizer does not support feature:
370+
speech_adaptation_boost``. Support elsewhere is a per-model, per-language
371+
matrix that Google validates on its own, so every other model is passed
372+
through and left to report its own errors.
373+
"""
374+
return (model or "").lower() != "telephony"
375+
376+
377+
def _normalize_speech_adaptation(
378+
adaptation: dict[str, Any] | cloud_speech.SpeechAdaptation,
379+
) -> cloud_speech.SpeechAdaptation:
380+
"""Normalize adaptation input to a SpeechAdaptation message.
381+
382+
A ``phrase_sets`` entry may be a resource name string, an
383+
``AdaptationPhraseSet`` object, or a bare inline phrase set; a single
384+
entry may be given in place of a list.
385+
386+
Args:
387+
adaptation: A ``SpeechAdaptation`` message, or a dict payload to convert.
388+
389+
Returns:
390+
The equivalent ``SpeechAdaptation`` message.
391+
392+
Raises:
393+
ValueError: If the payload has a shape Google's API can't represent.
394+
"""
395+
if isinstance(adaptation, cloud_speech.SpeechAdaptation):
396+
return adaptation
397+
398+
normalized = dict(adaptation)
399+
400+
raw_phrase_sets = normalized.get("phrase_sets", [])
401+
if isinstance(raw_phrase_sets, (str, dict)):
402+
raw_phrase_sets = [raw_phrase_sets]
403+
phrase_sets = list(raw_phrase_sets)
404+
405+
converted_phrase_sets: list[dict[str, Any]] = []
406+
for phrase_set in phrase_sets:
407+
if isinstance(phrase_set, str):
408+
converted_phrase_sets.append({"phrase_set": phrase_set})
409+
continue
410+
411+
if not isinstance(phrase_set, dict):
412+
raise ValueError(
413+
"Invalid Google SpeechAdaptation phrase_set entry: expected string or object, "
414+
f"got {type(phrase_set).__name__}."
415+
)
416+
417+
if "phrase_set" in phrase_set or "inline_phrase_set" in phrase_set:
418+
converted_phrase_sets.append(phrase_set)
419+
continue
420+
421+
converted_phrase_sets.append({"inline_phrase_set": phrase_set})
422+
423+
if converted_phrase_sets:
424+
normalized["phrase_sets"] = converted_phrase_sets
425+
426+
return cloud_speech.SpeechAdaptation(normalized)
427+
428+
366429
@dataclass
367430
class GoogleSTTSettings(STTSettings):
368431
"""Settings for GoogleSTTService.
@@ -388,6 +451,22 @@ class GoogleSTTSettings(STTSettings):
388451
enable_word_confidence: Include confidence scores for each word.
389452
enable_interim_results: Stream partial recognition results.
390453
enable_voice_activity_events: Detect voice activity in audio.
454+
adaptation: Phrase sets biasing recognition toward specific words, as a
455+
``SpeechAdaptation`` message or an equivalent dict. Each
456+
``phrase_sets`` entry is either the resource name of a phrase set or
457+
an inline one::
458+
459+
adaptation={
460+
"phrase_sets": [
461+
"projects/my-project/locations/global/phraseSets/catalog",
462+
{"phrases": [{"value": "pipecat", "boost": 15.0}]},
463+
]
464+
}
465+
466+
Referenced phrase sets must live in the same location as the
467+
service. The telephony model rejects adaptation and transcribes
468+
without it; support otherwise varies by model and language — see
469+
https://cloud.google.com/speech-to-text/v2/docs/speech-to-text-supported-languages
391470
"""
392471

393472
languages: list[Language] | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
@@ -403,6 +482,9 @@ class GoogleSTTSettings(STTSettings):
403482
enable_word_confidence: bool | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
404483
enable_interim_results: bool | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
405484
enable_voice_activity_events: bool | _NotGiven = field(default_factory=lambda: NOT_GIVEN)
485+
adaptation: dict[str, Any] | cloud_speech.SpeechAdaptation | None | _NotGiven = field(
486+
default_factory=lambda: NOT_GIVEN
487+
)
406488

407489

408490
class GoogleSTTService(STTService):
@@ -453,8 +535,11 @@ class InputParams(BaseModel):
453535
enable_word_confidence: Include confidence scores for each word.
454536
enable_interim_results: Stream partial recognition results.
455537
enable_voice_activity_events: Detect voice activity in audio.
538+
adaptation: Optional Google SpeechAdaptation payload.
456539
"""
457540

541+
model_config = ConfigDict(arbitrary_types_allowed=True)
542+
458543
languages: Language | list[Language] = Field(default_factory=lambda: [Language.EN_US])
459544
model: str | None = "latest_long"
460545
use_separate_recognition_per_channel: bool | None = False
@@ -466,6 +551,7 @@ class InputParams(BaseModel):
466551
enable_word_confidence: bool | None = False
467552
enable_interim_results: bool | None = True
468553
enable_voice_activity_events: bool | None = False
554+
adaptation: dict[str, Any] | cloud_speech.SpeechAdaptation | None = None
469555

470556
@field_validator("languages", mode="before")
471557
@classmethod
@@ -538,6 +624,7 @@ def __init__(
538624
enable_word_confidence=False,
539625
enable_interim_results=True,
540626
enable_voice_activity_events=False,
627+
adaptation=None,
541628
)
542629

543630
# 2. No direct init arg overrides
@@ -559,11 +646,15 @@ def __init__(
559646
default_settings.enable_word_confidence = params.enable_word_confidence
560647
default_settings.enable_interim_results = params.enable_interim_results
561648
default_settings.enable_voice_activity_events = params.enable_voice_activity_events
649+
default_settings.adaptation = params.adaptation
562650

563651
# 4. Apply settings delta (canonical API, always wins)
564652
if settings is not None:
565653
default_settings.apply_update(settings)
566654

655+
if is_given(default_settings.adaptation) and default_settings.adaptation is not None:
656+
default_settings.adaptation = _normalize_speech_adaptation(default_settings.adaptation)
657+
567658
super().__init__(
568659
sample_rate=sample_rate,
569660
ttfs_p99_latency=ttfs_p99_latency,
@@ -662,6 +753,14 @@ def _get_language_codes(self) -> list[str]:
662753
return list(language_codes)
663754
return ["en-US"]
664755

756+
def _get_speech_adaptation(self) -> cloud_speech.SpeechAdaptation | None:
757+
"""Build the SpeechAdaptation message from settings."""
758+
adaptation = self._settings.adaptation
759+
if not is_given(adaptation) or adaptation is None:
760+
return None
761+
# Already a message: normalizing on the way in leaves nothing to convert.
762+
return _normalize_speech_adaptation(adaptation)
763+
665764
async def _reconnect_if_needed(self):
666765
"""Reconnect the stream if it's currently active."""
667766
if self._streaming_task:
@@ -700,8 +799,6 @@ async def _update_settings(self, delta: Settings) -> dict[str, Any]:
700799
Returns:
701800
Dict mapping changed field names to their previous values.
702801
"""
703-
from pipecat.services.settings import is_given
704-
705802
# If base set_language sent a Language value, convert to languages list
706803
if is_given(delta.language):
707804
delta.languages = [delta.language]
@@ -719,6 +816,9 @@ async def _update_settings(self, delta: Settings) -> dict[str, Any]:
719816
stacklevel=2,
720817
)
721818

819+
if is_given(delta.adaptation) and delta.adaptation is not None:
820+
delta.adaptation = _normalize_speech_adaptation(delta.adaptation)
821+
722822
changed = await super()._update_settings(delta)
723823

724824
if changed:
@@ -775,6 +875,7 @@ async def update_options(
775875
enable_word_confidence: bool | None = None,
776876
enable_interim_results: bool | None = None,
777877
enable_voice_activity_events: bool | None = None,
878+
adaptation: dict[str, Any] | cloud_speech.SpeechAdaptation | None | _NotGiven = NOT_GIVEN,
778879
location: str | None = None,
779880
) -> None:
780881
"""Update service options dynamically.
@@ -794,6 +895,7 @@ async def update_options(
794895
enable_word_confidence: Enable/disable word confidence scores.
795896
enable_interim_results: Enable/disable interim results.
796897
enable_voice_activity_events: Enable/disable voice activity detection.
898+
adaptation: New Google SpeechAdaptation payload.
797899
location: New Google Cloud location.
798900
799901
Note:
@@ -823,6 +925,8 @@ async def update_options(
823925
delta.enable_interim_results = enable_interim_results
824926
if enable_voice_activity_events is not None:
825927
delta.enable_voice_activity_events = enable_voice_activity_events
928+
if is_given(adaptation):
929+
delta.adaptation = adaptation
826930

827931
if location is not None:
828932
logger.debug(f"Updating location to: {location}")
@@ -838,24 +942,36 @@ async def _connect(self):
838942
self._stream_start_time = int(time.time() * 1000)
839943
self._new_stream = True
840944

841-
self._config = cloud_speech.StreamingRecognitionConfig(
842-
config=cloud_speech.RecognitionConfig(
843-
explicit_decoding_config=cloud_speech.ExplicitDecodingConfig(
844-
encoding=cloud_speech.ExplicitDecodingConfig.AudioEncoding.LINEAR16,
845-
sample_rate_hertz=self.sample_rate,
846-
audio_channel_count=1,
847-
),
848-
language_codes=self._get_language_codes(),
849-
model=self._settings.model,
850-
features=cloud_speech.RecognitionFeatures(
851-
enable_automatic_punctuation=self._settings.enable_automatic_punctuation,
852-
enable_spoken_punctuation=self._settings.enable_spoken_punctuation,
853-
enable_spoken_emojis=self._settings.enable_spoken_emojis,
854-
profanity_filter=self._settings.profanity_filter,
855-
enable_word_time_offsets=self._settings.enable_word_time_offsets,
856-
enable_word_confidence=self._settings.enable_word_confidence,
857-
),
945+
recognition_config = cloud_speech.RecognitionConfig(
946+
explicit_decoding_config=cloud_speech.ExplicitDecodingConfig(
947+
encoding=cloud_speech.ExplicitDecodingConfig.AudioEncoding.LINEAR16,
948+
sample_rate_hertz=self.sample_rate,
949+
audio_channel_count=1,
858950
),
951+
language_codes=self._get_language_codes(),
952+
model=self._settings.model,
953+
features=cloud_speech.RecognitionFeatures(
954+
enable_automatic_punctuation=self._settings.enable_automatic_punctuation,
955+
enable_spoken_punctuation=self._settings.enable_spoken_punctuation,
956+
enable_spoken_emojis=self._settings.enable_spoken_emojis,
957+
profanity_filter=self._settings.profanity_filter,
958+
enable_word_time_offsets=self._settings.enable_word_time_offsets,
959+
enable_word_confidence=self._settings.enable_word_confidence,
960+
),
961+
)
962+
963+
speech_adaptation = self._get_speech_adaptation()
964+
if speech_adaptation is not None:
965+
if _model_supports_adaptation(self._settings.model):
966+
recognition_config.adaptation = speech_adaptation
967+
else:
968+
logger.warning(
969+
"Google STT model '{}' rejects adaptation; transcribing without it.",
970+
self._settings.model,
971+
)
972+
973+
self._config = cloud_speech.StreamingRecognitionConfig(
974+
config=recognition_config,
859975
streaming_features=cloud_speech.StreamingRecognitionFeatures(
860976
enable_voice_activity_events=self._settings.enable_voice_activity_events,
861977
interim_results=self._settings.enable_interim_results,

0 commit comments

Comments
 (0)