Files
yellowjacket/.planning/research/ARCHITECTURE.md
T

26 KiB
Raw Blame History

Architecture Patterns: OGG Vorbis + WAV Tag Writer Integration

Domain: Adding OGG Vorbis and WAV tag writing to existing music player tag editing pipeline Researched: 2026-03-18 Confidence: HIGH (based on full codebase analysis of existing architecture + container format research)

OGG Vorbis and WAV tag writing integrate into the existing tagwriter package by implementing two new format-specific writer functions (writeOggTags and writeWavTags) that follow the exact pattern established by writeMp3Tags and writeFlacTags. The existing pipeline (WriteTrackTags → file write → DB sync → event emission) requires only a switch-case extension — no new interfaces, no refactoring.

High-Level Data Flow (Unchanged)

UI: track-details "Save" click
  → Wails binding: TagWriter.WriteTrackTagsByPath(filePath, changes)
    → 1. Resolve audio_file by path
    → 2. DetectFormat(filePath)  ← EXTEND: add .ogg, .wav cases
    → 3. AcquirePipelineLock
    → 4. PlayerStopper check
    → 5. Format switch:
         case FormatMP3:  writeMp3Tags(...)   ← existing
         case FormatFLAC: writeFlacTags(...)  ← existing
         case FormatOGG:  writeOggTags(...)   ← NEW
         case FormatWAV:  writeWavTags(...)   ← NEW
    → 6. syncDatabase(...)  ← NO CHANGES
    → 7. EventsEmit(TrackMetadataChanged)  ← NO CHANGES

Key insight: The existing architecture was designed for format extension. The pipeline.go switch statement is the single point of modification. Everything downstream (DB sync, FTS5, orphan cleanup, event emission, batch processing, cover art pipeline) is format-agnostic and works unchanged.

Component Boundaries

Component Status Changes
backend/tagwriter/tagwriter.go MODIFY Add FormatOGG, FormatWAV constants; extend DetectFormat() switch
backend/tagwriter/pipeline.go MODIFY Add two cases to format switch in WriteTrackTags()
backend/tagwriter/ogg.go NEW writeOggTags() — OGG container rewrite with Vorbis Comment manipulation
backend/tagwriter/wav.go NEW writeWavTags() — RIFF/WAV chunk manipulation with ID3v2 or LIST-INFO
backend/tagwriter/ogg_test.go NEW 7 round-trip tests following FLAC pattern
backend/tagwriter/wav_test.go NEW 7 round-trip tests following FLAC pattern
backend/tagwriter/dbsync.go UNCHANGED Format-agnostic entity sync
backend/tagwriter/helpers_test.go UNCHANGED Shared test helpers (tinyJPEG, assertEqual, etc.)
backend/metadata/tags.go UNCHANGED dhowden/tag already reads OGG and WAV tags
backend/fileutil/atomicwrite.go UNCHANGED Used by both new writers
frontend/src/components/track-details/ UNCHANGED Format-agnostic edit UI

OGG Vorbis Writer: writeOggTags()

Container Structure

An OGG Vorbis file consists of OGG pages containing three types of Vorbis packets:

  1. Identification header (page 0, BOS flag) — audio parameters, must not be modified
  2. Comment header (page 1) — Vorbis Comments (tags) + optional METADATA_BLOCK_PICTURE
  3. Setup header (page 1 or 2) — codebooks, must not be modified
  4. Audio data (remaining pages) — compressed audio, must not be modified

Writing tags means replacing only the comment header packet while preserving everything else exactly.

Approach: Full-File Rewrite via AtomicWrite

Why full rewrite, not in-place: Changing the comment header changes its size. OGG pages have fixed-size segment tables (max 255 segments × 255 bytes = ~64KB per page). A larger comment header may require different page segmentation. Every subsequent page has a page sequence number and CRC-32 checksum that must be recalculated. In-place editing is impossible — the file must be rewritten.

This is fine: AtomicWrite already does full-file rewrite for MP3 and FLAC. OGG Vorbis files are typically 3-10MB (compressed audio). Memory usage is bounded because we stream page-by-page, not load the entire file.

Implementation Strategy

