Skip to content

Commit c1c6824

Browse files
authored
Merge pull request #63 from pragmatrix/google-diarization
Support speaker diarization for Google
2 parents 4cf5eb0 + 5a61c06 commit c1c6824

4 files changed

Lines changed: 57 additions & 10 deletions

File tree

examples/transcribe.rs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,8 @@ async fn recognize_from_microphone(
119119
) -> Result<()> {
120120
// Keep an output sink alive so Bluetooth headsets can switch to a bidirectional profile.
121121
let _output_sink = match DeviceSinkBuilder::open_default_sink() {
122-
Ok(sink) => {
122+
Ok(mut sink) => {
123+
sink.log_on_drop(false);
123124
println!("Opened default output sink for headset profile");
124125
Some(sink)
125126
}
@@ -279,9 +280,6 @@ async fn start_conversation(
279280
.await
280281
}
281282
Provider::Google => {
282-
if diarization {
283-
bail!("--diarization is only supported for the azure provider");
284-
}
285283
let region = region
286284
.map(str::to_owned)
287285
.or_else(|| env::var("GOOGLE_TRANSCRIBE_REGION").ok());
@@ -297,11 +295,15 @@ async fn start_conversation(
297295
None => google_transcribe::transcribe::Region::default(),
298296
};
299297

298+
// Check model/language/region feature support (including diarization):
299+
// https://docs.cloud.google.com/speech-to-text/docs/speech-to-text-supported-languages
300+
300301
let params = google_transcribe::transcribe::Params {
301302
model: model.map(str::to_owned).unwrap_or_else(|| {
302303
env::var("GOOGLE_TRANSCRIBE_MODEL").unwrap_or_else(|_| "latest_long".to_owned())
303304
}),
304305
language: languages.join_csv(),
306+
diarization,
305307
region,
306308
};
307309
GoogleTranscribe.conversation(params, conversation).await

justfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,6 @@ transcribe-google-latest-short:
2222
transcribe-google-latest-long:
2323
cargo run --example transcribe -- google --language de-DE --model latest_long --region eu
2424

25+
transcribe-google-diarization:
26+
cargo run --example transcribe -- google --diarization --language de-DE --model chirp_3 --region eu
27+

services/google-transcribe/src/client.rs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ use google_cloud_token::TokenSource;
1414
use googleapis_tonic_google_cloud_speech_v2::google::cloud::speech::v2::recognition_config::DecodingConfig;
1515
use googleapis_tonic_google_cloud_speech_v2::google::cloud::speech::v2::speech_client::SpeechClient;
1616
use googleapis_tonic_google_cloud_speech_v2::google::cloud::speech::v2::{
17-
ExplicitDecodingConfig, RecognitionConfig, StreamingRecognitionConfig,
17+
ExplicitDecodingConfig, RecognitionConfig, RecognitionFeatures, StreamingRecognitionConfig,
1818
StreamingRecognitionFeatures, StreamingRecognizeRequest, StreamingRecognizeResponse,
19+
SpeakerDiarizationConfig,
1920
explicit_decoding_config,
2021
};
2122
use googleapis_tonic_google_cloud_speech_v2::google::cloud::speech::v2::streaming_recognize_request::StreamingRequest;
@@ -165,6 +166,7 @@ impl TranscribeClient {
165166
&mut self,
166167
model: &str,
167168
language_codes: &[String],
169+
diarization: bool,
168170
interim_results: bool,
169171
audio_format: AudioFormat,
170172
mut audio_receiver: UnboundedReceiver<Vec<i16>>,
@@ -180,7 +182,13 @@ impl TranscribeClient {
180182
// TODO: configure
181183
model: model.into(),
182184
language_codes: language_codes.to_vec(),
183-
features: None,
185+
features: diarization.then_some(RecognitionFeatures {
186+
diarization_config: Some(SpeakerDiarizationConfig {
187+
min_speaker_count: 0,
188+
max_speaker_count: 0,
189+
}),
190+
..Default::default()
191+
}),
184192
adaptation: None,
185193
transcript_normalization: None,
186194
denoiser_config: None,
@@ -206,6 +214,7 @@ impl TranscribeClient {
206214
recognizer = %recognizer,
207215
model = %model,
208216
language_codes = ?language_codes,
217+
diarization,
209218
interim_results,
210219
"Starting Google streaming_recognize"
211220
);

services/google-transcribe/src/transcribe.rs

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ use anyhow::{Context, Result};
22
use async_trait::async_trait;
33
use futures::{Stream, StreamExt};
44
use googleapis_tonic_google_cloud_speech_v2::google::cloud::speech::v2::{
5-
StreamingRecognizeResponse, streaming_recognize_response::SpeechEventType,
5+
StreamingRecognizeResponse, WordInfo, streaming_recognize_response::SpeechEventType,
66
};
77
use serde::Deserialize;
8+
use std::collections::HashMap;
89
use tokio::sync::mpsc::UnboundedReceiver;
910
use tonic::Code;
1011

@@ -23,6 +24,8 @@ pub struct Params {
2324
pub model: String,
2425
pub language: String,
2526
#[serde(default)]
27+
pub diarization: bool,
28+
#[serde(default)]
2629
pub region: Region,
2730
}
2831

@@ -154,6 +157,7 @@ async fn transcribe_and_process_stream_session(
154157
.transcribe(
155158
&params.model,
156159
languages,
160+
params.diarization,
157161
interim_results,
158162
audio_format,
159163
audio_receiver,
@@ -221,7 +225,12 @@ where
221225
let language = include_detected_language
222226
.then(|| one.language_code.trim().to_owned())
223227
.filter(|x| !x.is_empty());
224-
text_output.final_text(alternative.transcript.trim().to_owned(), language)?;
228+
let speaker = speaker_with_max_assigned_characters(&alternative.words);
229+
text_output.final_text(
230+
alternative.transcript.trim().to_owned(),
231+
language,
232+
speaker,
233+
)?;
225234
}
226235
[_, ..] => {
227236
let interim_text = response
@@ -321,6 +330,25 @@ fn should_restart_for_stream_limit(code: Code, message: &str) -> bool {
321330
code == Code::Aborted && message.contains("max duration of 5 minutes reached for stream")
322331
}
323332

333+
fn speaker_with_max_assigned_characters(words: &[WordInfo]) -> Option<String> {
334+
let mut char_count_by_speaker = HashMap::<&str, usize>::new();
335+
336+
for word in words {
337+
let speaker = word.speaker_label.trim();
338+
if speaker.is_empty() {
339+
continue;
340+
}
341+
342+
let spoken_chars = word.word.chars().count();
343+
*char_count_by_speaker.entry(speaker).or_default() += spoken_chars;
344+
}
345+
346+
char_count_by_speaker
347+
.into_iter()
348+
.max_by_key(|(_, char_count)| *char_count)
349+
.map(|(speaker, _)| speaker.to_owned())
350+
}
351+
324352
// Keeps interim/final emission behavior in one place across all exit paths.
325353
// If a session ends without any final result, we promote the last interim text
326354
// to final in Drop so callers still receive a terminal text event.
@@ -338,8 +366,13 @@ impl<'a> StreamTextOutput<'a> {
338366
}
339367
}
340368

341-
fn final_text(&mut self, text: String, language: Option<String>) -> Result<()> {
342-
self.output.text(true, text, language, None)?;
369+
fn final_text(
370+
&mut self,
371+
text: String,
372+
language: Option<String>,
373+
speaker: Option<String>,
374+
) -> Result<()> {
375+
self.output.text(true, text, language, speaker)?;
343376
self.pending_interim_text = None;
344377
Ok(())
345378
}

0 commit comments

Comments
 (0)