Compare commits

...

57 Commits

Author SHA1 Message Date
admin 36b0b6f241 chore: set max line length to 58 chars in Ruff config
Mirror and run GitLab CI / build (push) Waiting to run
2026-06-18 18:10:06 +00:00
admin 6640bc050d feat: add chunked ASR for long audio with env-configurable chunk duration
Mirror and run GitLab CI / build (push) Waiting to run
Ruff / ruff (push) Waiting to run
- Integrate chunking into LocalAI client to avoid GPU OOM on long audio.
- Split long files into overlapping chunks; transcribe each chunk; merge segments with corrected timestamps.
- Auto-enable chunking when audio duration > LOCALAI_MAX_SINGLE_REQUEST_DURATION (default 300s).
- Add env variables:
    LOCALAI_CHUNK_DURATION (default 180)
    LOCALAI_CHUNK_OVERLAP (default 2)
    LOCALAI_MAX_SINGLE_REQUEST_DURATION (default 300)
- Add unit and integration tests for chunking logic.
- Confirmed working end-to-end with vibevoice-cpp-asr on 88-minute file.
2026-06-18 17:46:29 +00:00
admin 59363c5dcd Set content max_chars to 54
Mirror and run GitLab CI / build (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
2026-06-17 17:08:41 +00:00
admin 0e27537a68 Enforce 60-char max per full line including spaces
Mirror and run GitLab CI / build (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
- Reduce content max_chars to 48 so that:
  - line_number (up to 2) + spaces (up to 9) + content (48) <= 60.
2026-06-17 17:03:33 +00:00
admin 0947e91f15 Add extra white space between line number and text
Mirror and run GitLab CI / build (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
- First line of each speaker turn: 2 base + 7 extra spaces.
- Continuation lines: 2 base + 3 extra spaces.
2026-06-17 16:55:31 +00:00
admin 1d447f2836 Center footer page numbers
Mirror and run GitLab CI / build (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
2026-06-17 02:11:56 +00:00
admin 49e607e1e1 Add page numbers to footer: 'X of Y' (bottom left)
Mirror and run GitLab CI / build (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
- Use PAGE and NUMPAGES fields for dynamic page numbering.
- Footer aligned left.
2026-06-17 02:10:42 +00:00
admin bd4393addc Increase max characters per visual line to 60
Mirror and run GitLab CI / build (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
2026-06-17 02:01:35 +00:00
admin f5836d83f3 Add two spaces after line number and reduce max chars to 57
Mirror and run GitLab CI / build (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
- Insert two spaces after the tab between line number and content.
- Reduce max_chars from 58 to 57 to slightly shorten each visual line.
2026-06-17 02:00:08 +00:00
admin b2dce9e048 Set 29 lines per page and fix page break insertion
Mirror and run GitLab CI / build (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
- Use 29 visual lines per page before inserting a page break.
- Use w:pageBreak element for reliable page breaks across editors.
- Restart line numbering at 1 on each new page.
2026-06-16 19:51:24 +00:00
admin 4d9414fee9 Set line spacing to 1.5; page break every 32 lines
Mirror and run GitLab CI / build (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
- Increase line spacing to 1.5 (360 twips).
- Insert page break after every 32 visual lines.
- Restart line numbering at 1 on each new page.
2026-06-16 19:49:01 +00:00
admin d4ed84f68d Set line spacing to 1.2 and 32 lines per page
Mirror and run GitLab CI / build (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
- Increase line spacing to 1.2 (288 twips).
- Restart line numbering at 1 every 32 lines with page break.
2026-06-16 19:42:25 +00:00
admin eb83a37f02 Restart line numbering at 1 every 45 lines with page break
Mirror and run GitLab CI / build (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
- Insert page break after 45 visual lines.
- Reset line counter so each page starts at 1.
- Uses embedded line numbers for consistent behavior across editors.
2026-06-16 19:40:15 +00:00
admin e7aa5ebf25 Ensure first visual line respects 58-char limit including label
Mirror and run GitLab CI / build (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
- Trim first line at word boundary if label + content > 58.
- Subsequent lines continue at full width.
2026-06-16 19:26:30 +00:00
admin 1265a664cd Clip visual lines at 58 characters
Mirror and run GitLab CI / build (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
2026-06-16 19:23:09 +00:00
admin 83f3c09218 Make line numbers reflect visual lines, not speaker turns
Mirror and run GitLab CI / build (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
- Split long lines into multiple visual lines at word boundaries.
- Each visual line is its own paragraph with its own embedded line number.
- Continuous numbering across speakers and pages.
- Portable across Word, LibreOffice, Google Docs.
2026-06-16 19:21:04 +00:00
admin d828a91bf3 Use embedded line numbers instead of built-in line numbering
Mirror and run GitLab CI / build (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
- Remove w:lnNumType; line numbers are now plain text in each paragraph.
- Ensures first line is always '1' across Word, LibreOffice, Google Docs.
- Each paragraph: line number + tab + content.
2026-06-16 19:15:47 +00:00
admin 670c6d3e2b Fix first-page line numbering off-by-one in transcript DOCX
Mirror and run GitLab CI / build (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
- Remove docGrid element to prevent phantom grid-based line offset.
- Ensure exactly one lnNumType element (no duplicates).
- First visible line on page 1 now correctly numbered as 1.
2026-06-16 19:09:26 +00:00
admin f20102d564 Fix transcript DOCX line numbering (spacing and column fixes)
Mirror and run GitLab CI / build (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
- Ensure single column layout (cols num='1')
- Set explicit single line spacing (before/after=0, line=240 twips)
- Prevents Word from counting extra lines due to spacing/columns
2026-06-16 18:08:46 +00:00
admin 0e6bc53cf8 Fix duplicate pgMar causing line numbering issue
Mirror and run GitLab CI / build (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
- Update existing pgMar instead of appending a second one
- Prevents Word from miscounting lines on first page
2026-06-16 18:03:39 +00:00
admin c43076efd4 Increase timeouts for large-file transcription
Mirror and run GitLab CI / build (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
- LocalAI client timeout: 600s -> 3600s
- Summarizer timeout: 600s -> 3600s
- Add task_time_limit=14400s (4h) and soft_time_limit=13500s to transcription task
2026-06-16 17:18:09 +00:00
admin 03d66219d9 Rebuild transcript DOCX generation flow
Mirror and run GitLab CI / build (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
- Clean, single-pass implementation for transcript and summary DOCX
- Explicit margins, font, line numbering per OOXML spec
- Disable docGrid to prevent off-by-one line numbering
- Ensure first content line is line 1
2026-06-16 16:54:48 +00:00
admin 0c0e52dfb8 Fix syntax error in speaker identification prompt string
Mirror and run GitLab CI / build (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
2026-06-16 16:05:02 +00:00
admin 604bfa3f41 Ensure identified speaker names/roles are printed in ALL CAPS
Mirror and run GitLab CI / build (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
2026-06-16 16:02:55 +00:00
admin 8ff473f3e6 Fix transcript DOCX line numbering starting at 2 (docGrid)
Mirror and run GitLab CI / build (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
- Disable document grid (w:type='none') when enabling line numbering
- Prevents Word from treating an empty grid line as line 1
2026-06-16 16:00:09 +00:00
admin 0b3f737e5b Update speaker identification to use real names or roles instead of random names
Mirror and run GitLab CI / build (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
2026-06-16 15:49:39 +00:00
admin 598f8630de Fix transcript DOCX line numbering (invalid 'eachPage' value)
Mirror and run GitLab CI / build (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
- Replace invalid 'eachPage' with valid 'newPage' for w:lnNumType restart attribute
- This ensures Word starts line numbering at 1 on the first page
2026-06-16 15:41:12 +00:00
admin 7fac0e7d9c Fix transcript DOCX line numbering starting at line 2 (robust)
Mirror and run GitLab CI / build (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
- Fully clear default paragraphs from document body so Word's line numbering starts at the first real line
2026-06-15 16:26:28 +00:00
admin 5dd56a3368 Fix missing subject on emails with attachments
Mirror and run GitLab CI / build (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
- Ensure Subject header is set on the outermost MIME part when attachments are present
- Restructure send_email to use multipart/mixed as root with headers when attachments exist
2026-06-15 15:03:50 +00:00
admin 7364d572d5 Fix transcript DOCX line numbering starting at line 2
Mirror and run GitLab CI / build (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
- Remove initial empty paragraph so Word's line numbering starts at first real line
2026-06-15 14:54:32 +00:00
admin d51b006a19 Fix Gradio launch error and adjust upload template
Mirror and run GitLab CI / build (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
- Remove unsupported 'enable_api' argument from app.launch()
- Hide API link via CSS instead
- Remove queue-position paragraph from upload_notification_template.html
2026-06-15 04:06:55 +00:00
admin ea5a0752df Update README to reflect current behavior
Mirror and run GitLab CI / build (push) Has been cancelled
- Remove PDF-related references
- Clarify DOCX format: no cover pages, transcript line-numbered
- Align output files and env vars with current implementation
2026-06-15 03:58:56 +00:00
admin b0a1bc059b Simplify email subject handling and remove duplicate functions
Mirror and run GitLab CI / build (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
- Remove send_success_email/send_error_email from email_sender.py
- Centralize subject logic in tasks.py using _get_subject()
- Use EMAIL_SUBJECT_SUCCESS and EMAIL_SUBJECT_ERROR with clear defaults
- Ensure subject is always set and logged before sending
2026-06-15 03:52:19 +00:00
admin e27e5b8522 Revert PDF generation; simplify to DOCX + MD + JSON only
Mirror and run GitLab CI / build (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
- Remove PDF helpers, LibreOffice, PyPDF2, reportlab
- Transcript DOCX: standalone, no cover page, with line numbering
- Summary DOCX: standalone, no cover page, no line numbering
- Attachments:
  - Transcribe: JSON, transcript MD, transcript DOCX
  - Transcribe & Summarize: JSON, transcript MD, transcript DOCX, summary MD, summary DOCX
2026-06-15 03:38:12 +00:00
admin 6233a41f61 Remove Gradio API page and 'Use via API' link from web UI
Mirror and run GitLab CI / build (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
- Set enable_api=False in app.launch()
- Hide API-related links via CSS
2026-06-15 03:26:34 +00:00
admin 237bd4b37c Refactor PDF generation and attachment logic
Mirror and run GitLab CI / build (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
- Generate PDFs by:
  - Creating individual .docx components (cover, transcript, summary)
  - Converting each .docx to PDF
  - Merging PDFs in correct order
  - Adding page numbers to final PDFs

- Transcribe & Summarize:
  - Attach: JSON, transcript MD, summary MD, TRANSCRIPT.pdf, SUMMARY.pdf, COMBINED.pdf

- Transcribe only:
  - Attach: JSON, transcript MD, TRANSCRIPT.pdf

- Ensure transcript line numbering is isolated to its own .docx before PDF merge
2026-06-15 03:16:53 +00:00
admin 7ece1a50c2 Update Web UI: rename option, increase title font, default identify speakers
Mirror and run GitLab CI / build (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
- Rename 'Transcript & Summarize' to 'Transcribe & summarize'
- Increase title font size to 60px via CSS
- Set 'Identify speakers' checkbox to default selected
2026-06-15 03:02:19 +00:00
admin 46fbcf80af Ensure success and error emails always have a subject
Mirror and run GitLab CI / build (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
- Use EMAIL_SUBJECT_SUCCESS env var for success emails
- Use EMAIL_SUBJECT_ERROR env var for error emails
- Provide safe defaults if env vars are missing or blank
- Add final guard in send_email() to prevent blank subjects
2026-06-15 02:57:09 +00:00
admin 42a155aeaa Add PDF-based document generation with LibreOffice; fix line numbering and margins
Mirror and run GitLab CI / build (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
- Add LibreOffice Writer and DejaVu fonts to Dockerfile for PDF generation
- Add PyPDF2 and reportlab to requirements.txt
- Refactor email_sender.py:
  - Enforce 1-inch margins on all sides
  - Isolate line numbering to transcript section only
  - Add generate_pdf_documents() to build:
    - TRANSCRIPT.pdf (cover + transcript)
    - SUMMARY.pdf (cover + summary)
    - COMBINED.pdf (transcript cover + summary + TRANSCRIPT header + transcript)
  - Add page numbers (bottom-right) to all PDFs via reportlab
- Update tasks.py:
  - Use generate_pdf_documents() after creating DOCX files
  - Attach source JSON, MD files, and compiled PDFs in success email
- Add test_docx_generation.py for transcript/summary/combined DOCX testing
2026-06-15 02:19:17 +00:00
admin b0a23b32e1 Fix page numbering: correct field insertion for PAGE and NUMPAGES
Mirror and run GitLab CI / build (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
2026-06-14 23:08:51 +00:00
admin 2e2bc3fb29 Fix page numbering: use correct python-docx field insertion for PAGE and NUMPAGES
Mirror and run GitLab CI / build (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
2026-06-14 23:03:12 +00:00
admin 2f9299389b Fix line numbering: only transcript pages; ensure page numbering fields are set correctly
Mirror and run GitLab CI / build (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
2026-06-14 22:25:26 +00:00
admin e0d2fd6963 Fix combined .docx: line numbering only for transcript, centered cover pages, correct date format, reliable page numbering
Mirror and run GitLab CI / build (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
2026-06-14 22:07:36 +00:00
admin 4651c5f8b2 Ensure success email subject is never blank; add final guard in send_email
Mirror and run GitLab CI / build (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
2026-06-14 21:56:04 +00:00
admin 6c11a8f19a Add 'Page X of Y' footer to all .docx files
Mirror and run GitLab CI / build (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
2026-06-14 21:51:12 +00:00
admin 2a2a5e024c Update combined .docx order: cover page, page break, summary, page break, transcript
Mirror and run GitLab CI / build (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
2026-06-14 21:47:36 +00:00
admin 7adca3d921 Add cover pages to transcript/summary .docx with AI-generated descriptions; include combined .docx when both requested
Mirror and run GitLab CI / build (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
2026-06-14 21:33:15 +00:00
admin efb34dd9ff Translate markdown headings to WYSIWYG styles in summary .docx
Mirror and run GitLab CI / build (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
2026-06-14 21:18:42 +00:00
admin 11e5309a8e End underline at colon in transcript .docx, not over following space
Mirror and run GitLab CI / build (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
2026-06-14 21:14:45 +00:00
admin a3ca1f3505 Ensure success email subject is wired to EMAIL_SUBJECT_SUCCESS and never blank
Mirror and run GitLab CI / build (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
2026-06-14 21:11:53 +00:00
admin 154cac6c7b Ensure success email subject is wired to EMAIL_SUBJECT_SUCCESS and never blank
Mirror and run GitLab CI / build (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
2026-06-14 21:09:25 +00:00
admin 18f4a4e8de Reduce logo to 75px desktop / 50px mobile; increase title font by 3pt
Mirror and run GitLab CI / build (push) Has been cancelled
2026-06-14 21:05:19 +00:00
admin 2f304e3ed1 Fix header.html template escaping so title and logo render correctly
Mirror and run GitLab CI / build (push) Has been cancelled
2026-06-14 21:02:59 +00:00
admin fd94e2daa0 Center logo above title in header for desktop and mobile
Mirror and run GitLab CI / build (push) Has been cancelled
2026-06-14 21:00:04 +00:00
admin e74bc04cb3 Show timestamp and speaker name on same line as text in transcript .docx
Mirror and run GitLab CI / build (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
2026-06-14 20:57:32 +00:00
admin c792fa17e8 Fix .logo-container: remove flex, limit to 75px
Mirror and run GitLab CI / build (push) Has been cancelled
2026-06-14 20:54:19 +00:00
admin e55f36a131 Improve queue position accuracy and wording in upload email
Mirror and run GitLab CI / build (push) Has been cancelled
Ruff / ruff (push) Has been cancelled
2026-06-14 20:51:34 +00:00
13 changed files with 1301 additions and 146 deletions
+8 -7
View File
@@ -3,7 +3,7 @@
ScrAIbe is a transcription and summarization service that:
- Sends audio to a LocalAI server running vibevoice.cpp for transcription and speaker diarization.
- Optionally uses a second LLM to generate a detailed, structured summary.
- Optionally uses a second LLM to generate a structured summary.
- Provides:
- A web GUI for uploading audio and receiving transcripts via email.
- A CLI and Python API for direct integration.
@@ -29,12 +29,12 @@ For more information: https://apstrom.ca
- Jobs are queued and processed in the background (Celery + Redis).
- Emails:
- Immediate confirmation with queue position.
- Final transcript (MD + JSON) when ready.
- Summary as MD file (if requested).
- Final transcript (MD + DOCX + JSON) when ready.
- Summary as MD + DOCX (if requested).
- Error notification if processing fails.
- File formats:
- Transcript: .md and .docx
- Summary (if requested): .md and .docx
- Transcript: .md and .docx (line-numbered, no cover page)
- Summary (if requested): .md and .docx (no line numbering, no cover page)
- Full structured output: .json
- Customizable branding:
- Web GUI title, logo, and accent color via environment variables.
@@ -256,12 +256,13 @@ Email subject lines (customizable):
Output files (async web GUI):
When a job completes, the user receives:
- Transcript:
- .md file
- .docx file
- .docx file (line-numbered, no cover page)
- Summary (if requested):
- .md file
- .docx file
- .docx file (no line numbering, no cover page)
- JSON:
- Structured transcript with diarization and metadata
+35 -36
View File
@@ -9,46 +9,56 @@
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;700&display=swap" rel="stylesheet">
<style>
.header-container {{
.header-wrapper {{
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px 20px 0;
box-sizing: border-box;
}}
.logo-container {{
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 40px;
box-sizing: border-box;
justify-content: center;
margin-bottom: 10px;
}}
.logo {{
width: 75px;
height: auto;
display: block;
}}
.header-title {{
font-family: 'Cormorant Garamond', serif;
font-size: 42px;
font-size: 45px;
font-weight: bold;
color: {accent_color};
margin: 0;
position: relative;
padding: 0.4em 0;
flex: 1;
text-align: center;
max-width: 90%;
}}
.header-title::before, .header-title::after {{
.header-title::before,
.header-title::after {{
content: "";
position: absolute;
height: 2px;
width: 100%;
width: 80%;
background-color: {accent_color};
left: 0;
left: 10%;
}}
.header-title::before {{ top: 0.4em; }}
.header-title::after {{ bottom: 0.4em; }}
.logo-container {{
flex-shrink: 0;
margin-left: 20px;
.header-title::before {{
top: 0.4em;
}}
.logo {{
height: 20px;
width: auto;
display: block;
.header-title::after {{
bottom: 0.4em;
}}
.header-description {{
@@ -71,29 +81,18 @@
}}
@media (max-width: 768px) {{
.header-container {{
flex-direction: column;
align-items: center;
padding: 15px;
gap: 10px;
}}
.header-title {{
font-size: 28px;
text-align: center;
font-size: 31px;
}}
.header-title::before, .header-title::after {{
.header-title::before,
.header-title::after {{
width: 80%;
left: 10%;
}}
.logo-container {{
margin-left: 0;
}}
.logo {{
height: 16px;
width: 50px;
}}
.header-description {{
@@ -103,13 +102,13 @@
</style>
</head>
<body>
<div class="header-container">
<h1 class="header-title">{webui_title}</h1>
<div class="header-wrapper">
<div class="logo-container">
<a href="{header_logo_url}">
<img src="{header_logo_src}" alt="{webui_title}" class="logo">
</a>
</div>
<h1 class="header-title">{webui_title}</h1>
</div>
<div class="header-description">
<p>
-1
View File
@@ -13,7 +13,6 @@
<h1 style="color:{accent_color};">Upload Successful</h1>
<p>Dear user,</p>
<p>Your file has been successfully uploaded and is now in our processing queue. This means that our system has received your file, and it is waiting to be processed. We will handle your file as soon as possible.</p>
<p class="success-message">Your current position in the queue is: <span style="color:{accent_color}; font-weight:bold;">{queue_position}</span>. This is the order in which your file will be processed. We appreciate your patience as we work through the queue.</p>
<p>We will notify you once your file has been processed. If you have any urgent needs or further questions, feel free to reach out to our support team.</p>
<div class="contact">
<p>You can contact our support team at <a href="mailto:{contact_email}" style="color:{accent_color};">{contact_email}</a>. Please note that our support team is here to help with any questions or issues you might have.</p>
+3
View File
@@ -72,6 +72,9 @@ scraibe = "scraibe.cli:cli"
[tool.poetry.extras]
app = ["scraibe-webui"]
[tool.ruff]
line-length = 58
[tool.ruff.lint.extend-per-file-ignores]
"__init__.py" = ["E402", "F403", "F401"]
"scraibe/misc.py" = ["E722"]
+114
View File
@@ -7,13 +7,21 @@ Simplified audio processor for ScrAIbe.
Previously this used torch and pyannote-style processing. In the LocalAI-backed
version, we primarily pass files to the API, but we keep a lightweight helper
for backward compatibility.
Now also includes utilities for chunking long audio into smaller segments
to avoid GPU memory limits when using vibevoice-cpp on LocalAI.
"""
import json
import os
import tempfile
from subprocess import CalledProcessError, run
import numpy as np
SAMPLE_RATE = 16000
NORMALIZATION_FACTOR = 32768.0
DEFAULT_CHUNK_DURATION = 180.0 # seconds
DEFAULT_CHUNK_OVERLAP = 2.0 # seconds
class AudioProcessor:
@@ -106,3 +114,109 @@ class AudioProcessor:
def __repr__(self) -> str:
return f"AudioProcessor(waveform_len={len(self.waveform)}, sr={self.sr})"
def get_audio_duration(file_path: str) -> float:
"""
Get the duration of an audio file in seconds using ffprobe.
Args:
file_path: Path to the audio file.
Returns:
Duration in seconds as a float.
Raises:
RuntimeError: If ffprobe fails.
"""
cmd = [
"ffprobe",
"-v", "error",
"-show_entries", "format=duration",
"-of", "json",
file_path,
]
try:
result = run(cmd, capture_output=True, text=True, check=True)
data = json.loads(result.stdout)
return float(data["format"]["duration"])
except (CalledProcessError, json.JSONDecodeError, KeyError) as e:
raise RuntimeError(f"Failed to get audio duration for {file_path}: {e}")
def split_audio_into_chunks(
input_path: str,
max_duration: float = DEFAULT_CHUNK_DURATION,
overlap: float = DEFAULT_CHUNK_OVERLAP,
output_format: str = "wav",
sample_rate: int = 24000,
) -> list:
"""
Split a long audio file into overlapping chunks using ffmpeg.
Args:
input_path: Path to the input audio file.
max_duration: Maximum duration of each chunk in seconds.
overlap: Overlap duration in seconds between consecutive chunks.
output_format: Output format (e.g., 'wav').
sample_rate: Sample rate for output chunks.
Returns:
List of dicts:
[{"path": "chunk.wav", "start": 0.0, "end": 180.0}, ...]
Files must be cleaned up by the caller.
"""
duration = get_audio_duration(input_path)
# If file is shorter than max_duration, no need to split
if duration <= max_duration:
return [{"path": input_path, "start": 0.0, "end": duration}]
chunks = []
start = 0.0
chunk_id = 0
while start < duration:
chunk_end = min(start + max_duration, duration)
chunk_duration = chunk_end - start
tmp = tempfile.NamedTemporaryFile(
delete=False,
suffix=f".{output_format}",
prefix="scraibe_chunk_",
)
chunk_path = tmp.name
tmp.close()
cmd = [
"ffmpeg",
"-y",
"-nostdin",
"-ss", str(start),
"-i", input_path,
"-t", str(chunk_duration),
"-ar", str(sample_rate),
"-ac", "1",
"-c:a", "pcm_s16le",
chunk_path,
]
try:
run(cmd, capture_output=True, check=True)
except CalledProcessError as e:
# Clean up on error
if os.path.exists(chunk_path):
os.remove(chunk_path)
raise RuntimeError(
f"Failed to create audio chunk {chunk_id} for {input_path}: {e.stderr.decode()}"
)
chunks.append({
"path": chunk_path,
"start": start,
"end": chunk_end,
})
start += max_duration - overlap
chunk_id += 1
return chunks
+338 -76
View File
@@ -8,20 +8,22 @@ Template placeholders are primarily filled via environment variables.
"""
import base64
import json
import logging
import os
import re
import smtplib
import logging
from email import encoders
from email.mime.base import MIMEBase
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import List, Optional, Dict, Any
from typing import Any, Dict, List, Optional
from docx import Document
from docx.shared import Inches, Pt
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.shared import Inches, Pt
from docx.enum.text import WD_ALIGN_PARAGRAPH
logger = logging.getLogger("scraibe.email_sender")
@@ -214,27 +216,34 @@ def send_email(
if not to_list:
raise EmailError("No valid 'To' email addresses provided.")
# Build message
msg = MIMEMultipart("alternative")
# Ensure subject is never blank
if not subject or not subject.strip():
logger.warning("Subject was blank or missing; using default subject.")
subject = "ScrAIbe: Your transcript is ready"
subject = subject.strip()
has_attachments = bool(attachments)
# Build the text/HTML part (alternative)
alt = MIMEMultipart("alternative")
alt.attach(MIMEText(body, "plain"))
if html:
alt.attach(MIMEText(html, "html"))
if has_attachments:
# Outer message: multipart/mixed with headers
msg = MIMEMultipart("mixed")
msg["From"] = cfg["from_address"]
msg["To"] = ", ".join(to_list)
if cc_list:
msg["Cc"] = ", ".join(cc_list)
msg["Subject"] = subject
# Attach plain text
msg.attach(MIMEText(body, "plain"))
# Attach HTML if provided
if html:
msg.attach(MIMEText(html, "html"))
# Attach files in a separate multipart/mixed part
if attachments:
mixed = MIMEMultipart("mixed")
mixed.attach(msg)
msg = mixed
# Attach the alternative (text/HTML) part
msg.attach(alt)
# Attach files
for file_path in attachments:
if not os.path.isfile(file_path):
logger.warning("Attachment file not found, skipping: %s", file_path)
@@ -253,6 +262,14 @@ def send_email(
msg.attach(part)
except Exception as e:
logger.warning("Failed to attach file %s: %s", file_path, e)
else:
# No attachments: use the alternative part as the root message
msg = alt
msg["From"] = cfg["from_address"]
msg["To"] = ", ".join(to_list)
if cc_list:
msg["Cc"] = ", ".join(cc_list)
msg["Subject"] = subject
# Connect and send
try:
@@ -273,9 +290,10 @@ def send_email(
)
server.quit()
logger.info(
"Email sent to %s (CC: %s)",
"Email sent to %s (CC: %s) with subject: %s",
to_list,
cc_list or "None",
subject,
)
return True
@@ -284,87 +302,331 @@ def send_email(
raise EmailError(f"Failed to send email: {e}")
# ------------ DOCX helpers ------------
# Namespaces
W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
def _set_element_attr(elem, attr, value):
elem.set(f"{{{W_NS}}}{attr}", str(value))
def _create_transcript_section_properties(section):
"""
Configure the section properties for transcript DOCX:
- Margins: 1 inch all sides
- Single column layout
- No built-in line numbering (we embed line numbers as text for portability)
- Remove document grid to avoid off-by-one line numbering
"""
sectPr = section._sectPr
# Margins: 1 inch = 1440 twips
pgMar = sectPr.find(f"{{{W_NS}}}pgMar")
if pgMar is None:
pgMar = OxmlElement("w:pgMar")
sectPr.append(pgMar)
_set_element_attr(pgMar, "top", "1440")
_set_element_attr(pgMar, "right", "1440")
_set_element_attr(pgMar, "bottom", "1440")
_set_element_attr(pgMar, "left", "1440")
_set_element_attr(pgMar, "header", "720")
_set_element_attr(pgMar, "footer", "720")
_set_element_attr(pgMar, "gutter", "0")
# Ensure single column (no multi-column layout)
cols = sectPr.find(f"{{{W_NS}}}cols")
if cols is not None:
_set_element_attr(cols, "num", "1")
_set_element_attr(cols, "space", "720")
# Remove document grid entirely
for docGrid in sectPr.findall(f"{{{W_NS}}}docGrid"):
sectPr.remove(docGrid)
# Remove any built-in line numbering; we will use text-based line numbers
for lnNumType in sectPr.findall(f"{{{W_NS}}}lnNumType"):
sectPr.remove(lnNumType)
def _add_transcript_paragraph(doc, line_text, line_number):
"""
Add a single transcript line as a paragraph with an embedded line number.
Uses a left tab stop so the line number appears in the left margin area,
independent of built-in line numbering, ensuring consistent behavior
across Word, LibreOffice, Google Docs, etc.
"""
line_text = line_text.strip()
if not line_text:
return
p = doc.add_paragraph()
# Set up paragraph formatting:
# - No left indent; we control spacing via tab stop
# - Single line spacing, no extra before/after
pPr = p._p.get_or_add_pPr()
# Remove any default indent
pPr.find(f"{{{W_NS}}}ind") and pPr.remove(pPr.find(f"{{{W_NS}}}ind"))
# Define a left tab stop for line numbers (e.g. 360 twips ≈ 0.25")
tabs = OxmlElement("w:tabs")
tab = OxmlElement("w:tab")
tab.set("{http://schemas.openxmlformats.org/wordprocessingml/2006/main}val", "left")
tab.set("{http://schemas.openxmlformats.org/wordprocessingml/2006/main}pos", "360")
tabs.append(tab)
pPr.append(tabs)
spacing = OxmlElement("w:spacing")
_set_element_attr(spacing, "before", "0")
_set_element_attr(spacing, "after", "0")
_set_element_attr(spacing, "line", "360") # 1.5 line spacing (12pt * 1.5 = 18pt → 360 twips)
_set_element_attr(spacing, "lineRule", "auto")
pPr.append(spacing)
# Try to match: [00:00] SPEAKER 1: content
m = re.match(r"\[(\d+:\d+(?::\d+)?)\]\s*(.+?):\s*(.*)", line_text)
# Line number run (no underline)
run_ln = p.add_run(str(line_number))
run_ln.font.name = "Courier"
run_ln.font.size = Pt(12)
run_ln.underline = False
# Tab + spaces between line number and content
# - 2 base spaces + 7 more for first line of speaker turn
# - 2 base spaces + 3 more for continuation lines
if m:
extra_spaces = " " # 7 spaces for speaker lines
else:
extra_spaces = " " # 3 spaces for continuation lines
run_tab = p.add_run("\t " + extra_spaces)
run_tab.font.name = "Courier"
run_tab.font.size = Pt(12)
run_tab.underline = False
if m:
ts, speaker, content = m.groups()
label_text = f"[{ts}] {speaker.upper()}:"
# Label run (underline)
run_label = p.add_run(label_text)
run_label.underline = True
run_label.font.name = "Courier"
run_label.font.size = Pt(12)
# Space run (no underline)
run_space = p.add_run(" ")
run_space.underline = False
run_space.font.name = "Courier"
run_space.font.size = Pt(12)
# Content run (no underline)
run_txt = p.add_run(content.strip())
run_txt.underline = False
run_txt.font.name = "Courier"
run_txt.font.size = Pt(12)
else:
# Non-standard line: plain text
run = p.add_run(line_text)
run.underline = False
run.font.name = "Courier"
run.font.size = Pt(12)
# ------------ Public DOCX functions ------------
def create_transcript_docx(text: str, filename: str):
"""
Create a .docx transcript with:
- 1.5" left margin, 1" right margin
- 12pt Courier
- Continuous line numbering on the left
- Speaker names capitalized and indented; spoken text further indented
- No section headings; use bold/underline only.
Create a transcript DOCX with:
- 1" margins on all sides
- 12pt Courier font
- Embedded line numbers starting at 1 on the first page
(portable across Word, LibreOffice, Google Docs)
- Line numbers reflect visual lines on the page, not speaker turns.
- Proper formatting for timestamps and speaker labels
"""
doc = Document()
# Set margins via section properties
section = doc.sections[0]
section.left_margin = Inches(1.5)
section.right_margin = Inches(1.0)
section.top_margin = Inches(1.0)
section.bottom_margin = Inches(1.0)
# Enable continuous line numbering on the left
sectPr = section._sectPr
lnNumType = sectPr.find(qn("w:lnNumType"))
if lnNumType is None:
lnNumType = OxmlElement("w:lnNumType")
sectPr.append(lnNumType)
lnNumType.set(qn("w:start"), "continuous")
lnNumType.set(qn("w:countBy"), "1")
# Default font
# Set base font (Normal style)
style = doc.styles["Normal"]
font = style.font
font.name = "Courier"
font.size = Pt(12)
style.font.name = "Courier"
style.font.size = Pt(12)
# Parse lines
lines = text.strip().split("\n")
for line in lines:
# Remove any default paragraphs (ensure no phantom first line)
body = doc.element.body
for p in list(body.findall(f"{{{W_NS}}}p")):
body.remove(p)
# Configure section properties (margins, no built-in line numbering)
_create_transcript_section_properties(doc.sections[0])
# Max characters per visual line (content only; total line including line number and spaces <= 60)
max_chars = 54
# Lines per page before restarting numbering
lines_per_page = 29
# Current line counter for visual lines
line_number = 0
# Split transcript into logical lines
logical_lines = text.strip().splitlines()
def ensure_new_page_if_needed():
nonlocal line_number
if line_number >= lines_per_page:
# Insert a page break paragraph (no line number, no text)
p_break = doc.add_paragraph()
pPr = p_break._p.get_or_add_pPr()
# Clear any inherited formatting
for child in list(pPr):
tag = child.tag.split("}")[-1] if "}" in child.tag else child.tag
if tag in ("tabs", "spacing", "ind"):
pPr.remove(child)
# Standard page break via paragraph property
page_break = OxmlElement("w:pageBreak")
page_break.set("{http://schemas.openxmlformats.org/wordprocessingml/2006/main}val", "1")
pPr.append(page_break)
# Reset line counter for new page
line_number = 0
for line in logical_lines:
line = line.strip()
if not line:
continue
# Try to parse: [00:00] SPEAKER: text
# Try to match: [00:00] SPEAKER 1: content
m = re.match(r"\[(\d+:\d+(?::\d+)?)\]\s*(.+?):\s*(.*)", line)
if m:
ts, speaker, content = m.groups()
# Speaker line: bold, underlined, indented
p_spk = doc.add_paragraph()
p_spk.paragraph_format.left_indent = Inches(0.25)
run_spk = p_spk.add_run(f"[{ts}] {speaker.upper()}")
run_spk.bold = True
run_spk.underline = True
run_spk.font.name = "Courier"
run_spk.font.size = Pt(12)
# Spoken text line: further indented
p_txt = doc.add_paragraph()
p_txt.paragraph_format.left_indent = Inches(0.5)
run_txt = p_txt.add_run(content.strip())
run_txt.font.name = "Courier"
run_txt.font.size = Pt(12)
label_text = f"[{ts}] {speaker.upper()}:"
content = content.strip()
else:
# Fallback for non-standard lines
p = doc.add_paragraph()
run = p.add_run(line)
run.font.name = "Courier"
run.font.size = Pt(12)
label_text = ""
content = line.strip()
# Split content into visual lines at word boundaries
content_lines = []
words = content.split()
current = ""
for w in words:
if len(current) == 0:
current = w
elif len(current) + 1 + len(w) <= max_chars:
current += " " + w
else:
content_lines.append(current)
current = w
if current:
content_lines.append(current)
# First visual line: include label if present, ensuring total <= max_chars
if content_lines:
ensure_new_page_if_needed()
first_content = content_lines.pop(0)
if label_text:
prefix = label_text + " "
# If too long, trim first_content at word boundary
if len(prefix) + len(first_content) > max_chars:
allowed = max_chars - len(prefix)
if allowed < 1:
allowed = 1
# Truncate at word boundary
candidate = first_content[:allowed]
last_space = candidate.rfind(" ")
if last_space > 0:
candidate = candidate[:last_space]
first_content = candidate
first_line_text = prefix + first_content
else:
first_line_text = first_content
line_number += 1
_add_transcript_paragraph(doc, first_line_text, line_number=line_number)
# Subsequent visual lines: no label, just content
for cl in content_lines:
ensure_new_page_if_needed()
line_number += 1
_add_transcript_paragraph(doc, cl, line_number=line_number)
# Add page numbers to footer: "X of Y" (bottom left)
section = doc.sections[0]
footer = section.footer
footer.is_linked_to_previous = False
footer_para = footer.paragraphs[0] if footer.paragraphs else footer.add_paragraph()
footer_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
# Clear any existing content
for r in footer_para.runs:
r.text = ""
def add_field(run, code):
fldChar = OxmlElement("w:fldChar")
fldChar.set(qn("w:fldCharType"), "begin")
run._r.append(fldChar)
instrText = OxmlElement("w:instrText")
instrText.set(qn("xml:space"), "preserve")
instrText.text = code
run._r.append(instrText)
fldCharEnd = OxmlElement("w:fldChar")
fldCharEnd.set(qn("w:fldCharType"), "end")
run._r.append(fldCharEnd)
run_page = footer_para.add_run()
add_field(run_page, " PAGE ")
run_of = footer_para.add_run(" of ")
run_total = footer_para.add_run()
add_field(run_total, " NUMPAGES ")
# Save
doc.save(filename)
def create_summary_docx(text: str, filename: str):
"""
Create a .docx summary with consistent font.
No section headings; use bold/underline only.
Create a summary DOCX with:
- 1" margins on all sides
- 12pt Courier font
- No line numbering
"""
doc = Document()
style = doc.styles["Normal"]
font = style.font
font.name = "Courier"
font.size = Pt(12)
for line in text.splitlines():
# Base font
style = doc.styles["Normal"]
style.font.name = "Courier"
style.font.size = Pt(12)
# Margins: 1 inch all sides
for section in doc.sections:
section.left_margin = Inches(1.0)
section.right_margin = Inches(1.0)
section.top_margin = Inches(1.0)
section.bottom_margin = Inches(1.0)
# Remove default paragraph
body = doc.element.body
for p in list(body.findall(f"{{{W_NS}}}p")):
body.remove(p)
# Add summary content
lines = text.strip().splitlines()
for line in lines:
line = line.strip()
if not line:
continue
p = doc.add_paragraph(line)
p.paragraph_format.space_after = Pt(4)
+308 -1
View File
@@ -9,11 +9,21 @@ It replaces the previous local Whisper + Pyannote pipeline by sending
audio files to the /v1/audio/diarization endpoint and mapping the
response into the same Transcript format used by the UI.
For long audio files, it can chunk the input to avoid GPU OOM errors.
Environment Variables:
LOCALAI_API_URL: (required) Base URL of the LocalAI server
(e.g., http://localhost:8080)
LOCALAI_API_KEY: (optional) API key, if configured
LOCALAI_MODEL: (optional) Model name to use (default: vibevoice-diarize)
Chunking / long audio (all optional):
LOCALAI_CHUNK_DURATION: Max duration of each chunk in seconds
(default: 180.0)
LOCALAI_CHUNK_OVERLAP: Overlap between consecutive chunks in seconds
(default: 2.0)
LOCALAI_MAX_SINGLE_REQUEST_DURATION: If audio duration exceeds this, chunking
is enabled automatically (default: 300.0)
"""
import os
@@ -24,6 +34,8 @@ from typing import Dict, List, Any, Optional
import httpx
from .audio import get_audio_duration, split_audio_into_chunks
logger = logging.getLogger("scraibe.localai_client")
@@ -41,14 +53,20 @@ class LocalAIClient:
- Upload audio file as multipart/form-data.
- Parse diarization + transcription response (verbose_json).
- Map response into the same structure expected by Scraibe's Transcript.
- For long audio: chunk, transcribe each chunk, merge results.
"""
# Default thresholds for chunking long audio to avoid GPU OOM.
# These can be overridden via environment or at call time.
DEFAULT_CHUNK_DURATION = 180.0 # seconds
DEFAULT_CHUNK_OVERLAP = 2.0 # seconds
def __init__(
self,
api_url: Optional[str] = None,
api_key: Optional[str] = None,
model: Optional[str] = None,
timeout: float = 600.0,
timeout: float = 3600.0,
):
"""
Args:
@@ -82,6 +100,55 @@ class LocalAIClient:
follow_redirects=True,
)
@staticmethod
def _env_float(var: str, default: float) -> float:
"""
Read a float from environment with a fallback default.
"""
val = (os.getenv(var) or "").strip()
if val == "":
return default
try:
return float(val)
except ValueError:
logger.warning(
"Invalid value for %s: %s; using default %s", var, val, default
)
return default
def _effective_chunk_duration(self, provided: Optional[float]) -> float:
"""
Resolve chunk_duration using this precedence:
1) provided argument
2) LOCALAI_CHUNK_DURATION env
3) class default
"""
if provided is not None:
return provided
return self._env_float("LOCALAI_CHUNK_DURATION", self.DEFAULT_CHUNK_DURATION)
def _effective_chunk_overlap(self, provided: Optional[float]) -> float:
"""
Resolve chunk_overlap:
1) provided argument
2) LOCALAI_CHUNK_OVERLAP env
3) class default
"""
if provided is not None:
return provided
return self._env_float("LOCALAI_CHUNK_OVERLAP", self.DEFAULT_CHUNK_OVERLAP)
def _effective_max_single_request_duration(self, provided: Optional[float]) -> float:
"""
Resolve max_single_request_duration:
1) provided argument
2) LOCALAI_MAX_SINGLE_REQUEST_DURATION env
3) default 300.0
"""
if provided is not None:
return provided
return self._env_float("LOCALAI_MAX_SINGLE_REQUEST_DURATION", 300.0)
def close(self):
"""Close the underlying HTTP client."""
self._client.close()
@@ -107,6 +174,10 @@ class LocalAIClient:
include_text: Optional[bool] = None,
verbose: bool = False,
return_raw: bool = False,
use_chunking: Optional[bool] = None,
chunk_duration: Optional[float] = None,
chunk_overlap: Optional[float] = None,
max_single_request_duration: Optional[float] = None,
**_ignored,
) -> Dict[str, Any]:
"""
@@ -114,6 +185,8 @@ class LocalAIClient:
- A normalized dict with segments, speakers, transcripts.
- Optionally, the raw verbose_json response (for JSON export).
For long audio, it can automatically chunk the file to avoid GPU OOM.
Args:
audio_path: Path to the audio file.
language: Language hint, forwarded if set.
@@ -129,6 +202,93 @@ class LocalAIClient:
Defaults to True.
verbose: If True, prints progress messages.
return_raw: If True, also return the raw API response in 'raw_result'.
use_chunking: Whether to enable chunking for long audio.
If None, enabled automatically based on duration.
chunk_duration: Max duration per chunk in seconds.
Falls back to LOCALAI_CHUNK_DURATION env, then 180.0.
chunk_overlap: Overlap between chunks in seconds.
Falls back to LOCALAI_CHUNK_OVERLAP env, then 2.0.
max_single_request_duration: If audio duration exceeds this, chunking
is enabled (unless explicitly disabled).
Falls back to LOCALAI_MAX_SINGLE_REQUEST_DURATION
env, then 300.0.
"""
if verbose:
print("Starting diarization and transcription via LocalAI.")
logger.info("diarize_and_transcribe requested for: %s", audio_path)
# Resolve chunking parameters with environment support
chunk_duration = self._effective_chunk_duration(chunk_duration)
chunk_overlap = self._effective_chunk_overlap(chunk_overlap)
max_single = self._effective_max_single_request_duration(max_single_request_duration)
if use_chunking is None:
try:
duration = get_audio_duration(audio_path)
except RuntimeError:
duration = None
use_chunking = (duration is not None and duration > max_single)
logger.info(
"Auto-chunking decision: duration=%s, threshold=%s, use_chunking=%s",
duration,
max_single,
use_chunking,
)
if use_chunking:
return self._diarize_and_transcribe_chunked(
audio_path=audio_path,
language=language,
num_speakers=num_speakers,
min_speakers=min_speakers,
max_speakers=max_speakers,
clustering_threshold=clustering_threshold,
min_duration_on=min_duration_on,
min_duration_off=min_duration_off,
response_format=response_format,
include_text=include_text,
verbose=verbose,
return_raw=return_raw,
chunk_duration=chunk_duration,
chunk_overlap=chunk_overlap,
)
# Single-request path (existing behavior)
return self._diarize_and_transcribe_single(
audio_path=audio_path,
language=language,
num_speakers=num_speakers,
min_speakers=min_speakers,
max_speakers=max_speakers,
clustering_threshold=clustering_threshold,
min_duration_on=min_duration_on,
min_duration_off=min_duration_off,
response_format=response_format,
include_text=include_text,
verbose=verbose,
return_raw=return_raw,
)
def _diarize_and_transcribe_single(
self,
audio_path: str,
*,
language: Optional[str] = None,
num_speakers: Optional[int] = None,
min_speakers: Optional[int] = None,
max_speakers: Optional[int] = None,
clustering_threshold: Optional[float] = None,
min_duration_on: Optional[float] = None,
min_duration_off: Optional[float] = None,
response_format: Optional[str] = None,
include_text: Optional[bool] = None,
verbose: bool = False,
return_raw: bool = False,
) -> Dict[str, Any]:
"""
Internal: single-request diarization and transcription.
"""
if verbose:
print("Starting diarization and transcription via LocalAI.")
@@ -214,6 +374,153 @@ class LocalAIClient:
return parsed
def _diarize_and_transcribe_chunked(
self,
audio_path: str,
*,
language: Optional[str] = None,
num_speakers: Optional[int] = None,
min_speakers: Optional[int] = None,
max_speakers: Optional[int] = None,
clustering_threshold: Optional[float] = None,
min_duration_on: Optional[float] = None,
min_duration_off: Optional[float] = None,
response_format: Optional[str] = None,
include_text: Optional[bool] = None,
verbose: bool = False,
return_raw: bool = False,
chunk_duration: float = DEFAULT_CHUNK_DURATION,
chunk_overlap: float = DEFAULT_CHUNK_OVERLAP,
) -> Dict[str, Any]:
"""
Internal: chunked diarization and transcription for long audio.
- Splits audio into overlapping chunks.
- Transcribes each chunk via /v1/audio/diarization.
- Merges segments with adjusted timestamps.
"""
if verbose:
print("Audio is long; splitting into chunks to avoid GPU memory issues.")
logger.info(
"Chunked transcription: chunk_duration=%s, overlap=%s",
chunk_duration,
chunk_overlap,
)
chunks = split_audio_into_chunks(
input_path=audio_path,
max_duration=chunk_duration,
overlap=chunk_overlap,
)
if len(chunks) == 1:
# No actual split needed; fall back to single-request path
return self._diarize_and_transcribe_single(
audio_path=chunks[0]["path"],
language=language,
num_speakers=num_speakers,
min_speakers=min_speakers,
max_speakers=max_speakers,
clustering_threshold=clustering_threshold,
min_duration_on=min_duration_on,
min_duration_off=min_duration_off,
response_format=response_format,
include_text=include_text,
verbose=verbose,
return_raw=return_raw,
)
all_segments: List[List[float]] = []
all_speakers: List[str] = []
all_transcripts: List[str] = []
raw_results: List[Dict[str, Any]] = []
temp_files = [c["path"] for c in chunks]
try:
for i, chunk_info in enumerate(chunks):
chunk_path = chunk_info["path"]
chunk_start = chunk_info["start"]
if verbose:
print(
f"Transcribing chunk {i+1}/{len(chunks)} "
f"(start={chunk_start:.1f}s)"
)
logger.info(
"Transcribing chunk %d/%d, start=%.1f", i + 1, len(chunks), chunk_start
)
# Use single-request logic for each chunk
chunk_result = self._diarize_and_transcribe_single(
audio_path=chunk_path,
language=language,
num_speakers=num_speakers,
min_speakers=min_speakers,
max_speakers=max_speakers,
clustering_threshold=clustering_threshold,
min_duration_on=min_duration_on,
min_duration_off=min_duration_off,
response_format=response_format,
include_text=include_text,
verbose=False,
return_raw=return_raw,
)
segs = chunk_result.get("segments", [])
spks = chunk_result.get("speakers", [])
txts = chunk_result.get("transcripts", [])
raw = chunk_result.get("raw_result")
# Adjust timestamps to global timeline
adjusted_segs = []
for seg, sp, txt in zip(segs, spks, txts):
start = float(seg[0]) + chunk_start
end = float(seg[1]) + chunk_start
adjusted_segs.append([start, end])
all_speakers.append(sp)
all_transcripts.append(txt)
all_segments.extend(adjusted_segs)
if return_raw and raw is not None:
raw_results.append(raw)
finally:
# Clean up temporary chunk files
for path in temp_files:
if path and os.path.exists(path) and path != audio_path:
try:
os.remove(path)
except Exception as e:
logger.warning("Failed to remove chunk file %s: %s", path, e)
# Sort segments by start time
combined = list(zip(all_segments, all_speakers, all_transcripts))
combined.sort(key=lambda x: x[0][0])
all_segments = [x[0] for x in combined]
all_speakers = [x[1] for x in combined]
all_transcripts = [x[2] for x in combined]
if verbose:
print(
f"Chunked transcription complete. Total segments: {len(all_segments)}"
)
result = {
"segments": all_segments,
"speakers": all_speakers,
"transcripts": all_transcripts,
}
if return_raw and raw_results:
result["raw_result"] = {
"chunked": True,
"chunks": raw_results,
}
return result
def _parse_diarization_response(self, result: Dict[str, Any]) -> Dict[str, Any]:
"""
Convert LocalAI verbose_json response into the internal format used by Scraibe:
+1 -1
View File
@@ -43,7 +43,7 @@ class SummarizerClient:
api_url: Optional[str] = None,
api_key: Optional[str] = None,
model: Optional[str] = None,
timeout: float = 600.0,
timeout: float = 3600.0,
):
self.api_url = (api_url or os.getenv("SUMMARIZER_API_URL")).strip().rstrip("/")
self.api_key = api_key or os.getenv("SUMMARIZER_API_KEY") or None
+64 -20
View File
@@ -70,20 +70,37 @@ def _get_subject(env_var: str, default: str) -> str:
def get_queue_position(task_id: str) -> int:
"""
Estimate the job's position in the queue.
Returns:
- A positive int if we can estimate (1 = first in line).
- 0 if we cannot reliably determine position.
"""
try:
inspect = celery_app.control.inspect()
ready = inspect.active() or {}
reserved = inspect.reserved() or {}
count = 0
for _, tasks in list(ready.values()) + list(reserved.values()):
reserved = inspect.reserved() or {} # queued but not yet running
active = inspect.active() or {} # currently running
# Count tasks ahead of this one in the reserved (waiting) queue
ahead = 0
found = False
for _, tasks in list(reserved.values()):
for t in tasks:
if t.get("id") == task_id:
tid = t.get("id")
if tid == task_id:
found = True
break
count += 1
return max(count + 1, 1)
ahead += 1
if found:
break
# If not found in reserved, it may already be active or not yet visible.
# In that case, treat it as position 1.
if found:
return max(ahead + 1, 1)
else:
return 1
except Exception:
return -1
# If inspection fails, don't guess; caller should use a safe message.
return 0
def send_initial_email(to: str, queue_pos: int):
@@ -103,8 +120,12 @@ def send_initial_email(to: str, queue_pos: int):
if queue_pos > 0:
body += f"Your request is currently number {queue_pos} in the queue.\n"
queue_position_display = (
f'<span style="color:{_accent_color()}; font-weight:bold;">{queue_pos}</span>'
)
else:
body += "Your request has been queued for processing.\n"
queue_position_display = "the queue"
body += (
"\n"
@@ -119,7 +140,7 @@ def send_initial_email(to: str, queue_pos: int):
try:
html = load_template(
"upload_notification_template.html",
queue_position=str(max(queue_pos, 1)),
queue_position_text=queue_position_display,
)
except EmailError as e:
logger.warning("Failed to render upload notification template: %s", e)
@@ -141,6 +162,7 @@ def send_success_email(
"""
Send final email with transcript and attachments.
Subject is customizable via EMAIL_SUBJECT_SUCCESS.
Falls back to a safe default if the env var is missing or blank.
"""
subject = _get_subject(
"EMAIL_SUBJECT_SUCCESS",
@@ -183,7 +205,7 @@ def send_success_email(
html=html,
attachments=attachments,
)
logger.info("Success email sent to %s for job %s", to, task_id)
logger.info("Success email sent to %s for job %s with subject: %s", to, task_id, subject)
except EmailError as e:
logger.error("Failed to send success email to %s for job %s: %s", to, task_id, e)
@@ -229,6 +251,8 @@ def send_error_email(to: str, error_message: str, task_id: str):
name="scraibe.tasks.process_transcription_task",
bind=True,
max_retries=1,
task_time_limit=14400, # 4 hours
task_soft_time_limit=13500, # warn at 3h45m
)
def process_transcription_task(
self,
@@ -306,11 +330,15 @@ def process_transcription_task(
prompt = (
"Below is a transcript with speaker labels like 'SPEAKER 1', 'SPEAKER 2', etc. "
"Based on how they speak and the context, suggest realistic names for each speaker. "
"Based on the context and how each speaker talks, identify each speaker as:\n"
"- Their real name, if it is clearly mentioned or strongly implied, OR\n"
"- A concise role/position (e.g., Judge, Doctor, Manager, Interviewer, Client, Witness), "
"if their identity is not clear.\n"
"Do not invent random personal names. "
"Do not add extra commentary. Output ONLY a mapping in this exact format, one per line:\n"
"SPEAKER 1: Suggested Name\n"
"SPEAKER 2: Suggested Name\n"
"SPEAKER 3: Suggested Name\n"
"SPEAKER 1: Name or Role\n"
"SPEAKER 2: Name or Role\n"
"SPEAKER 3: Name or Role\n"
"\n"
"Transcript:\n"
+ transcript_text
@@ -331,7 +359,7 @@ def process_transcription_task(
re.IGNORECASE,
):
spk = f"SPEAKER {m.group(1).strip()}"
name = m.group(2).strip().rstrip(".")
name = m.group(2).strip().rstrip(".").upper()
if name:
speaker_map[spk] = name
@@ -389,9 +417,12 @@ def process_transcription_task(
f.write(transcript_text)
temp_files.append(md_transcript_path)
# Transcript .docx
# Transcript .docx (standalone, no cover page)
docx_transcript_path = _safe_filename("TRANSCRIPT", local, date_tag, ".docx")
create_transcript_docx(transcript_text, docx_transcript_path)
create_transcript_docx(
transcript_text,
docx_transcript_path,
)
temp_files.append(docx_transcript_path)
# JSON as SOURCE
@@ -415,26 +446,39 @@ def process_transcription_task(
temp_files.append(json_path)
# Summary files (if present)
md_summary_path = None
docx_summary_path = None
if summary_text:
# Summary .md
md_summary_path = _safe_filename("SUMMARY", local, date_tag, ".md")
with open(md_summary_path, "w", encoding="utf-8") as f:
f.write("# Summary\n\n")
f.write(summary_text)
temp_files.append(md_summary_path)
# Summary .docx (standalone, no cover page)
docx_summary_path = _safe_filename("SUMMARY", local, date_tag, ".docx")
create_summary_docx(summary_text, docx_summary_path)
create_summary_docx(
summary_text,
docx_summary_path,
)
temp_files.append(docx_summary_path)
# 5) Build attachments list
# Always: JSON, transcript MD, transcript DOCX
attachments = [
md_transcript_path,
docx_transcript_path,
json_path,
]
# If summary is present, add summary MD and DOCX
if summary_text:
attachments += [md_summary_path, docx_summary_path]
# 5) Send success email
# 6) Send success email
send_success_email(
to=email_to,
transcript_text=transcript_text,
@@ -454,7 +498,7 @@ def process_transcription_task(
)
raise e
finally:
# 6) Cleanup
# 7) Cleanup
for path in temp_files:
_remove_file(path)
if audio_path:
+16 -2
View File
@@ -144,7 +144,7 @@ def create_app():
task_choice = gr.Radio(
choices=[
("Transcribe", "transcribe"),
("Transcript & Summarize", "transcript_and_summarize"),
("Transcribe & summarize", "transcript_and_summarize"),
],
value="transcribe",
label="Task",
@@ -153,7 +153,7 @@ def create_app():
identify_speakers = gr.Checkbox(
label="Identify speakers (best effort using AI)",
value=False,
value=True,
info="If enabled, AI will attempt to infer real names for speakers and replace Speaker 1/2/etc. in the transcript.",
)
@@ -307,6 +307,20 @@ def create_app():
body {{
font-family: Arial, sans-serif;
}}
/* Increase main title font size */
h1,
.webui-title,
.header-title {{
font-size: 60px !important;
}}
/* Hide Gradio's "Use via API" link/button */
#share-btn,
a[href*="/api"],
a[href*="#/api"],
a[href*="#api"],
.gradio-container a[href*="api"] {{
display: none !important;
}}
/* Mobile-friendly adjustments */
@media (max-width: 700px) {{
.gradio-container {{
+86
View File
@@ -0,0 +1,86 @@
import os
import subprocess
import tempfile
import pytest
from scraibe.audio import (
get_audio_duration,
split_audio_into_chunks,
)
TEST_AUDIO_1 = "tests/audio_test_1.mp4"
TEST_AUDIO_2 = "tests/audio_test_2.mp4"
@pytest.fixture(params=[TEST_AUDIO_1, TEST_AUDIO_2])
def test_audio_path(request):
return request.param
def test_get_audio_duration(test_audio_path):
dur = get_audio_duration(test_audio_path)
assert isinstance(dur, float)
assert dur > 0
def test_split_audio_into_chunks_no_split_short(test_audio_path):
# For short files, should return the same file with no extra chunks
chunks = split_audio_into_chunks(
input_path=test_audio_path,
max_duration=600.0, # larger than both test files
overlap=2.0,
)
assert len(chunks) == 1
assert chunks[0]["path"] == test_audio_path
assert chunks[0]["start"] == 0.0
dur = get_audio_duration(test_audio_path)
assert abs(chunks[0]["end"] - dur) < 0.05
def test_split_audio_into_chunks_creates_chunks(tmp_path):
# Use a small chunk duration to force splitting
chunks = split_audio_into_chunks(
input_path=TEST_AUDIO_1,
max_duration=2.0,
overlap=0.5,
)
assert len(chunks) > 1
# Check that each chunk file exists and is non-empty
for c in chunks:
assert os.path.exists(c["path"])
assert os.path.getsize(c["path"]) > 0
# Check time ordering and overlap
for i in range(1, len(chunks)):
prev = chunks[i - 1]
curr = chunks[i]
assert curr["start"] >= prev["start"]
assert curr["start"] < prev["end"] # overlap
# Cleanup
for c in chunks:
if os.path.exists(c["path"]):
os.remove(c["path"])
def test_split_audio_into_chunks_total_coverage(test_audio_path):
dur = get_audio_duration(test_audio_path)
# Use small chunks to ensure coverage
chunks = split_audio_into_chunks(
input_path=test_audio_path,
max_duration=2.0,
overlap=0.5,
)
# First chunk starts at 0
assert chunks[0]["start"] == 0.0
# Last chunk end should cover the duration
assert chunks[-1]["end"] >= dur - 0.05
# Cleanup
for c in chunks:
if os.path.exists(c["path"]):
os.remove(c["path"])
+96
View File
@@ -0,0 +1,96 @@
"""
Local test for transcript/summary/combined .docx generation.
Checks:
- Line numbering only on transcript pages.
- Page numbering (X of Y) in footer.
- Cover pages present and centered.
- Combined document structure.
"""
import sys
import os
import tempfile
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from scraibe.email_sender import (
create_transcript_docx,
create_summary_docx,
create_combined_docx,
)
TRANSCRIPT_TEXT = """[00:00] Speaker 1: Good morning, everyone. Thank you for joining today's meeting.
[00:12] Speaker 2: Good morning. I'm looking forward to discussing the new requirements.
[00:25] Speaker 1: Let's start with the timeline. We need to finalize the scope by Friday.
[00:38] Speaker 2: Agreed. I'll send a summary of the key points after this call.
[00:45] Speaker 1: Perfect. If there are no other items, we can wrap up here."""
SUMMARY_TEXT = """# Meeting Overview
## Key Discussion Points
### Timeline and Scope
#### Next Steps"""
COVER_DATE = "June 14, 2026"
TRANSCRIPT_DESC = "Transcript of a project planning meeting discussing timelines and scope."
SUMMARY_DESC = "Summary of a project planning meeting covering key decisions and next steps."
def main():
with tempfile.TemporaryDirectory() as tmpdir:
print("Using temp directory:", tmpdir)
# 1) Transcript-only
transcript_path = os.path.join(tmpdir, "TRANSCRIPT_TEST.docx")
print("Creating transcript-only docx:", transcript_path)
create_transcript_docx(
text=TRANSCRIPT_TEXT,
filename=transcript_path,
include_cover=True,
cover_date=COVER_DATE,
cover_desc=TRANSCRIPT_DESC,
)
print("OK: transcript-only created.")
# 2) Summary-only
summary_path = os.path.join(tmpdir, "SUMMARY_TEST.docx")
print("Creating summary-only docx:", summary_path)
create_summary_docx(
text=SUMMARY_TEXT,
filename=summary_path,
include_cover=True,
cover_date=COVER_DATE,
cover_desc=SUMMARY_DESC,
)
print("OK: summary-only created.")
# 3) Combined
combined_path = os.path.join(tmpdir, "COMBINED_TEST.docx")
print("Creating combined docx:", combined_path)
create_combined_docx(
transcript_text=TRANSCRIPT_TEXT,
summary_text=SUMMARY_TEXT,
filename=combined_path,
transcript_cover_date=COVER_DATE,
transcript_cover_desc=TRANSCRIPT_DESC,
summary_cover_date=COVER_DATE,
summary_cover_desc=SUMMARY_DESC,
)
print("OK: combined created.")
# Basic size sanity checks
for path in [transcript_path, summary_path, combined_path]:
size = os.path.getsize(path)
print(f"File: {os.path.basename(path)} - size: {size} bytes")
if size < 10000:
print("WARNING: File seems unusually small:", path)
print("\nAll .docx files generated successfully.")
print("Please open them in Word to verify:")
print("- Only transcript pages have line numbers.")
print("- Footer shows 'X of Y' on all pages.")
print("- Cover pages are centered and use the correct date format.")
print("- Combined doc order: cover, page break, summary, page break, transcript.")
if __name__ == "__main__":
main()
+230
View File
@@ -0,0 +1,230 @@
import os
import json
import tempfile
from unittest.mock import patch, MagicMock
import pytest
from scraibe.localai_client import LocalAIClient, LocalAIError
from scraibe.audio import get_audio_duration, split_audio_into_chunks
TEST_AUDIO_1 = "tests/audio_test_1.mp4"
def make_fake_segments(start=0.0, count=3):
segments = []
for i in range(count):
s = start + i * 2.0
e = s + 2.0
segments.append({
"start": s,
"end": e,
"speaker": "SPEAKER_00",
"text": f"Segment text {i}",
})
return segments
def fake_localai_response(segments):
return {
"segments": segments,
"text": " ".join(seg["text"] for seg in segments),
}
@pytest.fixture
def client():
with patch.object(LocalAIClient, "__init__", lambda self, **kw: None):
c = LocalAIClient()
c.api_url = "http://localhost:8080"
c.model = "vibevoice-diarize"
c.api_key = None
c._client = MagicMock()
return c
def test_parse_diarization_response(client):
segs = make_fake_segments()
raw = fake_localai_response(segs)
out = client._parse_diarization_response(raw)
assert "segments" in out
assert "speakers" in out
assert "transcripts" in out
assert len(out["segments"]) == len(segs)
for i, s in enumerate(segs):
assert out["segments"][i][0] == s["start"]
assert out["segments"][i][1] == s["end"]
assert out["speakers"][i] == s["speaker"]
assert out["transcripts"][i] == s["text"]
def test_parse_diarization_empty(client):
out = client._parse_diarization_response({"segments": []})
assert out["segments"] == []
assert out["speakers"] == []
assert out["transcripts"] == []
def test_diarize_and_transcribe_single_happy(client):
with patch.object(client, "_client") as mock_client:
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = fake_localai_response(make_fake_segments())
mock_client.post.return_value = mock_resp
result = client.diarize_and_transcribe(
audio_path=TEST_AUDIO_1,
verbose=False,
return_raw=True,
)
assert "segments" in result
assert "raw_result" in result
assert len(result["segments"]) > 0
def test_chunking_triggered_for_long_audio(client):
# Simulate long audio by patching get_audio_duration
with patch("scraibe.localai_client.get_audio_duration") as mock_dur, \
patch.object(client, "_diarize_and_transcribe_chunked") as mock_chunked:
mock_dur.return_value = 600.0 # 10 minutes
mock_chunked.return_value = {
"segments": [],
"speakers": [],
"transcripts": [],
}
client.diarize_and_transcribe(
audio_path=TEST_AUDIO_1,
verbose=False,
use_chunking=None,
max_single_request_duration=300.0,
)
mock_chunked.assert_called_once()
def test_chunking_not_triggered_for_short_audio(client):
with patch("scraibe.localai_client.get_audio_duration") as mock_dur, \
patch.object(client, "_diarize_and_transcribe_chunked") as mock_chunked, \
patch.object(client, "_diarize_and_transcribe_single") as mock_single:
mock_dur.return_value = 120.0
mock_single.return_value = {
"segments": [],
"speakers": [],
"transcripts": [],
}
client.diarize_and_transcribe(
audio_path=TEST_AUDIO_1,
verbose=False,
use_chunking=None,
max_single_request_duration=300.0,
)
mock_chunked.assert_not_called()
mock_single.assert_called_once()
def test_chunked_transcription_adjusts_timestamps(client):
# Mock split_audio_into_chunks to return two chunks
chunk1_path = TEST_AUDIO_1
chunk2_path = TEST_AUDIO_1 # reusing same file; in real usage different
chunks = [
{"path": chunk1_path, "start": 0.0, "end": 10.0},
{"path": chunk2_path, "start": 10.0, "end": 20.0},
]
with patch("scraibe.localai_client.split_audio_into_chunks") as mock_split, \
patch.object(client, "_diarize_and_transcribe_single") as mock_single, \
patch("os.remove"):
mock_split.return_value = chunks
# First chunk: segments 04
# Second chunk: segments 04 (local times)
def side_effect(audio_path, **kw):
if audio_path == chunk1_path:
segs = make_fake_segments(start=0.0, count=2)
else:
segs = make_fake_segments(start=0.0, count=2)
return client._parse_diarization_response(fake_localai_response(segs))
mock_single.side_effect = side_effect
result = client._diarize_and_transcribe_chunked(
audio_path=TEST_AUDIO_1,
verbose=False,
return_raw=False,
chunk_duration=10.0,
chunk_overlap=2.0,
)
# Check we got 4 segments total
assert len(result["segments"]) == 4
# First two segments should be in [0, 4]
assert result["segments"][0][0] == 0.0
assert result["segments"][1][0] == 2.0
# Next two segments should be shifted by 10
assert result["segments"][2][0] == 10.0
assert result["segments"][3][0] == 12.0
@pytest.mark.integration
def test_integration_chunked_transcription_with_localai():
"""
Integration test: run chunked transcription against a live LocalAI instance.
Only runs if LOCALAI_API_URL is set and an audio file is provided.
This test is skipped by default unless run with:
pytest -m integration
"""
api_url = os.getenv("LOCALAI_API_URL")
if not api_url:
pytest.skip("LOCALAI_API_URL not set; skipping integration test")
# Use one of the bundled test audio files
audio_path = TEST_AUDIO_1
if not os.path.exists(audio_path):
pytest.skip(f"Test audio not found: {audio_path}")
# Force chunking with a very small max_single_request_duration
# Use environment-configured model or a sensible default
model = os.getenv("LOCALAI_MODEL") or "vibevoice-cpp-asr"
client = LocalAIClient(api_url=api_url, model=model)
try:
result = client.diarize_and_transcribe(
audio_path=audio_path,
verbose=True,
return_raw=True,
use_chunking=True,
chunk_duration=3.0,
chunk_overlap=0.5,
max_single_request_duration=1.0,
)
assert "segments" in result
assert len(result["segments"]) > 0
# Basic sanity: segments are time-ordered
for i in range(1, len(result["segments"])):
prev_end = result["segments"][i - 1][1]
curr_start = result["segments"][i][0]
assert curr_start >= result["segments"][i - 1][0]
# If raw_result indicates chunked, ensure structure is sensible
raw = result.get("raw_result")
if raw and raw.get("chunked"):
assert "chunks" in raw
assert len(raw["chunks"]) > 1
finally:
client.close()