2026-07-16 17:24:50 +03:00
|
|
|
// Package captions turns audio packets into presentation-neutral caption events.
|
2026-07-16 12:12:01 +03:00
|
|
|
package captions
|
|
|
|
|
|
|
|
|
|
import (
|
2026-07-16 17:24:50 +03:00
|
|
|
"strings"
|
2026-07-17 10:35:59 +03:00
|
|
|
"time"
|
2026-07-16 12:12:01 +03:00
|
|
|
|
|
|
|
|
"tea.chunkbyte.com/kato/captioneer/src/audio"
|
|
|
|
|
"tea.chunkbyte.com/kato/captioneer/src/config"
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-17 10:35:59 +03:00
|
|
|
// Provisional recognition uses only a recent window. Re-decoding an entire
|
|
|
|
|
// long utterance every refresh grows increasingly expensive, while the UI only
|
|
|
|
|
// presents the latest provisional lines. The final event still decodes the
|
|
|
|
|
// complete VAD segment.
|
|
|
|
|
const maxPreviewDuration = 10 * time.Second
|
|
|
|
|
|
2026-07-16 12:12:01 +03:00
|
|
|
type Processor struct {
|
2026-07-16 17:24:50 +03:00
|
|
|
settings config.Settings
|
|
|
|
|
transcriber Transcriber
|
|
|
|
|
emit func(Event) error
|
2026-07-16 12:12:01 +03:00
|
|
|
|
2026-07-17 10:35:59 +03:00
|
|
|
fixedSamples []float32
|
|
|
|
|
previewSamples []float32
|
|
|
|
|
previewActive bool
|
|
|
|
|
previewStart int
|
|
|
|
|
nextPreviewAt int
|
|
|
|
|
previewReceived int
|
|
|
|
|
totalSamples int
|
2026-07-16 12:12:01 +03:00
|
|
|
}
|
|
|
|
|
|
2026-07-16 17:24:50 +03:00
|
|
|
func New(settings config.Settings, transcriber Transcriber, emit func(Event) error) *Processor {
|
2026-07-16 12:12:01 +03:00
|
|
|
return &Processor{
|
|
|
|
|
settings: settings,
|
2026-07-16 17:24:50 +03:00
|
|
|
transcriber: transcriber,
|
|
|
|
|
emit: emit,
|
2026-07-16 12:12:01 +03:00
|
|
|
nextPreviewAt: audio.DurationSamples(settings.ChunkDuration),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-16 17:24:50 +03:00
|
|
|
func (p *Processor) Accept(samples []float32) error {
|
2026-07-16 12:12:01 +03:00
|
|
|
if len(samples) == 0 {
|
2026-07-16 17:24:50 +03:00
|
|
|
return nil
|
2026-07-16 12:12:01 +03:00
|
|
|
}
|
|
|
|
|
if p.settings.Mode == config.ModeFixed {
|
2026-07-16 17:24:50 +03:00
|
|
|
return p.acceptFixed(samples)
|
2026-07-16 12:12:01 +03:00
|
|
|
}
|
2026-07-16 17:24:50 +03:00
|
|
|
return p.acceptVAD(samples)
|
2026-07-16 12:12:01 +03:00
|
|
|
}
|
|
|
|
|
|
2026-07-16 17:24:50 +03:00
|
|
|
func (p *Processor) Flush() error {
|
2026-07-16 12:12:01 +03:00
|
|
|
if p.settings.Mode == config.ModeFixed {
|
|
|
|
|
if len(p.fixedSamples) > 0 {
|
2026-07-17 10:35:59 +03:00
|
|
|
return p.emitFixedFinal(p.totalSamples, p.totalSamples+len(p.fixedSamples), p.fixedSamples)
|
2026-07-16 12:12:01 +03:00
|
|
|
}
|
2026-07-16 17:24:50 +03:00
|
|
|
return nil
|
2026-07-16 12:12:01 +03:00
|
|
|
}
|
2026-07-16 17:24:50 +03:00
|
|
|
p.transcriber.FlushVAD()
|
2026-07-17 10:35:59 +03:00
|
|
|
if err := p.drainVAD(); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if p.previewActive {
|
|
|
|
|
p.resetPreview()
|
|
|
|
|
return p.emit(Event{Kind: Hide})
|
|
|
|
|
}
|
|
|
|
|
return nil
|
2026-07-16 12:12:01 +03:00
|
|
|
}
|
|
|
|
|
|
2026-07-16 17:24:50 +03:00
|
|
|
func (p *Processor) acceptFixed(samples []float32) error {
|
2026-07-16 12:12:01 +03:00
|
|
|
p.fixedSamples = append(p.fixedSamples, samples...)
|
|
|
|
|
chunkSize := audio.DurationSamples(p.settings.ChunkDuration)
|
|
|
|
|
for len(p.fixedSamples) >= chunkSize {
|
2026-07-17 10:35:59 +03:00
|
|
|
if err := p.emitFixedFinal(p.totalSamples, p.totalSamples+chunkSize, p.fixedSamples[:chunkSize]); err != nil {
|
2026-07-16 17:24:50 +03:00
|
|
|
return err
|
|
|
|
|
}
|
2026-07-16 12:12:01 +03:00
|
|
|
p.fixedSamples = p.fixedSamples[chunkSize:]
|
|
|
|
|
p.totalSamples += chunkSize
|
|
|
|
|
}
|
2026-07-16 17:24:50 +03:00
|
|
|
return nil
|
2026-07-16 12:12:01 +03:00
|
|
|
}
|
|
|
|
|
|
2026-07-16 17:24:50 +03:00
|
|
|
func (p *Processor) acceptVAD(samples []float32) error {
|
2026-07-16 12:12:01 +03:00
|
|
|
p.totalSamples += len(samples)
|
2026-07-16 17:24:50 +03:00
|
|
|
p.transcriber.AcceptVAD(samples)
|
2026-07-16 12:12:01 +03:00
|
|
|
|
2026-07-17 10:35:59 +03:00
|
|
|
speechActive := p.transcriber.SpeechActive()
|
|
|
|
|
if speechActive && (p.previewActive || audio.RMSDBFS(samples) >= p.settings.PreviewThresholdDB) {
|
2026-07-16 17:24:50 +03:00
|
|
|
if !p.previewActive {
|
|
|
|
|
p.previewActive = true
|
|
|
|
|
p.previewStart = p.totalSamples - len(samples)
|
|
|
|
|
}
|
2026-07-16 12:12:01 +03:00
|
|
|
p.previewSamples = append(p.previewSamples, samples...)
|
2026-07-17 10:35:59 +03:00
|
|
|
p.previewReceived += len(samples)
|
|
|
|
|
p.trimPreviewWindow()
|
|
|
|
|
if p.previewReceived >= p.nextPreviewAt {
|
2026-07-16 17:24:50 +03:00
|
|
|
if err := p.emitProvisional(); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
2026-07-16 12:12:01 +03:00
|
|
|
p.nextPreviewAt += audio.DurationSamples(p.settings.ChunkDuration)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-17 10:35:59 +03:00
|
|
|
if err := p.drainVAD(); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
// A short VAD false-positive can become active without meeting the minimum
|
|
|
|
|
// speech duration, so no final segment is produced. Clear its provisional
|
|
|
|
|
// text instead of leaving a stale caption on screen.
|
|
|
|
|
if !speechActive && p.previewActive {
|
|
|
|
|
p.resetPreview()
|
|
|
|
|
return p.emit(Event{Kind: Hide})
|
|
|
|
|
}
|
|
|
|
|
return nil
|
2026-07-16 12:12:01 +03:00
|
|
|
}
|
|
|
|
|
|
2026-07-16 17:24:50 +03:00
|
|
|
func (p *Processor) drainVAD() error {
|
2026-07-16 12:12:01 +03:00
|
|
|
for {
|
2026-07-16 17:24:50 +03:00
|
|
|
start, samples, ok := p.transcriber.NextSpeechSegment()
|
2026-07-16 12:12:01 +03:00
|
|
|
if !ok {
|
2026-07-16 17:24:50 +03:00
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
if err := p.emitFinal(start, start+len(samples), samples); err != nil {
|
|
|
|
|
return err
|
2026-07-16 12:12:01 +03:00
|
|
|
}
|
2026-07-17 10:35:59 +03:00
|
|
|
p.resetPreview()
|
2026-07-16 12:12:01 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 10:35:59 +03:00
|
|
|
func (p *Processor) trimPreviewWindow() {
|
|
|
|
|
maxSamples := audio.DurationSamples(maxPreviewDuration)
|
|
|
|
|
if len(p.previewSamples) <= maxSamples {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
dropped := len(p.previewSamples) - maxSamples
|
|
|
|
|
p.previewSamples = p.previewSamples[dropped:]
|
|
|
|
|
p.previewStart += dropped
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (p *Processor) resetPreview() {
|
|
|
|
|
p.previewSamples = nil
|
|
|
|
|
p.previewActive = false
|
|
|
|
|
p.previewStart = 0
|
|
|
|
|
p.previewReceived = 0
|
|
|
|
|
p.nextPreviewAt = audio.DurationSamples(p.settings.ChunkDuration)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-16 17:24:50 +03:00
|
|
|
func (p *Processor) emitProvisional() error {
|
|
|
|
|
text := strings.TrimSpace(p.transcriber.Decode(p.previewSamples))
|
2026-07-16 12:12:01 +03:00
|
|
|
if text == "" {
|
2026-07-16 17:24:50 +03:00
|
|
|
return nil
|
2026-07-16 12:12:01 +03:00
|
|
|
}
|
2026-07-16 17:24:50 +03:00
|
|
|
return p.emit(Event{
|
|
|
|
|
Kind: Provisional,
|
|
|
|
|
Text: text,
|
|
|
|
|
StartedAt: audio.SamplesDuration(p.previewStart),
|
|
|
|
|
EndedAt: audio.SamplesDuration(p.previewStart + len(p.previewSamples)),
|
|
|
|
|
})
|
2026-07-16 12:12:01 +03:00
|
|
|
}
|
|
|
|
|
|
2026-07-16 17:24:50 +03:00
|
|
|
func (p *Processor) emitFinal(start, end int, samples []float32) error {
|
|
|
|
|
text := strings.TrimSpace(p.transcriber.Decode(samples))
|
2026-07-16 12:12:01 +03:00
|
|
|
if text == "" {
|
2026-07-16 17:24:50 +03:00
|
|
|
return p.emit(Event{Kind: Hide})
|
2026-07-16 12:12:01 +03:00
|
|
|
}
|
2026-07-16 17:24:50 +03:00
|
|
|
return p.emit(Event{
|
|
|
|
|
Kind: Final,
|
|
|
|
|
Text: text,
|
|
|
|
|
StartedAt: audio.SamplesDuration(start),
|
|
|
|
|
EndedAt: audio.SamplesDuration(end),
|
|
|
|
|
})
|
2026-07-16 12:12:01 +03:00
|
|
|
}
|
2026-07-17 10:35:59 +03:00
|
|
|
|
|
|
|
|
func (p *Processor) emitFixedFinal(start, end int, samples []float32) error {
|
|
|
|
|
if audio.RMSDBFS(samples) < p.settings.PreviewThresholdDB {
|
|
|
|
|
return p.emit(Event{Kind: Hide})
|
|
|
|
|
}
|
|
|
|
|
return p.emitFinal(start, end, samples)
|
|
|
|
|
}
|