init commit

This commit is contained in:
2026-01-25 17:13:15 -06:00
commit 1bbfc332d8
27 changed files with 2462 additions and 0 deletions

112
internal/srt/parser.go Normal file
View File

@@ -0,0 +1,112 @@
package srt
import (
"fmt"
"os"
"path/filepath"
"strings"
astisub "github.com/asticode/go-astisub"
)
// Load loads an SRT file from the given path
func Load(path string) (*Transcript, error) {
subs, err := astisub.OpenFile(path)
if err != nil {
return nil, fmt.Errorf("failed to parse SRT file: %w", err)
}
transcript := &Transcript{
FilePath: path,
IsTemp: strings.HasSuffix(path, ".tmp"),
Cues: make([]Cue, len(subs.Items)),
}
lineNum := 1 // SRT files are 1-indexed
for i, item := range subs.Items {
var textParts []string
for _, line := range item.Lines {
var lineParts []string
for _, lineItem := range line.Items {
lineParts = append(lineParts, lineItem.Text)
}
textParts = append(textParts, strings.Join(lineParts, ""))
}
text := strings.Join(textParts, "\n")
transcript.Cues[i] = Cue{
Index: i + 1,
Start: item.StartAt,
End: item.EndAt,
Text: text,
LineNumber: lineNum,
}
// Calculate lines used by this cue:
// 1 (index) + 1 (timestamp) + text lines + 1 (blank line)
textLines := 1
if text != "" {
textLines = strings.Count(text, "\n") + 1
}
lineNum += 2 + textLines + 1 // index + timestamp + text + blank
}
return transcript, nil
}
// FindTranscript looks for an SRT file next to the audio file
func FindTranscript(audioPath string) string {
ext := filepath.Ext(audioPath)
basePath := strings.TrimSuffix(audioPath, ext)
// Try common SRT naming patterns
patterns := []string{
basePath + ".srt",
basePath + ".en.srt",
audioPath + ".srt",
}
for _, pattern := range patterns {
if _, err := os.Stat(pattern); err == nil {
return pattern
}
}
return ""
}
// CreateTempTranscript creates a temporary SRT file with placeholder content
func CreateTempTranscript(audioPath string) (string, error) {
basename := filepath.Base(audioPath)
ext := filepath.Ext(basename)
nameOnly := strings.TrimSuffix(basename, ext)
tempPath := filepath.Join(os.TempDir(), nameOnly+".srt.tmp")
content := fmt.Sprintf(`1
00:00:00,000 --> 00:00:05,000
[No transcript found for: %s]
2
00:00:05,000 --> 00:00:15,000
This is a temporary transcript file.
You can edit it using vim-style commands.
Press 'i' to enter edit mode, 'esc' to exit.
3
00:00:15,000 --> 00:00:25,000
To generate a transcript automatically, try:
https://git.beitzah.net/ysandler/transcribe
4
00:00:25,000 --> 00:00:35,000
Or launch with an existing transcript:
playback %s -t /path/to/transcript.srt
`, basename, basename)
if err := os.WriteFile(tempPath, []byte(content), 0644); err != nil {
return "", fmt.Errorf("failed to create temp transcript: %w", err)
}
return tempPath, nil
}

39
internal/srt/types.go Normal file
View File

@@ -0,0 +1,39 @@
package srt
import "time"
// Cue represents a single subtitle entry
type Cue struct {
Index int
Start time.Duration
End time.Duration
Text string
LineNumber int // Line number in the SRT file (1-indexed)
}
// Transcript represents a complete subtitle file
type Transcript struct {
Cues []Cue
FilePath string
IsTemp bool
}
// CueAt returns the cue that contains the given time position
func (t *Transcript) CueAt(pos time.Duration) *Cue {
for i := range t.Cues {
if pos >= t.Cues[i].Start && pos < t.Cues[i].End {
return &t.Cues[i]
}
}
return nil
}
// CueIndexAt returns the index of the cue at the given position, or -1
func (t *Transcript) CueIndexAt(pos time.Duration) int {
for i := range t.Cues {
if pos >= t.Cues[i].Start && pos < t.Cues[i].End {
return i
}
}
return -1
}

79
internal/srt/writer.go Normal file
View File

@@ -0,0 +1,79 @@
package srt
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// formatDuration formats a duration as SRT timestamp (HH:MM:SS,mmm)
func formatDuration(d int64) string {
ms := d % 1000
d /= 1000
s := d % 60
d /= 60
m := d % 60
h := d / 60
return fmt.Sprintf("%02d:%02d:%02d,%03d", h, m, s, ms)
}
// Save writes the transcript to an SRT file
func (t *Transcript) Save() error {
return t.SaveTo(t.FilePath)
}
// SaveTo writes the transcript to the specified path
func (t *Transcript) SaveTo(path string) error {
var sb strings.Builder
for i, cue := range t.Cues {
if i > 0 {
sb.WriteString("\n")
}
sb.WriteString(fmt.Sprintf("%d\n", cue.Index))
sb.WriteString(fmt.Sprintf("%s --> %s\n",
formatDuration(cue.Start.Milliseconds()),
formatDuration(cue.End.Milliseconds())))
sb.WriteString(cue.Text)
sb.WriteString("\n")
}
// Ensure directory exists
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}
if err := os.WriteFile(path, []byte(sb.String()), 0644); err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
return nil
}
// PromoteTempFile saves the transcript to a permanent location
func (t *Transcript) PromoteTempFile(audioPath string) (string, error) {
if !t.IsTemp {
return t.FilePath, nil
}
// Create permanent path next to audio file
ext := filepath.Ext(audioPath)
permanentPath := strings.TrimSuffix(audioPath, ext) + ".srt"
if err := t.SaveTo(permanentPath); err != nil {
return "", err
}
// Update transcript state
t.FilePath = permanentPath
t.IsTemp = false
// Remove temp file
tempPath := filepath.Join(os.TempDir(), filepath.Base(audioPath))
os.Remove(strings.TrimSuffix(tempPath, filepath.Ext(tempPath)) + ".srt.tmp")
return permanentPath, nil
}