Skip to content

Commit 9537678

Browse files
eauesqueclaude
andcommitted
feat: return per-segment timestamps from speech2text/transcribe
/v1/infer/speech2text/transcribe returned a single flattened string (generate_all_text()), silently dropping the segment-level start/end timestamps that Python's s2t_inference.transcribe_segments() (and the UI, which iterates d.segments) expects. Add yu_hailort_s2t_generate_segments() to the shim, wrapping the SDK's generate_all_segments() and marshaling the result as a JSON array (reusing the existing escape_json_string() helper already used for chat message JSON). The Rust wrapper deserializes it into Vec<Segment> via serde. The route now returns both `segments` (array of {text,start,end}) and `text` (the segments' text joined with spaces, kept for callers that only want the flat transcript). Strengthened the existing #[ignore] real-hw smoke test to assert on the response shape (data.segments is an array, data.text is a string) instead of only the HTTP status, so a broken JSON marshal wouldn't silently pass. Verified against Whisper-Tiny.hef on real Hailo-10H hardware (all 56 non-hardware tests still pass, clippy clean). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HoKquUKDTB333MNkdBi6uV
1 parent ff71dce commit 9537678

5 files changed

Lines changed: 183 additions & 2 deletions

File tree

‎yu-hailo-infer/src/hailort/shim.cpp‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,71 @@ int yu_hailort_s2t_generate_text(
345345
return (nullptr == *out_text) ? HAILO_OUT_OF_HOST_MEMORY : HAILO_SUCCESS;
346346
}
347347

348+
int yu_hailort_s2t_generate_segments(
349+
YuHailortSpeech2Text *ctx,
350+
const float *audio,
351+
size_t audio_count,
352+
int task,
353+
const char *language,
354+
float repetition_penalty,
355+
uint32_t timeout_ms,
356+
char **out_json)
357+
{
358+
if ((nullptr == ctx) || (nullptr == audio) || (nullptr == out_json)) {
359+
return HAILO_INVALID_ARGUMENT;
360+
}
361+
*out_json = nullptr;
362+
363+
auto params = ctx->speech2text->create_generator_params();
364+
if (!params) {
365+
return params.status();
366+
}
367+
auto generator_params = params.release();
368+
auto status = generator_params.set_task(static_cast<hailort::genai::Speech2TextTask>(task));
369+
if (HAILO_SUCCESS != status) {
370+
return status;
371+
}
372+
if ((nullptr != language) && ('\0' != language[0])) {
373+
status = generator_params.set_language(std::string_view(language));
374+
if (HAILO_SUCCESS != status) {
375+
return status;
376+
}
377+
}
378+
status = generator_params.set_repetition_penalty(repetition_penalty);
379+
if (HAILO_SUCCESS != status) {
380+
return status;
381+
}
382+
383+
auto audio_view = hailort::MemoryView(
384+
const_cast<float *>(audio),
385+
audio_count * sizeof(float));
386+
auto segments = ctx->speech2text->generate_all_segments(
387+
audio_view,
388+
generator_params,
389+
std::chrono::milliseconds(timeout_ms));
390+
if (!segments) {
391+
return segments.status();
392+
}
393+
394+
std::string json = "[";
395+
bool first = true;
396+
for (const auto &segment : segments.value()) {
397+
if (!first) {
398+
json += ",";
399+
}
400+
first = false;
401+
char bounds[64];
402+
std::snprintf(bounds, sizeof(bounds), "{\"start_sec\":%.6f,\"end_sec\":%.6f,",
403+
static_cast<double>(segment.start_sec), static_cast<double>(segment.end_sec));
404+
json += bounds;
405+
json += "\"text\":\"" + escape_json_string(segment.text) + "\"}";
406+
}
407+
json += "]";
408+
409+
*out_json = copy_string_to_c(json);
410+
return (nullptr == *out_json) ? HAILO_OUT_OF_HOST_MEMORY : HAILO_SUCCESS;
411+
}
412+
348413
int yu_hailort_s2t_tokenize(
349414
YuHailortSpeech2Text *ctx,
350415
const char *text,

‎yu-hailo-infer/src/hailort/shim.h‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,19 @@ int yu_hailort_s2t_generate_text(
6767
float repetition_penalty,
6868
uint32_t timeout_ms,
6969
char **out_text);
70+
// Same generation as yu_hailort_s2t_generate_text, but returns the SDK's
71+
// per-segment breakdown (start/end timestamps + text) as a JSON array
72+
// string: [{"start_sec":F,"end_sec":F,"text":"..."},...]. Caller frees
73+
// *out_json with yu_hailort_string_free, same as out_text above.
74+
int yu_hailort_s2t_generate_segments(
75+
YuHailortSpeech2Text *ctx,
76+
const float *audio,
77+
size_t audio_count,
78+
int task,
79+
const char *language,
80+
float repetition_penalty,
81+
uint32_t timeout_ms,
82+
char **out_json);
7083
int yu_hailort_s2t_tokenize(
7184
YuHailortSpeech2Text *ctx,
7285
const char *text,

‎yu-hailo-infer/src/hailort/shim_stub.cpp‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ void yu_hailort_s2t_release(YuHailortSpeech2Text *ctx)
100100
++s2t_release_count;
101101
}
102102
int yu_hailort_s2t_generate_text(YuHailortSpeech2Text *, const float *, size_t, int, const char *, float, uint32_t, char **) { return HAILO_STUB_ERR; }
103+
int yu_hailort_s2t_generate_segments(YuHailortSpeech2Text *, const float *, size_t, int, const char *, float, uint32_t, char **) { return HAILO_STUB_ERR; }
103104
int yu_hailort_s2t_tokenize(YuHailortSpeech2Text *, const char *, int *, size_t *) { return HAILO_STUB_ERR; }
104105

105106
int yu_hailort_llm_create(const char *, const char *, bool, YuHailortLlm **out)

‎yu-hailo-infer/src/hailort/speech2text.rs‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
use std::ffi::{c_char, c_float, c_int, CString};
22
use std::ptr::NonNull;
33

4+
use serde::Deserialize;
5+
46
use super::{check_status, ffi, HailoRtError, HailoRtResult};
57

68
pub(super) enum YuHailortSpeech2Text {}
@@ -11,6 +13,15 @@ pub(crate) enum Speech2TextTask {
1113
Translate = 1,
1214
}
1315

16+
/// One transcribed segment, mirroring the SDK's `Speech2Text::SegmentInfo`
17+
/// (`generate_all_segments()`).
18+
#[derive(Debug, Clone, Deserialize)]
19+
pub(crate) struct Segment {
20+
pub(crate) start_sec: f32,
21+
pub(crate) end_sec: f32,
22+
pub(crate) text: String,
23+
}
24+
1425
extern "C" {
1526
pub(super) fn yu_hailort_s2t_create(
1627
model_path: *const c_char,
@@ -27,6 +38,16 @@ extern "C" {
2738
timeout_ms: u32,
2839
out_text: *mut *mut c_char,
2940
) -> ffi::HailoStatus;
41+
fn yu_hailort_s2t_generate_segments(
42+
ctx: *mut YuHailortSpeech2Text,
43+
audio: *const c_float,
44+
audio_count: usize,
45+
task: c_int,
46+
language: *const c_char,
47+
repetition_penalty: c_float,
48+
timeout_ms: u32,
49+
out_json: *mut *mut c_char,
50+
) -> ffi::HailoStatus;
3051
fn yu_hailort_s2t_tokenize(
3152
ctx: *mut YuHailortSpeech2Text,
3253
text: *const c_char,
@@ -79,6 +100,40 @@ impl Speech2Text {
79100
take_c_string(out)
80101
}
81102

103+
/// Same generation as [`Speech2Text::generate_text`], but returns the
104+
/// SDK's per-segment breakdown (start/end timestamps + text) instead of
105+
/// a single flattened string. Python's `s2t_inference.transcribe_segments()`
106+
/// returns this shape; the flat-text path silently drops it.
107+
pub(crate) fn generate_segments(
108+
&mut self,
109+
audio: &[f32],
110+
task: Speech2TextTask,
111+
language: Option<&str>,
112+
repetition_penalty: f32,
113+
timeout_ms: u32,
114+
) -> HailoRtResult<Vec<Segment>> {
115+
let language = CString::new(language.unwrap_or(""))?;
116+
let mut out = std::ptr::null_mut();
117+
// SAFETY: audio slice and language C string live until call returns; out pointer is valid.
118+
let status = unsafe {
119+
yu_hailort_s2t_generate_segments(
120+
self.raw.as_ptr(),
121+
audio.as_ptr(),
122+
audio.len(),
123+
task as c_int,
124+
language.as_ptr(),
125+
repetition_penalty,
126+
timeout_ms,
127+
&mut out,
128+
)
129+
};
130+
check_status("s2t_generate_segments", status)?;
131+
let json = take_c_string(out)?;
132+
serde_json::from_str(&json).map_err(|_| {
133+
HailoRtError::InvalidMetadata("shim returned malformed segments JSON")
134+
})
135+
}
136+
82137
pub(crate) fn tokenize(&mut self, text: &str) -> HailoRtResult<Vec<i32>> {
83138
let text = CString::new(text)?;
84139
let mut count = 0usize;

‎yu-hailo-infer/src/speech2text_route.rs‎

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ async fn speech2text_transcribe(
246246
let hef_path_str = hef_path_str.clone();
247247
move |ctx| {
248248
ctx.speech2text(&hef_path_str).and_then(|mut s2t| {
249-
s2t.generate_text(
249+
s2t.generate_segments(
250250
&audio,
251251
task,
252252
language.as_deref(),
@@ -258,7 +258,34 @@ async fn speech2text_transcribe(
258258
})
259259
.await;
260260
match result {
261-
Ok(text) => api_ok(json!({"hef_path": hef_path_str, "text": text, "task": task_name})),
261+
Ok(segments) => {
262+
// The flat `text` field is kept for callers that only want the
263+
// transcript (and for backward compat with the pre-segment
264+
// response shape) -- it is exactly what generate_all_text()
265+
// would have produced, joined the same way Whisper's own
266+
// segment-to-text concatenation does.
267+
let text = segments
268+
.iter()
269+
.map(|segment| segment.text.as_str())
270+
.collect::<Vec<_>>()
271+
.join(" ");
272+
let segments_json: Vec<_> = segments
273+
.into_iter()
274+
.map(|segment| {
275+
json!({
276+
"text": segment.text,
277+
"start": segment.start_sec,
278+
"end": segment.end_sec,
279+
})
280+
})
281+
.collect();
282+
api_ok(json!({
283+
"hef_path": hef_path_str,
284+
"text": text,
285+
"segments": segments_json,
286+
"task": task_name,
287+
}))
288+
}
262289
Err(error) => hailort_api_error(error, "hailort_s2t_generate_failed"),
263290
}
264291
}
@@ -561,5 +588,25 @@ mod tests {
561588
.await
562589
.unwrap();
563590
assert_eq!(response.status(), StatusCode::OK);
591+
592+
// Lock in the response contract added for segment support: `segments`
593+
// must be present and array-shaped even for silence (which may
594+
// legitimately transcribe to zero segments) -- this is what would
595+
// regress silently if generate_all_segments()'s JSON marshaling
596+
// broke, since the flat `text` field alone wouldn't catch it.
597+
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
598+
.await
599+
.expect("read response body");
600+
let parsed: serde_json::Value =
601+
serde_json::from_slice(&body).expect("response body is valid JSON");
602+
let data = &parsed["data"];
603+
assert!(
604+
data["segments"].is_array(),
605+
"expected data.segments to be an array, got: {parsed}"
606+
);
607+
assert!(
608+
data["text"].is_string(),
609+
"expected data.text to be a string, got: {parsed}"
610+
);
564611
}
565612
}

0 commit comments

Comments
 (0)