feat: generate transcript and event-driven particle processing

This generates the transcript and shows the caption experience on the
client side for media particles. It also simplifies other side effects
that we must perform such as updating the `last_child_created_at` field
for stream and container particles.
This commit is contained in:
talksik
2026-03-25 14:43:47 -07:00
parent 92bbaa11b3
commit 986a389606
17 changed files with 578 additions and 52 deletions
+53
View File
@@ -0,0 +1,53 @@
package particle
import "time"
type FirestoreMediaParticle struct {
CreatedByHumanId string `firestore:"created_by_human_id"`
Type string `firestore:"type"`
Properties FirestoreMediaParticleProperties `firestore:"properties"`
CreatedAt time.Time `firestore:"created_at,serverTimestamp"`
UpdatedAt *time.Time `firestore:"updated_at,omitempty"`
}
type FirestoreTranscriptWord struct {
Word string `firestore:"word"`
Start float64 `firestore:"start"`
End float64 `firestore:"end"`
}
type FirestoreTranscriptSentence struct {
Text string `firestore:"text"`
Start float64 `firestore:"start"`
End float64 `firestore:"end"`
}
type FirestoreTranscriptParagraph struct {
Sentences []FirestoreTranscriptSentence `firestore:"sentences"`
Start float64 `firestore:"start"`
End float64 `firestore:"end"`
}
type FirestoreTranscript struct {
Transcript string `firestore:"transcript"`
Words []FirestoreTranscriptWord `firestore:"words"`
Paragraphs []FirestoreTranscriptParagraph `firestore:"paragraphs"`
}
type FirestoreMediaParticleProperties struct {
ObjectId string `firestore:"object_id"`
MimeType string `firestore:"mime_type"`
DurationMs int `firestore:"duration_ms"`
SizeBytes int `firestore:"size_bytes"`
Transcript *FirestoreTranscript `firestore:"transcript,omitempty"`
}
type FirestoreStreamParticle struct {
CreatedByHumanId string `firestore:"created_by_human_id"`
Type string `firestore:"type"`
// Properties FirestoreStreamParticleProperties `firestore:"properties"`
CreatedAt time.Time `firestore:"created_at,serverTimestamp"`
LastChildCreatedAt *time.Time `firestore:"last_child_created_at,omitempty"`
VisibleTo []string `firestore:"visible_to"`
UpdatedAt *time.Time `firestore:"updated_at,omitempty"`
}
@@ -0,0 +1,37 @@
package particle
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
)
type ProcessingRepository interface {
IsProcessed(ctx context.Context, particleID string) (bool, error)
MarkProcessed(ctx context.Context, particleID string) error
}
type processingRepositoryImpl struct {
pool *pgxpool.Pool
}
func NewProcessingRepository(pool *pgxpool.Pool) ProcessingRepository {
return &processingRepositoryImpl{pool: pool}
}
func (r *processingRepositoryImpl) IsProcessed(ctx context.Context, particleID string) (bool, error) {
var exists bool
err := r.pool.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM processed_particles WHERE particle_id = $1)`,
particleID,
).Scan(&exists)
return exists, err
}
func (r *processingRepositoryImpl) MarkProcessed(ctx context.Context, particleID string) error {
_, err := r.pool.Exec(ctx,
`INSERT INTO processed_particles (particle_id) VALUES ($1) ON CONFLICT (particle_id) DO NOTHING`,
particleID,
)
return err
}
+99
View File
@@ -0,0 +1,99 @@
package speech
import (
"context"
"log/slog"
dgapi "github.com/deepgram/deepgram-go-sdk/v3/pkg/api/listen/v1/rest"
interfaces "github.com/deepgram/deepgram-go-sdk/v3/pkg/client/interfaces"
client "github.com/deepgram/deepgram-go-sdk/v3/pkg/client/listen"
)
type TranscriptWord struct {
Word string
Start float64
End float64
}
type TranscriptSentence struct {
Text string
Start float64
End float64
}
type TranscriptParagraph struct {
Sentences []TranscriptSentence
Start float64
End float64
}
type TranscriptResult struct {
Transcript string
Words []TranscriptWord
Paragraphs []TranscriptParagraph
}
type SpeechService interface {
Transcribe(ctx context.Context, mediaUrl string) (*TranscriptResult, error)
}
type speechServiceImpl struct {
deepgramClient *dgapi.Client
}
func NewSpeechService(ctx context.Context) SpeechService {
return &speechServiceImpl{
deepgramClient: dgapi.New(client.NewRESTWithDefaults()),
}
}
func (s *speechServiceImpl) Transcribe(ctx context.Context, mediaUrl string) (*TranscriptResult, error) {
options := &interfaces.PreRecordedTranscriptionOptions{
Model: "nova-3",
SmartFormat: true,
Paragraphs: true,
}
response, err := s.deepgramClient.FromURL(ctx, mediaUrl, options)
if err != nil {
slog.Error("failed to transcribe prerecorded media", "error", err)
return nil, err
}
alt := response.Results.Channels[0].Alternatives[0]
words := make([]TranscriptWord, len(alt.Words))
for i, w := range alt.Words {
words[i] = TranscriptWord{
Word: w.PunctuatedWord,
Start: w.Start,
End: w.End,
}
}
var paragraphs []TranscriptParagraph
if alt.Paragraphs != nil {
paragraphs = make([]TranscriptParagraph, len(alt.Paragraphs.Paragraphs))
for i, p := range alt.Paragraphs.Paragraphs {
sentences := make([]TranscriptSentence, len(p.Sentences))
for j, s := range p.Sentences {
sentences[j] = TranscriptSentence{
Text: s.Text,
Start: s.Start,
End: s.End,
}
}
paragraphs[i] = TranscriptParagraph{
Sentences: sentences,
Start: p.Start,
End: p.End,
}
}
}
return &TranscriptResult{
Transcript: alt.Transcript,
Words: words,
Paragraphs: paragraphs,
}, nil
}