Version: 1.0 Status: Draft Created: 2025-11-02
- Overview
- Goals and Scope
- Text Normalization
- Punctuation and Capitalization
- De-filler Rules
- Segmentation Strategy
- Configuration and API Surface
- Implementation Approaches
- Examples and Edge Cases
- Testing Strategy
- Future Enhancements
This specification defines a post-processing module for transcript cleanup that works with both YouTube auto-captions and Whisper-generated transcripts. The module transforms raw transcription output into readable, properly formatted text suitable for human consumption and downstream processing.
Raw transcripts from both YouTube and Whisper often contain:
- Inconsistent or missing punctuation
- No sentence boundaries or improper segmentation
- Filler words and disfluencies (um, uh, like, you know)
- Inconsistent capitalization
- Special tokens (e.g.,
[MUSIC],[APPLAUSE]) - Excessive whitespace and Unicode normalization issues
- Run-on segments that span multiple sentences
- Start Simple: Begin with deterministic, rule-based approaches
- Preserve Accuracy: Never change the semantic meaning of content
- Configurable: Allow users to control cleanup aggressiveness
- Non-Destructive: Always preserve raw transcripts; cleanup creates new representations
- Idempotent: Running cleanup multiple times produces same result
- Language-Aware: Support multi-language content appropriately
- Text normalization: Whitespace, Unicode, special characters
- Punctuation restoration: Add/fix periods, commas, question marks, exclamation points
- Capitalization: Sentence-initial and proper nouns
- De-filler rules: Remove or minimize common filler words
- Segmentation: Break transcripts into sentence/phrase boundaries
- Speaker formatting: Preserve and enhance speaker labels from diarization
- Configuration system: Flexible settings for different use cases
- API extensions: New endpoints and parameters for accessing cleaned transcripts
- Grammar correction beyond punctuation
- Spell checking or word correction
- Content summarization
- Translation
- Named entity recognition
- Emotion/sentiment labeling
- Real-time streaming cleanup (batch only)
Purpose: Standardize spacing for consistent processing and display
Rules:
- Replace all Unicode whitespace variants (nbsp, thin space, etc.) with ASCII space (U+0020)
- Collapse multiple consecutive spaces into single space
- Remove leading and trailing whitespace from segments
- Normalize line endings to LF (
\n) - Remove zero-width characters (zero-width space, zero-width joiner, etc.)
Implementation:
import re
import unicodedata
def normalize_whitespace(text: str) -> str:
"""Normalize all whitespace variants to single ASCII space."""
# Replace Unicode whitespace with ASCII space
text = re.sub(r'[\u00A0\u1680\u2000-\u200B\u202F\u205F\u3000\uFEFF]', ' ', text)
# Collapse multiple spaces
text = re.sub(r' {2,}', ' ', text)
# Remove zero-width characters
text = re.sub(r'[\u200B-\u200D\uFEFF]', '', text)
# Normalize line endings
text = text.replace('\r\n', '\n').replace('\r', '\n')
return text.strip()Purpose: Standardize character representations for consistent matching and display
Rules:
- Apply NFC (Canonical Composition) normalization by default
- Convert lookalike characters to ASCII equivalents when appropriate
- Preserve emoji and necessary Unicode characters
Implementation:
def normalize_unicode(text: str) -> str:
"""Apply Unicode NFC normalization."""
return unicodedata.normalize('NFC', text)Purpose: Process or remove non-speech annotations from raw transcripts
Common Special Tokens:
[MUSIC],[APPLAUSE],[LAUGHTER](inaudible),(unintelligible)♪ music ♪- Whisper hallucinations: repetitive phrases, credits, subtitles info
Rules:
- Remove YouTube-style brackets:
[MUSIC],[APPLAUSE] - Remove music notation:
♪symbols - Optionally preserve or remove parenthetical notations
- Detect and remove Whisper hallucinations (repetitive text at end)
Configuration Options:
remove_special_tokens: Boolean (default:true)preserve_sound_events: Boolean (default:false) - Keep[MUSIC],[APPLAUSE]remove_parentheticals: Boolean (default:false)
Implementation:
def remove_special_tokens(text: str, preserve_sound_events: bool = False) -> str:
"""Remove special tokens from transcript."""
if not preserve_sound_events:
# Remove common sound event markers
text = re.sub(r'\[(?:MUSIC|APPLAUSE|LAUGHTER|NOISE)\]', '', text, flags=re.IGNORECASE)
# Remove music notation
text = re.sub(r'♪+', '', text)
# Remove common Whisper artifacts
text = re.sub(r'\(.*?Subtitles by.*?\)', '', text, flags=re.IGNORECASE)
text = re.sub(r'\(.*?www\..*?\.com.*?\)', '', text, flags=re.IGNORECASE)
return textCommon Patterns:
- Repetitive phrases (same sentence repeated 3+ times)
- Credits/attribution appearing at end
- "Thanks for watching" boilerplate
- Timestamp artifacts
Detection Strategy:
def detect_hallucination(segments: list) -> list:
"""Detect and mark potential hallucinations in segments."""
if len(segments) < 3:
return segments
# Check last few segments for repetition
last_texts = [s['text'].strip().lower() for s in segments[-5:]]
if len(last_texts) >= 3:
if len(set(last_texts[-3:])) == 1: # Same text 3 times
# Mark or remove these segments
for seg in segments[-3:]:
seg['likely_hallucination'] = True
return segmentsPurpose: Add or fix sentence-ending punctuation and internal commas for readability
Approach Options:
Advantages:
- Fast, deterministic
- No model dependencies
- Predictable behavior
- Works offline
Rules:
- Add period to end of segment if missing terminal punctuation
- Detect questions by interrogative words (who, what, where, when, why, how) → add
? - Detect common comma positions (after introductory words, before conjunctions)
- Use pause duration from timestamps as comma hint (pause > 0.5s)
Implementation:
def add_sentence_punctuation(text: str, is_question: bool = None) -> str:
"""Add terminal punctuation to text."""
text = text.strip()
if not text:
return text
# Check if already has terminal punctuation
if text[-1] in '.!?':
return text
# Auto-detect questions
if is_question is None:
is_question = bool(re.match(
r'^(who|what|where|when|why|how|is|are|can|could|would|should|do|does|did)\b',
text.lower()
))
return text + ('?' if is_question else '.')
def add_internal_punctuation(text: str, pause_ms: int = None) -> str:
"""Add commas based on linguistic rules and optional pause duration."""
# After introductory words
text = re.sub(r'^(however|furthermore|moreover|additionally|meanwhile|therefore)\s+',
r'\1, ', text, flags=re.IGNORECASE)
# Before coordinating conjunctions with clauses
text = re.sub(r'\s+(and|but|or|so|yet)\s+',
r', \1 ', text)
# If pause duration available and significant (>500ms), consider comma
# This would be integrated with segment merging logic
return textAdvantages:
- More accurate punctuation
- Better handling of complex sentences
- Can handle multiple languages with language-specific models
Disadvantages:
- Requires model loading (memory + startup time)
- GPU preferred for speed
- Additional dependency
- Less predictable
Recommended Models (from Hugging Face research):
- General Purpose: Fine-tuned T5 or BERT models for token classification
- Custom training on YouTube caption data
- Multi-language support via mT5 models
- English Specific:
- Community models like
shashank2123/t5-base-fine-tuned-for-Punctuation-Restoration - Token classification models using BERT/RoBERTa
- Community models like
Implementation Pattern:
from transformers import pipeline
class PunctuationRestorer:
def __init__(self, model_name: str = None):
self.model = None
if model_name:
self.model = pipeline("token-classification", model=model_name)
def restore(self, text: str) -> str:
"""Restore punctuation using ML model."""
if not self.model:
raise ValueError("Model not loaded")
# Model returns token classifications
# Post-process to insert punctuation
# Details depend on specific model output format
passConfiguration:
PUNCTUATION_MODE = "rule-based" # or "model-based"
PUNCTUATION_MODEL = None # or model path/namePurpose: Apply proper sentence-initial and proper noun capitalization
Rules:
- Capitalize first letter of each sentence
- Capitalize after terminal punctuation (. ! ?)
- Capitalize "I" pronoun
- Preserve all-caps words (likely acronyms) - but provide option to fix
- Optionally capitalize known proper nouns (requires dictionary)
Implementation:
def capitalize_sentences(text: str) -> str:
"""Capitalize first letter of sentences."""
# Split on sentence boundaries
sentences = re.split(r'([.!?]+\s+)', text)
result = []
for i, part in enumerate(sentences):
if i % 2 == 0 and part: # Text part, not separator
# Capitalize first letter
part = part[0].upper() + part[1:] if len(part) > 0 else part
result.append(part)
text = ''.join(result)
# Capitalize standalone "i"
text = re.sub(r'\bi\b', 'I', text)
return text
def fix_all_caps(text: str, min_length: int = 4) -> bool:
"""Detect if text is inappropriately all-caps."""
# Ignore short strings (likely acronyms)
if len(text) < min_length:
return False
# Count uppercase letters
upper_count = sum(1 for c in text if c.isupper())
letter_count = sum(1 for c in text if c.isalpha())
if letter_count == 0:
return False
# If >80% uppercase, likely needs fixing
return (upper_count / letter_count) > 0.8Purpose: Remove or minimize verbal disfluencies that clutter transcripts
Common Filler Words (English):
um,uh,uhm,ummer,ermlike(when used as filler)you knowI meansort of,kind of- Stutters:
I I I think
Strategy Levels:
- Remove only clear non-semantic fillers:
um,uh,er - Preserve words that might be semantic:
like,you know
- Remove Level 1 + obvious filler phrases:
you know,I mean - Handle stutters:
I I I→I
- Remove all common fillers including
like,sort of,kind of - Risk: May change meaning in some contexts
Implementation:
def remove_fillers(text: str, level: int = 1) -> str:
"""Remove filler words based on aggressiveness level."""
# Level 1: Clear fillers only
if level >= 1:
# Remove um, uh, er (with word boundaries)
text = re.sub(r'\b(um+|uh+m?|er+m?)\b', '', text, flags=re.IGNORECASE)
# Level 2: Moderate - add filler phrases
if level >= 2:
text = re.sub(r'\byou know\b', '', text, flags=re.IGNORECASE)
text = re.sub(r'\bI mean\b', '', text, flags=re.IGNORECASE)
# Handle stutters - repeated words
text = re.sub(r'\b(\w+)(\s+\1){2,}\b', r'\1', text, flags=re.IGNORECASE)
# Level 3: Aggressive
if level >= 3:
text = re.sub(r'\blike\b', '', text, flags=re.IGNORECASE)
text = re.sub(r'\b(sort|kind) of\b', '', text, flags=re.IGNORECASE)
# Clean up extra spaces created by removals
text = re.sub(r'\s+', ' ', text)
return text.strip()Configuration:
DE_FILLER_LEVEL = 1 # 0=disabled, 1=conservative, 2=moderate, 3=aggressiveSupport for multiple languages:
- Spanish:
este,pues,o sea - French:
euh,ben,alors - German:
ähm,also
Implementation Pattern:
FILLER_PATTERNS = {
'en': [r'\bum+\b', r'\buh+\b', r'\ber+m?\b', r'\byou know\b'],
'es': [r'\beste\b', r'\bpues\b', r'\bo sea\b'],
'fr': [r'\beuh+\b', r'\bben\b', r'\balors\b'],
'de': [r'\bähm+\b', r'\balso\b'],
}
def remove_fillers_multi(text: str, language: str = 'en', level: int = 1) -> str:
"""Remove language-specific fillers."""
patterns = FILLER_PATTERNS.get(language, FILLER_PATTERNS['en'])
for pattern in patterns[:level]: # Use only patterns up to level
text = re.sub(pattern, '', text, flags=re.IGNORECASE)
return re.sub(r'\s+', ' ', text).strip()Purpose: Split run-on segments into natural sentence boundaries for better readability
Problem: Whisper often generates segments based on:
- Audio pauses (may not align with sentences)
- Fixed-length windows
- VAD (Voice Activity Detection) segments
This results in segments containing multiple sentences or partial sentences.
Strategy:
Rules:
- Split on terminal punctuation followed by capital letter:
. A,! S,? W - Respect quotation marks - don't split within quotes
- Handle abbreviations (Dr., Mr., U.S.) - don't split after these
- Minimum sentence length threshold (e.g., 10 characters)
Implementation:
# Common abbreviations that shouldn't trigger splits
ABBREVIATIONS = {'dr', 'mr', 'mrs', 'ms', 'prof', 'sr', 'jr',
'etc', 'vs', 'i.e', 'e.g', 'u.s', 'u.k'}
def split_into_sentences(text: str) -> list[str]:
"""Split text into sentences using rule-based approach."""
# Add space after periods not preceded by abbreviations
def should_split(match):
word_before = match.group(1).lower().rstrip('.')
if word_before in ABBREVIATIONS:
return match.group(0) # Don't split
return match.group(1) + match.group(2) + ' <SPLIT> '
# Mark split points
text = re.sub(r'(\w+\.)\s+([A-Z])', should_split, text)
text = re.sub(r'([!?])\s+([A-Z])', r'\1 <SPLIT> \2', text)
# Split and clean
sentences = [s.strip() for s in text.split('<SPLIT>')]
sentences = [s for s in sentences if len(s) > 10] # Min length filter
return sentencesPurpose: Use timing information to inform better segment boundaries
Strategy:
- Merge segments if gap < threshold (e.g., 500ms) AND no sentence boundary
- Split segments if gap > threshold AND contains multiple sentences
- Preserve speaker changes as hard boundaries
Implementation:
def merge_segments_by_timing(segments: list, max_gap_ms: int = 500) -> list:
"""Merge segments with small gaps that don't have sentence boundaries."""
if not segments:
return segments
merged = []
current = segments[0].copy()
for next_seg in segments[1:]:
gap_ms = next_seg['start_ms'] - current['end_ms']
# Check for speaker change (hard boundary)
speaker_change = (current.get('speaker_label') != next_seg.get('speaker_label'))
# Check for sentence boundary
has_boundary = current['text'].rstrip()[-1] in '.!?' if current['text'] else False
if gap_ms <= max_gap_ms and not speaker_change and not has_boundary:
# Merge
current['end_ms'] = next_seg['end_ms']
current['text'] = current['text'] + ' ' + next_seg['text']
else:
# Start new segment
merged.append(current)
current = next_seg.copy()
merged.append(current)
return mergedPurpose: Preserve and enhance speaker labels from diarization
Format Options:
[Speaker 1] Hello everyone, welcome to the video.
[Speaker 2] Thanks for having me.
[Speaker 1] Let's get started with the topic.
Speaker 1: Hello everyone, welcome to the video.
Speaker 2: Thanks for having me.
Speaker 1: Let's get started with the topic.
{
"segments": [
{
"speaker": "Speaker 1",
"text": "Hello everyone, welcome to the video.",
"start_ms": 1000,
"end_ms": 3500
}
]
}Implementation:
def format_with_speakers(segments: list, format: str = 'dialogue') -> str:
"""Format segments with speaker labels."""
if format == 'inline':
lines = [f"[{s['speaker_label']}] {s['text']}" for s in segments]
return '\n'.join(lines)
elif format == 'dialogue':
lines = []
current_speaker = None
for seg in segments:
speaker = seg.get('speaker_label')
if speaker != current_speaker:
if current_speaker is not None:
lines.append('') # Blank line between speakers
lines.append(f"{speaker}: {seg['text']}")
current_speaker = speaker
else:
# Same speaker continues
lines.append(seg['text'])
return '\n\n'.join(lines)
else: # structured - return list
return segmentsPurpose: Group related sentences into paragraphs for longer transcripts
Heuristics:
- Same speaker → same paragraph (up to max length)
- Long pause (>2s) → paragraph break
- Topic change detection (optional, advanced)
- Maximum paragraph length (e.g., 5 sentences or 500 chars)
Implementation:
def group_into_paragraphs(segments: list, max_sentences: int = 5) -> list:
"""Group segments into paragraphs."""
paragraphs = []
current_para = []
sentence_count = 0
current_speaker = None
for seg in segments:
speaker = seg.get('speaker_label')
# Start new paragraph on speaker change
if speaker != current_speaker and current_para:
paragraphs.append(current_para)
current_para = []
sentence_count = 0
current_para.append(seg)
sentence_count += 1
current_speaker = speaker
# Start new paragraph after max sentences
if sentence_count >= max_sentences:
paragraphs.append(current_para)
current_para = []
sentence_count = 0
if current_para:
paragraphs.append(current_para)
return paragraphsSettings Location: app/settings.py
# Cleanup module settings
CLEANUP_ENABLED: bool = True
# Text normalization
NORMALIZE_UNICODE: bool = True
NORMALIZE_WHITESPACE: bool = True
REMOVE_SPECIAL_TOKENS: bool = True
PRESERVE_SOUND_EVENTS: bool = False
# Punctuation and capitalization
PUNCTUATION_MODE: Literal["none", "rule-based", "model-based"] = "rule-based"
PUNCTUATION_MODEL: Optional[str] = None # HF model name if model-based
ADD_SENTENCE_PUNCTUATION: bool = True
ADD_INTERNAL_PUNCTUATION: bool = False
CAPITALIZE_SENTENCES: bool = True
FIX_ALL_CAPS: bool = True
# De-filler
DE_FILLER_LEVEL: int = 1 # 0=disabled, 1=conservative, 2=moderate, 3=aggressive
# Segmentation
SEGMENT_BY_SENTENCES: bool = True
MERGE_SHORT_SEGMENTS: bool = True
MIN_SEGMENT_LENGTH_MS: int = 1000
MAX_GAP_FOR_MERGE_MS: int = 500
SPEAKER_FORMAT: Literal["inline", "dialogue", "structured"] = "structured"
# Advanced
DETECT_HALLUCINATIONS: bool = True
LANGUAGE_SPECIFIC_RULES: bool = TrueGET /videos/{video_id}/transcript
Add optional query parameters for cleanup:
@router.get("/videos/{video_id}/transcript")
async def get_transcript(
video_id: uuid.UUID,
format: Literal["raw", "cleaned", "formatted"] = "raw",
cleanup_config: Optional[CleanupConfig] = None,
):
"""
Get transcript with optional cleanup.
Parameters:
- format:
- "raw": Original transcript, no processing
- "cleaned": Apply text normalization, punctuation, de-filler
- "formatted": Apply cleaning + segmentation + speaker formatting
- cleanup_config: Optional override of default cleanup settings
"""
passNew Schema:
class CleanupConfig(BaseModel):
"""Configuration for transcript cleanup."""
# Normalization
normalize_unicode: bool = True
normalize_whitespace: bool = True
remove_special_tokens: bool = True
# Punctuation
add_punctuation: bool = True
punctuation_mode: Literal["rule-based", "model-based"] = "rule-based"
capitalize: bool = True
# De-filler
remove_fillers: bool = True
filler_level: int = Field(1, ge=0, le=3)
# Segmentation
segment_sentences: bool = True
merge_short_segments: bool = True
speaker_format: Literal["inline", "dialogue", "structured"] = "structured"
class CleanedSegment(BaseModel):
"""Segment with cleaned text."""
start_ms: int
end_ms: int
text_raw: str # Original text
text_cleaned: str # After cleanup
speaker_label: Optional[str] = None
sentence_boundary: bool = False # True if this ends a sentence
class CleanedTranscriptResponse(BaseModel):
"""Cleaned transcript response."""
video_id: uuid.UUID
segments: List[CleanedSegment]
cleanup_config: CleanupConfig
metadata: Dict[str, Any] # Stats about cleanup (fillers removed, etc.)POST /transcripts/{transcript_id}/cleanup
@router.post("/transcripts/{transcript_id}/cleanup")
async def cleanup_transcript(
transcript_id: uuid.UUID,
config: CleanupConfig,
) -> CleanedTranscriptResponse:
"""
Apply cleanup to a transcript and return result.
Does not modify original transcript in database.
"""
passPOST /transcripts/{transcript_id}/cleanup/save
@router.post("/transcripts/{transcript_id}/cleanup/save")
async def save_cleaned_transcript(
transcript_id: uuid.UUID,
config: CleanupConfig,
) -> uuid.UUID:
"""
Apply cleanup and save as a new transcript variant.
Returns new transcript_id for the cleaned version.
"""
passOption 1: Add Cleanup Metadata Column
-- Add to existing transcripts table
ALTER TABLE transcripts ADD COLUMN cleanup_config JSONB;
ALTER TABLE transcripts ADD COLUMN is_cleaned BOOLEAN DEFAULT false;
-- Add to segments table
ALTER TABLE segments ADD COLUMN text_cleaned TEXT;
ALTER TABLE segments ADD COLUMN cleanup_applied BOOLEAN DEFAULT false;Option 2: Separate Cleaned Transcripts Table
CREATE TABLE cleaned_transcripts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
transcript_id UUID NOT NULL REFERENCES transcripts(id) ON DELETE CASCADE,
cleanup_config JSONB NOT NULL,
full_text_cleaned TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE cleaned_segments (
id BIGSERIAL PRIMARY KEY,
cleaned_transcript_id UUID NOT NULL REFERENCES cleaned_transcripts(id) ON DELETE CASCADE,
segment_id BIGINT REFERENCES segments(id),
text_cleaned TEXT NOT NULL,
start_ms INT NOT NULL,
end_ms INT NOT NULL,
speaker_label TEXT
);Recommendation: Use Option 1 for simplicity. Add columns to existing tables with default values for backward compatibility.
Description: Apply cleanup on-the-fly when transcript is requested via API
Advantages:
- No database schema changes required
- Easy to iterate on cleanup logic
- No storage overhead for cleaned versions
- Always uses latest cleanup rules
Disadvantages:
- Processing overhead on each request
- May be slower for large transcripts
- No caching of cleaned results
Implementation:
# In app/routes/videos.py
@router.get("/videos/{video_id}/transcript")
async def get_transcript(
video_id: uuid.UUID,
format: Literal["raw", "cleaned", "formatted"] = "raw",
db: Session = Depends(get_db),
):
# Fetch raw segments
segments = fetch_segments(db, video_id)
if format == "raw":
return {"segments": segments}
# Apply cleanup
from worker.cleanup import apply_cleanup
config = CleanupConfig() # Use defaults or from request
cleaned_segments = apply_cleanup(segments, config)
if format == "cleaned":
return {"segments": cleaned_segments}
# Format with speakers, paragraphs
formatted = format_transcript(cleaned_segments, config)
return {"transcript": formatted}Worker Module Structure:
worker/
cleanup/
__init__.py
normalizer.py # Text normalization functions
punctuation.py # Punctuation restoration
filler.py # De-filler logic
segmentation.py # Sentence boundary detection
formatter.py # Output formatting
Description: Run cleanup as a background job, cache results in database
Advantages:
- Fast API responses (return cached results)
- Can run expensive ML models without blocking
- Results are versioned and persistent
Disadvantages:
- More complex implementation
- Storage overhead
- Need to handle cache invalidation when cleanup logic changes
Implementation Sketch:
# Worker adds cleanup step to pipeline
def process_video(video_id):
# ... existing transcription ...
# Apply default cleanup
cleaned_segments = apply_cleanup(segments, DEFAULT_CLEANUP_CONFIG)
# Save to database
save_cleaned_transcript(video_id, cleaned_segments, DEFAULT_CLEANUP_CONFIG)Description: Cache common cleanup configs, compute custom configs on-demand
Strategy:
- Worker applies default cleanup and caches in
text_cleanedcolumn - API returns cached result for default config (fast path)
- API computes custom cleanup on-demand (slow path, with caching layer)
Implementation:
@router.get("/videos/{video_id}/transcript")
async def get_transcript(
video_id: uuid.UUID,
format: Literal["raw", "cleaned", "formatted"] = "raw",
cleanup_config: Optional[CleanupConfig] = None,
cache: Cache = Depends(get_cache),
db: Session = Depends(get_db),
):
if format == "raw":
return fetch_raw_segments(db, video_id)
# Check if using default config
if cleanup_config is None or cleanup_config == DEFAULT_CLEANUP_CONFIG:
# Fast path - return pre-computed cleaned text
return fetch_cleaned_segments(db, video_id)
# Custom config - check cache
cache_key = f"cleaned:{video_id}:{hash(cleanup_config)}"
cached = cache.get(cache_key)
if cached:
return cached
# Compute and cache
segments = fetch_raw_segments(db, video_id)
cleaned = apply_cleanup(segments, cleanup_config)
cache.set(cache_key, cleaned, ttl=3600) # 1 hour
return cleaned| Aspect | In-Memory | Background | Hybrid |
|---|---|---|---|
| Implementation Complexity | Low | High | Medium |
| API Response Time | Variable | Fast | Fast (default) / Variable (custom) |
| Storage Requirements | None | High | Medium |
| Flexibility | High | Medium | High |
| Maintenance | Easy | Complex | Medium |
| Recommended For | MVP, prototyping | High-volume production | Production with flexibility |
Recommendation for v1: Start with In-Memory Processing approach. This allows rapid iteration on cleanup logic without database migrations. Add caching layer in v2 if performance becomes an issue.
Input (Raw YouTube Auto-Caption):
[Music]
so today we're gonna talk about
um
the new features and uh
you know how to use them
[Applause]
this is really exciting
Output (Cleaned, Level 1):
So today we're going to talk about the new features and how to use them. This is really exciting.
Transformations Applied:
- Removed
[Music]and[Applause] - Removed fillers:
um,uh,you know - Fixed:
gonna→going to - Added capitalization and punctuation
- Merged segments into sentences
Input (Raw Whisper):
Welcome to my channel.
If you enjoyed this video, please like and subscribe.
Thanks for watching!
Thanks for watching!
Thanks for watching!
Output (Cleaned with Hallucination Detection):
Welcome to my channel. If you enjoyed this video, please like and subscribe. Thanks for watching!
Transformations Applied:
- Detected repetition (last 3 segments identical)
- Removed hallucinated repetitions
Input (Raw Segments):
[
{"speaker": "SPEAKER_00", "text": "hello everyone", "start_ms": 0, "end_ms": 1500},
{"speaker": "SPEAKER_00", "text": "welcome to the show", "start_ms": 1500, "end_ms": 3000},
{"speaker": "SPEAKER_01", "text": "thanks for having me", "start_ms": 3500, "end_ms": 5000},
{"speaker": "SPEAKER_01", "text": "excited to be here", "start_ms": 5000, "end_ms": 6500}
]Output (Formatted Dialogue):
Speaker 1: Hello everyone, welcome to the show.
Speaker 2: Thanks for having me, excited to be here.
Transformations Applied:
- Renamed
SPEAKER_00→Speaker 1,SPEAKER_01→Speaker 2 - Merged consecutive segments from same speaker
- Added proper capitalization and punctuation
- Applied dialogue formatting
Input:
HELLO EVERYONE AND WELCOME TO MY CHANNEL TODAY WE'RE GOING TO TALK ABOUT DOCKER
Output:
Hello everyone and welcome to my channel. Today we're going to talk about Docker.
Transformations Applied:
- Detected inappropriate all-caps
- Converted to sentence case
- Split into sentences
- Preserved "Docker" as proper noun
Challenge: Don't split sentences after abbreviations like "Dr." or "U.S."
Input: Dr. Smith said the U.S. economy is strong.
Expected: Single sentence (no split)
Handling: Maintain abbreviation dictionary, check before splitting
Challenge: Punctuation inside quotes
Input: He said "what time is it" and left.
Expected: He said, "What time is it?" and left.
Handling: Quote-aware punctuation rules (advanced)
Challenge: Different punctuation rules across languages
Input: Hello! ¿Cómo estás? I'm fine.
Expected: Preserve language-specific punctuation
Handling: Language detection per segment, apply appropriate rules
Challenge: Code snippets, URLs, technical terms
Input: Visit github.com slash user slash repo for the code
Expected: Preserve technical content, minimal cleanup
Handling: Pattern detection for URLs, code patterns; disable aggressive cleanup
Challenge: Segments with only fillers or very brief
Input: um, uh huh, yeah
Expected: Handle gracefully, possibly merge or remove
Handling: Minimum segment length threshold, merge with adjacent
Scope: Test individual cleanup functions in isolation
Test Files:
tests/unit/
test_normalizer.py
test_punctuation.py
test_filler.py
test_segmentation.py
test_formatter.py
Example Tests:
# tests/unit/test_normalizer.py
def test_normalize_whitespace():
input_text = "Hello world\u00A0test"
expected = "Hello world test"
assert normalize_whitespace(input_text) == expected
def test_remove_special_tokens():
input_text = "[MUSIC] Hello world [APPLAUSE]"
expected = "Hello world"
assert remove_special_tokens(input_text) == expected
def test_detect_hallucination():
segments = [
{"text": "Hello"},
{"text": "Thanks for watching"},
{"text": "Thanks for watching"},
{"text": "Thanks for watching"},
]
result = detect_hallucination(segments)
assert result[-1]["likely_hallucination"] == True# tests/unit/test_punctuation.py
def test_add_sentence_punctuation():
assert add_sentence_punctuation("hello world") == "hello world."
assert add_sentence_punctuation("what time is it") == "what time is it?"
assert add_sentence_punctuation("hello world!") == "hello world!"
def test_capitalize_sentences():
input_text = "hello. world. how are you."
expected = "Hello. World. How are you."
assert capitalize_sentences(input_text) == expected# tests/unit/test_filler.py
def test_remove_fillers_level1():
input_text = "um hello uh world er test"
expected = "hello world test"
assert remove_fillers(input_text, level=1) == expected
def test_remove_fillers_level2():
input_text = "I I I think you know it's good"
expected = "I think it's good"
assert remove_fillers(input_text, level=2) == expectedScope: Test full cleanup pipeline with realistic data
Test Files:
tests/integration/
test_cleanup_pipeline.py
test_cleanup_api.py
Example Tests:
# tests/integration/test_cleanup_pipeline.py
def test_full_cleanup_youtube_transcript():
"""Test cleanup on realistic YouTube transcript."""
raw_segments = [
{"start_ms": 0, "end_ms": 2000, "text": "[Music] um hello everyone"},
{"start_ms": 2000, "end_ms": 4000, "text": "welcome to the show"},
]
config = CleanupConfig(
remove_fillers=True,
filler_level=1,
add_punctuation=True,
)
result = apply_cleanup(raw_segments, config)
assert len(result) == 1 # Merged into one segment
assert "[Music]" not in result[0]["text_cleaned"]
assert "um" not in result[0]["text_cleaned"]
assert result[0]["text_cleaned"].endswith(".")
def test_cleanup_preserves_speaker_labels():
"""Test that speaker labels are preserved during cleanup."""
raw_segments = [
{"speaker_label": "Speaker 1", "text": "hello", "start_ms": 0, "end_ms": 1000},
{"speaker_label": "Speaker 2", "text": "hi there", "start_ms": 1500, "end_ms": 2500},
]
result = apply_cleanup(raw_segments, CleanupConfig())
assert result[0]["speaker_label"] == "Speaker 1"
assert result[1]["speaker_label"] == "Speaker 2"# tests/integration/test_cleanup_api.py
async def test_get_cleaned_transcript(client, sample_video_id):
"""Test API endpoint for cleaned transcript."""
response = await client.get(
f"/videos/{sample_video_id}/transcript?format=cleaned"
)
assert response.status_code == 200
data = response.json()
assert "segments" in data
# Verify cleanup was applied
for segment in data["segments"]:
assert "text_cleaned" in segment
# Should have punctuation
assert segment["text_cleaned"].endswith((".", "!", "?"))
async def test_custom_cleanup_config(client, sample_video_id):
"""Test API with custom cleanup configuration."""
config = {
"remove_fillers": True,
"filler_level": 3, # Aggressive
"add_punctuation": True,
}
response = await client.get(
f"/videos/{sample_video_id}/transcript?format=cleaned",
json={"cleanup_config": config}
)
assert response.status_code == 200Scope: Test cleanup as part of full transcription workflow
# tests/e2e/test_cleanup_workflow.py
async def test_transcription_with_automatic_cleanup(client):
"""Test that cleanup is automatically applied after transcription."""
# Create job
job_response = await client.post("/jobs", json={
"url": "https://youtube.com/watch?v=test",
"kind": "single",
})
job_id = job_response.json()["id"]
# Wait for completion (mock or use test video)
# ...
# Get transcript - should have cleaned version available
response = await client.get(f"/videos/{video_id}/transcript?format=cleaned")
assert response.status_code == 200
assert "segments" in response.json()Purpose: Ensure cleanup doesn't break existing functionality
Test Cases:
- API backward compatibility -
format=rawstill works - Database schema backward compatibility
- Export functionality (SRT, VTT, PDF) works with cleaned text
- Search indexing includes cleaned text
Metrics to Track:
- Cleanup processing time per 1000 segments
- Memory usage during cleanup
- API response time with cleanup enabled
Benchmarks:
import time
def benchmark_cleanup():
"""Benchmark cleanup performance."""
# Generate large test dataset
segments = generate_test_segments(count=10000)
start = time.time()
result = apply_cleanup(segments, CleanupConfig())
elapsed = time.time() - start
print(f"Processed {len(segments)} segments in {elapsed:.2f}s")
print(f"Rate: {len(segments)/elapsed:.0f} segments/sec")
# Assert performance target
assert elapsed < 5.0 # Should process 10k segments in <5sGoal: Improve punctuation accuracy using fine-tuned transformer models
Tasks:
- Collect training data from high-quality transcripts
- Fine-tune T5 or BERT model for punctuation restoration
- Integrate model inference into cleanup pipeline
- Add model serving infrastructure (GPU-accelerated inference)
- Benchmark against rule-based approach
Success Metrics:
- Punctuation accuracy >95% on test set
- <50ms inference time per segment on GPU
- User preference testing shows improvement
Goal: Improve segment boundaries using ML-based approaches
Approaches:
- Sentence boundary detection models
- Topic modeling for paragraph breaks
- Intent detection for conversation flow
Goal: Extend cleanup to support 10+ languages
Tasks:
- Build language-specific filler word dictionaries
- Adapt punctuation rules for language-specific conventions
- Train or fine-tune multi-language punctuation models
- Add language detection to auto-select appropriate rules
Goal: Make transcripts more readable through semantic understanding
Features:
- Named entity recognition → proper capitalization
- Grammar correction beyond punctuation
- Disfluency repair (fix false starts, self-corrections)
- Summarization for long transcripts
Goal: Apply cleanup to streaming transcripts in real-time
Challenges:
- Partial sentence handling
- Low-latency processing
- Incremental updates to cleaned text
Use Cases:
- Live streaming transcription
- Real-time captions with cleanup
English:
ENGLISH_FILLERS = {
'level_1': ['um', 'uh', 'uhm', 'umm', 'er', 'erm', 'ah', 'ahh'],
'level_2': ['you know', 'i mean', 'like', 'well', 'actually', 'basically'],
'level_3': ['sort of', 'kind of', 'you see', 'right', 'okay', 'so'],
}Spanish:
SPANISH_FILLERS = {
'level_1': ['eh', 'este', 'pues', 'mm'],
'level_2': ['o sea', 'bueno', 'entonces', 'claro'],
'level_3': ['como', 'digamos', 'algo así'],
}ABBREVIATIONS = {
'en': ['dr', 'mr', 'mrs', 'ms', 'prof', 'sr', 'jr', 'phd', 'md',
'etc', 'vs', 'i.e', 'e.g', 'et al', 'inc', 'corp', 'ltd',
'u.s', 'u.k', 'u.n', 'ft', 'st', 'ave', 'blvd'],
}For testing and benchmarking, create a curated dataset:
Structure:
tests/fixtures/transcripts/
youtube_auto_captions/
sample_01_raw.json
sample_01_expected_cleaned.json
whisper_output/
sample_02_raw.json
sample_02_expected_cleaned.json
edge_cases/
all_caps.json
heavy_fillers.json
multi_speaker.json
hallucination.json
Sample Format:
{
"name": "YouTube Auto-Caption Sample 1",
"description": "Tech talk with moderate fillers",
"raw_segments": [...],
"expected_cleaned": {
"level_1": [...],
"level_2": [...],
"level_3": [...]
}
}Predefined cleanup profiles for common use cases:
CLEANUP_PROFILES = {
'minimal': CleanupConfig(
remove_fillers=False,
add_punctuation=False,
normalize_whitespace=True,
),
'standard': CleanupConfig(
remove_fillers=True,
filler_level=1,
add_punctuation=True,
capitalize=True,
),
'aggressive': CleanupConfig(
remove_fillers=True,
filler_level=3,
add_punctuation=True,
capitalize=True,
segment_sentences=True,
),
'youtube_to_whisper': CleanupConfig(
# Optimized for cleaning YouTube auto-captions
remove_special_tokens=True,
remove_fillers=True,
filler_level=2,
add_punctuation=True,
),
}API Usage:
# Use predefined profile
GET /videos/{id}/transcript?format=cleaned&profile=standard
# Or custom config
POST /videos/{id}/transcript/cleanup
{
"profile": "aggressive",
"overrides": {
"filler_level": 2
}
}| Version | Date | Changes |
|---|---|---|
| 1.0 | 2025-11-02 | Initial draft specification |
- Defined text normalization (whitespace, Unicode, special tokens)
- Defined sentence punctuation strategy (rule-based + optional model)
- Defined de-filler rules with multiple aggressiveness levels
- Defined capitalization rules
- Defined segmentation strategy (sentence boundaries, timing-aware)
- Defined per-speaker formatting options
- Defined configuration flags and API surface
- Defined implementation approaches with trade-offs
- Included concrete examples for each transformation type
- Documented edge cases and handling strategies
- Proposed testing strategy (unit, integration, e2e)
- Outlined future enhancements
- Provided appendices with supporting data
End of Specification