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

Reply via email to