func writeOggTags(logger *slog.Logger, filePath string, changes TagChanges) error {
    // 1. Open and parse OGG file page-by-page
    // 2. Read the three header packets (identification, comment, setup)
    // 3. Parse existing Vorbis Comment from comment header packet
    // 4. Apply changes to Vorbis Comment fields (same field mapping as FLAC)
    // 5. If cover_art changed: encode METADATA_BLOCK_PICTURE and add/remove
    //    from Vorbis Comment as base64 field
    // 6. Re-serialize comment header packet
    // 7. AtomicWrite: stream all pages to temp file
    //    - Page 0 (BOS): rewrite with correct CRC (identification header unchanged)
    //    - Page 1+: rewrite with new comment header + setup header, recalculate
    //      segmentation and CRC
    //    - Remaining pages: copy byte-for-byte (page sequence numbers and CRCs
    //      only need recalculation if page boundaries shifted)
    // 8. Rename over original
}

OGG Page Structure (For Implementation)

OGG Page Header (27 bytes + segment table):
  - Capture pattern: "OggS" (4 bytes)
  - Stream structure version: 0 (1 byte)
  - Header type flag: BOS/EOS/continued (1 byte)
  - Absolute granule position: int64 (8 bytes)
  - Stream serial number: uint32 (4 bytes)
  - Page sequence number: uint32 (4 bytes)
  - CRC-32 checksum: uint32 (4 bytes)
  - Number of page segments: uint8 (1 byte)
  - Segment table: [num_segments]uint8

OGG Page Body:
  - Raw data (sum of segment table values bytes)

Vorbis Comment Format (Shared with FLAC)

The comment header packet uses the exact same Vorbis Comment format as FLAC's Vorbis Comment metadata block, with one difference:

  • In FLAC: Vorbis Comments are a metadata block (type 4), binary data
  • In OGG: Vorbis Comments are preceded by a 7-byte Vorbis packet header (\x03vorbis)

The field mapping is identical to applyFlacTextChanges():

Field Vorbis Comment Key
title TITLE
artist ARTIST
album ALBUM
album_artist ALBUMARTIST
genre GENRE
year DATE
track_number TRACKNUMBER
disc_number DISCNUMBER
composer COMPOSER

Code Reuse Opportunity

The replaceVorbisComment() function in flac.go operates on *flacvorbis.MetaDataBlockVorbisComment which is specific to the go-flac/flacvorbis library. However, the Vorbis Comment binary format is identical across FLAC and OGG. Two approaches:

  1. Build a minimal Vorbis Comment parser/serializer (~80 lines) directly in ogg.go that reads/writes the vendor_string + comments[] binary format. This avoids pulling in FLAC dependencies for OGG files. The existing parseVorbisComment() helper in flac_test.go already demonstrates the parsing logic — promote it to a shared implementation.

  2. Reuse go-flac/flacvorbis by constructing a fake MetaDataBlock from the OGG comment packet (strip the 7-byte \x03vorbis prefix). This is hacky and creates a false dependency.

Recommendation: Approach 1. Write a self-contained vorbisComment type in ogg.go with parse(data []byte) and marshal() []byte methods. The format is simple (little-endian length-prefixed strings) and the test already has the parser. This is ~80 lines and avoids coupling.

Cover Art in OGG: METADATA_BLOCK_PICTURE

OGG Vorbis stores cover art as a Vorbis Comment field named METADATA_BLOCK_PICTURE. The value is a base64-encoded binary blob using the same FLAC PICTURE block format:

METADATA_BLOCK_PICTURE binary format:
  - Picture type: uint32 BE (3 = front cover)
  - MIME string length: uint32 BE
  - MIME string: UTF-8
  - Description length: uint32 BE
  - Description: UTF-8
  - Width: uint32 BE
  - Height: uint32 BE
  - Color depth: uint32 BE
  - Colors used: uint32 BE
  - Data length: uint32 BE
  - Data: raw image bytes

This is then base64-encoded and stored as: METADATA_BLOCK_PICTURE=<base64 data>

Implementation: Encode using encoding/base64 and the same detectMIME() helper. Width/height/depth can be set to 0 (players derive them from the image data). This adds ~30 lines to the cover art handling.

