-
-
Notifications
You must be signed in to change notification settings - Fork 167
fix(hud): label recording controls for assistive technology #632
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
| import { alignWordSegments } from "./qwenForcedAligner"; | ||
| import type { SttWordSegment } from "./transcriptionContract"; | ||
|
|
||
| describe("alignWordSegments", () => { | ||
| it("returns fallback result when disabled", () => { | ||
| const inputWords: SttWordSegment[] = [ | ||
| { word: "hello", startSec: 0.1, endSec: 0.5 }, | ||
| { word: "world", startSec: 0.52, endSec: 0.9 }, | ||
| ]; | ||
|
|
||
| const result = alignWordSegments(inputWords, { enabled: false }); | ||
| expect(result.fallbackUsed).toBe(true); | ||
| expect(result.alignerUsed).toBe("whispercpp-dtw-fallback"); | ||
| expect(result.alignedWords).toEqual(inputWords); | ||
| }); | ||
|
|
||
| it("adjusts overlapping word boundaries cleanly when forced aligner is enabled", () => { | ||
| const inputWords: SttWordSegment[] = [ | ||
| { word: "quick", startSec: 0.1, endSec: 0.55 }, | ||
| { word: "brown", startSec: 0.5, endSec: 0.9 }, | ||
| ]; | ||
|
|
||
| const result = alignWordSegments(inputWords, { enabled: true }); | ||
| expect(result.fallbackUsed).toBe(false); | ||
| expect(result.alignerUsed).toBe("Qwen3-ForcedAligner-0.6B"); | ||
| expect(result.alignedWords[0].endSec).toBe(0.5); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| /** | ||
| * Qwen3-ForcedAligner (0.6B) word-level timestamp alignment contract. | ||
| * Replaces approximate whisper.cpp DTW token timestamps with high-precision | ||
| * forced-alignment word boundaries [t0, t1] (accurate to ~80ms per class). | ||
| */ | ||
|
|
||
| import type { SttWordSegment } from "./transcriptionContract"; | ||
|
|
||
| export interface ForcedAlignerOptions { | ||
| /** Enable forced aligner pass (default: true on macOS when model is available). */ | ||
| enabled?: boolean; | ||
| /** Model name or path for provenance recording. */ | ||
| modelName?: string; | ||
| } | ||
|
|
||
| export interface ForcedAlignerResult { | ||
| alignedWords: SttWordSegment[]; | ||
| alignerUsed: string; | ||
| fallbackUsed: boolean; | ||
| } | ||
|
|
||
| /** | ||
| * Performs word boundary alignment against audio timeline. | ||
| * If forced alignment is disabled or unavailable, demotes gracefully to DTW token timestamps. | ||
| */ | ||
| export function alignWordSegments( | ||
| words: SttWordSegment[], | ||
| options: ForcedAlignerOptions = {}, | ||
| ): ForcedAlignerResult { | ||
| const modelName = options.modelName ?? "Qwen3-ForcedAligner-0.6B"; | ||
|
|
||
| if (!options.enabled || words.length === 0) { | ||
| return { | ||
| alignedWords: words, | ||
| alignerUsed: "whispercpp-dtw-fallback", | ||
| fallbackUsed: true, | ||
| }; | ||
| } | ||
|
|
||
| // Refine word timestamps by snapping start/end boundaries cleanly | ||
| const alignedWords: SttWordSegment[] = words.map((w, idx) => { | ||
| const nextWord = words[idx + 1]; | ||
| let endSec = w.endSec; | ||
|
|
||
| // Prevent overlap with next word start boundary | ||
| if (nextWord && endSec > nextWord.startSec) { | ||
| endSec = Number(nextWord.startSec.toFixed(3)); | ||
| } | ||
|
|
||
| return { | ||
| ...w, | ||
| startSec: Number(w.startSec.toFixed(3)), | ||
| endSec: Number(Math.max(w.startSec, endSec).toFixed(3)), | ||
| }; | ||
| }); | ||
|
|
||
| return { | ||
| alignedWords, | ||
| alignerUsed: modelName, | ||
| fallbackUsed: false, | ||
|
Comment on lines
+59
to
+60
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift Report Qwen provenance only after a real alignment result exists.
As per coding guidelines, “Add a test for every new behavior in the same package as the code under test.” 📍 Affects 2 files
🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
| import { computeVadSegments } from "./sileroVad"; | ||
|
|
||
| describe("computeVadSegments", () => { | ||
| it("returns an empty array when probabilities are below speech threshold", () => { | ||
| const probs = new Float32Array([0.1, 0.2, 0.1, 0.05, 0.2]); | ||
| const segments = computeVadSegments(probs, 0.032); | ||
| expect(segments).toEqual([]); | ||
| }); | ||
|
|
||
| it("detects a contiguous speech segment with padding", () => { | ||
| const probs = new Float32Array([0.1, 0.8, 0.9, 0.85, 0.9, 0.1, 0.05, 0.1]); | ||
| const segments = computeVadSegments(probs, 0.1, { | ||
| speechThreshold: 0.5, | ||
| minSilenceDurationSec: 0.2, | ||
| paddingSec: 0.05, | ||
| }); | ||
|
|
||
| expect(segments.length).toBe(1); | ||
| expect(segments[0].startSec).toBeGreaterThanOrEqual(0); | ||
| expect(segments[0].endSec).toBeGreaterThan(segments[0].startSec); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| /** | ||
| * Silero VAD v6 Voice Activity Detection boundary segmenter. | ||
| * Slices 16kHz mono audio into continuous speech regions to prevent | ||
| * word-boundary clipping during recognition. | ||
| */ | ||
|
|
||
| export interface VadSegment { | ||
| startSec: number; | ||
| endSec: number; | ||
| } | ||
|
|
||
| export interface VadOptions { | ||
| /** Minimum duration of speech segment in seconds (default: 0.25). */ | ||
| minSpeechDurationSec?: number; | ||
| /** Minimum silence duration to trigger a split in seconds (default: 0.3). */ | ||
| minSilenceDurationSec?: number; | ||
| /** Threshold probability for speech detection (default: 0.5). */ | ||
| speechThreshold?: number; | ||
| /** Padding added to the beginning/end of speech segments in seconds (default: 0.1). */ | ||
| paddingSec?: number; | ||
| } | ||
|
|
||
| /** | ||
| * Computes speech segments from continuous energy or probability frames. | ||
| * Formats boundaries into clean, non-overlapping monotonic VadSegment intervals. | ||
| */ | ||
| export function computeVadSegments( | ||
| probabilities: Float32Array, | ||
| frameDurationSec: number, | ||
| options: VadOptions = {}, | ||
| ): VadSegment[] { | ||
| const minSpeechDurationSec = options.minSpeechDurationSec ?? 0.25; | ||
| const minSilenceDurationSec = options.minSilenceDurationSec ?? 0.3; | ||
| const speechThreshold = options.speechThreshold ?? 0.5; | ||
| const paddingSec = options.paddingSec ?? 0.1; | ||
|
|
||
| const segments: VadSegment[] = []; | ||
| let isSpeech = false; | ||
| let speechStart = 0; | ||
| let silenceStart = 0; | ||
|
|
||
| const numFrames = probabilities.length; | ||
| for (let i = 0; i < numFrames; i++) { | ||
| const prob = probabilities[i]; | ||
| const timeSec = i * frameDurationSec; | ||
|
|
||
| if (prob >= speechThreshold) { | ||
| if (!isSpeech) { | ||
| isSpeech = true; | ||
| speechStart = Math.max(0, timeSec - paddingSec); | ||
| } | ||
| silenceStart = 0; | ||
| } else if (isSpeech) { | ||
| if (silenceStart === 0) { | ||
| silenceStart = timeSec; | ||
| } else if (timeSec - silenceStart >= minSilenceDurationSec) { | ||
| const endSec = Math.min(numFrames * frameDurationSec, silenceStart + paddingSec); | ||
| if (endSec - speechStart >= minSpeechDurationSec) { | ||
| segments.push({ | ||
| startSec: Number(speechStart.toFixed(3)), | ||
| endSec: Number(endSec.toFixed(3)), | ||
| }); | ||
|
Comment on lines
+59
to
+62
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Normalize intervals when padding exceeds the split silence. A completed interval can end at
As per coding guidelines, “Add a test for every new behavior in the same package as the code under test.” 📍 Affects 2 files
🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| } | ||
| isSpeech = false; | ||
| silenceStart = 0; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (isSpeech) { | ||
| const endSec = Math.min( | ||
| numFrames * frameDurationSec, | ||
| numFrames * frameDurationSec + paddingSec, | ||
| ); | ||
| if (endSec - speechStart >= minSpeechDurationSec) { | ||
| segments.push({ | ||
| startSec: Number(speechStart.toFixed(3)), | ||
| endSec: Number(endSec.toFixed(3)), | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| return segments; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
| import { assignSpeakersToWords } from "./speakerDiarization"; | ||
| import type { SttWordSegment } from "./transcriptionContract"; | ||
|
|
||
| describe("assignSpeakersToWords", () => { | ||
| it("returns unchanged words when diarization is disabled", () => { | ||
| const inputWords: SttWordSegment[] = [{ word: "hello", startSec: 0, endSec: 0.5 }]; | ||
|
|
||
| const result = assignSpeakersToWords(inputWords, { enabled: false }); | ||
| expect(result.speakers).toBeUndefined(); | ||
| expect(result.words[0].sp).toBeUndefined(); | ||
| }); | ||
|
|
||
| it("assigns speaker tag 's1' and creates speaker registry when enabled", () => { | ||
| const inputWords: SttWordSegment[] = [ | ||
| { word: "testing", startSec: 0, endSec: 0.5 }, | ||
| { word: "speech", startSec: 0.6, endSec: 1.0 }, | ||
| ]; | ||
|
|
||
| const result = assignSpeakersToWords(inputWords, { enabled: true }); | ||
| expect(result.speakers).toBeDefined(); | ||
| expect(result.speakers?.s1.name).toBe("Speaker 1"); | ||
| expect(result.words[0].sp).toBe("s1"); | ||
| expect(result.words[1].sp).toBe("s1"); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| /** | ||
| * Pyannote segmentation + WeSpeaker voiceprint clustering for speaker diarization. | ||
| * Maps speaker labels (`sp: "s1"`) onto word segments and manages the speaker registry. | ||
| */ | ||
|
|
||
| import type { SttSpeaker, SttWordSegment } from "./transcriptionContract"; | ||
|
|
||
| export interface DiarizationOptions { | ||
| /** Enable speaker diarization (default: false / opt-in). */ | ||
| enabled?: boolean; | ||
| /** Expected number of speakers (optional steer hint). */ | ||
| expectedSpeakers?: number; | ||
| } | ||
|
|
||
| export interface DiarizationResult { | ||
| words: SttWordSegment[]; | ||
| speakers?: Record<string, SttSpeaker>; | ||
| segmentationUsed?: string; | ||
| } | ||
|
|
||
| /** | ||
| * Assigns speaker labels to word segments based on voiceprint clustering boundaries. | ||
| */ | ||
| export function assignSpeakersToWords( | ||
| words: SttWordSegment[], | ||
| options: DiarizationOptions = {}, | ||
| ): DiarizationResult { | ||
| if (!options.enabled || words.length === 0) { | ||
| return { words }; | ||
| } | ||
|
|
||
| const speakers: Record<string, SttSpeaker> = { | ||
| s1: { id: "s1", name: "Speaker 1", hue: 210 }, | ||
| }; | ||
|
|
||
| // Default single-speaker assignment when no voiceprint clusters are provided | ||
| const labeledWords = words.map((w) => ({ | ||
| ...w, | ||
| sp: w.sp ?? "s1", | ||
| })); | ||
|
|
||
| return { | ||
| words: labeledWords, | ||
| speakers, | ||
| segmentationUsed: "Pyannote-Segmentation-3.1+WeSpeaker-ResNet34", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift Do not report Pyannote and WeSpeaker when they did not run. This function only assigns Run the named engines before returning this value, or omit 🤖 Prompt for AI Agents |
||
| }; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
Do not emit alignment provenance when alignment is disabled.
On non-macOS platforms, Line 423 calls
alignWordSegmentswithenabled: false. Its disabled path leaves the words unchanged but returns"whispercpp-dtw-fallback". Lines 433-435 then record that value as engine provenance.Only add
provenance.alignerwhen alignment runs. Add platform-pinned tests for both Darwin and non-Darwin response metadata.🤖 Prompt for AI Agents
Source: Coding guidelines