From dd09499e687f87cf6481aadc20b8f3ebef6cd9b2 Mon Sep 17 00:00:00 2001 From: funkpower Date: Sun, 20 Sep 2026 09:00:26 -0400 Subject: [PATCH] bindings: cueseg, so a transcript can be chaptered in process --- Cargo.lock | 1 + crates/bindings/Cargo.toml | 1 + crates/bindings/README.md | 1 + crates/bindings/npm/README.md | 1 + crates/bindings/npm/typecheck.ts | 10 +++++-- crates/bindings/python/catena/__init__.py | 2 ++ crates/bindings/python/catena/__init__.pyi | 3 ++ crates/bindings/src/capi.rs | 23 +++++++++++++++ crates/bindings/src/lib.rs | 34 ++++++++++++++++++++++ crates/bindings/src/python.rs | 16 ++++++++++ crates/bindings/src/wasm.rs | 23 +++++++++++++++ 11 files changed, 112 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d0921d6..704704a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -234,6 +234,7 @@ dependencies = [ "corpus-core", "cuekit", "cuepack", + "cueseg", "idnorm", "mdscrub", "mdsplit", diff --git a/crates/bindings/Cargo.toml b/crates/bindings/Cargo.toml index 487793d..802f9db 100644 --- a/crates/bindings/Cargo.toml +++ b/crates/bindings/Cargo.toml @@ -34,6 +34,7 @@ corpus-core.workspace = true idnorm.workspace = true cuekit.workspace = true cuepack.workspace = true +cueseg.workspace = true mdscrub.workspace = true mdsplit.workspace = true rrf.workspace = true diff --git a/crates/bindings/README.md b/crates/bindings/README.md index a581961..7c51632 100644 --- a/crates/bindings/README.md +++ b/crates/bindings/README.md @@ -64,6 +64,7 @@ except ValueError as why: | `count_tokens` | [`textkit`](../textkit/README.md) | | `parse_subtitles` | [`cuenorm`](../cuenorm/README.md) | | `pack_cues` | [`cuepack`](../cuepack/README.md) | +| `segment_cues` | [`cueseg`](../cueseg/README.md) | | `split_collection` | [`mdsplit`](../mdsplit/README.md) | | `fuse_hits` | [`rrf`](../rrf/README.md) | | `identify` | [`booksniff`](../booksniff/README.md) | diff --git a/crates/bindings/npm/README.md b/crates/bindings/npm/README.md index 139feba..005e15d 100644 --- a/crates/bindings/npm/README.md +++ b/crates/bindings/npm/README.md @@ -45,6 +45,7 @@ try { | `countTokens` | Tokens, by the encoding the budgets count in | | `parseSubtitles` | SubRip, WebVTT or ASS into cues | | `packCues` | Cues into passages, keeping the times they were said at | +| `segmentCues` | A timed transcript into topic chapters | | `splitCollection` | One markdown file into the works inside it | | `fuseHits` | Several ranked lists into one | | `identify` | What a file is, from its first bytes | diff --git a/crates/bindings/npm/typecheck.ts b/crates/bindings/npm/typecheck.ts index 5cadbea..b8af201 100644 --- a/crates/bindings/npm/typecheck.ts +++ b/crates/bindings/npm/typecheck.ts @@ -3,8 +3,8 @@ // check, and nothing else would notice. import { chunkMarkdown, scrub, countTokens, parseSubtitles, packCues, - splitCollection, fuseHits, identify, seriesOf, parseIdentifier, - type Chunk, type Cue, type Section, type Hit, type Doc, type SeriesProposal, type Identifier, + splitCollection, fuseHits, identify, seriesOf, parseIdentifier, segmentCues, + type Chunk, type Cue, type Section, type Hit, type Doc, type SeriesProposal, type Identifier, type Segment, } from "./dist/web/catena.js"; const chunks: Chunk[] = chunkMarkdown("# One\n\nbody\n", 512, 64, 50, 768); @@ -19,6 +19,10 @@ const cues: Cue[] = parseSubtitles("1\n00:00:01,000 --> 00:00:04,000\nline\n\n") const start: number = cues[0].start; const packed: Chunk[] = packCues(cues, 75, 512); +const chapters: Segment[] = segmentCues(cues, 3600); +const chapterTitle: string = chapters[0].title; +segmentCues(cues); + const works: Section[] = splitCollection("# One\n\nbody\n"); const slug: string = works[0].slug; @@ -42,4 +46,4 @@ seriesOf("Aquinas (Companion)", "Cambridge"); const ident: Identifier | undefined = parseIdentifier("10.1000/182"); const checksum: boolean | null | undefined = ident?.checksum; -export const checked = [volume, checksum, page, chapter, flags, clean, tokens, start, packed, slug, fused, kind, notes]; +export const checked = [volume, checksum, chapterTitle, page, chapter, flags, clean, tokens, start, packed, slug, fused, kind, notes]; diff --git a/crates/bindings/python/catena/__init__.py b/crates/bindings/python/catena/__init__.py index 6025e83..ca28915 100644 --- a/crates/bindings/python/catena/__init__.py +++ b/crates/bindings/python/catena/__init__.py @@ -20,6 +20,7 @@ from ._catena import ( parse_identifier, parse_subtitles, scrub, + segment_cues, series_of, split_collection, ) @@ -34,6 +35,7 @@ __all__ = [ "parse_identifier", "parse_subtitles", "scrub", + "segment_cues", "series_of", "split_collection", ] diff --git a/crates/bindings/python/catena/__init__.pyi b/crates/bindings/python/catena/__init__.pyi index 8e462a9..c37ae67 100644 --- a/crates/bindings/python/catena/__init__.pyi +++ b/crates/bindings/python/catena/__init__.pyi @@ -21,6 +21,9 @@ def pack_cues( seconds: float | None = ..., max_tokens: int | None = ..., ) -> list[dict[str, Any]]: ... +def segment_cues( + cues: Iterable[Mapping[str, Any]], *, duration: float | None = ... +) -> list[dict[str, Any]]: ... def split_collection(markdown: str) -> list[dict[str, Any]]: ... def fuse_hits( hits: Iterable[Mapping[str, Any]], diff --git a/crates/bindings/src/capi.rs b/crates/bindings/src/capi.rs index f4391c0..7b18224 100644 --- a/crates/bindings/src/capi.rs +++ b/crates/bindings/src/capi.rs @@ -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::>(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 diff --git a/crates/bindings/src/lib.rs b/crates/bindings/src/lib.rs index 251d8e6..0b586ee 100644 --- a/crates/bindings/src/lib.rs +++ b/crates/bindings/src/lib.rs @@ -128,6 +128,18 @@ pub fn pack_cues(cues: &[Cue], budgets: CueBudgets) -> Vec { ) } +/// 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 { + 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 = (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()); diff --git a/crates/bindings/src/python.rs b/crates/bindings/src/python.rs index fd388bf..7e0d732 100644 --- a/crates/bindings/src/python.rs +++ b/crates/bindings/src/python.rs @@ -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, +) -> PyResult> { + let cues: Vec = 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)?)?; diff --git a/crates/bindings/src/wasm.rs b/crates/bindings/src/wasm.rs index 80a1235..3fd6874 100644 --- a/crates/bindings/src/wasm.rs +++ b/crates/bindings/src/wasm.rs @@ -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) -> Result { + let cues: Vec = 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, limit?: number): Hit[]; export function identify(name: string, data: Uint8Array): Doc;