3. Turn Boundary Detection from Frame-Level Speech Labels
turn.start, turn.eager_end, turn.end — and Cartesia describes this as semantic endpointing (meaning-based turn detection) rather than pure silence/VAD-based detection (Source: cartesia.ai/blog/ink-2). This problem models the simpler, hysteresis-based frame-labeling version of that same start/end-boundary problem as a general building block, not a description of Ink-2's own semantic algorithm.Problem
A streaming speech model classifies each short audio frame as speech or silence, but you can't flag a turn boundary on the very first silent or speaking frame — a single flickering frame (a breath, a click) would create false turn boundaries constantly.
Real systems require a run of consecutive frames in one state before committing to a transition, then a run of consecutive frames in the other state before committing back. Given a full stream of per-frame labels and the two thresholds, detect every completed turn.
Source: src/3_turn_boundary_detector.py
def detect_turns(frame_labels: list[int], start_threshold: int, end_threshold: int) -> list[tuple[int, int]]:
...
Examples:
>>> detect_turns([0, 1, 1, 0, 0], 2, 2)
[(1, 2)]
>>> detect_turns([0, 0, 0], 1, 1)
[]
Step-by-Step Approach
- Track a small state machine with two states,
idleandspeaking, plus two run counters:speech_runandsilence_run. - On a speech frame, increment
speech_runand resetsilence_runto 0. If currentlyidleandspeech_runjust reachedstart_threshold, transition tospeakingand record the turn's start asi - start_threshold + 1— the first frame of the qualifying run, not the frame where the threshold was crossed. - On a silence frame, increment
silence_runand resetspeech_runto 0. If currentlyspeakingandsilence_runjust reachedend_threshold, transition back toidleand close the turn ati - end_threshold— the last speech frame before the silence run began. - Append each closed
(start, end)pair to the results list as soon as it closes; don't wait until the end of the stream to build them. - After the loop, check whether the state machine is still
speaking— the stream ended before a full silence run closed the turn. If so, close it at the last frame index so no in-progress turn is silently dropped. - Handle the trivial cases: a stream with no speech at all never reaches
start_thresholdand returns an empty list; thresholds of 1 make every single frame flip the state immediately, which is a valid degenerate case, not a special one.
The key insight is hysteresis: entering and leaving "speaking" both require sustained evidence, and the recorded boundary always points back to where the sustained run actually began, not to the frame where the threshold counter happened to tick over.
Reference solution
def detect_turns(frame_labels: list[int], start_threshold: int, end_threshold: int) -> list[tuple[int, int]]:
state = "idle"
speech_run = 0
silence_run = 0
turn_start = None
turns: list[tuple[int, int]] = []
for i, label in enumerate(frame_labels):
if label == 1:
speech_run += 1
silence_run = 0
if state == "idle" and speech_run == start_threshold:
# crossed the hysteresis threshold: the run's first frame is the true start
state = "speaking"
turn_start = i - start_threshold + 1
else:
silence_run += 1
speech_run = 0
if state == "speaking" and silence_run == end_threshold:
# last speech frame is end_threshold frames before this one
state = "idle"
turns.append((turn_start, i - end_threshold))
turn_start = None
if state == "speaking":
# stream ended mid-turn: close it at the final frame
turns.append((turn_start, len(frame_labels) - 1))
return turns
Key Functions & Tricks
- Two independent run counters —
speech_runandsilence_run, each reset by the opposite label, drive the hysteresis without needing a lookback window. i - start_threshold + 1— back-computes the true start index from the frame where the threshold check fires.i - end_threshold— back-computes the last speech frame from the frame where the closing silence run completes.- State variable gating the transition checks —
if state == "idle"/if state == "speaking"prevents re-triggering a transition mid-run. - Post-loop flush — closes any turn still open when the input ends, matching how a real system forces
turn.endat stream close.
How to Recognize This Pattern
The signal is "classify a stream into on/off segments from a noisy binary label sequence, requiring sustained evidence before flipping state" — common to voice activity detection, but also flaky-sensor debouncing, or any changepoint-with-hysteresis problem. The move is a tiny state machine with per-state run counters rather than a sliding window recomputed from scratch at every index. A common variation makes start_threshold and end_threshold asymmetric on purpose (quick to detect speech starting, slower to declare it over) to avoid clipping the ends of words. A common pitfall is forgetting the post-loop flush for a turn still open at the end of the stream, or computing the boundary index off by the threshold amount in the wrong direction.