Read-back verification: dhowden/tag already reads METADATA_BLOCK_PICTURE from OGG files and returns it via the Picture() method. Round-trip tests will work with metadata.ExtractTags() unchanged.

No Existing Go Library

Confirmed: There is no pure-Go library for writing OGG Vorbis tags. The existing jfreymuth/oggvorbis library (already an indirect dependency via beep) is read-only — it provides NewReader(), Read(), CommentHeader(), and GetCommentHeader() but no write functionality. The ogg.go source reveals the page-level reading infrastructure (page.read(), page.readHeader(), page.readContent()) but no page writing.

Impact: We must implement OGG page writing ourselves. This is ~200 lines of code for the page serializer + CRC calculation. The jfreymuth/oggvorbis package's crc.go provides the CRC-32 lookup table (crcUpdate()) we can reference for the polynomial (0x04C11DB7, same as in the OGG spec). However, since it's unexported, we must either vendor the CRC table or compute it at init time.

Risk Assessment

Risk Severity Mitigation
OGG page re-segmentation correctness HIGH Comprehensive round-trip tests; verify with dhowden/tag read-back
CRC-32 calculation error HIGH Use the same polynomial as the OGG spec; test against known-good files
METADATA_BLOCK_PICTURE encoding LOW Well-documented format; base64 encoding is trivial
Large OGG files (>100MB) LOW OGG Vorbis files are typically <15MB; stream page-by-page
Multi-stream OGG files LOW Music files are single-stream; reject multi-stream as unsupported

WAV Writer: writeWavTags()

Container Structure

WAV files use the RIFF (Resource Interchange File Format) container:

RIFF header:
  "RIFF" (4 bytes)
  File size - 8 (uint32 LE)
  "WAVE" (4 bytes)

Chunks (in any order):
  "fmt " chunk — audio format parameters (required)
  "data" chunk — raw PCM audio samples (required)
  "LIST" chunk (subtype "INFO") — metadata as sub-chunks
  "id3 " or "ID3 " chunk — embedded ID3v2 tag
  other chunks (fact, cue, etc.)

Metadata Approach: ID3v2 in RIFF Chunk

WAV metadata can be stored in two ways:

  1. LIST-INFO chunks: Simple key-value pairs (INAM=title, IART=artist, IPRD=album, etc.). No cover art support. Limited charset (originally ASCII, some tools use UTF-8).
  2. id3 chunk: A full ID3v2 tag embedded in a RIFF chunk named id3 or ID3 . Supports all fields including cover art. Most modern tools (foobar2000, MusicBee, TagLib) use this approach.

Recommendation: Use the id3 chunk approach because:

  • Reuses the existing bogem/id3v2 library already used by writeMp3Tags()
  • Supports cover art embedding (LIST-INFO does not)
  • dhowden/tag reads ID3v2 from WAV files, so round-trip tests work
  • Consistent field semantics with MP3 tag writing

Implementation Strategy

func writeWavTags(logger *slog.Logger, filePath string, changes TagChanges) error {
    // 1. Open WAV file, parse RIFF header
    // 2. Enumerate chunks: find existing "id3 " chunk (if any),
    //    locate "fmt " and "data" chunks
    // 3. If existing id3 chunk found:
    //    a. Parse existing ID3v2 tag
    //    b. Apply changes using same applyTextChanges()/applyCoverArtChanges()
    //       as MP3 writer
    //    c. Serialize updated tag
    // 4. If no existing id3 chunk:
    //    a. Create new ID3v2 tag
    //    b. Apply changes
    //    c. Serialize
    // 5. AtomicWrite: rebuild RIFF file
    //    a. Write RIFF header with new total size
    //    b. Copy all original chunks EXCEPT old id3 chunk
    //    c. Append new id3 chunk (with proper RIFF chunk header)
    //    d. Update RIFF file size in header
}

Code Reuse with MP3 Writer

The MP3 writer's applyTextChanges(tag, changes) and applyCoverArtChanges(tag, changes) functions operate on *id3v2.Tag objects. These exact functions can be reused by the WAV writer since the ID3v2 tag format is identical:

