summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-01-21 21:43:40 +0200
committerPaul Buetow <paul@buetow.org>2026-01-21 21:43:40 +0200
commit8a4b935792c50101cf65b36e44f376c90c2d08c1 (patch)
tree2a8288e935fe89738fbc1243253bb638dc2620a8 /internal
parent2bd22f79f739136a7d30cf156979e2f2b23b7c65 (diff)
improve: better audio player UI and debugging for bg-bg cards
- Add debug logging to navigation.go to diagnose audio file loading issues Prints paths being checked and whether files are found - Improve AudioPlayer UI for Bulgarian-Bulgarian cards: - Add labels showing 'Front' and 'Back' for bg-bg audio buttons - Labels only show when audio files are actually loaded - Better visual distinction between the two playable audios - Reorganized button layout with VBox for cleaner appearance - Track bg-bg state in AudioPlayer (isBgBg field) - Automatically set when back audio file is loaded - Used to determine when to show labels This makes it clearer that Bulgarian-Bulgarian cards have two independently playable audio outputs, and helps debug why audio isn't being loaded.
Diffstat (limited to 'internal')
-rw-r--r--internal/anki/apkg_generator.go304
-rw-r--r--internal/anki/generator.go57
-rw-r--r--internal/batch/processor.go95
-rw-r--r--internal/batch/processor_test.go70
-rw-r--r--internal/cardtype.go58
-rw-r--r--internal/gui/app.go292
-rw-r--r--internal/gui/audio_player.go116
-rw-r--r--internal/gui/generator.go117
-rw-r--r--internal/gui/navigation.go14
-rw-r--r--internal/gui/queue.go2
-rw-r--r--internal/processor/processor.go138
11 files changed, 1035 insertions, 228 deletions
diff --git a/internal/anki/apkg_generator.go b/internal/anki/apkg_generator.go
index 9707937..c5a31d2 100644
--- a/internal/anki/apkg_generator.go
+++ b/internal/anki/apkg_generator.go
@@ -16,12 +16,13 @@ import (
// APKGGenerator creates Anki package files (.apkg)
type APKGGenerator struct {
- deckName string
- deckID int64
- modelID int64
- cards []Card
- mediaFiles map[string]int // maps original filename to media number
- mediaCounter int
+ deckName string
+ deckID int64
+ modelID int64
+ modelIDBgBg int64 // Separate model for bg-bg cards
+ cards []Card
+ mediaFiles map[string]int // maps original filename to media number
+ mediaCounter int
}
// NewAPKGGenerator creates a new APKG generator
@@ -32,6 +33,7 @@ func NewAPKGGenerator(deckName string) *APKGGenerator {
deckName: deckName,
deckID: now,
modelID: now + 1,
+ modelIDBgBg: now + 2,
cards: make([]Card, 0),
mediaFiles: make(map[string]int),
mediaCounter: 0,
@@ -242,7 +244,8 @@ func (g *APKGGenerator) insertCollection(db *sql.DB) error {
// Create model (note type) configuration
models := map[string]interface{}{
- fmt.Sprintf("%d", g.modelID): g.createNoteTypeConfig(),
+ fmt.Sprintf("%d", g.modelID): g.createNoteTypeConfig(),
+ fmt.Sprintf("%d", g.modelIDBgBg): g.createBgBgNoteTypeConfig(),
}
modelsJSON, _ := json.Marshal(models)
@@ -515,6 +518,20 @@ func (g *APKGGenerator) getCSS() string {
margin: 20px 0;
}
+.bulgarian-front {
+ font-size: 32px;
+ font-weight: bold;
+ color: #2c3e50;
+ margin: 20px 0;
+}
+
+.bulgarian-back {
+ font-size: 28px;
+ font-weight: bold;
+ color: #27ae60;
+ margin: 20px 0;
+}
+
.audio {
margin: 15px 0;
}
@@ -533,6 +550,171 @@ hr#answer {
}`
}
+// createBgBgNoteTypeConfig creates the note type configuration for Bulgarian-Bulgarian cards
+func (g *APKGGenerator) createBgBgNoteTypeConfig() map[string]interface{} {
+ return map[string]interface{}{
+ "id": g.modelIDBgBg,
+ "name": "Bulgarian-Bulgarian from TotalRecall",
+ "type": 0,
+ "mod": time.Now().Unix(),
+ "usn": -1,
+ "sortf": 0,
+ "did": g.deckID,
+ "req": [][]interface{}{[]interface{}{0, "all", []int{0}}, []interface{}{1, "all", []int{1}}},
+ "vers": []int{},
+ "tags": []string{},
+ "latexPre": `\documentclass[12pt]{article}
+\special{papersize=3in,5in}
+\usepackage[utf8]{inputenc}
+\usepackage{amssymb,amsmath}
+\pagestyle{empty}
+\setlength{\parindent}{0in}
+\begin{document}`,
+ "latexPost": `\end{document}`,
+ "flds": []map[string]interface{}{
+ {
+ "name": "BulgarianFront",
+ "ord": 0,
+ "sticky": false,
+ "rtl": false,
+ "font": "Arial",
+ "size": 20,
+ "media": []string{},
+ },
+ {
+ "name": "BulgarianBack",
+ "ord": 1,
+ "sticky": false,
+ "rtl": false,
+ "font": "Arial",
+ "size": 20,
+ "media": []string{},
+ },
+ {
+ "name": "Image",
+ "ord": 2,
+ "sticky": false,
+ "rtl": false,
+ "font": "Arial",
+ "size": 20,
+ "media": []string{},
+ },
+ {
+ "name": "AudioFront",
+ "ord": 3,
+ "sticky": false,
+ "rtl": false,
+ "font": "Arial",
+ "size": 20,
+ "media": []string{},
+ },
+ {
+ "name": "AudioBack",
+ "ord": 4,
+ "sticky": false,
+ "rtl": false,
+ "font": "Arial",
+ "size": 20,
+ "media": []string{},
+ },
+ {
+ "name": "Notes",
+ "ord": 5,
+ "sticky": false,
+ "rtl": false,
+ "font": "Arial",
+ "size": 16,
+ "media": []string{},
+ },
+ },
+ "tmpls": []map[string]interface{}{
+ {
+ "name": "Forward",
+ "ord": 0,
+ "qfmt": g.getBgBgFrontTemplate(),
+ "afmt": g.getBgBgBackTemplate(),
+ "did": nil,
+ "bqfmt": "",
+ "bafmt": "",
+ },
+ {
+ "name": "Reverse",
+ "ord": 1,
+ "qfmt": g.getBgBgReverseFrontTemplate(),
+ "afmt": g.getBgBgReverseBackTemplate(),
+ "did": nil,
+ "bqfmt": "",
+ "bafmt": "",
+ },
+ },
+ "css": g.getCSS(),
+ }
+}
+
+// getBgBgFrontTemplate returns the question template for bg-bg cards
+func (g *APKGGenerator) getBgBgFrontTemplate() string {
+ return `<div class="front">
+{{#Image}}
+<div class="image-container">
+{{Image}}
+</div>
+{{/Image}}
+<div class="bulgarian-front">{{BulgarianFront}}</div>
+{{#AudioFront}}
+<div class="audio">{{AudioFront}}</div>
+{{/AudioFront}}
+</div>`
+}
+
+// getBgBgBackTemplate returns the answer template for bg-bg cards
+func (g *APKGGenerator) getBgBgBackTemplate() string {
+ return `{{FrontSide}}
+
+<hr id="answer">
+
+<div class="back">
+<div class="bulgarian-back">{{BulgarianBack}}</div>
+{{#AudioBack}}
+<div class="audio">{{AudioBack}}</div>
+{{/AudioBack}}
+{{#Notes}}
+<div class="notes">{{Notes}}</div>
+{{/Notes}}
+</div>`
+}
+
+// getBgBgReverseFrontTemplate returns the question template for bg-bg reverse cards
+func (g *APKGGenerator) getBgBgReverseFrontTemplate() string {
+ return `<div class="front">
+<div class="bulgarian-back">{{BulgarianBack}}</div>
+{{#AudioBack}}
+{{AudioBack}}
+{{/AudioBack}}
+</div>`
+}
+
+// getBgBgReverseBackTemplate returns the answer template for bg-bg reverse cards
+func (g *APKGGenerator) getBgBgReverseBackTemplate() string {
+ return `{{FrontSide}}
+
+<hr id="answer">
+
+<div class="back">
+<div class="bulgarian-front">{{BulgarianFront}}</div>
+{{#AudioFront}}
+<div class="audio">{{AudioFront}}</div>
+{{/AudioFront}}
+{{#Image}}
+<div class="image-container">
+{{Image}}
+</div>
+{{/Image}}
+{{#Notes}}
+<div class="notes">{{Notes}}</div>
+{{/Notes}}
+</div>`
+}
+
// insertNotesAndCards inserts all notes and cards into the database
func (g *APKGGenerator) insertNotesAndCards(db *sql.DB) error {
now := time.Now()
@@ -543,58 +725,78 @@ func (g *APKGGenerator) insertNotesAndCards(db *sql.DB) error {
cardID1 := noteID + 1
cardID2 := noteID + 2
- // Prepare field values
- english := card.Translation
- if english == "" {
- english = "Translation needed"
- }
+ // Determine if this is a bg-bg card
+ isBgBg := card.CardType == "bg-bg"
imageField := ""
if card.ImageFile != "" && fileExists(card.ImageFile) {
- // Get card ID from the source path (parent directory name)
- cardID := filepath.Base(filepath.Dir(card.ImageFile))
+ cardDirID := filepath.Base(filepath.Dir(card.ImageFile))
originalFilename := filepath.Base(card.ImageFile)
- // Create unique filename with card ID prefix
- uniqueFilename := fmt.Sprintf("%s_%s", cardID, originalFilename)
-
+ uniqueFilename := fmt.Sprintf("%s_%s", cardDirID, originalFilename)
if _, ok := g.mediaFiles[uniqueFilename]; ok {
- // Use the unique filename in the card content
imageField = fmt.Sprintf(`<img src="%s">`, uniqueFilename)
}
}
audioField := ""
if card.AudioFile != "" && fileExists(card.AudioFile) {
- // Get card ID from the source path (parent directory name)
- cardID := filepath.Base(filepath.Dir(card.AudioFile))
+ cardDirID := filepath.Base(filepath.Dir(card.AudioFile))
originalFilename := filepath.Base(card.AudioFile)
- // Create unique filename with card ID prefix
- uniqueFilename := fmt.Sprintf("%s_%s", cardID, originalFilename)
-
+ uniqueFilename := fmt.Sprintf("%s_%s", cardDirID, originalFilename)
if _, ok := g.mediaFiles[uniqueFilename]; ok {
- // Use the unique filename in the card content
audioField = fmt.Sprintf("[sound:%s]", uniqueFilename)
}
}
- // Join fields with field separator (ASCII 31)
- fields := strings.Join([]string{
- english,
- card.Bulgarian,
- imageField,
- audioField,
- card.Notes,
- }, "\x1f")
+ audioFieldBack := ""
+ if card.AudioFileBack != "" && fileExists(card.AudioFileBack) {
+ cardDirID := filepath.Base(filepath.Dir(card.AudioFileBack))
+ originalFilename := filepath.Base(card.AudioFileBack)
+ uniqueFilename := fmt.Sprintf("%s_%s", cardDirID, originalFilename)
+ if _, ok := g.mediaFiles[uniqueFilename]; ok {
+ audioFieldBack = fmt.Sprintf("[sound:%s]", uniqueFilename)
+ }
+ }
- // Generate GUID
- guid := fmt.Sprintf("tr_%d_%s", now.Unix(), card.Bulgarian)
+ var fields string
+ var modelID int64
+ var guid string
+
+ if isBgBg {
+ // Bulgarian-Bulgarian card: BulgarianFront, BulgarianBack, Image, AudioFront, AudioBack, Notes
+ fields = strings.Join([]string{
+ card.Bulgarian,
+ card.Translation,
+ imageField,
+ audioField,
+ audioFieldBack,
+ card.Notes,
+ }, "\x1f")
+ modelID = g.modelIDBgBg
+ guid = fmt.Sprintf("tr_bgbg_%d_%s", now.Unix(), card.Bulgarian)
+ } else {
+ // English-Bulgarian card: English, Bulgarian, Image, Audio, Notes
+ english := card.Translation
+ if english == "" {
+ english = "Translation needed"
+ }
+ fields = strings.Join([]string{
+ english,
+ card.Bulgarian,
+ imageField,
+ audioField,
+ card.Notes,
+ }, "\x1f")
+ modelID = g.modelID
+ guid = fmt.Sprintf("tr_%d_%s", now.Unix(), card.Bulgarian)
+ }
// Insert note
noteQuery := `INSERT INTO notes VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
_, err := db.Exec(noteQuery,
noteID, // id
guid, // guid
- g.modelID, // mid
+ modelID, // mid
now.Unix(), // mod
-1, // usn
"", // tags
@@ -665,16 +867,12 @@ func (g *APKGGenerator) insertNotesAndCards(db *sql.DB) error {
// copyMediaFiles copies media files and assigns them numbers
func (g *APKGGenerator) copyMediaFiles(tempDir string) error {
- // Media files go directly in the temp directory with numeric names
-
for _, card := range g.cards {
- // Copy audio file
+ // Copy audio file (front audio for bg-bg, only audio for en-bg)
if card.AudioFile != "" && fileExists(card.AudioFile) {
- // Get card ID from the source path (parent directory name)
- cardID := filepath.Base(filepath.Dir(card.AudioFile))
+ cardDirID := filepath.Base(filepath.Dir(card.AudioFile))
originalFilename := filepath.Base(card.AudioFile)
- // Create unique filename with card ID prefix
- uniqueFilename := fmt.Sprintf("%s_%s", cardID, originalFilename)
+ uniqueFilename := fmt.Sprintf("%s_%s", cardDirID, originalFilename)
if _, exists := g.mediaFiles[uniqueFilename]; !exists {
targetPath := filepath.Join(tempDir, fmt.Sprintf("%d", g.mediaCounter))
@@ -686,13 +884,27 @@ func (g *APKGGenerator) copyMediaFiles(tempDir string) error {
}
}
+ // Copy back audio file (only for bg-bg cards)
+ if card.AudioFileBack != "" && fileExists(card.AudioFileBack) {
+ cardDirID := filepath.Base(filepath.Dir(card.AudioFileBack))
+ originalFilename := filepath.Base(card.AudioFileBack)
+ uniqueFilename := fmt.Sprintf("%s_%s", cardDirID, originalFilename)
+
+ if _, exists := g.mediaFiles[uniqueFilename]; !exists {
+ targetPath := filepath.Join(tempDir, fmt.Sprintf("%d", g.mediaCounter))
+ if err := copyFile(card.AudioFileBack, targetPath); err != nil {
+ return fmt.Errorf("failed to copy back audio file %s: %w", card.AudioFileBack, err)
+ }
+ g.mediaFiles[uniqueFilename] = g.mediaCounter
+ g.mediaCounter++
+ }
+ }
+
// Copy image file
if card.ImageFile != "" && fileExists(card.ImageFile) {
- // Get card ID from the source path (parent directory name)
- cardID := filepath.Base(filepath.Dir(card.ImageFile))
+ cardDirID := filepath.Base(filepath.Dir(card.ImageFile))
originalFilename := filepath.Base(card.ImageFile)
- // Create unique filename with card ID prefix
- uniqueFilename := fmt.Sprintf("%s_%s", cardID, originalFilename)
+ uniqueFilename := fmt.Sprintf("%s_%s", cardDirID, originalFilename)
if _, exists := g.mediaFiles[uniqueFilename]; !exists {
targetPath := filepath.Join(tempDir, fmt.Sprintf("%d", g.mediaCounter))
diff --git a/internal/anki/generator.go b/internal/anki/generator.go
index 0b393f3..85a4155 100644
--- a/internal/anki/generator.go
+++ b/internal/anki/generator.go
@@ -6,15 +6,19 @@ import (
"os"
"path/filepath"
"strings"
+
+ "codeberg.org/snonux/totalrecall/internal"
)
// Card represents a single Anki flashcard
type Card struct {
- Bulgarian string // The Bulgarian word/phrase
- AudioFile string // Path to audio file
- ImageFile string // Path to image file
- Translation string // Optional translation
- Notes string // Optional notes
+ Bulgarian string // The Bulgarian word/phrase
+ AudioFile string // Path to audio file (for en-bg: Bulgarian audio, for bg-bg: front audio)
+ AudioFileBack string // Path to back audio file (only for bg-bg cards)
+ ImageFile string // Path to image file
+ Translation string // Translation (English for en-bg, Bulgarian definition for bg-bg)
+ Notes string // Optional notes
+ CardType string // Card type: "en-bg" or "bg-bg"
}
// GeneratorOptions configures the Anki export
@@ -143,23 +147,27 @@ func (g *Generator) GenerateFromDirectory(dir string) error {
if err != nil {
return fmt.Errorf("failed to read directory: %w", err)
}
-
+
// Process each subdirectory as a word
for _, entry := range entries {
if !entry.IsDir() {
continue
}
-
+
// Skip hidden directories like .trashbin
if strings.HasPrefix(entry.Name(), ".") {
continue
}
-
+
wordDir := filepath.Join(dir, entry.Name())
-
+
// Create card for this word
card := Card{}
-
+
+ // Load card type (defaults to en-bg for backwards compatibility)
+ cardType := internal.LoadCardType(wordDir)
+ card.CardType = string(cardType)
+
// Read the original Bulgarian word from word.txt
wordFile := filepath.Join(wordDir, "word.txt")
if data, err := os.ReadFile(wordFile); err == nil {
@@ -174,7 +182,7 @@ func (g *Generator) GenerateFromDirectory(dir string) error {
continue
}
}
-
+
// Try to load translation
translationFile := filepath.Join(wordDir, "translation.txt")
if data, err := os.ReadFile(translationFile); err == nil {
@@ -183,17 +191,32 @@ func (g *Generator) GenerateFromDirectory(dir string) error {
card.Translation = strings.TrimSpace(parts[1])
}
}
-
- // Look for audio file
+
+ // Look for audio file(s)
audioFormats := []string{"mp3", "wav"}
for _, format := range audioFormats {
+ // For bg-bg cards, look for audio_front and audio_back
+ if cardType.IsBgBg() {
+ frontAudio := filepath.Join(wordDir, fmt.Sprintf("audio_front.%s", format))
+ backAudio := filepath.Join(wordDir, fmt.Sprintf("audio_back.%s", format))
+ if _, err := os.Stat(frontAudio); err == nil {
+ card.AudioFile = frontAudio
+ }
+ if _, err := os.Stat(backAudio); err == nil {
+ card.AudioFileBack = backAudio
+ }
+ if card.AudioFile != "" {
+ break
+ }
+ }
+ // For en-bg cards (or fallback), look for standard audio file
audioFile := filepath.Join(wordDir, fmt.Sprintf("audio.%s", format))
if _, err := os.Stat(audioFile); err == nil {
card.AudioFile = audioFile
break
}
}
-
+
// Look for image files
imagePatterns := []string{
"image.jpg",
@@ -206,7 +229,7 @@ func (g *Generator) GenerateFromDirectory(dir string) error {
break
}
}
-
+
// Load phonetic information as notes
phoneticFile := filepath.Join(wordDir, "phonetic.txt")
if data, err := os.ReadFile(phoneticFile); err == nil {
@@ -214,13 +237,13 @@ func (g *Generator) GenerateFromDirectory(dir string) error {
notes := strings.TrimSpace(string(data))
card.Notes = strings.ReplaceAll(notes, "\n", "<br>")
}
-
+
// Only add card if it has at least some content
if card.AudioFile != "" || card.ImageFile != "" || card.Translation != "" {
g.AddCard(card)
}
}
-
+
return nil
}
diff --git a/internal/batch/processor.go b/internal/batch/processor.go
index 0b20179..6867c3c 100644
--- a/internal/batch/processor.go
+++ b/internal/batch/processor.go
@@ -4,6 +4,8 @@ import (
"fmt"
"os"
"strings"
+
+ "codeberg.org/snonux/totalrecall/internal"
)
// WordEntry represents a word with optional translation
@@ -12,6 +14,8 @@ type WordEntry struct {
Translation string
// NeedsTranslation indicates if translation from English to Bulgarian is needed
NeedsTranslation bool
+ // CardType indicates whether this is en-bg or bg-bg card
+ CardType internal.CardType
}
// ReadBatchFile reads words from a file and returns WordEntry slice
@@ -19,6 +23,7 @@ type WordEntry struct {
// - Bulgarian word only: "ябълка" (will be translated to English)
// - With translation: "ябълка = apple" (both provided, no translation needed)
// - English only: "= apple" (will be translated to Bulgarian)
+// - Bulgarian-Bulgarian: "word1 == definition" (bg-bg card, double equals)
func ReadBatchFile(filename string) ([]WordEntry, error) {
content, err := os.ReadFile(filename)
if err != nil {
@@ -30,42 +35,72 @@ func ReadBatchFile(filename string) ([]WordEntry, error) {
for _, line := range splitLines(lines) {
if line = trimSpace(line); line != "" {
- // Check if line contains '=' for translation format
- if strings.Contains(line, "=") {
- parts := strings.SplitN(line, "=", 2)
- if len(parts) == 2 {
- bulgarian := strings.TrimSpace(parts[0])
- english := strings.TrimSpace(parts[1])
-
- if bulgarian == "" && english != "" {
- // Format: "= ENGLISH" - need to translate English to Bulgarian
- entries = append(entries, WordEntry{
- Bulgarian: "", // Will be filled by translation
- Translation: english,
- NeedsTranslation: true,
- })
- } else if bulgarian != "" && english != "" {
- // Format: "BULGARIAN = ENGLISH" - both provided
- entries = append(entries, WordEntry{
- Bulgarian: bulgarian,
- Translation: english,
- NeedsTranslation: false,
- })
- }
- // Ignore lines with empty English part
+ entry := parseBatchLine(line)
+ if entry != nil {
+ entries = append(entries, *entry)
+ }
+ }
+ }
+
+ return entries, nil
+}
+
+// parseBatchLine parses a single batch file line and returns the appropriate WordEntry
+func parseBatchLine(line string) *WordEntry {
+ // Check for Bulgarian-Bulgarian format first (double equals ==)
+ if strings.Contains(line, "==") {
+ parts := strings.SplitN(line, "==", 2)
+ if len(parts) == 2 {
+ bulgarian1 := strings.TrimSpace(parts[0])
+ bulgarian2 := strings.TrimSpace(parts[1])
+
+ if bulgarian1 != "" && bulgarian2 != "" {
+ return &WordEntry{
+ Bulgarian: bulgarian1,
+ Translation: bulgarian2,
+ NeedsTranslation: false,
+ CardType: internal.CardTypeBgBg,
}
- } else {
- // Just a Bulgarian word - needs translation to English
- entries = append(entries, WordEntry{
- Bulgarian: line,
- Translation: "",
+ }
+ }
+ return nil
+ }
+
+ // Check for English-Bulgarian format (single equals =)
+ if strings.Contains(line, "=") {
+ parts := strings.SplitN(line, "=", 2)
+ if len(parts) == 2 {
+ bulgarian := strings.TrimSpace(parts[0])
+ english := strings.TrimSpace(parts[1])
+
+ if bulgarian == "" && english != "" {
+ // Format: "= ENGLISH" - need to translate English to Bulgarian
+ return &WordEntry{
+ Bulgarian: "",
+ Translation: english,
+ NeedsTranslation: true,
+ CardType: internal.CardTypeEnBg,
+ }
+ } else if bulgarian != "" && english != "" {
+ // Format: "BULGARIAN = ENGLISH" - both provided
+ return &WordEntry{
+ Bulgarian: bulgarian,
+ Translation: english,
NeedsTranslation: false,
- })
+ CardType: internal.CardTypeEnBg,
+ }
}
}
+ return nil
}
- return entries, nil
+ // Just a Bulgarian word - needs translation to English
+ return &WordEntry{
+ Bulgarian: line,
+ Translation: "",
+ NeedsTranslation: false,
+ CardType: internal.CardTypeEnBg,
+ }
}
// splitLines splits a string by newlines
diff --git a/internal/batch/processor_test.go b/internal/batch/processor_test.go
index fd9065b..fc4f7b0 100644
--- a/internal/batch/processor_test.go
+++ b/internal/batch/processor_test.go
@@ -5,6 +5,8 @@ import (
"path/filepath"
"reflect"
"testing"
+
+ "codeberg.org/snonux/totalrecall/internal"
)
func TestReadBatchFile(t *testing.T) {
@@ -30,9 +32,9 @@ func TestReadBatchFile(t *testing.T) {
котка = cat
куче = dog`,
want: []WordEntry{
- {Bulgarian: "ябълка", Translation: "apple", NeedsTranslation: false},
- {Bulgarian: "котка", Translation: "cat", NeedsTranslation: false},
- {Bulgarian: "куче", Translation: "dog", NeedsTranslation: false},
+ {Bulgarian: "ябълка", Translation: "apple", NeedsTranslation: false, CardType: internal.CardTypeEnBg},
+ {Bulgarian: "котка", Translation: "cat", NeedsTranslation: false, CardType: internal.CardTypeEnBg},
+ {Bulgarian: "куче", Translation: "dog", NeedsTranslation: false, CardType: internal.CardTypeEnBg},
},
},
{
@@ -42,10 +44,10 @@ func TestReadBatchFile(t *testing.T) {
куче
хляб = bread`,
want: []WordEntry{
- {Bulgarian: "ябълка", Translation: "", NeedsTranslation: false},
- {Bulgarian: "котка", Translation: "cat", NeedsTranslation: false},
- {Bulgarian: "куче", Translation: "", NeedsTranslation: false},
- {Bulgarian: "хляб", Translation: "bread", NeedsTranslation: false},
+ {Bulgarian: "ябълка", Translation: "", NeedsTranslation: false, CardType: internal.CardTypeEnBg},
+ {Bulgarian: "котка", Translation: "cat", NeedsTranslation: false, CardType: internal.CardTypeEnBg},
+ {Bulgarian: "куче", Translation: "", NeedsTranslation: false, CardType: internal.CardTypeEnBg},
+ {Bulgarian: "хляб", Translation: "bread", NeedsTranslation: false, CardType: internal.CardTypeEnBg},
},
},
{
@@ -59,25 +61,25 @@ func TestReadBatchFile(t *testing.T) {
`,
want: []WordEntry{
- {Bulgarian: "ябълка", Translation: "", NeedsTranslation: false},
- {Bulgarian: "котка", Translation: "cat", NeedsTranslation: false},
- {Bulgarian: "куче", Translation: "", NeedsTranslation: false},
+ {Bulgarian: "ябълка", Translation: "", NeedsTranslation: false, CardType: internal.CardTypeEnBg},
+ {Bulgarian: "котка", Translation: "cat", NeedsTranslation: false, CardType: internal.CardTypeEnBg},
+ {Bulgarian: "куче", Translation: "", NeedsTranslation: false, CardType: internal.CardTypeEnBg},
},
},
{
name: "windows line endings",
fileContent: "ябълка\r\nкотка = cat\r\nкуче",
want: []WordEntry{
- {Bulgarian: "ябълка", Translation: "", NeedsTranslation: false},
- {Bulgarian: "котка", Translation: "cat", NeedsTranslation: false},
- {Bulgarian: "куче", Translation: "", NeedsTranslation: false},
+ {Bulgarian: "ябълка", Translation: "", NeedsTranslation: false, CardType: internal.CardTypeEnBg},
+ {Bulgarian: "котка", Translation: "cat", NeedsTranslation: false, CardType: internal.CardTypeEnBg},
+ {Bulgarian: "куче", Translation: "", NeedsTranslation: false, CardType: internal.CardTypeEnBg},
},
},
{
name: "multiple equals signs",
fileContent: `test = word = with = equals`,
want: []WordEntry{
- {Bulgarian: "test", Translation: "word = with = equals", NeedsTranslation: false},
+ {Bulgarian: "test", Translation: "word = with = equals", NeedsTranslation: false, CardType: internal.CardTypeEnBg},
},
},
{
@@ -86,9 +88,9 @@ func TestReadBatchFile(t *testing.T) {
= cat
= dog`,
want: []WordEntry{
- {Bulgarian: "", Translation: "apple", NeedsTranslation: true},
- {Bulgarian: "", Translation: "cat", NeedsTranslation: true},
- {Bulgarian: "", Translation: "dog", NeedsTranslation: true},
+ {Bulgarian: "", Translation: "apple", NeedsTranslation: true, CardType: internal.CardTypeEnBg},
+ {Bulgarian: "", Translation: "cat", NeedsTranslation: true, CardType: internal.CardTypeEnBg},
+ {Bulgarian: "", Translation: "dog", NeedsTranslation: true, CardType: internal.CardTypeEnBg},
},
},
{
@@ -100,12 +102,34 @@ func TestReadBatchFile(t *testing.T) {
= table
стол`,
want: []WordEntry{
- {Bulgarian: "ябълка", Translation: "", NeedsTranslation: false},
- {Bulgarian: "котка", Translation: "cat", NeedsTranslation: false},
- {Bulgarian: "", Translation: "dog", NeedsTranslation: true},
- {Bulgarian: "хляб", Translation: "bread", NeedsTranslation: false},
- {Bulgarian: "", Translation: "table", NeedsTranslation: true},
- {Bulgarian: "стол", Translation: "", NeedsTranslation: false},
+ {Bulgarian: "ябълка", Translation: "", NeedsTranslation: false, CardType: internal.CardTypeEnBg},
+ {Bulgarian: "котка", Translation: "cat", NeedsTranslation: false, CardType: internal.CardTypeEnBg},
+ {Bulgarian: "", Translation: "dog", NeedsTranslation: true, CardType: internal.CardTypeEnBg},
+ {Bulgarian: "хляб", Translation: "bread", NeedsTranslation: false, CardType: internal.CardTypeEnBg},
+ {Bulgarian: "", Translation: "table", NeedsTranslation: true, CardType: internal.CardTypeEnBg},
+ {Bulgarian: "стол", Translation: "", NeedsTranslation: false, CardType: internal.CardTypeEnBg},
+ },
+ },
+ {
+ name: "bulgarian-bulgarian format with double equals",
+ fileContent: `ябълка == плод
+котка == домашно животно`,
+ want: []WordEntry{
+ {Bulgarian: "ябълка", Translation: "плод", NeedsTranslation: false, CardType: internal.CardTypeBgBg},
+ {Bulgarian: "котка", Translation: "домашно животно", NeedsTranslation: false, CardType: internal.CardTypeBgBg},
+ },
+ },
+ {
+ name: "mixed en-bg and bg-bg formats",
+ fileContent: `ябълка = apple
+котка == домашно животно
+куче = dog
+вода == течност`,
+ want: []WordEntry{
+ {Bulgarian: "ябълка", Translation: "apple", NeedsTranslation: false, CardType: internal.CardTypeEnBg},
+ {Bulgarian: "котка", Translation: "домашно животно", NeedsTranslation: false, CardType: internal.CardTypeBgBg},
+ {Bulgarian: "куче", Translation: "dog", NeedsTranslation: false, CardType: internal.CardTypeEnBg},
+ {Bulgarian: "вода", Translation: "течност", NeedsTranslation: false, CardType: internal.CardTypeBgBg},
},
},
}
diff --git a/internal/cardtype.go b/internal/cardtype.go
new file mode 100644
index 0000000..9b49236
--- /dev/null
+++ b/internal/cardtype.go
@@ -0,0 +1,58 @@
+package internal
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// CardType represents the type of flashcard
+type CardType string
+
+const (
+ // CardTypeEnBg represents English-Bulgarian cards (default)
+ CardTypeEnBg CardType = "en-bg"
+ // CardTypeBgBg represents Bulgarian-Bulgarian cards
+ CardTypeBgBg CardType = "bg-bg"
+)
+
+// String returns the string representation of the card type
+func (ct CardType) String() string {
+ return string(ct)
+}
+
+// IsBgBg returns true if this is a Bulgarian-Bulgarian card
+func (ct CardType) IsBgBg() bool {
+ return ct == CardTypeBgBg
+}
+
+// DisplayName returns a human-readable name for the card type
+func (ct CardType) DisplayName() string {
+ switch ct {
+ case CardTypeBgBg:
+ return "Bulgarian → Bulgarian"
+ default:
+ return "English → Bulgarian"
+ }
+}
+
+// SaveCardType saves the card type to a file in the card directory
+func SaveCardType(cardDir string, cardType CardType) error {
+ cardTypePath := filepath.Join(cardDir, "cardtype.txt")
+ return os.WriteFile(cardTypePath, []byte(string(cardType)), 0644)
+}
+
+// LoadCardType loads the card type from a card directory
+// Returns CardTypeEnBg as default for backwards compatibility
+func LoadCardType(cardDir string) CardType {
+ cardTypePath := filepath.Join(cardDir, "cardtype.txt")
+ data, err := os.ReadFile(cardTypePath)
+ if err != nil {
+ return CardTypeEnBg
+ }
+ cardType := CardType(strings.TrimSpace(string(data)))
+ if cardType == CardTypeBgBg {
+ return CardTypeBgBg
+ }
+ return CardTypeEnBg
+}
diff --git a/internal/gui/app.go b/internal/gui/app.go
index 9374973..cb0a4fe 100644
--- a/internal/gui/app.go
+++ b/internal/gui/app.go
@@ -39,6 +39,7 @@ type Application struct {
imageDisplay *ImageDisplay
audioPlayer *AudioPlayer
translationEntry *CustomEntry
+ cardTypeSelect *widget.Select
statusLabel *widget.Label
queueStatusLabel *widget.Label
imagePromptEntry *CustomMultiLineEntry
@@ -57,19 +58,21 @@ type Application struct {
deleteButton *ttwidget.Button
// State management
- currentWord string
- currentAudioFile string
- currentImage string
- currentTranslation string
- currentPhonetic string // Full phonetic information
- currentJobID int
- savedCards []anki.Card
- existingWords []string // Words already in anki_cards folder
- currentWordIndex int
- deleteConfirming bool // Track if we're in delete confirmation mode
- quitConfirming bool // Track if we're in quit confirmation mode
- wordChangeTimer *time.Timer // Timer for detecting word changes
- fileCheckTicker *time.Ticker // Ticker for checking missing files
+ currentWord string
+ currentAudioFile string
+ currentAudioFileBack string // Back audio file for bg-bg cards
+ currentImage string
+ currentTranslation string
+ currentPhonetic string // Full phonetic information
+ currentCardType string // Card type: "en-bg" or "bg-bg"
+ currentJobID int
+ savedCards []anki.Card
+ existingWords []string // Words already in anki_cards folder
+ currentWordIndex int
+ deleteConfirming bool // Track if we're in delete confirmation mode
+ quitConfirming bool // Track if we're in quit confirmation mode
+ wordChangeTimer *time.Timer // Timer for detecting word changes
+ fileCheckTicker *time.Ticker // Ticker for checking missing files
// Word processing queue
queue *WordQueue
@@ -257,6 +260,19 @@ func (a *Application) setupUI() {
a.window.Canvas().Unfocus()
})
+ // Create card type selector
+ a.cardTypeSelect = widget.NewSelect([]string{"English → Bulgarian", "Bulgarian → Bulgarian"}, func(selected string) {
+ if selected == "Bulgarian → Bulgarian" {
+ a.currentCardType = "bg-bg"
+ a.translationEntry.SetPlaceHolder("Bulgarian definition...")
+ } else {
+ a.currentCardType = "en-bg"
+ a.translationEntry.SetPlaceHolder("English translation...")
+ }
+ })
+ a.cardTypeSelect.SetSelected("English → Bulgarian")
+ a.currentCardType = "en-bg"
+
// Create navigation buttons (tooltips will be set after tooltip layer is created)
a.submitButton = ttwidget.NewButton("", a.onSubmit)
a.submitButton.Icon = theme.ConfirmIcon()
@@ -267,10 +283,11 @@ func (a *Application) setupUI() {
a.nextWordBtn = ttwidget.NewButton("", a.onNextWord)
a.nextWordBtn.Icon = theme.NavigateNextIcon()
- // Create a grid layout for inputs
- inputGrid := container.New(layout.NewGridLayout(2),
+ // Create a grid layout for inputs with card type selector
+ inputGrid := container.New(layout.NewGridLayout(3),
a.wordInput,
a.translationEntry,
+ a.cardTypeSelect,
)
inputSection := container.NewBorder(
@@ -468,51 +485,55 @@ func (a *Application) Run() {
// onSubmit handles word submission
func (a *Application) onSubmit() {
bulgarianText := strings.TrimSpace(a.wordInput.Text)
- englishText := strings.TrimSpace(a.translationEntry.Text)
+ secondaryText := strings.TrimSpace(a.translationEntry.Text)
+ isBgBg := a.currentCardType == "bg-bg"
// Determine which word to process and if translation is needed
var wordToProcess string
var needsTranslation bool
var translationDirection string
- if bulgarianText != "" && englishText != "" {
+ if isBgBg {
+ // Bulgarian-Bulgarian mode: both fields should be Bulgarian
+ if bulgarianText == "" {
+ return
+ }
+ wordToProcess = bulgarianText
+ needsTranslation = false
+ a.currentTranslation = secondaryText
+ } else if bulgarianText != "" && secondaryText != "" {
// Both provided - use Bulgarian as primary, no translation needed
wordToProcess = bulgarianText
needsTranslation = false
- a.currentTranslation = englishText
- } else if bulgarianText != "" && englishText == "" {
+ a.currentTranslation = secondaryText
+ } else if bulgarianText != "" && secondaryText == "" {
// Only Bulgarian provided - translate to English
wordToProcess = bulgarianText
needsTranslation = true
translationDirection = "bg-to-en"
- } else if bulgarianText == "" && englishText != "" {
+ } else if bulgarianText == "" && secondaryText != "" {
// Only English provided - translate to Bulgarian
needsTranslation = true
translationDirection = "en-to-bg"
- // We'll get the Bulgarian word after translation
} else {
- // Both empty
return
}
// Handle English to Bulgarian translation first if needed
if translationDirection == "en-to-bg" {
- a.updateStatus(fmt.Sprintf("Translating '%s' to Bulgarian...", englishText))
- bulgarian, err := a.translateEnglishToBulgarian(englishText)
+ a.updateStatus(fmt.Sprintf("Translating '%s' to Bulgarian...", secondaryText))
+ bulgarian, err := a.translateEnglishToBulgarian(secondaryText)
if err != nil {
dialog.ShowError(fmt.Errorf("Translation failed: %w", err), a.window)
return
}
wordToProcess = bulgarian
a.wordInput.SetText(bulgarian)
- a.currentTranslation = englishText
- // Update current word for saving
+ a.currentTranslation = secondaryText
a.currentWord = bulgarian
- // Save the translation immediately
a.saveTranslation()
- needsTranslation = false // We've already done the translation, don't translate back
+ needsTranslation = false
} else if translationDirection == "bg-to-en" {
- // Handle Bulgarian to English translation immediately
a.updateStatus(fmt.Sprintf("Translating '%s' to English...", bulgarianText))
english, err := a.translateWord(bulgarianText)
if err != nil {
@@ -521,8 +542,7 @@ func (a *Application) onSubmit() {
}
a.currentTranslation = english
a.translationEntry.SetText(english)
- needsTranslation = false // We've already done the translation
- // Save the translation immediately
+ needsTranslation = false
a.saveTranslation()
}
@@ -532,6 +552,14 @@ func (a *Application) onSubmit() {
return
}
+ // For bg-bg cards, also validate the back text
+ if isBgBg && secondaryText != "" {
+ if err := audio.ValidateBulgarianText(secondaryText); err != nil {
+ dialog.ShowError(fmt.Errorf("invalid back text: %w", err), a.window)
+ return
+ }
+ }
+
// Get custom prompt from the UI
customPrompt := a.imagePromptEntry.Text
@@ -540,13 +568,11 @@ func (a *Application) onSubmit() {
// Store whether translation is needed and the translation if already provided
job.NeedsTranslation = needsTranslation
+ job.CardType = a.currentCardType
if a.currentTranslation != "" {
job.Translation = a.currentTranslation
}
- // Don't clear the input fields yet - they should stay populated
- // until the user is ready to enter a new word
-
// Update status to show word was queued
a.updateStatus(fmt.Sprintf("Added '%s' to queue (Job #%d)", wordToProcess, job.ID))
@@ -1004,36 +1030,128 @@ func (a *Application) onRegenerateRandomImage() {
}()
}
-// onRegenerateAudio regenerates audio with a different voice
+// onRegenerateAudio regenerates front audio (or single audio for en-bg cards)
func (a *Application) onRegenerateAudio() {
// Only disable the audio-related buttons
a.regenerateAudioBtn.Disable()
a.regenerateAllBtn.Disable()
- a.showProgress("Regenerating audio...")
+
+ isBgBg := a.currentCardType == "bg-bg"
+ if isBgBg {
+ a.showProgress("Regenerating front audio...")
+ } else {
+ a.showProgress("Regenerating audio...")
+ }
a.incrementProcessing() // Audio processing starts
a.wg.Add(1)
go func() {
defer a.wg.Done()
- defer a.decrementProcessing() // Image processing ends
+ defer a.decrementProcessing()
+
+ // Store the word we're generating for
+ wordForGeneration := a.currentWord
+
+ a.startOperation(wordForGeneration)
+ defer a.endOperation(wordForGeneration)
+
+ // Get or create context for this card
+ cardCtx, _ := a.getOrCreateCardContext(wordForGeneration)
+
+ // Ensure card directory exists
+ cardDir, err := a.ensureCardDirectory(wordForGeneration)
+ if err != nil {
+ fyne.Do(func() {
+ a.showError(fmt.Errorf("Failed to create card directory: %w", err))
+ })
+ return
+ }
+
+ if isBgBg {
+ // For bg-bg cards, regenerate only front audio
+ audioFile, err := a.generateAudioFront(cardCtx, wordForGeneration, cardDir)
+ if err != nil {
+ fyne.Do(func() {
+ a.showError(fmt.Errorf("Front audio regeneration failed: %w", err))
+ })
+ } else {
+ a.mu.Lock()
+ if a.currentWord == wordForGeneration {
+ a.currentAudioFile = audioFile
+ a.mu.Unlock()
+ fyne.Do(func() {
+ a.mu.Lock()
+ if a.currentWord == wordForGeneration {
+ a.audioPlayer.SetAudioFile(audioFile)
+ }
+ a.mu.Unlock()
+ })
+ } else {
+ a.mu.Unlock()
+ }
+ }
+ } else {
+ // For en-bg cards, regenerate single audio file
+ audioFile, err := a.generateAudio(cardCtx, wordForGeneration, cardDir)
+ if err != nil {
+ fyne.Do(func() {
+ a.showError(fmt.Errorf("Audio regeneration failed: %w", err))
+ })
+ } else {
+ a.mu.Lock()
+ if a.currentWord == wordForGeneration {
+ a.currentAudioFile = audioFile
+ a.mu.Unlock()
+ fyne.Do(func() {
+ a.mu.Lock()
+ if a.currentWord == wordForGeneration {
+ a.audioPlayer.SetAudioFile(audioFile)
+ }
+ a.mu.Unlock()
+ })
+ } else {
+ a.mu.Unlock()
+ }
+ }
+ }
+
+ fyne.Do(func() {
+ a.hideProgress()
+ a.regenerateAudioBtn.Enable()
+ a.regenerateAllBtn.Enable()
+ })
+ }()
+}
+
+// onRegenerateBackAudio regenerates back audio for bg-bg cards
+func (a *Application) onRegenerateBackAudio() {
+ if a.currentCardType != "bg-bg" {
+ return
+ }
+
+ a.regenerateAudioBtn.Disable()
+ a.regenerateAllBtn.Disable()
+ a.showProgress("Regenerating back audio...")
+
+ a.incrementProcessing()
+
+ a.wg.Add(1)
+ go func() {
+ defer a.wg.Done()
+ defer a.decrementProcessing()
- // Use the current translation to avoid re-translating
translation := a.currentTranslation
if translation == "" {
- // Use the text from translationEntry if currentTranslation is not set
translation = strings.TrimSpace(a.translationEntry.Text)
}
- // Store the word we're generating for
wordForGeneration := a.currentWord
- a.startOperation(wordForGeneration) // Track operation start
- defer a.endOperation(wordForGeneration) // Track operation end
+ a.startOperation(wordForGeneration)
+ defer a.endOperation(wordForGeneration)
- // Get or create context for this card
cardCtx, _ := a.getOrCreateCardContext(wordForGeneration)
- // Ensure card directory exists
cardDir, err := a.ensureCardDirectory(wordForGeneration)
if err != nil {
fyne.Do(func() {
@@ -1042,22 +1160,20 @@ func (a *Application) onRegenerateAudio() {
return
}
- audioFile, err := a.generateAudio(cardCtx, wordForGeneration, cardDir)
+ audioFile, err := a.generateAudioBack(cardCtx, translation, cardDir)
if err != nil {
fyne.Do(func() {
- a.showError(fmt.Errorf("Audio regeneration failed: %w", err))
+ a.showError(fmt.Errorf("Back audio regeneration failed: %w", err))
})
} else {
- // Only update if we're still on the same word
a.mu.Lock()
if a.currentWord == wordForGeneration {
- a.currentAudioFile = audioFile
+ a.currentAudioFileBack = audioFile
a.mu.Unlock()
fyne.Do(func() {
- // Double-check inside the UI update that we're still on the same word
a.mu.Lock()
if a.currentWord == wordForGeneration {
- a.audioPlayer.SetAudioFile(audioFile)
+ a.audioPlayer.SetBackAudioFile(audioFile)
}
a.mu.Unlock()
})
@@ -1068,7 +1184,6 @@ func (a *Application) onRegenerateAudio() {
fyne.Do(func() {
a.hideProgress()
- // Re-enable audio-related buttons
a.regenerateAudioBtn.Enable()
a.regenerateAllBtn.Enable()
})
@@ -1416,9 +1531,13 @@ func (a *Application) onShowHotkeys() {
## Regeneration
**i/и** Regenerate image
**m/м** Random image
-**a/а** Regenerate audio
+**a/а** Regenerate audio (front for bg-bg)
+**A/А** Regenerate back audio (bg-bg only)
**r/р** Regenerate all
-**p/п** Play audio
+
+## Playback
+**p/п** Play audio (front for bg-bg)
+**P/П** Play back audio (bg-bg only)
**u/у** Toggle auto-play
## Export & Archive
@@ -1694,7 +1813,7 @@ func (a *Application) setupTooltips() {
a.regenerateRandomImageBtn.SetToolTip("Random image (m)")
}
if a.regenerateAudioBtn != nil {
- a.regenerateAudioBtn.SetToolTip("Regenerate audio (a)")
+ a.regenerateAudioBtn.SetToolTip("Regenerate audio (a/A for back)")
}
if a.regenerateAllBtn != nil {
a.regenerateAllBtn.SetToolTip("Regenerate all (r)")
@@ -1708,7 +1827,10 @@ func (a *Application) setupTooltips() {
// Audio player tooltips
if a.audioPlayer != nil && a.audioPlayer.playButton != nil {
- a.audioPlayer.playButton.SetToolTip("Play audio (p)")
+ a.audioPlayer.playButton.SetToolTip("Play audio (p/P for back)")
+ }
+ if a.audioPlayer != nil && a.audioPlayer.playBackButton != nil {
+ a.audioPlayer.playBackButton.SetToolTip("Play back audio (P)")
}
if a.audioPlayer != nil && a.audioPlayer.stopButton != nil {
a.audioPlayer.stopButton.SetToolTip("Stop audio")
@@ -1860,12 +1982,22 @@ func (a *Application) processWordJob(job *WordJob) {
return
}
+ // Determine if this is a bg-bg card
+ isBgBg := job.CardType == "bg-bg"
+
+ // Save card type
+ if isBgBg {
+ internal.SaveCardType(cardDir, internal.CardTypeBgBg)
+ } else {
+ internal.SaveCardType(cardDir, internal.CardTypeEnBg)
+ }
+
// Handle translation
var translation string
var err error
- if job.NeedsTranslation {
- // Translate word
+ if job.NeedsTranslation && !isBgBg {
+ // Translate word (only for en-bg cards)
fyne.Do(func() {
a.updateStatus(fmt.Sprintf("Translating '%s'...", job.Word))
})
@@ -1900,8 +2032,9 @@ func (a *Application) processWordJob(job *WordJob) {
// Create channels for parallel operations
type audioResult struct {
- file string
- err error
+ file string
+ fileBack string
+ err error
}
type imageResult struct {
file string
@@ -1925,17 +2058,25 @@ func (a *Application) processWordJob(job *WordJob) {
// 1. Audio generation
go func() {
- a.startOperation(job.Word) // Track operation start
- defer a.endOperation(job.Word) // Track operation end
+ a.startOperation(job.Word)
+ defer a.endOperation(job.Word)
fyne.Do(func() {
- a.incrementProcessing() // Audio processing starts
+ a.incrementProcessing()
})
- audioFile, err := a.generateAudio(cardCtx, job.Word, cardDir)
- a.decrementProcessing() // Audio processing ends
+ var audioFile, audioFileBack string
+ var err error
- audioChan <- audioResult{file: audioFile, err: err}
+ if isBgBg && translation != "" {
+ // Generate audio for both sides
+ audioFile, audioFileBack, err = a.generateAudioBgBg(cardCtx, job.Word, translation, cardDir)
+ } else {
+ audioFile, err = a.generateAudio(cardCtx, job.Word, cardDir)
+ }
+ a.decrementProcessing()
+
+ audioChan <- audioResult{file: audioFile, fileBack: audioFileBack, err: err}
}()
// 2. Image generation (includes scene description)
@@ -2010,7 +2151,7 @@ func (a *Application) processWordJob(job *WordJob) {
}()
// Wait for all operations to complete
- var audioFile, imageFile string
+ var audioFile, audioFileBack, imageFile string
var phoneticInfo string
var hasError bool
@@ -2021,18 +2162,19 @@ func (a *Application) processWordJob(job *WordJob) {
hasError = true
} else {
audioFile = audioRes.file
+ audioFileBack = audioRes.fileBack
// Update UI with audio immediately if this is still the current job
a.mu.Lock()
isCurrentJob := a.currentJobID == job.ID
if isCurrentJob {
a.currentAudioFile = audioFile
+ a.currentAudioFileBack = audioFileBack
}
a.mu.Unlock()
if isCurrentJob {
fyne.Do(func() {
- // Double-check that we're still on the same job before updating UI
a.mu.Lock()
if a.currentJobID != job.ID {
a.mu.Unlock()
@@ -2041,7 +2183,9 @@ func (a *Application) processWordJob(job *WordJob) {
a.mu.Unlock()
a.audioPlayer.SetAudioFile(audioFile)
- // Enable audio-related actions
+ if isBgBg && audioFileBack != "" {
+ a.audioPlayer.SetBackAudioFile(audioFileBack)
+ }
a.regenerateAudioBtn.Enable()
})
}
@@ -2282,10 +2426,14 @@ func (a *Application) setupKeyboardShortcuts() {
if !a.regenerateRandomImageBtn.Disabled() {
a.onRegenerateRandomImage()
}
- case 'а', 'А': // а = a
+ case 'a', 'а': // a = regenerate front audio
if !a.regenerateAudioBtn.Disabled() {
a.onRegenerateAudio()
}
+ case 'A', 'А': // A = regenerate back audio (for bg-bg cards)
+ if a.currentCardType == "bg-bg" && !a.regenerateAudioBtn.Disabled() {
+ a.onRegenerateBackAudio()
+ }
case 'р', 'Р': // р = r
if !a.regenerateAllBtn.Disabled() {
a.onRegenerateAll()
@@ -2294,10 +2442,14 @@ func (a *Application) setupKeyboardShortcuts() {
if !a.deleteButton.Disabled() {
a.onDelete()
}
- case 'п', 'П': // п = p (play audio)
+ case 'p', 'п': // p = play front audio
if a.currentAudioFile != "" {
a.audioPlayer.Play()
}
+ case 'P', 'П': // P = play back audio (for bg-bg cards)
+ if a.currentAudioFileBack != "" {
+ a.audioPlayer.PlayBack()
+ }
case 'ж', 'Ж': // ж = x
a.onExportToAnki()
case 'в', 'В': // в = v
diff --git a/internal/gui/audio_player.go b/internal/gui/audio_player.go
index c0b3a0d..f9d0ad7 100644
--- a/internal/gui/audio_player.go
+++ b/internal/gui/audio_player.go
@@ -21,13 +21,18 @@ import (
type AudioPlayer struct {
widget.BaseWidget
- container *fyne.Container
- playButton *ttwidget.Button
- stopButton *ttwidget.Button
- statusLabel *widget.Label
- phoneticLabel *widget.Label
+ container *fyne.Container
+ playButton *ttwidget.Button
+ playButtonLabel *widget.Label // Label for front audio button
+ playBackButton *ttwidget.Button // Play back audio for bg-bg cards
+ playBackLabel *widget.Label // Label for back audio button
+ stopButton *ttwidget.Button
+ statusLabel *widget.Label
+ phoneticLabel *widget.Label
audioFile string
+ audioFileBack string // Back audio file for bg-bg cards
+ isBgBg bool // Track if this is a bg-bg card
isPlaying bool
playCmd *exec.Cmd
voiceInfo string // Stores voice and speed info
@@ -41,6 +46,15 @@ func NewAudioPlayer() *AudioPlayer {
// Create controls (tooltips will be set later after tooltip layer is created)
p.playButton = ttwidget.NewButton("", p.onPlay)
p.playButton.Icon = theme.MediaPlayIcon()
+
+ p.playButtonLabel = widget.NewLabel("")
+ p.playButtonLabel.TextStyle = fyne.TextStyle{Bold: true}
+
+ p.playBackButton = ttwidget.NewButton("", p.onPlayBack)
+ p.playBackButton.Icon = theme.MediaSkipNextIcon()
+
+ p.playBackLabel = widget.NewLabel("")
+ p.playBackLabel.TextStyle = fyne.TextStyle{Bold: true}
p.stopButton = ttwidget.NewButton("", p.onStop)
p.stopButton.Icon = theme.MediaStopIcon()
@@ -56,11 +70,17 @@ func NewAudioPlayer() *AudioPlayer {
// Initially disable controls
p.playButton.Disable()
+ p.playBackButton.Disable()
+ p.playBackButton.Hide() // Only show for bg-bg cards
+ p.playBackLabel.Hide()
p.stopButton.Disable()
// Create main container with phonetic display
p.container = container.NewHBox(
- p.playButton,
+ container.NewVBox(
+ container.NewHBox(p.playButton, p.playButtonLabel),
+ container.NewHBox(p.playBackButton, p.playBackLabel),
+ ),
p.stopButton,
p.phoneticLabel,
layout.NewSpacer(),
@@ -108,6 +128,13 @@ func (p *AudioPlayer) SetAudioFile(audioFile string) {
p.voiceInfo = ""
}
+ // Update button label based on whether this is bg-bg
+ if p.isBgBg {
+ p.playButtonLabel.SetText("Front")
+ } else {
+ p.playButtonLabel.SetText("")
+ }
+
// Format status text with voice and speed info
statusText := fmt.Sprintf("Audio: %s%s", filepath.Base(audioFile), p.voiceInfo)
p.statusLabel.SetText(statusText)
@@ -128,16 +155,53 @@ func (p *AudioPlayer) SetAudioFile(audioFile string) {
}
}
+// SetBackAudioFile sets the back audio file for bg-bg cards
+func (p *AudioPlayer) SetBackAudioFile(audioFile string) {
+ p.audioFileBack = audioFile
+ if audioFile != "" {
+ p.isBgBg = true
+ p.playBackButton.Enable()
+ p.playBackButton.Show()
+ p.playBackLabel.SetText("Back")
+ p.playBackLabel.Show()
+ // Update front label now that we know it's bg-bg
+ p.playButtonLabel.SetText("Front")
+ } else {
+ p.isBgBg = false
+ p.playBackButton.Disable()
+ p.playBackButton.Hide()
+ p.playBackLabel.SetText("")
+ p.playBackLabel.Hide()
+ // Clear front label if not bg-bg
+ p.playButtonLabel.SetText("")
+ }
+ // Refresh container to update layout after show/hide
+ if p.container != nil {
+ p.container.Refresh()
+ }
+}
+
// Clear clears the audio player
func (p *AudioPlayer) Clear() {
- p.onStop() // Stop any playing audio
+ p.onStop()
p.audioFile = ""
+ p.audioFileBack = ""
+ p.isBgBg = false
p.isPlaying = false
p.voiceInfo = ""
p.playButton.Disable()
+ p.playBackButton.Disable()
+ p.playBackButton.Hide()
+ p.playButtonLabel.SetText("")
+ p.playBackLabel.SetText("")
+ p.playBackLabel.Hide()
p.stopButton.Disable()
p.statusLabel.SetText("No audio loaded")
p.phoneticLabel.SetText("")
+ // Refresh container to update layout after hiding back button
+ if p.container != nil {
+ p.container.Refresh()
+ }
}
// SetPhonetic sets the phonetic transcription text
@@ -179,6 +243,35 @@ func (p *AudioPlayer) onPlay() {
p.statusLabel.SetText(fmt.Sprintf("Playing: %s%s", filepath.Base(p.audioFile), p.voiceInfo))
}
+// onPlayBack handles back audio button click (for bg-bg cards)
+func (p *AudioPlayer) onPlayBack() {
+ if p.audioFileBack == "" {
+ return
+ }
+
+ if p.isPlaying {
+ p.onStop()
+ }
+
+ // Temporarily swap audio files to play the back audio
+ originalFile := p.audioFile
+ p.audioFile = p.audioFileBack
+
+ if err := p.startPlayback(); err != nil {
+ p.statusLabel.SetText(fmt.Sprintf("Error: %v", err))
+ p.audioFile = originalFile
+ return
+ }
+
+ p.isPlaying = true
+ p.playButton.SetIcon(theme.MediaPauseIcon())
+ p.stopButton.Enable()
+ p.statusLabel.SetText(fmt.Sprintf("Playing back audio: %s", filepath.Base(p.audioFileBack)))
+
+ // Restore original file after playback starts
+ p.audioFile = originalFile
+}
+
// onStop handles stop button click
func (p *AudioPlayer) onStop() {
if p.playCmd != nil && p.playCmd.Process != nil {
@@ -201,6 +294,15 @@ func (p *AudioPlayer) Play() {
}
}
+// PlayBack triggers back audio playback (for bg-bg cards)
+func (p *AudioPlayer) PlayBack() {
+ if !p.playBackButton.Disabled() {
+ fyne.Do(func() {
+ p.onPlayBack()
+ })
+ }
+}
+
// startPlayback starts audio playback using platform-specific commands
func (p *AudioPlayer) startPlayback() error {
var cmd *exec.Cmd
diff --git a/internal/gui/generator.go b/internal/gui/generator.go
index cfb8ad3..a3a0a4d 100644
--- a/internal/gui/generator.go
+++ b/internal/gui/generator.go
@@ -152,6 +152,123 @@ func (a *Application) generateAudio(ctx context.Context, word string, cardDir st
return outputFile, nil
}
+// generateAudioFront generates front audio for a bg-bg card
+func (a *Application) generateAudioFront(ctx context.Context, word string, cardDir string) (string, error) {
+ if cardDir == "" {
+ return "", fmt.Errorf("card directory not provided")
+ }
+
+ allVoices := []string{"alloy", "ash", "ballad", "coral", "echo", "fable", "onyx", "nova", "sage", "shimmer", "verse"}
+ rand.Seed(time.Now().UnixNano())
+ voice := allVoices[rand.Intn(len(allVoices))]
+ speed := 0.90 + rand.Float64()*0.10
+
+ audioConfig := *a.audioConfig
+ audioConfig.OpenAIVoice = voice
+ audioConfig.OpenAISpeed = speed
+ audioConfig.OutputDir = a.config.OutputDir
+
+ provider, err := audio.NewProvider(&audioConfig)
+ if err != nil {
+ return "", err
+ }
+
+ fmt.Printf("Generating front audio for '%s' with voice: %s, speed: %.2f\n", word, voice, speed)
+ frontFile := filepath.Join(cardDir, fmt.Sprintf("audio_front.%s", a.config.AudioFormat))
+ if err := provider.GenerateAudio(ctx, word, frontFile); err != nil {
+ return "", fmt.Errorf("failed to generate front audio: %w", err)
+ }
+
+ // Update metadata
+ metadataFile := filepath.Join(cardDir, "audio_metadata.txt")
+ metadata := fmt.Sprintf("voice=%s\nspeed=%.2f\ncardtype=bg-bg\n", voice, speed)
+ if err := os.WriteFile(metadataFile, []byte(metadata), 0644); err != nil {
+ fmt.Printf("Warning: Failed to save audio metadata: %v\n", err)
+ }
+
+ return frontFile, nil
+}
+
+// generateAudioBack generates back audio for a bg-bg card
+func (a *Application) generateAudioBack(ctx context.Context, text string, cardDir string) (string, error) {
+ if cardDir == "" {
+ return "", fmt.Errorf("card directory not provided")
+ }
+
+ allVoices := []string{"alloy", "ash", "ballad", "coral", "echo", "fable", "onyx", "nova", "sage", "shimmer", "verse"}
+ rand.Seed(time.Now().UnixNano())
+ voice := allVoices[rand.Intn(len(allVoices))]
+ speed := 0.90 + rand.Float64()*0.10
+
+ audioConfig := *a.audioConfig
+ audioConfig.OpenAIVoice = voice
+ audioConfig.OpenAISpeed = speed
+ audioConfig.OutputDir = a.config.OutputDir
+
+ provider, err := audio.NewProvider(&audioConfig)
+ if err != nil {
+ return "", err
+ }
+
+ fmt.Printf("Generating back audio for '%s' with voice: %s, speed: %.2f\n", text, voice, speed)
+ backFile := filepath.Join(cardDir, fmt.Sprintf("audio_back.%s", a.config.AudioFormat))
+ if err := provider.GenerateAudio(ctx, text, backFile); err != nil {
+ return "", fmt.Errorf("failed to generate back audio: %w", err)
+ }
+
+ return backFile, nil
+}
+
+// generateAudioBgBg generates audio for both sides of a bg-bg card
+func (a *Application) generateAudioBgBg(ctx context.Context, front, back, cardDir string) (string, string, error) {
+ if cardDir == "" {
+ return "", "", fmt.Errorf("card directory not provided")
+ }
+
+ allVoices := []string{"alloy", "ash", "ballad", "coral", "echo", "fable", "onyx", "nova", "sage", "shimmer", "verse"}
+ rand.Seed(time.Now().UnixNano())
+ voice := allVoices[rand.Intn(len(allVoices))]
+ speed := 0.90 + rand.Float64()*0.10
+
+ audioConfig := *a.audioConfig
+ audioConfig.OpenAIVoice = voice
+ audioConfig.OpenAISpeed = speed
+ audioConfig.OutputDir = a.config.OutputDir
+
+ provider, err := audio.NewProvider(&audioConfig)
+ if err != nil {
+ return "", "", err
+ }
+
+ // Generate front audio
+ fmt.Printf("Generating front audio for '%s' with voice: %s, speed: %.2f\n", front, voice, speed)
+ frontFile := filepath.Join(cardDir, fmt.Sprintf("audio_front.%s", a.config.AudioFormat))
+ if err := provider.GenerateAudio(ctx, front, frontFile); err != nil {
+ return "", "", fmt.Errorf("failed to generate front audio: %w", err)
+ }
+
+ // Generate back audio
+ fmt.Printf("Generating back audio for '%s' with voice: %s, speed: %.2f\n", back, voice, speed)
+ backFile := filepath.Join(cardDir, fmt.Sprintf("audio_back.%s", a.config.AudioFormat))
+ if err := provider.GenerateAudio(ctx, back, backFile); err != nil {
+ return frontFile, "", fmt.Errorf("failed to generate back audio: %w", err)
+ }
+
+ // Save audio attribution
+ if err := a.saveAudioAttribution(front, frontFile, voice, speed); err != nil {
+ fmt.Printf("Warning: Failed to save audio attribution: %v\n", err)
+ }
+
+ // Save voice metadata
+ metadataFile := filepath.Join(cardDir, "audio_metadata.txt")
+ metadata := fmt.Sprintf("voice=%s\nspeed=%.2f\ncardtype=bg-bg\n", voice, speed)
+ if err := os.WriteFile(metadataFile, []byte(metadata), 0644); err != nil {
+ fmt.Printf("Warning: Failed to save audio metadata: %v\n", err)
+ }
+
+ return frontFile, backFile, nil
+}
+
// generateImages downloads images for a word
func (a *Application) generateImages(ctx context.Context, word string, cardDir string) (string, error) {
return a.generateImagesWithPrompt(ctx, word, "", "", cardDir)
diff --git a/internal/gui/navigation.go b/internal/gui/navigation.go
index 4b1f8bf..82176d2 100644
--- a/internal/gui/navigation.go
+++ b/internal/gui/navigation.go
@@ -429,26 +429,40 @@ func (a *Application) loadExistingFiles(word string) {
frontAudio := filepath.Join(wordDir, fmt.Sprintf("audio_front.%s", a.config.AudioFormat))
backAudio := filepath.Join(wordDir, fmt.Sprintf("audio_back.%s", a.config.AudioFormat))
+ fmt.Printf("Looking for front audio at: %s\n", frontAudio)
+ fmt.Printf("Looking for back audio at: %s\n", backAudio)
+
if _, err := os.Stat(frontAudio); err == nil {
+ fmt.Printf("Found front audio file: %s\n", frontAudio)
a.currentAudioFile = frontAudio
fyne.Do(func() {
a.audioPlayer.SetAudioFile(frontAudio)
})
+ } else {
+ fmt.Printf("Front audio file not found (error: %v)\n", err)
}
+
if _, err := os.Stat(backAudio); err == nil {
+ fmt.Printf("Found back audio file: %s\n", backAudio)
a.currentAudioFileBack = backAudio
fyne.Do(func() {
a.audioPlayer.SetBackAudioFile(backAudio)
})
+ } else {
+ fmt.Printf("Back audio file not found (error: %v)\n", err)
}
} else {
// For en-bg cards, load standard audio file
audioFile := filepath.Join(wordDir, fmt.Sprintf("audio.%s", a.config.AudioFormat))
+ fmt.Printf("Looking for audio at: %s\n", audioFile)
if _, err := os.Stat(audioFile); err == nil {
+ fmt.Printf("Found audio file: %s\n", audioFile)
a.currentAudioFile = audioFile
fyne.Do(func() {
a.audioPlayer.SetAudioFile(audioFile)
})
+ } else {
+ fmt.Printf("Audio file not found (error: %v)\n", err)
}
// Hide back audio button for en-bg cards
a.currentAudioFileBack = ""
diff --git a/internal/gui/queue.go b/internal/gui/queue.go
index 1c52c9f..86c6ffd 100644
--- a/internal/gui/queue.go
+++ b/internal/gui/queue.go
@@ -13,6 +13,7 @@ type WordJob struct {
Word string
Translation string
AudioFile string
+ AudioFileBack string // Back audio file (only for bg-bg cards)
ImageFile string // Changed from ImageFiles []string to single image
Status JobStatus
Error error
@@ -20,6 +21,7 @@ type WordJob struct {
CompletedAt time.Time
CustomPrompt string // Custom prompt for image generation
NeedsTranslation bool // Whether translation is needed
+ CardType string // Card type: "en-bg" or "bg-bg"
}
// JobStatus represents the current state of a job
diff --git a/internal/processor/processor.go b/internal/processor/processor.go
index 4bb6b0a..0947961 100644
--- a/internal/processor/processor.go
+++ b/internal/processor/processor.go
@@ -103,10 +103,9 @@ func (p *Processor) ProcessBatch() error {
fmt.Printf(" [DEBUG] Word is not fully processed, will process it\n")
}
- if err := p.ProcessWordWithTranslation(entry.Bulgarian, entry.Translation); err != nil {
+ if err := p.ProcessWordWithTranslationAndType(entry.Bulgarian, entry.Translation, entry.CardType); err != nil {
fmt.Fprintf(os.Stderr, "Error processing '%s': %v\n", entry.Bulgarian, err)
errorCount++
- // Continue with next word
} else {
processedCount++
}
@@ -141,38 +140,52 @@ func (p *Processor) ProcessSingleWord(word string) error {
return p.ProcessWordWithTranslation(word, "")
}
-// ProcessWordWithTranslation processes a word with optional provided translation
+// ProcessWordWithTranslation processes a word with optional provided translation (en-bg mode)
func (p *Processor) ProcessWordWithTranslation(word, providedTranslation string) error {
+ return p.ProcessWordWithTranslationAndType(word, providedTranslation, internal.CardTypeEnBg)
+}
+
+// ProcessWordWithTranslationAndType processes a word with optional provided translation and card type
+func (p *Processor) ProcessWordWithTranslationAndType(word, providedTranslation string, cardType internal.CardType) error {
var translationText string
- // Use provided translation if available, otherwise translate
+ // For bg-bg cards, translation is the back side (Bulgarian definition)
+ // For en-bg cards, translation is the English word
if providedTranslation != "" {
translationText = providedTranslation
- fmt.Printf(" Using provided translation: %s\n", translationText)
- } else {
- // Translate the word first
+ if cardType.IsBgBg() {
+ fmt.Printf(" Using provided definition: %s\n", translationText)
+ } else {
+ fmt.Printf(" Using provided translation: %s\n", translationText)
+ }
+ } else if !cardType.IsBgBg() {
+ // Only translate to English for en-bg cards
fmt.Printf(" Translating to English...\n")
var err error
translationText, err = p.translator.TranslateWord(word)
if err != nil {
fmt.Printf(" Warning: Translation failed: %v\n", err)
- translationText = "" // Continue without translation
+ translationText = ""
} else {
fmt.Printf(" Translation: %s\n", translationText)
}
}
+ // Find or create word directory
+ wordDir := p.findOrCreateWordDirectory(word)
+
+ // Save card type
+ if err := internal.SaveCardType(wordDir, cardType); err != nil {
+ fmt.Printf(" Warning: Failed to save card type: %v\n", err)
+ }
+
// Store translation for Anki export
if translationText != "" {
p.translationCache.Add(word, translationText)
- // Find or create word directory
- wordDir := p.findOrCreateWordDirectory(word)
-
// Check if translation file already exists
translationFile := filepath.Join(wordDir, "translation.txt")
if _, err := os.Stat(translationFile); os.IsNotExist(err) {
- // Save translation to file
if err := translation.SaveTranslation(wordDir, word, translationText); err != nil {
fmt.Printf(" Warning: Failed to save translation: %v\n", err)
}
@@ -183,9 +196,7 @@ func (p *Processor) ProcessWordWithTranslation(word, providedTranslation string)
// Fetch phonetic information
fmt.Printf(" Fetching phonetic information...\n")
- wordDir := p.findOrCreateWordDirectory(word)
if err := p.phoneticFetcher.FetchAndSave(word, wordDir); err != nil {
- // Don't fail the whole process if phonetic info fails
fmt.Printf(" Warning: Failed to fetch phonetic info: %v\n", err)
} else {
fmt.Printf(" Saved phonetic information\n")
@@ -194,8 +205,15 @@ func (p *Processor) ProcessWordWithTranslation(word, providedTranslation string)
// Generate audio
if !p.flags.SkipAudio {
fmt.Printf(" Generating audio...\n")
- if err := p.generateAudio(word); err != nil {
- return fmt.Errorf("audio generation failed: %w", err)
+ if cardType.IsBgBg() {
+ // Generate audio for both sides
+ if err := p.generateAudioBgBg(word, translationText); err != nil {
+ return fmt.Errorf("audio generation failed: %w", err)
+ }
+ } else {
+ if err := p.generateAudio(word); err != nil {
+ return fmt.Errorf("audio generation failed: %w", err)
+ }
}
}
@@ -242,8 +260,39 @@ func (p *Processor) generateAudio(word string) error {
return nil
}
+// generateAudioBgBg generates audio files for both sides of a bg-bg card
+func (p *Processor) generateAudioBgBg(front, back string) error {
+ allVoicesList := []string{"alloy", "ash", "ballad", "coral", "echo", "fable", "onyx", "nova", "sage", "shimmer", "verse"}
+
+ // Select a random voice (same voice for both sides for consistency)
+ voice := allVoicesList[rand.Intn(len(allVoicesList))]
+ if p.flags.OpenAIVoice != "" {
+ voice = p.flags.OpenAIVoice
+ }
+ fmt.Printf(" Using voice: %s\n", voice)
+
+ // Generate front audio
+ fmt.Printf(" Generating front audio for '%s'...\n", front)
+ if err := p.generateAudioWithVoiceAndFilename(front, voice, "audio_front"); err != nil {
+ return fmt.Errorf("failed to generate front audio: %w", err)
+ }
+
+ // Generate back audio
+ fmt.Printf(" Generating back audio for '%s'...\n", back)
+ if err := p.generateAudioWithVoiceAndFilename(back, voice, "audio_back"); err != nil {
+ return fmt.Errorf("failed to generate back audio: %w", err)
+ }
+
+ return nil
+}
+
// generateAudioWithVoice generates audio for a word with a specific voice
func (p *Processor) generateAudioWithVoice(word, voice string) error {
+ return p.generateAudioWithVoiceAndFilename(word, voice, "audio")
+}
+
+// generateAudioWithVoiceAndFilename generates audio for a word with a specific voice and filename
+func (p *Processor) generateAudioWithVoiceAndFilename(word, voice, filenameBase string) error {
// Generate random speed between 0.90 and 1.00 if not explicitly set
speed := p.flags.OpenAISpeed
if p.flags.OpenAISpeed == 0.9 && !viper.IsSet("audio.openai_speed") {
@@ -288,12 +337,12 @@ func (p *Processor) generateAudioWithVoice(word, voice string) error {
// Find existing card directory or create new one
wordDir := p.findOrCreateWordDirectory(word)
- // Add voice name to filename if generating multiple voices
+ // Build filename using the provided base
var outputFile string
- if p.flags.AllVoices {
- outputFile = filepath.Join(wordDir, fmt.Sprintf("audio_%s.%s", voice, p.flags.AudioFormat))
+ if p.flags.AllVoices && filenameBase == "audio" {
+ outputFile = filepath.Join(wordDir, fmt.Sprintf("%s_%s.%s", filenameBase, voice, p.flags.AudioFormat))
} else {
- outputFile = filepath.Join(wordDir, fmt.Sprintf("audio.%s", p.flags.AudioFormat))
+ outputFile = filepath.Join(wordDir, fmt.Sprintf("%s.%s", filenameBase, p.flags.AudioFormat))
}
// Generate the audio
@@ -581,23 +630,42 @@ func (p *Processor) isWordFullyProcessed(word string) bool {
// Check for audio-related files (unless skipped)
if !p.flags.SkipAudio {
- // Add audio-related files to required list
- requiredFiles = append(requiredFiles,
- "audio_attribution.txt",
- "audio_metadata.txt",
- )
-
- // Check for audio file (without voice suffix for single voice mode)
- audioFile := filepath.Join(wordDir, fmt.Sprintf("audio.%s", p.flags.AudioFormat))
- if _, err := os.Stat(audioFile); os.IsNotExist(err) {
- // Also check for audio files with voice suffix (for all-voices mode)
- audioPattern := fmt.Sprintf("audio_*.%s", p.flags.AudioFormat)
- matches, _ := filepath.Glob(filepath.Join(wordDir, audioPattern))
- if len(matches) == 0 {
+ // Load card type to determine required audio files
+ cardType := internal.LoadCardType(wordDir)
+
+ if cardType.IsBgBg() {
+ // For bg-bg cards, check for audio_front and audio_back
+ frontAudio := filepath.Join(wordDir, fmt.Sprintf("audio_front.%s", p.flags.AudioFormat))
+ backAudio := filepath.Join(wordDir, fmt.Sprintf("audio_back.%s", p.flags.AudioFormat))
+ if _, err := os.Stat(frontAudio); os.IsNotExist(err) {
if os.Getenv("DEBUG_BATCH") != "" {
- fmt.Printf(" [DEBUG] No audio file found: %s or pattern %s\n", audioFile, audioPattern)
+ fmt.Printf(" [DEBUG] No front audio file found: %s\n", frontAudio)
+ }
+ return false
+ }
+ if _, err := os.Stat(backAudio); os.IsNotExist(err) {
+ if os.Getenv("DEBUG_BATCH") != "" {
+ fmt.Printf(" [DEBUG] No back audio file found: %s\n", backAudio)
+ }
+ return false
+ }
+ } else {
+ // For en-bg cards, check for standard audio file
+ requiredFiles = append(requiredFiles,
+ "audio_attribution.txt",
+ "audio_metadata.txt",
+ )
+
+ audioFile := filepath.Join(wordDir, fmt.Sprintf("audio.%s", p.flags.AudioFormat))
+ if _, err := os.Stat(audioFile); os.IsNotExist(err) {
+ audioPattern := fmt.Sprintf("audio_*.%s", p.flags.AudioFormat)
+ matches, _ := filepath.Glob(filepath.Join(wordDir, audioPattern))
+ if len(matches) == 0 {
+ if os.Getenv("DEBUG_BATCH") != "" {
+ fmt.Printf(" [DEBUG] No audio file found: %s or pattern %s\n", audioFile, audioPattern)
+ }
+ return false
}
- return false // No audio file found
}
}
}