branch: elpa/subed
commit b39cae3fadfda5daafb963afb82cc1ff81d8ff3d
Author: Sacha Chua <[email protected]>
Commit: Sacha Chua <[email protected]>
1.4.0: subed-word-data loads TextGrid files, adds word timestamps to VTT
* subed/subed.el: Version bump.
* subed/subed-word-data.el (subed-word-data--extract-words-from-textgrid):
New.
(subed-word-data-load-from-file): Support TextGrid.
(subed-word-data-subtitle-entries): Use fuzz factor.
(subed-word-data-fuzz-factor): New.
(subed-word-data-refresh-text-properties): Use fuzz factor.
(subed-word-data-add-word-timestamps): New command.
(subed-word-data-remove-word-timestamps): New command.
---
NEWS.org | 6 ++++
subed/subed-word-data.el | 82 +++++++++++++++++++++++++++++++++++++++---------
subed/subed.el | 2 +-
3 files changed, 75 insertions(+), 15 deletions(-)
diff --git a/NEWS.org b/NEWS.org
index f5f01bbf2a7..7f4f17a631e 100644
--- a/NEWS.org
+++ b/NEWS.org
@@ -1,6 +1,12 @@
#+OPTIONS: toc:nil
* subed news
+
+** Version 1.4.0 - 2026-02-10 - Sacha Chua
+
+- subed-word-data.el can now load word timing from TextGrid files produced by
the Montreal Forced Aligner.
+- New functions: ~subed-word-data-add-word-timestamps~,
~subed-word-data-remove-word-timestamps~
+
** Version 1.3.1 - 2026-01-03 - Sacha Chua
- ~subed-convert~ can convert from plain text files now. Each line is turned
into a subtitle.
diff --git a/subed/subed-word-data.el b/subed/subed-word-data.el
index 5f947f1789d..46268e1371a 100644
--- a/subed/subed-word-data.el
+++ b/subed/subed-word-data.el
@@ -22,16 +22,23 @@
;;; Commentary:
-;; This file parses timing data such as the ones you get from YouTube
-;; .srv2 or WhisperX JSON and tries to match the timing data with the
remaining text in
-;; the current subtitle in order to determine the word timestamp for
-;; splitting the subtitle.
+;; This file parses timing data such as the ones
+;; you get from YouTube .srv2, WhisperX JSON, or
+;; the Montreal Forced Aligner and tries to match
+;; the timing data with the remaining text in the
+;; current subtitle in order to determine the word
+;; timestamp for splitting the subtitle.
;; To try to automatically load word data from a similarly-named file
;; in the buffer, add this to your configuration:
;; (with-eval-after-load 'subed
;; (add-hook 'subed-mode-hook 'subed-word-data-load-maybe))
+;;
+;; After loading word data, you can add word-level
+;; timestamps to VTT files with
+;; subed-word-data-add-word-timestamps and remove them with
+;; subed-word-data-remove-word-timestamps .
;;; Code:
@@ -150,6 +157,26 @@ If FROM-STRING is non-nil, treat FILE as the data itself."
current (cdr current)))
base))
+(defun subed-word-data--extract-words-from-textgrid (filename &optional
from-string)
+ "Parse a Praat TextGrid file and return a list of intervals.
+Return a list of ((start . ?), (end . ?) (text . ?)).
+If FROM-STRING is non-nil, treat FILE as the data itself."
+ (interactive "fFile: ")
+ (with-temp-buffer
+ (if from-string (insert filename) (insert-file-contents filename))
+ (let (intervals)
+ (goto-char (point-min))
+ (let ((limit (or (save-excursion (re-search-forward "name *=
*\"phones\"" nil t))
+ (point-max))))
+ (while (re-search-forward
+ "intervals *\\[\\([0-9]+\\)\\]:[ \n\t\r]*xmin = \\([0-9.]+\\)[
\n\t\r]*xmax = \\([0-9.]+\\)[ \n\t\r]*text = \"\\([^\"]+\\)\""
+ limit t)
+ (push `((start . ,(* 1000 (string-to-number (match-string 2))))
+ (end . ,(* 1000 (string-to-number (match-string 3))))
+ (text . ,(match-string 4)))
+ intervals)))
+ (reverse intervals))))
+
(defun subed-word-data--load (data)
"Load word-level timing from DATA.
Supports WhisperX JSON, YouTube VTT, and Youtube SRV2 files."
@@ -163,8 +190,8 @@ Supports WhisperX JSON, YouTube VTT, and Youtube SRV2
files."
;;;###autoload
(defun subed-word-data-load-from-file (file &optional offset)
"Load word-level timing from FILE.
-Supports WhisperX JSON, YouTube VTT, and Youtube SRV2 files."
- (interactive (list (read-file-name "JSON, VTT, or srv2: "
+Supports WhisperX JSON, YouTube VTT, Youtube SRV2, and TextGrid files."
+ (interactive (list (read-file-name "JSON, VTT, srv2, or TextGrid: "
nil
nil
nil
@@ -172,14 +199,15 @@ Supports WhisperX JSON, YouTube VTT, and Youtube SRV2
files."
(lambda (f)
(or (file-directory-p f)
(string-match
- "\\.\\(json\\|srv2\\|vtt\\)\\'"
+
"\\.\\(json\\|srv2\\|vtt\\|TextGrid\\)\\'"
f))))
(when current-prefix-arg
(read-string "Start offset: "))))
(let ((data (pcase (file-name-extension file)
("json" (subed-word-data--extract-words-from-whisperx-json
file))
("srv2" (subed-word-data--extract-words-from-srv2
(xml-parse-file file)))
- ("vtt" (subed-word-data--extract-words-from-youtube-vtt
file)))))
+ ("vtt" (subed-word-data--extract-words-from-youtube-vtt file))
+ ("TextGrid" (subed-word-data--extract-words-from-textgrid
file)))))
(when offset (setq data (subed-word-data-adjust-times data offset)))
(subed-word-data--load data)))
@@ -278,10 +306,10 @@ Return non-nil if they are the same after normalization."
(let ((time (assoc-default 'start (subed-word-data--look-up-word))))
(when time (- time subed-subtitle-spacing))))))
-(defun subed-word-data-subtitle-entries ()
+(defun subed-word-data-subtitle-entries (&optional fuzz-factor)
"Return the entries that start and end within the current subtitle."
- (let ((start (subed-subtitle-msecs-start))
- (stop (+ (subed-subtitle-msecs-stop) subed-subtitle-spacing)))
+ (let ((start (- (subed-subtitle-msecs-start) (or fuzz-factor
subed-subtitle-spacing)))
+ (stop (+ (subed-subtitle-msecs-stop) (or fuzz-factor
subed-subtitle-spacing))))
(seq-filter
(lambda (o)
(and (<= (or (alist-get 'end o) most-positive-fixnum) stop)
@@ -291,6 +319,9 @@ Return non-nil if they are the same after normalization."
(defvar subed-word-data-threshold 5
"Number of words to consider for matching.")
+(defvar subed-word-data-fuzz-factor 200
+ "Milliseconds to consider before or after a subtitle.")
+
(defun subed-word-data-refresh-text-properties-for-subtitle ()
"Refresh the text properties for the current subtitle."
(interactive)
@@ -298,7 +329,7 @@ Return non-nil if they are the same after normalization."
'(subed-word-data-start subed-word-data-end
font-lock-face))
(let* ((text-start (progn (subed-jump-to-subtitle-text) (point)))
pos
- (word-data (reverse (subed-word-data-subtitle-entries)))
+ (word-data (reverse (subed-word-data-subtitle-entries
subed-word-data-fuzz-factor)))
candidate
cand-count)
(subed-jump-to-subtitle-end)
@@ -313,7 +344,7 @@ Return non-nil if they are the same after normalization."
(while (and candidate
(< cand-count subed-word-data-threshold)
(not (subed-word-data-compare (buffer-substring (point)
pos)
- (alist-get 'text
candidate))))
+ (alist-get 'text candidate))))
(setq candidate (car try-list) cand-count (1+ cand-count))
(when (> cand-count subed-word-data-threshold)
(setq candidate nil))
@@ -323,6 +354,29 @@ Return non-nil if they are the same after normalization."
(subed-word-data--add-word-properties (point) pos candidate)
(setq word-data try-list))))))
+(defun subed-word-data-add-word-timestamps ()
+ "Add word timestamps.
+It uses the text properties to determine the start of each word.
+This only works for VTTs."
+ (interactive)
+ (save-excursion
+ (subed-word-data-remove-word-timestamps)
+ (subed-for-each-subtitle (point-min) (point-max) t
+ (let ((start (save-excursion (subed-jump-to-subtitle-text) (point))))
+ (subed-jump-to-subtitle-end)
+ (while (> (point) start)
+ (backward-word)
+ (when (get-text-property (point) 'subed-word-data-start)
+ (save-excursion
+ (insert (format "<%s>" (subed-msecs-to-timestamp
(get-text-property (point) 'subed-word-data-start)))))))))))
+
+(defun subed-word-data-remove-word-timestamps ()
+ "Remove all word timestamps."
+ (interactive)
+ (goto-char (point-min))
+ (while (re-search-forward "<[0-9]+:[0-9]+:[0-9]+\\.[0-9]+>" nil t)
+ (replace-match "")))
+
(defun subed-word-data-refresh-region (beg end)
"Refresh text properties in region."
(when subed-word-data--cache
@@ -361,7 +415,7 @@ Return non-nil if they are the same after normalization."
(while (not (eobp))
(let* ((text-start (progn (subed-jump-to-subtitle-text) (point)))
pos
- (word-data (reverse (subed-word-data-subtitle-entries)))
+ (word-data (reverse (subed-word-data-subtitle-entries
subed-word-data-fuzz-factor)))
candidate)
(subed-jump-to-subtitle-end)
(while (> (point) text-start)
diff --git a/subed/subed.el b/subed/subed.el
index c90cc894a1b..087d32c59f3 100644
--- a/subed/subed.el
+++ b/subed/subed.el
@@ -1,6 +1,6 @@
;;; subed.el --- A major mode for editing subtitles -*- lexical-binding: t;
-*-
-;; Version: 1.3.1
+;; Version: 1.4.0
;; Maintainer: Sacha Chua <[email protected]>
;; Author: Random User
;; Keywords: convenience, files, hypermedia, multimedia