// In wav.go — reuse existing functions from mp3.go:
tag, err := id3v2.Open(...)  // or id3v2.ParseReader(...)
applyTextChanges(tag, changes)    // ← same function from mp3.go
applyCoverArtChanges(tag, changes) // ← same function from mp3.go

RIFF Chunk Parser (~100 lines)

A minimal RIFF parser needs to:

  1. Read 12-byte RIFF header ("RIFF" + size + "WAVE")
  2. Iterate chunks: 8-byte chunk header (id[4] + size[4]), skip body
  3. Track positions and sizes of each chunk
  4. Handle padding bytes (RIFF chunks are word-aligned — if data size is odd, a pad byte follows)

This is straightforward binary parsing. No external library needed.

AtomicWrite for Large WAV Files

Concern: WAV files can be very large (uncompressed audio: a 60-minute CD-quality WAV is ~630MB). AtomicWrite creates a full copy in a temp file before renaming.

Assessment: This is acceptable because:

  1. AtomicWrite already streams data via io.Copy — it doesn't load the file into memory
  2. The WAV writer copies chunks sequentially: read chunk from source → write to temp file
  3. Disk space for the temp file is the only cost (~2× file size temporarily)
  4. The alternative (in-place chunk modification) risks corruption if the process is interrupted mid-write
  5. The existing FLAC writer already handles large files this way (with a warning for >500MB)

