fix(stt/google): honor googleOptions.recognizerId for Speech v2

`rOpts.sgoogleOptions?.recognizerId` (note the stray `s`) is always
undefined, so GOOGLE_SPEECH_RECOGNIZER_ID was never set on the channel.
The media server therefore fell back to the wildcard recognizer "_" with a
full inline config, and every caller passing a recognizerId silently got the
stock v2 model instead of their custom recognizer. Present since 8999c85, so
in v0.9.7-rc1 and v0.9.8.

Also in the same v2 path:

- Accept a full resource path in `recognizerId`, not just a bare id. The
  media server builds <parent>/recognizers/<id>, so a full path would be
  concatenated onto the parent into a nonsense recognizer name. A parent
  embedded in the path now also wins over `parentPath` and feeds the
  regional-endpoint derivation.

- Add the four v2-only vars to google's stickyVars. Without them a v2 gather
  leaks its recognizer, endpoint and service version into a later v1 (or
  wildcard) gather on the same call. Clearing a sticky var sends "", which
  FreeSWITCH treats as a delete (switch_channel.c: zstr(value) ->
  switch_event_del_header), so the media server sees the var as absent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
xquanluu
2026-07-31 11:04:42 +07:00
co-authored by Claude Opus 5
parent f35b694877
commit 024d658200
+33 -10
View File
@@ -11,7 +11,13 @@ const stickyVars = {
'GOOGLE_SPEECH_USE_ENHANCED',
'GOOGLE_SPEECH_ALTERNATIVE_LANGUAGE_CODES',
'GOOGLE_SPEECH_METADATA_INTERACTION_TYPE',
'GOOGLE_SPEECH_METADATA_INDUSTRY_NAICS_CODE'
'GOOGLE_SPEECH_METADATA_INDUSTRY_NAICS_CODE',
/* v2-only vars; without these a v2 gather leaks its recognizer and
endpoint into a later v1 (or wildcard-recognizer) gather on the same call */
'GOOGLE_SPEECH_CLOUD_SERVICES_VERSION',
'GOOGLE_SPEECH_RECOGNIZER_PARENT',
'GOOGLE_SPEECH_RECOGNIZER_ID',
'GOOGLE_SPEECH_TO_TEXT_URI'
],
microsoft: [
'AZURE_SPEECH_HINTS',
@@ -212,6 +218,25 @@ const selectDefaultGoogleModel = (task, language, version) => {
(useV2 ? 'telephony_short' : 'command_and_search') :
(useV2 ? 'long' : 'latest_long');
};
/**
* Google STT v2 addresses a recognizer as
* `projects/{project}/locations/{location}/recognizers/{id}`, which the media
* server assembles from GOOGLE_SPEECH_RECOGNIZER_PARENT + GOOGLE_SPEECH_RECOGNIZER_ID.
* Callers supply `recognizerId` either as the bare id or as the full resource
* path, so split a full path back into its parts before handing them over --
* otherwise the media server concatenates the path onto the parent and Google
* gets a nonsense recognizer name. A parent embedded in the path wins over
* `parentPath`, being the more specific of the two.
*/
const parseGoogleRecognizer = (recognizerId, parentPath) => {
if (typeof recognizerId !== 'string' || !recognizerId.trim()) return {parent: parentPath};
const id = recognizerId.trim();
const m = /^(projects\/[^/]+\/locations\/[^/]+)\/recognizers\/(.+)$/.exec(id);
if (m) return {parent: m[1], recognizerId: m[2]};
return {parent: parentPath, recognizerId: id};
};
const consolidateTranscripts = (bufferedTranscripts, channel, language, vendor) => {
if (bufferedTranscripts.length === 1) {
bufferedTranscripts[0].is_final = true;
@@ -822,20 +847,21 @@ module.exports = (logger) => {
if ('google' === vendor) {
const useV2 = rOpts.googleOptions?.serviceVersion === 'v2';
const {parentPath} = rOpts.googleOptions || {};
const {parent: recognizerParent, recognizerId} =
parseGoogleRecognizer(rOpts.googleOptions?.recognizerId, rOpts.googleOptions?.parentPath);
const version = useV2 ? 'v2' : 'v1';
let {model} = rOpts;
model = model || selectDefaultGoogleModel(task, language, version);
/**
* For STT v2, Google routes regional resources only via regional endpoints.
* A parentPath ending in `/locations/<region>` (anything other than `global`)
* A parent ending in `/locations/<region>` (anything other than `global`)
* requires the request to go to `<region>-speech.googleapis.com` instead of
* the default `speech.googleapis.com`.
*/
let derivedSttUri;
if (useV2 && parentPath) {
const m = parentPath.match(/\/locations\/([^/]+)/);
if (useV2 && recognizerParent) {
const m = recognizerParent.match(/\/locations\/([^/]+)/);
if (m && m[1] && m[1] !== 'global') derivedSttUri = `${m[1]}-speech.googleapis.com`;
}
opts = {
@@ -871,7 +897,7 @@ module.exports = (logger) => {
...(rOpts.naicsCode > 0 && {GOOGLE_SPEECH_METADATA_INDUSTRY_NAICS_CODE: rOpts.naicsCode}),
GOOGLE_SPEECH_METADATA_RECORDING_DEVICE_TYPE: 'phone_line',
...(useV2 && {
GOOGLE_SPEECH_RECOGNIZER_PARENT: parentPath ||
GOOGLE_SPEECH_RECOGNIZER_PARENT: recognizerParent ||
`projects/${sttCredentials.credentials.project_id}/locations/global`,
GOOGLE_SPEECH_CLOUD_SERVICES_VERSION: 'v2',
...(derivedSttUri && {GOOGLE_SPEECH_TO_TEXT_URI: derivedSttUri}),
@@ -887,10 +913,7 @@ module.exports = (logger) => {
...(rOpts.googleOptions?.enableVoiceActivityEvents && {
GOOGLE_SPEECH_ENABLE_VOICE_ACTIVITY_EVENTS: rOpts.googleOptions.enableVoiceActivityEvents
}),
...(rOpts.sgoogleOptions?.recognizerId) && {GOOGLE_SPEECH_RECOGNIZER_ID: rOpts.googleOptions.recognizerId},
...(rOpts.googleOptions?.enableVoiceActivityEvents && {
GOOGLE_SPEECH_ENABLE_VOICE_ACTIVITY_EVENTS: rOpts.googleOptions.enableVoiceActivityEvents
}),
...(recognizerId && {GOOGLE_SPEECH_RECOGNIZER_ID: recognizerId}),
}),
};
}