Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ members = [
]

[workspace.package]
version = "3.7.2"
version = "3.8.0"
edition = "2024"
license = "MIT"
repository = "https://github.com/pragmatrix/context-switch"
Expand Down
69 changes: 69 additions & 0 deletions docs/adr/0005-google-transcribe-numerals-class-token.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# ADR 0005: Google transcribe `numerals` as a class-token adaptation hint

Date: 2026-09-16
Status: Accepted

## Context

Deepgram's transcriber exposes a `numerals` option that formats recognized
numbers as digits. Google Cloud Speech-to-Text V2 has no equivalent
formatting toggle: `RecognitionConfig`/`RecognitionFeatures` contain no
numerals field, and Google already emits recognized numbers as digits by
default (built-in ITN).

What Google does offer is *recognition biasing* via `SpeechAdaptation`:
an inline `PhraseSet` whose phrases may contain class tokens such as
`$OOV_CLASS_DIGIT_SEQUENCE` ("nine four one two" → `9412`). This changes
what the recognizer hears, not how the transcript is formatted, but the
observable contract for callers is the same as Deepgram's `numerals`:
numbers come out as digits.

## Decision

- `google_transcribe::Params` gains a `numerals: bool`. When set, the
service sends an inline `SpeechAdaptation` containing a single phrase
with the `$OOV_CLASS_DIGIT_SEQUENCE` class token.
- The full class-token vocabulary (27 strings across the `$OOV_CLASS_*`
and bare `$*` families) is documented as public constants in
`services/google-transcribe/src/class_tokens.rs`. Only
`$OOV_CLASS_DIGIT_SEQUENCE` is wired up; the rest exist as reference
documentation.
- The hint is sent unconditionally when `numerals` is requested. There is
no model/locale filter: Google publishes no (model × locale) support
matrix for class tokens (the class-tokens page is locale-only), and
Google silently ignores tokens unsupported for the request's locale.
- The phrase carries a `boost` of 20.0 (the maximum), taken over from an
internal project that parameterized Google via FreeSWITCH.
- The example CLI accepts `--numerals` for the Google provider.

## Consequences

- `--numerals` now works for both Deepgram and Google with the same
observable effect, though the underlying mechanisms differ (formatting
vs. recognition bias). On Google the effect covers digit sequences
specifically, not every numeric phrase.
- Token availability depends on the selected model and locale; see
<https://docs.cloud.google.com/speech-to-text/docs/class-tokens>.
- If a support filter is ever needed, per-locale availability data must be
sourced fresh from Google's class-tokens page; it is not derivable from
the API reference or the proto crate.

## Rejected alternative: post-hoc alternative re-ranking

An earlier iteration added a `digitBoost` parameter that added a
confidence bonus to final alternatives whose transcript was digit-only,
plus a `maxAlternatives` parameter (default 4) so Google would return
lower alternatives to boost. It was removed because it cannot work
reliably:

