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
Generated
+1
View File
@@ -234,6 +234,7 @@ dependencies = [
"corpus-core",
"cuekit",
"cuepack",
"cueseg",
"idnorm",
"mdscrub",
"mdsplit",
+1
View File
@@ -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
+1
View File
@@ -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) |
+1
View File
@@ -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 |
+7 -3
View File
@@ -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];
@@ -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",
]
@@ -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]],
+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;