bindings: cueseg, so a transcript can be chaptered in process

This commit is contained in:
funkpower
2026-09-20 09:00:26 -04:00
parent f9f809f701
commit dd09499e68
11 changed files with 112 additions and 3 deletions
+23
View File
@@ -259,6 +259,29 @@ pub unsafe extern "C" fn catena_pack_cues(
give_json(&crate::pack_cues(&cues, budgets))
}
/// Cut a timed transcript into topic chapters. `cues` is a JSON array of
/// cues; returns a JSON array of segments.
///
/// A duration of zero or less is taken from the last cue.
///
/// # Safety
///
/// `cues` must be NUL-terminated UTF-8.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn catena_segment_cues(
cues: *const c_char,
duration: c_double,
) -> *mut c_char {
clear_error();
let Some(cues) = (unsafe { borrow(cues, "cues") }) else {
return ptr::null_mut();
};
let Some(cues) = take_json::<Vec<cuekit::Cue>>(cues, "cues") else {
return ptr::null_mut();
};
give_json(&crate::segment_cues(&cues, duration))
}
/// Split a collection into the works inside it. Returns a JSON array of
/// sections, or NULL when the document is one work rather than a collection —
/// which is an answer, not a failure, and the reason is in
+34
View File
@@ -128,6 +128,18 @@ pub fn pack_cues(cues: &[Cue], budgets: CueBudgets) -> Vec<Chunk> {
)
}
/// Cut a timed transcript into topic chapters.
///
/// `duration` is how long the recording is, in seconds; it decides how many
/// chapters to aim for. Zero or less means take it from the last cue.
pub fn segment_cues(cues: &[Cue], duration: f64) -> Vec<cueseg::Segment> {
let duration = match duration > 0.0 {
true => duration,
false => cues.last().map_or(0.0, |cue| cue.end),
};
cueseg::segment(cues, duration)
}
/// Split a collection into the works inside it.
///
/// `Err` when the document is one work rather than a collection, which is the
@@ -222,6 +234,28 @@ mod tests {
);
}
#[test]
fn a_transcript_comes_back_as_chapters() {
// Long enough to be worth chaptering at all: the segmenter refuses
// to cut a recording into pieces shorter than its minimum.
let cues: Vec<Cue> = (0..1200)
.map(|i| Cue {
v: corpus_core::V,
start: i as f64 * 5.0,
end: i as f64 * 5.0 + 5.0,
text: match i % 2 {
0 => "the mill stood at the northern end of the valley".into(),
_ => "parliament dissolved itself again amid objections".into(),
},
})
.collect();
let chapters = segment_cues(&cues, 6000.0);
assert!(!chapters.is_empty());
assert_eq!(chapters, cueseg::segment(&cues, 6000.0));
// The duration can be left to the cues themselves.
assert_eq!(segment_cues(&cues, 0.0), cueseg::segment(&cues, 6000.0));
}
#[test]
fn a_document_that_is_one_work_says_so_rather_than_splitting() {
assert!(split_collection(MARKDOWN).is_err());
+16
View File
@@ -158,6 +158,21 @@ fn pack_cues<'py>(
list_to_py(py, &crate::pack_cues(&cues, budgets))
}
/// Cut a timed transcript into topic chapters.
///
/// `duration` is the recording's length in seconds; omit it and the last cue
/// decides.
#[pyfunction]
#[pyo3(signature = (cues, *, duration = None))]
fn segment_cues<'py>(
py: Python<'py>,
cues: &Bound<'py, PyAny>,
duration: Option<f64>,
) -> PyResult<Bound<'py, PyList>> {
let cues: Vec<cuekit::Cue> = from_py_list(cues)?;
list_to_py(py, &crate::segment_cues(&cues, duration.unwrap_or(0.0)))
}
/// Split a collection into the works inside it.
///
/// Raises `ValueError` when the document is one work rather than a
@@ -228,6 +243,7 @@ fn _catena(module: &Bound<'_, PyModule>) -> PyResult<()> {
module.add_function(wrap_pyfunction!(count_tokens, module)?)?;
module.add_function(wrap_pyfunction!(parse_subtitles, module)?)?;
module.add_function(wrap_pyfunction!(pack_cues, module)?)?;
module.add_function(wrap_pyfunction!(segment_cues, module)?)?;
module.add_function(wrap_pyfunction!(split_collection, module)?)?;
module.add_function(wrap_pyfunction!(fuse_hits, module)?)?;
module.add_function(wrap_pyfunction!(identify, module)?)?;
+23
View File
@@ -50,6 +50,18 @@ export interface Cue {
text: string;
}
/** One topic chapter of a recording. */
export interface Segment {
v: number;
/** Seconds from the start of the recording. */
start: number;
end: number;
/** A title taken from the chapter's own first sentence. */
title: string;
/** The opening of the chapter, for a person choosing between them. */
excerpt: string;
}
/** One work inside a collection. */
export interface Section {
v: number;
@@ -196,6 +208,16 @@ pub fn pack_cues(
to_js(&crate::pack_cues(&cues, budgets))
}
/// Cut a timed transcript into topic chapters.
///
/// `duration` is the recording's length in seconds; omit it and the last cue
/// decides.
#[wasm_bindgen(js_name = segmentCues, skip_typescript)]
pub fn segment_cues(cues: JsValue, duration: Option<f64>) -> Result<JsValue, JsValue> {
let cues: Vec<cuekit::Cue> = from_js(cues, "cues")?;
to_js(&crate::segment_cues(&cues, duration.unwrap_or(0.0)))
}
/// Split a collection into the works inside it.
///
/// Throws when the document is one work rather than a collection, which is
@@ -266,6 +288,7 @@ const SIGNATURES: &'static str = r#"
export function chunkMarkdown(markdown: string, target?: number, overlap?: number, min_tokens?: number, hard_max?: number): Chunk[];
export function parseSubtitles(content: string, keep_repeats?: boolean): Cue[];
export function packCues(cues: Cue[], seconds?: number, max_tokens?: number): Chunk[];
export function segmentCues(cues: Cue[], duration?: number): Segment[];
export function splitCollection(markdown: string): Section[];
export function fuseHits(hits: Hit[], k?: number, weights?: Record<string, number>, limit?: number): Hit[];
export function identify(name: string, data: Uint8Array): Doc;