2727from typing import Any
2828
2929from loguru import logger
30- from pydantic import BaseModel , Field , field_validator
30+ from pydantic import BaseModel , ConfigDict , Field , field_validator
3131
3232from pipecat .frames .frames import (
3333 CancelFrame ,
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
4141from pipecat .services .stt_latency import GOOGLE_TTFS_P99
4242from pipecat .services .stt_service import STTService
4343from 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
367430class 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
408490class 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