Files
apex-public/internal/docx/voice.go
T
2026-07-06 11:05:50 -04:00

149 lines
3.7 KiB
Go

package docx
import (
"archive/zip"
"bytes"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
)
// VoiceExtractor handles extracting and transcribing audio from DOCX
type VoiceExtractor struct {
filePath string
}
// NewVoiceExtractor creates a new voice extractor
func NewVoiceExtractor(filePath string) *VoiceExtractor {
return &VoiceExtractor{filePath: filePath}
}
// ExtractAudioFiles finds and extracts audio files from DOCX
func (v *VoiceExtractor) ExtractAudioFiles() (map[string][]byte, error) {
audioFormats := map[string]bool{
".m4a": true,
".mp3": true,
".wav": true,
".ogg": true,
".aac": true,
".flac": true,
}
f, err := os.Open(v.filePath)
if err != nil {
return nil, fmt.Errorf("failed to open file: %w", err)
}
defer f.Close()
fileInfo, _ := f.Stat()
reader, err := zip.NewReader(f, fileInfo.Size())
if err != nil {
return nil, fmt.Errorf("failed to open ZIP: %w", err)
}
audioFiles := make(map[string][]byte)
for _, file := range reader.File {
// Check for audio files in media/ or anywhere in docProps
if strings.Contains(strings.ToLower(file.Name), "media") || strings.Contains(strings.ToLower(file.Name), "docprops") {
ext := strings.ToLower(filepath.Ext(file.Name))
if audioFormats[ext] {
rc, err := file.Open()
if err != nil {
continue
}
defer rc.Close()
data, err := io.ReadAll(rc)
if err != nil {
continue
}
fileName := filepath.Base(file.Name)
audioFiles[fileName] = data
}
}
}
return audioFiles, nil
}
// TranscribeAudio uses Claude CLI to transcribe audio
func (v *VoiceExtractor) TranscribeAudio(audioBytes []byte, filename string) (string, error) {
// Write audio to temp file
tmpFile, err := os.CreateTemp("", "audio-*."+strings.TrimPrefix(filepath.Ext(filename), "."))
if err != nil {
return "", fmt.Errorf("failed to create temp file: %w", err)
}
defer os.Remove(tmpFile.Name())
if _, err := tmpFile.Write(audioBytes); err != nil {
tmpFile.Close()
return "", fmt.Errorf("failed to write audio: %w", err)
}
tmpFile.Close()
// Call claude CLI to transcribe
// Note: This assumes claude binary is in PATH
cmd := exec.Command("claude", "-p", fmt.Sprintf("Transcribe this audio file: %s", tmpFile.Name()))
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err = cmd.Run()
if err != nil {
return "", fmt.Errorf("failed to transcribe: %v (stderr: %s)", err, stderr.String())
}
return stdout.String(), nil
}
// VoiceSample represents extracted voice + transcription
type VoiceSample struct {
Filename string `json:"filename"`
Content []byte `json:"content,omitempty"` // only for storage
Transcript string `json:"transcript"`
}
// ExtractAndTranscribe extracts audio from DOCX and transcribes it
func (v *VoiceExtractor) ExtractAndTranscribe() ([]VoiceSample, error) {
audioFiles, err := v.ExtractAudioFiles()
if err != nil {
return nil, err
}
var samples []VoiceSample
for filename, content := range audioFiles {
transcript, err := v.TranscribeAudio(content, filename)
if err != nil {
// Log but continue — transcription failure shouldn't block intake
fmt.Fprintf(os.Stderr, "Warning: failed to transcribe %s: %v\n", filename, err)
transcript = "[transcription failed]"
}
samples = append(samples, VoiceSample{
Filename: filename,
Content: content,
Transcript: transcript,
})
}
return samples, nil
}
// StorageFormat returns the sample as JSON for SQLite storage
func (vs *VoiceSample) JSON() (string, error) {
// Don't store the raw audio bytes in DB — only filename + transcript
storage := map[string]interface{}{
"filename": vs.Filename,
"transcript": vs.Transcript,
}
b, err := json.Marshal(storage)
return string(b), err
}