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 }