- Google populates `confidence` only on the top alternative of a final
streaming result; every lower alternative carries `0.0`, which is the
documented sentinel for "not set", not a real score. Re-ranking by
confidence therefore compares unknown values against one known value.
- The only reliable ranking signal is Google's own ordering ("alternatives
are ordered in terms of accuracy, with the top (first) alternative being
the most probable, as ranked by the recognizer"), which the service now
follows directly: final results take the first alternative, and
`max_alternatives` is hardcoded to 1 in `client.rs`.
- Lower alternatives' confidences are still logged for observability, but
never used for selection.
15 changes: 10 additions & 5 deletions examples/transcribe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -335,12 +335,16 @@ async fn start_conversation(
// https://docs.cloud.google.com/speech-to-text/docs/speech-to-text-supported-languages

let params = google_transcribe::Params {
model: provider_args.model.map(str::to_owned).unwrap_or_else(|| {
env::var("GOOGLE_TRANSCRIBE_MODEL").unwrap_or_else(|_| "latest_long".to_owned())
}),
language: languages.join_csv(),
diarization: provider_args.diarization,
region,
transcribe: google_transcribe::TranscribeParams {
model: provider_args.model.map(str::to_owned).unwrap_or_else(|| {
env::var("GOOGLE_TRANSCRIBE_MODEL")
.unwrap_or_else(|_| "latest_long".to_owned())
}),
language: languages.join_csv(),
diarization: provider_args.diarization,
numerals: provider_args.numerals,
},
};
GoogleTranscribe.conversation(params, conversation).await
}
Expand Down Expand Up @@ -449,6 +453,7 @@ impl Provider {
Provider::Google => {
capabilities.region = true;
capabilities.diarization = true;
capabilities.numerals = true;
capabilities.model = true;
}
Provider::Aristech => {
Expand Down
110 changes: 110 additions & 0 deletions services/google-transcribe/src/class_tokens.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
//! Google Cloud Speech-to-Text V2 class tokens for speech adaptation.
//!
//! Class tokens are placeholders that can be embedded in `PhraseSet` phrases to
//! bias the recognizer toward a whole class of values (numbers, dates, phone
//! numbers, ...) without enumerating every possible value. They are sent inside
//! an inline `PhraseSet` via `RecognitionConfig.adaptation`.
//!
//! Token availability varies by locale and transcription model; Google
//! silently ignores tokens that are not supported for the request's locale.
//! The authoritative per-locale table is published at
//! <https://docs.cloud.google.com/speech-to-text/docs/class-tokens>.
//!
//! Two naming families exist: the `$OOV_CLASS_*` prefixed tokens and the bare
//! `$*` tokens. Seven base names exist in both forms; the two families are not
//! interchangeable.

/// A sequence of letters `[a-z]` and/or digits, for example "a1b2c3".
pub const OOV_CLASS_ALPHANUMERIC_SEQUENCE: &str = "$OOV_CLASS_ALPHANUMERIC_SEQUENCE";

/// A sequence of letters `[a-z]`, for example "cqbcf".
pub const OOV_CLASS_ALPHA_SEQUENCE: &str = "$OOV_CLASS_ALPHA_SEQUENCE";

/// An AM radio frequency, for example "twelve twenty" → `1220`.
pub const OOV_CLASS_AM_RADIO_FREQUENCY: &str = "$OOV_CLASS_AM_RADIO_FREQUENCY";

/// A digit sequence of any length, for example "nine four one two" → `9412`.
pub const OOV_CLASS_DIGIT_SEQUENCE: &str = "$OOV_CLASS_DIGIT_SEQUENCE";

/// An FM radio frequency, for example "one oh four point three" → `104.3`.
pub const OOV_CLASS_FM_RADIO_FREQUENCY: &str = "$OOV_CLASS_FM_RADIO_FREQUENCY";

/// A street number for an address in the target locale (prefixed variant),
/// for example "one hundred ninety one" → `191`.
pub const OOV_CLASS_ADDRESSNUM: &str = "$OOV_CLASS_ADDRESSNUM";

/// A full date using numbers (prefixed variant), for example
/// "nine nine nine two thousand fourteen" → `9.9.2014` (locale-dependent format).
pub const OOV_CLASS_FULLDATE: &str = "$OOV_CLASS_FULLDATE";

/// A phone number as used in the target locale (prefixed variant), for example
/// "six five oh five five five six one oh one" → `650-555-6101`.
pub const OOV_CLASS_FULLPHONENUM: &str = "$OOV_CLASS_FULLPHONENUM";

/// A numerical value including whole numbers, fractions, and decimals
/// (prefixed variant), for example "twenty two" → `22`.
pub const OOV_CLASS_OPERAND: &str = "$OOV_CLASS_OPERAND";

/// An ordinal number (prefixed variant), for example "third" → `3rd`.
pub const OOV_CLASS_ORDINAL: &str = "$OOV_CLASS_ORDINAL";

/// A percentage value including the percent sign (prefixed variant), for
/// example "ten point five percent" → `10.5%`.
pub const OOV_CLASS_PERCENT: &str = "$OOV_CLASS_PERCENT";

/// A postal code as used in the target locale (prefixed variant), for example
/// "one zero zero one zero" → `10010`.
pub const OOV_CLASS_POSTALCODE: &str = "$OOV_CLASS_POSTALCODE";

/// A temperature in degrees, for example "minus one" → `-1`.
pub const OOV_CLASS_TEMPERATURE: &str = "$OOV_CLASS_TEMPERATURE";

/// A television channel number, for example "two zero two" → `202`.
pub const OOV_CLASS_TV_CHANNEL: &str = "$OOV_CLASS_TV_CHANNEL";

/// A street number for an address in the target locale, for example
/// "one hundred ninety one" → `191`.
pub const ADDRESSNUM: &str = "$ADDRESSNUM";

/// A full date using numbers, for example "nine nine nine two thousand
/// fourteen" → `9.9.2014` (locale-dependent format).
pub const FULLDATE: &str = "$FULLDATE";

/// A phone number as used in the target locale, for example
/// "one eight hundred five five five four oh oh one" → `+1-800-555-4001`.
pub const FULLPHONENUM: &str = "$FULLPHONENUM";

/// A numerical value including whole numbers, fractions, and decimals, for
/// example "twenty two" → `22`.
pub const OPERAND: &str = "$OPERAND";

/// An ordinal number, for example "third" → `3rd`.
pub const ORDINAL: &str = "$ORDINAL";

/// A percentage value including the percent sign, for example
/// "ten point five percent" → `10.5%`.
pub const PERCENT: &str = "$PERCENT";

/// A postal code as used in the target locale, for example
/// "one zero zero one zero" → `10010`.
pub const POSTALCODE: &str = "$POSTALCODE";

/// A numbered day within a month, for example "the twenty third" → `23rd`.
pub const DAY: &str = "$DAY";

/// An amount of money with a currency unit name, for example
/// "forty three dollars" → `$43`.
pub const MONEY: &str = "$MONEY";

/// A named month in a year, for example "july" → `July`. Contextual phrases
/// like "2 months from now" are not supported.
pub const MONTH: &str = "$MONTH";

/// A numbered street name, for example "fifty first" → `51st`.
pub const STREET: &str = "$STREET";

/// A specific time of day, for example "ten thirty" → `10:30`.
pub const TIME: &str = "$TIME";

/// A year, for example "twenty ten" → `2010`.
pub const YEAR: &str = "$YEAR";
60 changes: 50 additions & 10 deletions services/google-transcribe/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,25 @@ use anyhow::Result;
use async_stream::{stream, try_stream};
use futures::Stream;
use tokio::sync::mpsc::UnboundedReceiver;
use tracing::debug;
use tracing::{debug, info};

use googleapis_tonic_google_cloud_speech_v2::google::cloud::speech::v2::recognition_config::DecodingConfig;
use googleapis_tonic_google_cloud_speech_v2::google::cloud::speech::v2::speech_adaptation::AdaptationPhraseSet;
use googleapis_tonic_google_cloud_speech_v2::google::cloud::speech::v2::speech_adaptation::adaptation_phrase_set::Value as AdaptationPhraseSetValue;
use googleapis_tonic_google_cloud_speech_v2::google::cloud::speech::v2::{
ExplicitDecodingConfig, RecognitionConfig, RecognitionFeatures, StreamingRecognitionConfig,
StreamingRecognitionFeatures, StreamingRecognizeRequest, StreamingRecognizeResponse,
SpeakerDiarizationConfig,
ExplicitDecodingConfig, PhraseSet, RecognitionConfig, RecognitionFeatures,
StreamingRecognitionConfig, StreamingRecognitionFeatures, StreamingRecognizeRequest,
StreamingRecognizeResponse, SpeakerDiarizationConfig, SpeechAdaptation,
phrase_set::Phrase,
};
use googleapis_tonic_google_cloud_speech_v2::google::cloud::speech::v2::explicit_decoding_config;
use googleapis_tonic_google_cloud_speech_v2::google::cloud::speech::v2::streaming_recognize_request::StreamingRequest;

use context_switch_core::AudioFormat;
use context_switch_core::audio;

use crate::TranscribeParams;
use crate::class_tokens::OOV_CLASS_DIGIT_SEQUENCE;
use crate::host::Client;

/// A google transcribe client. Capable of streaming audio data in and transcribe results out.
Expand All @@ -40,32 +45,66 @@ impl TranscribeClient {

pub async fn transcribe<'a>(
&mut self,
model: &str,
language_codes: &[String],
diarization: bool,
params: &TranscribeParams,
interim_results: bool,
audio_format: AudioFormat,
mut audio_receiver: UnboundedReceiver<Vec<i16>>,
) -> Result<impl Stream<Item = Result<StreamingRecognizeResponse>> + 'a> {
let language_codes = params.languages()?;
let TranscribeParams {
model,
diarization,
numerals,
..
} = params;
let model = model.as_str();
let decoding_config = ExplicitDecodingConfig {
// We only support 16-bit signed little-endian PCM samples here for now.
encoding: explicit_decoding_config::AudioEncoding::Linear16.into(),
sample_rate_hertz: audio_format.sample_rate as i32,
audio_channel_count: audio_format.channels as i32,
};

// Bias digit-sequence recognition so spoken numbers are transcribed as digits.
// Sent unconditionally when requested; token availability depends on the model and
// locale, and Google silently ignores unsupported tokens.
let adaptation = numerals.then(|| {
info!(
token = OOV_CLASS_DIGIT_SEQUENCE,
"Sending speech adaptation class token"
);
SpeechAdaptation {
phrase_sets: vec![AdaptationPhraseSet {
value: Some(AdaptationPhraseSetValue::InlinePhraseSet(PhraseSet {
phrases: vec![Phrase {
value: OOV_CLASS_DIGIT_SEQUENCE.to_owned(),
// Maximum boost, taken over from the internal project that
// parameterized Google via FreeSWITCH (see ADR 0005).
boost: 20.0,
}],
..Default::default()
})),
}],
custom_classes: vec![],
}
});

let recognition_config = RecognitionConfig {
// TODO: configure
model: model.into(),
language_codes: language_codes.to_vec(),
features: diarization.then_some(RecognitionFeatures {
diarization_config: Some(SpeakerDiarizationConfig {
features: Some(RecognitionFeatures {
diarization_config: diarization.then_some(SpeakerDiarizationConfig {
min_speaker_count: 0,
max_speaker_count: 0,
}),
// We only ever emit the first alternative (see the selection comment in
// transcribe.rs); requesting more would only produce alternatives whose
// confidence is unset and cannot be compared.
max_alternatives: 1,
..Default::default()
}),
adaptation: None,
adaptation,
transcript_normalization: None,
denoiser_config: None,
translation_config: None,
Expand All @@ -91,6 +130,7 @@ impl TranscribeClient {
model = %model,
language_codes = ?language_codes,
diarization,
numerals,
interim_results,
"Starting Google streaming_recognize"
);
Expand Down
34 changes: 31 additions & 3 deletions services/google-transcribe/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
//! A Google Speech to Text V2 service.
use anyhow::{Context, Result};
use serde::Deserialize;

use context_switch_core::language::Languages;

pub mod class_tokens;
mod client;
mod host;
pub mod transcribe;
Expand All @@ -10,6 +14,20 @@ pub use transcribe::GoogleTranscribe;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Params {
/// Google Cloud location and API endpoint. Only `global`, `eu`, and `us` are supported.
/// Defaults to `global`.
#[serde(default)]
pub region: Region,
/// Recognition parameters passed through to the transcribe function.
#[serde(flatten)]
pub transcribe: TranscribeParams,
}

/// The subset of `Params` that configures recognition and is passed through to the
/// transcribe function.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TranscribeParams {
/// Google Cloud Speech-to-Text `V2` recognition model (for example, `latest_long`).
pub model: String,
/// One or more comma-separated BCP 47 locale codes sent as `language_codes`.
Expand All @@ -18,10 +36,20 @@ pub struct Params {
/// the selected model, language, and region.
#[serde(default)]
pub diarization: bool,
/// Google Cloud location and API endpoint. Only `global`, `eu`, and `us` are supported.
/// Defaults to `global`.
/// Bias recognition toward digit sequences so spoken numbers are transcribed as digits
/// (for example, "nine four one two" → `9412`). Implemented as a speech-adaptation hint
/// using the `$OOV_CLASS_DIGIT_SEQUENCE` class token; token availability depends on the
/// selected model and locale.
#[serde(default)]
pub region: Region,
pub numerals: bool,
}

impl TranscribeParams {
/// Extract the BCP 47 locale codes from the comma-separated `language` value.
pub fn languages(&self) -> Result<Languages> {
Languages::from_csv(&self.language)
.context("language must contain at least one locale code")
}
}

#[derive(Debug, Clone, Copy, Default, Deserialize)]
Expand Down
Loading
Loading