Skip to content

feat: persist raw audio to object storage#1

Merged
btc merged 13 commits into
mainfrom
feature/object-storage
Apr 4, 2026
Merged

feat: persist raw audio to object storage#1
btc merged 13 commits into
mainfrom
feature/object-storage

Conversation

@btc
Copy link
Copy Markdown
Owner

@btc btc commented Apr 3, 2026

Summary

  • Add ObjectStore interface (internal/storage/) with GCS and local filesystem implementations
  • Wire into Backend via DI — conductor uploads audio in a goroutine concurrent with transcription
  • Upload failures are logged with OTel spans but never block the interview flow
  • Session-scoped keys ({session_id}/{message_id}.webm) enable GDPR right-to-erasure via prefix delete
  • audio_url column (already existed, always NULL) is now populated after successful uploads

Details

New package internal/storage/:

  • ObjectStore interface: Put, Delete, DeletePrefix, Close (explicit lifecycle, no type assertions)
  • LocalStore — filesystem-backed, path traversal guard, empty dir cleanup
  • GCSStore — thin wrapper over cloud.google.com/go/storage SDK with ADC
  • MockStore — recording mock for test injection

Config: STORAGE_BACKEND (local/gcs), STORAGE_BUCKET, STORAGE_LOCAL_DIR

Backend wiring: Storage initialized before pool creation — no unnecessary cleanup on storage init failure. Same DI pattern as Transcriber/Synthesizer/Sender.