Practical consideration: WAV files >500MB are rare in music libraries (they're typically ripped CDs at ~30-50MB per track, or high-resolution at ~150MB). Add the same size warning as the FLAC writer.

if info.Size() > largeSizeThreshold {
    logger.Warn("large WAV file may take extra time/space for atomic write",
        slog.String("path", filePath),
        slog.Int64("size", info.Size()),
    )
}

Cover Art in WAV

Since we're using the id3 chunk approach, cover art embedding uses the exact same APIC frame mechanism as MP3. The applyCoverArtChanges() function handles this already.

Risk Assessment

Risk Severity Mitigation
Large file temp space MEDIUM Log warning for >500MB; streaming copy avoids memory issues
RIFF chunk alignment (pad bytes) MEDIUM Follow spec: odd-sized chunks get 1 pad byte
Existing LIST-INFO metadata LOW Preserve LIST-INFO chunks as-is; only modify/add id3 chunk
bogem/id3v2 reading from WAV LOW Library may need ParseReader instead of Open (investigate)
WAV files without existing tags LOW Create new id3 chunk; all other chunks preserved

Format Detection Extension

Current Implementation (tagwriter.go)

func DetectFormat(filePath string) (AudioFormat, error) {
    ext := strings.ToLower(filepath.Ext(filePath))
    switch ext {
    case ".mp3":
        return FormatMP3, nil
    case ".flac":
        return FormatFLAC, nil
    default:
        return "", fmt.Errorf("%w: %s", errUnsupportedFormat, ext)
    }
}

Required Changes

const (
    FormatMP3  AudioFormat = "mp3"
    FormatFLAC AudioFormat = "flac"
    FormatOGG  AudioFormat = "ogg"   // NEW
    FormatWAV  AudioFormat = "wav"   // NEW
)

func DetectFormat(filePath string) (AudioFormat, error) {
    ext := strings.ToLower(filepath.Ext(filePath))
    switch ext {
    case ".mp3":
        return FormatMP3, nil
    case ".flac":
        return FormatFLAC, nil
    case ".ogg":              // NEW
        return FormatOGG, nil // NEW
    case ".wav":              // NEW
        return FormatWAV, nil // NEW
    default:
        return "", fmt.Errorf("%w: %s", errUnsupportedFormat, ext)
    }
}

Extension-based detection is sufficient. The existing approach works because:

  1. The metadata package already uses extension-based routing for decoding (metadata/decoder.go)
  2. YellowJacket only indexes files with known extensions (.mp3, .flac, .ogg, .wav)
  3. File magic-byte detection is unnecessary — if a file is in the DB, it was already validated during scan

Pipeline Switch Extension

In pipeline.go, the format switch becomes:

switch format {
case FormatMP3:
    err = writeMp3Tags(tw.logger, audioFile.FilePath, changes)
case FormatFLAC:
    err = writeFlacTags(tw.logger, audioFile.FilePath, changes)
case FormatOGG:
    err = writeOggTags(tw.logger, audioFile.FilePath, changes)
case FormatWAV:
    err = writeWavTags(tw.logger, audioFile.FilePath, changes)
default:
    err = fmt.Errorf("%w: %s", errUnsupportedFormat, format)
}

Frontend Impact: None

The track-details dialog component is completely format-agnostic. It:

  1. Shows the same 8 editable fields regardless of format
  2. Shows the same cover art pick/replace/remove UI
  3. Sends the same TagChanges diff map to WriteTrackTagsByPath()
  4. Receives the same TrackMetadataChanged event
  5. Uses the same three-state field model for batch editing

The backend handles all format-specific logic. The frontend never sees or cares about the audio format.

Verified: No frontend changes needed for OGG or WAV tag writing.

Test Strategy

Follow FLAC Round-Trip Pattern (7 Tests per Format)

The FLAC writer has 7 tests that verify the complete write→read cycle using metadata.ExtractTags() for read-back. The same test structure applies to OGG and WAV:

Test What It Verifies
TestWriteOggTags_TextFields All 9 text fields round-trip correctly
TestWriteOggTags_CoverArt METADATA_BLOCK_PICTURE embedded and readable
TestWriteOggTags_ClearCoverArt Cover art removal works
TestWriteOggTags_PartialUpdate Unchanged fields preserved
TestWriteOggTags_PreservesAudioData Audio stream intact after tag write
TestWriteOggTags_ReplaceComment No duplicate Vorbis Comment entries
TestWriteOggTags_AtomicSafety Failed write leaves file untouched

Same 7 tests for WAV (TestWriteWavTags_*).

Test Fixture Helpers

Each format needs a makeMinimal*() helper:

  • makeMinimalOGG(t, path) — Creates a minimal valid OGG Vorbis file containing: BOS page with identification header, comment header page (empty Vorbis Comment), EOS page with setup header + minimal audio frame. This is complex (~60 lines) but required for round-trip testing.

  • makeMinimalWAV(t, path) — Creates a minimal valid WAV file containing: RIFF header, fmt chunk (PCM, 44100Hz, 16-bit, mono), data chunk (brief silence). This is simple (~30 lines) — just binary header construction.

Read-Back Verification

Both formats use metadata.ExtractTags() (which uses dhowden/tag) for read-back verification:

  • OGG: dhowden/tag reads Vorbis Comments from OGG files including METADATA_BLOCK_PICTURE. Verified: the library's ogg.go and vorbis.go handle this.
  • WAV: dhowden/tag reads ID3v2 tags from WAV files (it detects the id3 chunk in the RIFF container). Verified: the library handles this per its README (MP3/MP4/OGG/FLAC metadata parsing).

Confidence: HIGH — dhowden/tag is already the read-back library for MP3 and FLAC tests. It supports OGG and WAV reading.

Suggested Build Order

Based on dependency analysis and risk levels:

Phase 1: WAV Writer (Lower Risk, Faster)

Rationale: WAV writing reuses the existing bogem/id3v2 library and the existing applyTextChanges()/applyCoverArtChanges() functions. The RIFF container is much simpler than OGG (no checksums, no page segmentation). This can be built and tested quickly, giving confidence in the pipeline extension pattern before tackling OGG.

  1. Add FormatWAV constant and extend DetectFormat()
  2. Write RIFF chunk parser (~100 lines in wav.go)
  3. Implement writeWavTags() using id3v2 tag in RIFF chunk
  4. Add pipeline switch case
  5. Write makeMinimalWAV() test fixture
  6. Write 7 round-trip tests
  7. Verify with make lint

Phase 2: OGG Vorbis Writer (Higher Risk, More Code)

Rationale: OGG requires implementing the page-level write infrastructure (CRC-32, segmentation, page serialization) from scratch. This is the riskiest part of the milestone and benefits from having the WAV writer already proving the pipeline extension pattern works.

  1. Implement OGG CRC-32 calculation (~30 lines)
  2. Implement OGG page serializer (~80 lines)
  3. Implement Vorbis Comment parser/serializer (~80 lines)
  4. Implement METADATA_BLOCK_PICTURE encoding (~40 lines)
  5. Implement writeOggTags() orchestrator (~100 lines)
  6. Add FormatOGG constant and pipeline switch case
  7. Write makeMinimalOGG() test fixture (~60 lines)
  8. Write 7 round-trip tests
  9. Verify with make lint

Phase 3: Cleanup

  1. Remove OGG/WAV from "Out of Scope" in PROJECT.md
  2. Update milestone status
  3. Fix any lint warnings from v1.2

Total New Code Estimate

Component Lines (approx)
ogg.go (writer + helpers) ~350
wav.go (writer + RIFF parser) ~200
ogg_test.go ~350
wav_test.go ~250
tagwriter.go changes ~10
pipeline.go changes ~5
Total ~1,165

Patterns to Follow

Pattern 1: Format Writer Function Signature

What: All format writers follow the same signature: func write*Tags(logger *slog.Logger, filePath string, changes TagChanges) error

Why: Keeps the pipeline switch clean and uniform. No interface needed — package-internal functions with consistent signatures are simpler.

Example:

func writeOggTags(logger *slog.Logger, filePath string, changes TagChanges) error { ... }
func writeWavTags(logger *slog.Logger, filePath string, changes TagChanges) error { ... }

Pattern 2: AtomicWrite Integration

What: Every writer creates the complete output file inside the AtomicWrite callback, writing to tmp *os.File.

Example (from existing FLAC writer):

return fileutil.AtomicWrite(logger, filePath, func(tmp *os.File) error {
    _, writeErr := f.WriteTo(tmp)
    return writeErr
})

Pattern 3: Test Fixture + Round-Trip Verification

What: Each format has a makeMinimal*() helper that creates a valid file, and tests verify by writing tags then reading back with metadata.ExtractTags().

Why: Tests validate the complete pipeline without external tools. The same metadata.ExtractTags() function used in production reads back the tags, ensuring what we write is what gets read.

Anti-Patterns to Avoid

Anti-Pattern 1: FormatWriter Interface

What: Creating a FormatWriter interface with Write(path string, changes TagChanges) error

Why bad: The package has only 4 format writers, all internal. An interface adds abstraction without value. The switch statement is clearer and the functions share a signature by convention.

Instead: Keep format writers as package-internal functions with matching signatures.

Anti-Pattern 2: In-Place OGG Page Modification

What: Attempting to modify OGG pages in-place to avoid full file rewrite.

Why bad: OGG pages have checksums and sequence numbers. Changing the comment packet size shifts all subsequent page offsets. In-place modification requires recalculating every downstream page's CRC, which is effectively a full rewrite anyway — but without crash safety.

Instead: Full-file rewrite via AtomicWrite.

Anti-Pattern 3: In-Place RIFF Chunk Insertion

What: Attempting to insert or resize RIFF chunks in-place.

Why bad: Inserting a new id3 chunk or resizing an existing one shifts all subsequent chunks. The RIFF header's total size must be updated. In-place modification risks corruption.

Instead: Full-file rewrite via AtomicWrite.

Anti-Pattern 4: Shared Vorbis Comment Code via go-flac/flacvorbis

What: Importing go-flac/flacvorbis in the OGG writer to reuse Vorbis Comment parsing.

Why bad: Creates a dependency on a FLAC-specific library for OGG files. The flacvorbis types are coupled to FLAC MetaDataBlock structures. The binary format is simple enough to parse directly.

Instead: Self-contained Vorbis Comment parser in ogg.go (~80 lines).

Scalability Considerations

Concern Typical Case Edge Case Approach
OGG file size 3-10 MB 50 MB live recording Stream page-by-page, no full-file memory load
WAV file size 30-50 MB (CD track) 2 GB (24-bit/96kHz long recording) Streaming copy via io.Copy; warn for >500MB
Temp disk space 2× file size briefly 2× 2GB = 4GB temp Same concern as FLAC; document as known limitation
Batch edit 100 WAV files Sequential, 30-50 MB each 100 × 50 MB = 5 GB total I/O Existing batch pipeline with progress events

Sources