Conductor change:

  • WSMessage.AudioMIME carries the MIME type on the wire (default audio/webm for backward compat)
  • AudioExt() derives file extension from MIME subtype via strings.Cut (stdlib mime.ExtensionsByType doesn't know audio/webm)
  • Goroutine fires upload + SetAudioURL using context.WithoutCancel(ctx) — propagates trace context but detaches from cancellation
  • OTel span with structured error logging on failure
  • messageID generated at top of endTurn, single persistMessage call — no branching

Infra: Terraform Cloud Run env vars added, GCS bucket + IAM already provisioned

Test plan

  • 8 unit tests for LocalStore (put, delete, prefix delete, string semantics, path traversal, idempotent delete, dir creation, dir cleanup)
  • All 15 packages pass with -race
  • Cross-package coverage verified — new backend methods hit by existing voice integration test
  • AudioMIME default and explicit override tested
  • Manual: run locally with STORAGE_BACKEND=local, submit voice input, verify data/audio/{session_id}/{message_id}.webm created
  • Manual: verify audio_url populated in messages table after voice turn

🤖 Generated with Claude Code

btc and others added 7 commits April 3, 2026 23:03
…ntation

Introduces the ObjectStore interface abstracting Put/Delete/DeletePrefix
over GCS and local filesystem, with a LocalStore implementation backed
by the local filesystem and full TDD test coverage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements GCSStore backed by cloud.google.com/go/storage v1.61.3,
satisfying the ObjectStore interface with Put, Delete, DeletePrefix,
and Close. Uses Application Default Credentials.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…udioURL

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…nscription

Fires a goroutine in the voice branch of endTurn that uploads raw audio to
object storage and records the URL on the message row, using context.Background
so the upload is not cancelled when the request context ends. Adds OTel tracing
via a package-level tracer. Introduces persistMessageWithID to share the
pre-generated UUID between the upload goroutine and the DB insert.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@btc btc force-pushed the feature/object-storage branch from 6cfd3c7 to cd2e8ee Compare April 4, 2026 03:03
btc and others added 5 commits April 3, 2026 23:05
No need to close pool on storage failure if pool hasn't been created yet.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Use context.WithoutCancel(ctx) instead of context.Background() so the
upload span is a child of the request trace, not an orphan. Still
detached from cancellation so upload completes after request ends.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Explicit lifecycle — no type assertions. LocalStore and MockStore get
no-op Close(), GCSStore already had it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add AudioFormat field to WSMessage (defaults to "webm" for backward compat)
- Frontend sends audio_format explicitly
- Conductor uses msg.AudioFormat for storage key, content type, and transcription
- Remove persistMessageWithID indirection: generate messageID at top of
  endTurn, single persistMessage with explicit ID param

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Rename AudioFormat to AudioMIME on WSMessage (default: "audio/webm")
- Add AudioExt() method to derive extension from MIME type
- Conductor uses msg.AudioMIME for GCS content-type, msg.AudioExt()
  for storage key and Whisper format param
- Revert v0 frontend changes — v0 is a separate codebase

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@btc btc force-pushed the main branch 2 times, most recently from 0de58a4 to c8dcf52 Compare April 4, 2026 03:43
…/webm

mime.ExtensionsByType returns empty for audio/webm and audio/wav.
Simple strings.Cut on the MIME subtype is reliable for our use case.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@btc btc force-pushed the feature/object-storage branch from 497a7d6 to 6c20f81 Compare April 4, 2026 03:53
@btc btc merged commit 62a1954 into main Apr 4, 2026
1 check failed
@btc btc deleted the feature/object-storage branch April 4, 2026 03:57
btc added a commit that referenced this pull request May 5, 2026
* feat(storage): add ObjectStore interface and local filesystem implementation

Introduces the ObjectStore interface abstracting Put/Delete/DeletePrefix
over GCS and local filesystem, with a LocalStore implementation backed
by the local filesystem and full TDD test coverage.


* feat(storage): add GCS implementation of ObjectStore

Implements GCSStore backed by cloud.google.com/go/storage v1.61.3,
satisfying the ObjectStore interface with Put, Delete, DeletePrefix,
and Close. Uses Application Default Credentials.


* feat(config): add Storage config (STORAGE_BACKEND, STORAGE_BUCKET, STORAGE_LOCAL_DIR)

* feat(backend): wire ObjectStore into Backend with StoreAudio and SetAudioURL


* feat(conductor): upload audio to object storage concurrently with transcription

Fires a goroutine in the voice branch of endTurn that uploads raw audio to
object storage and records the URL on the message row, using context.Background
so the upload is not cancelled when the request context ends. Adds OTel tracing
via a package-level tracer. Introduces persistMessageWithID to share the
pre-generated UUID between the upload goroutine and the DB insert.


* feat(storage): add MockStore for testing

* deploy: add STORAGE_BACKEND and STORAGE_BUCKET env vars to Cloud Run

* refactor(backend): move storage init before pool creation

No need to close pool on storage failure if pool hasn't been created yet.


* fix(conductor): propagate trace context to upload goroutine

Use context.WithoutCancel(ctx) instead of context.Background() so the
upload span is a child of the request trace, not an orphan. Still
detached from cancellation so upload completes after request ends.


* refactor(storage): add Close to ObjectStore interface

Explicit lifecycle — no type assertions. LocalStore and MockStore get
no-op Close(), GCSStore already had it.


* feat(conductor): derive audio format from message, simplify persist

- Add AudioFormat field to WSMessage (defaults to "webm" for backward compat)
- Frontend sends audio_format explicitly
- Conductor uses msg.AudioFormat for storage key, content type, and transcription
- Remove persistMessageWithID indirection: generate messageID at top of
  endTurn, single persistMessage with explicit ID param


* refactor(conductor): use MIME type for audio format, revert v0 changes

- Rename AudioFormat to AudioMIME on WSMessage (default: "audio/webm")
- Add AudioExt() method to derive extension from MIME type
- Conductor uses msg.AudioMIME for GCS content-type, msg.AudioExt()
  for storage key and Whisper format param
- Revert v0 frontend changes — v0 is a separate codebase


* fix(conductor): use strings.Cut for AudioExt, stdlib mime lacks audio/webm

mime.ExtensionsByType returns empty for audio/webm and audio/wav.
Simple strings.Cut on the MIME subtype is reliable for our use case.


---------
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant