diff --git a/bounty-9/DESIGN.md b/bounty-9/DESIGN.md new file mode 100644 index 00000000..6c6019b2 --- /dev/null +++ b/bounty-9/DESIGN.md @@ -0,0 +1,118 @@ +# Audio Notes — Design Document + +## Overview + +Audio Notes brings full voice-recording, playback, transcription, and management capabilities to the warpSpeed Notes experience. Users can quickly capture voice memos, view automatic transcriptions, search across audio content, and manage recordings — all from a React Native mobile surface backed by a serverless API. + +## Architecture + +``` +┌──────────────────────────┐ ┌──────────────────────────┐ +│ React Native Client │ │ Cloudflare Workers API │ +│ ┌────────────────────┐ │ │ ┌──────────────────┐ │ +│ │ AudioRecorder │───┼─────┼──│ POST /upload │ │ +│ │ AudioPlayer │ │ │ │ POST /transcribe │ │ +│ │ TranscriptionView │ │ │ │ GET /notes │ │ +│ └────────────────────┘ │ │ │ DELETE /notes/:id │ │ +│ │ │ │ └──────────────────┘ │ +│ ▼ │ │ │ │ +│ ┌────────────────────┐ │ │ ▼ │ +│ │ api.ts (client) │───┼─────┼───► R2 Storage │ +│ └────────────────────┘ │ │ ┌──────────────────┐ │ +│ │ │ │ audio/*.webm │ │ +│ │ │ │ transcriptions/* │ │ +│ │ │ └──────────────────┘ │ +│ │ │ │ │ +│ │ │ ▼ │ +│ │ │ D1 (SQLite) │ +│ │ │ ┌──────────────────┐ │ +│ │ │ │ audio_notes │ │ +│ │ │ │ transcripts │ │ +│ │ │ └──────────────────┘ │ +└──────────────────────────┘ └──────────────────────────┘ +``` + +### Data Flow + +1. **Recording** → `AudioRecorder` captures PCM/WAV → streams to `POST /upload` → stored in R2 → record inserted into D1. +2. **Transcription** → `POST /transcribe` triggers Whisper (Workers AI) → result stored in D1 → WebSocket push to client. +3. **Playback** → `AudioPlayer` requests signed URL from `GET /notes/:id/url` → streams audio via `expo-av`. +4. **Management** → `GET /notes` (paginated) and `DELETE /notes/:id` (cascades to R2 + D1). + +## Data Model + +### `audio_notes` table (D1) + +| Column | Type | Description | +|-------------|----------|------------------------------------------| +| `id` | TEXT PK | UUID v7 | +| `title` | TEXT | User-facing title (nullable, auto-set) | +| `duration_ms`| INTEGER | Recording length in ms | +| `file_key` | TEXT | R2 object key (e.g. `audio/.webm`) | +| `file_size` | INTEGER | Bytes | +| `mime_type` | TEXT | `audio/webm` or `audio/wav` | +| `created_at`| TEXT | ISO-8601 timestamp | +| `updated_at`| TEXT | ISO-8601 timestamp | +| `archived` | INTEGER | Soft-delete flag (0/1) | + +### `transcripts` table (D1) + +| Column | Type | Description | +|-------------|----------|------------------------------------------| +| `id` | TEXT PK | UUID v7 | +| `note_id` | TEXT FK | References `audio_notes.id` | +| `text` | TEXT | Full transcript | +| `segments` | TEXT | JSON array of `{start, end, text}` | +| `model` | TEXT | Whisper model variant (`base`, `large`) | +| `confidence`| REAL | 0.0–1.0 | +| `language` | TEXT | Detected language code (e.g. `en`) | +| `created_at`| TEXT | ISO-8601 timestamp | + +## Component Tree + +``` + + ├── (record/stop/permissions) + ├── + │ └── + │ ├── (play/pause/seek/scrub) + │ └── (collapsible transcript) + └── (new recording) +``` + +## Key Decisions + +| Decision | Rationale | +|---------|----------| +| **expo-av** for playback | Mature, cross-platform, supports streaming URLs | +| **expo-audio-record** (or Expo Audio API) | Stable recording API with PCM/WAV output | +| **Cloudflare Workers + R2 + D1** | Warpspeed's stack; edge-close, no cold starts for audio | +| **WebSocket for transcription status** | Whisper takes 1–10s; avoid polling | +| **Chunked upload** | Large recordings (>10 MB) streamed in 64 KB chunks | +| **Soft-delete** | Prevents accidental permanent data loss | +| **Signed URLs** | Avoids public R2 buckets; 1-hour expiry | + +## Error States + +- **Permission denied** → show grant-permission CTA with `Linking.openSettings()` +- **Upload failure** → retry with exponential backoff (3 attempts); save blob locally for later sync +- **Transcription failure** → show "Transcription failed — tap to retry" inline on the card +- **Network offline** → `NetInfo` detection; recordings saved to local filesystem; queued for upload +- **Storage full** → warn user before recording starts +- **Audio decode error** → fallback to "unsupported format" message + +## Security & Privacy + +- All audio files stored in private R2 bucket. +- Signed URLs expire in 60 minutes. +- Transcriptions never leave the user's org boundary. +- Recording requires explicit microphone permission (iOS `NSMicrophoneUsageDescription`, Android `RECORD_AUDIO`). +- `archived` flag respected in all list queries — no accidental data leaks. + +## Performance Targets + +- **Record start latency** < 200 ms from button press +- **Upload 1 min audio** < 5 s (on LTE) +- **Transcription (1 min audio)** < 10 s p50 +- **Playback start** < 300 ms (signed URL + buffer) +- **List load (50 items)** < 500 ms (D1 query + signed URLs parallelized) diff --git a/bounty-9/README.md b/bounty-9/README.md new file mode 100644 index 00000000..3a7dd2cd --- /dev/null +++ b/bounty-9/README.md @@ -0,0 +1,137 @@ +# Audio Notes — warpSpeed Bounty #9 + +[![Status](https://img.shields.io/badge/status-reference%20implementation-blue)]() + +Full audio-note recording, playback, transcription, and management support for the warpSpeed Notes experience. + +**Bounty:** $750 +**Type:** Reference Implementation +**Stack:** React Native (Expo) + Cloudflare Workers + R2 + D1 + Workers AI (Whisper) + +--- + +## What's Included + +| File | Purpose | +|------|---------| +| `DESIGN.md` | Full architecture, data model, component tree, security/privacy, performance targets | +| `types.ts` | All TypeScript types: `AudioNote`, `RecordingState`, `PlaybackState`, `Transcription`, API DTOs, WebSocket events | +| `components/AudioRecorder.tsx` | Reusable recording component with permissions, timer, error handling, discard flow | +| `components/AudioPlayer.tsx` | Playback component with play/pause, seek ±15s, variable speed, mute, progress bar | +| `components/TranscriptionView.tsx` | Collapsible transcription display with segment list, language/confidence badges, low-confidence warning | +| `api.ts` | Full API client: CRUD notes, chunked upload, transcription (polling + WebSocket), signed URLs | + +--- + +## Quick Start + +```bash +# Install dependencies +npm install expo-av expo-file-system + +# Copy the files into your project: +# cp -r components/ types.ts api.ts src/audio-notes/ + +# Initialize the API client (app entry point) +import { initApi } from './api'; + +initApi({ + baseUrl: 'https://api.warpspeed.dev', + token: 'your-jwt-token', +}); +``` + +## Usage + +### Record an audio note + +```tsx +import { AudioRecorder } from './components/AudioRecorder'; +import { useApi } from './api'; + +function RecordScreen() { + const api = useApi(); + + return ( + { + await api.createAndUpload( + { durationMs, mimeType, fileSize: 0 }, + uri, + (progress) => console.log('Upload:', progress), + ); + }} + /> + ); +} +``` + +### Play back an audio note + +```tsx +import { AudioPlayer } from './components/AudioPlayer'; + + console.log('Done')} +/> +``` + +### Display transcription + +```tsx +import { TranscriptionView } from './components/TranscriptionView'; + + api.transcribeNote(noteId)} + onSegmentPress={(start) => player.seek(start * 1000)} +/> +``` + +--- + +## API Endpoints (Server-Side stubs needed) + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/notes` | List notes (paginated, ?cursor, ?limit, ?includeArchived) | +| `POST` | `/notes` | Create note + return signed upload URL | +| `GET` | `/notes/:id` | Get single note | +| `DELETE` | `/notes/:id` | Soft-delete (archive) note | +| `PATCH` | `/notes/:id/archive` | Set archived flag | +| `PATCH` | `/notes/:id/restore` | Clear archived flag | +| `POST` | `/notes/:id/transcribe` | Trigger Whisper transcription | +| `GET` | `/notes/:id/transcription` | Get transcription result | +| `GET` | `/notes/:id/playback-url` | Get signed playback URL (1hr expiry) | +| `WS` | `/notes/:id/transcribe/ws` | WebSocket for real-time transcription updates | + +### Worker Implementation Guidance + +Refer to `DESIGN.md` for the D1 schema and R2 object layout. The server should: + +1. Generate pre-signed R2 PUT URLs on `POST /notes` +2. Run Workers AI `@cf/openai/whisper` on `POST /notes/:id/transcribe` +3. Push transcription progress via WebSocket (Durable Objects or `ws` channel) +4. Generate pre-signed R2 GET URLs for `GET /notes/:id/playback-url` +5. List with cursor-based pagination from D1 + +--- + +## Testing + +```bash +# Unit tests (add Jest + Testing Library) +npm test + +# Lint +npx eslint components/ types.ts api.ts +``` + +--- + +## License + +MIT — part of the warpSpeed Bounty Program. diff --git a/bounty-9/api.ts b/bounty-9/api.ts new file mode 100644 index 00000000..cc46b354 --- /dev/null +++ b/bounty-9/api.ts @@ -0,0 +1,389 @@ +import type { + AudioNote, + CreateNoteRequest, + GetTranscriptionResponse, + ListNotesResponse, + TranscribeResponse, + Transcription, + UploadResponse, +} from './types'; + +// ─── Configuration ──────────────────────────────────────────────────── + +export interface ApiConfig { + /** Base URL for the warpSpeed Notes API (e.g. `https://api.example.com`) */ + baseUrl: string; + /** Auth token (JWT for Warpspeed Auth) */ + token: string; + /** Optional fetch implementation (for testing / Node environments) */ + fetch?: typeof globalThis.fetch; + /** Request timeout in milliseconds (default 30s) */ + timeoutMs?: number; +} + +// ─── API Error ──────────────────────────────────────────────────────── + +export class ApiError extends Error { + constructor( + public readonly status: number, + message: string, + public readonly body?: unknown, + ) { + super(message); + this.name = 'ApiError'; + } +} + +// ─── Helpers ────────────────────────────────────────────────────────── + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +const buildHeaders = (config: ApiConfig): Record => ({ + Authorization: `Bearer ${config.token}`, + 'Content-Type': 'application/json', + 'User-Agent': 'warpspeed-audio-notes/1.0', +}); + +const buildUrl = (base: string, path: string): string => { + const normalized = base.endsWith('/') ? base.slice(0, -1) : base; + return `${normalized}${path.startsWith('/') ? path : `/${path}`}`; +}; + +async function request( + config: ApiConfig, + path: string, + init: RequestInit = {}, +): Promise { + const url = buildUrl(config.baseUrl, path); + const fetcher = config.fetch ?? globalThis.fetch; + const controller = new AbortController(); + const timeoutId = setTimeout( + () => controller.abort(), + config.timeoutMs ?? 30_000, + ); + + try { + const response = await fetcher(url, { + ...init, + headers: { + ...buildHeaders(config), + ...(init.headers as Record | undefined), + }, + signal: controller.signal, + }); + + if (!response.ok) { + let body: unknown; + try { + body = await response.json(); + } catch { + body = await response.text().catch(() => null); + } + throw new ApiError( + response.status, + typeof body === 'object' && body !== null && 'message' in body + ? String((body as Record).message) + : `Request failed with status ${response.status}`, + body, + ); + } + + // 204 No Content + if (response.status === 204) { + return undefined as T; + } + + return (await response.json()) as T; + } finally { + clearTimeout(timeoutId); + } +} + +// ─── API Client ─────────────────────────────────────────────────────── + +export const createApiClient = (config: ApiConfig) => { + // ── Notes ────────────────────────────────────────────────────── + + const listNotes = async ( + options?: { cursor?: string; limit?: number; includeArchived?: boolean }, + ): Promise => { + const params = new URLSearchParams(); + if (options?.cursor) params.set('cursor', options.cursor); + if (options?.limit) params.set('limit', String(options.limit)); + if (options?.includeArchived) params.set('includeArchived', 'true'); + + const qs = params.toString(); + return request(config, `/notes${qs ? `?${qs}` : ''}`); + }; + + const getNote = async (id: string): Promise => { + return request(config, `/notes/${id}`); + }; + + /** + * Create a note record and get a pre-signed upload URL. + * After calling this, upload the audio file to `uploadUrl` via PUT. + */ + const createNote = async ( + data: CreateNoteRequest, + ): Promise => { + return request(config, '/notes', { + method: 'POST', + body: JSON.stringify(data), + }); + }; + + /** + * Upload audio file bytes to the pre-signed URL returned from `createNote`. + * This is a raw binary PUT — not JSON. + */ + const uploadAudio = async ( + uploadUrl: string, + fileUri: string, + mimeType: string, + onProgress?: (progress: number) => void, + ): Promise => { + const fetcher = config.fetch ?? globalThis.fetch; + + // Read file as blob + const response = await fetcher(fileUri); + const blob = await response.blob(); + + // For progress reporting, we chunk the upload + const CHUNK_SIZE = 64 * 1024; // 64 KB + const totalSize = blob.size; + let uploadedBytes = 0; + + if (totalSize <= CHUNK_SIZE || !blob.stream) { + // Small file or no streaming support — single PUT + const uploadResponse = await fetcher(uploadUrl, { + method: 'PUT', + body: blob, + headers: { 'Content-Type': mimeType }, + }); + if (!uploadResponse.ok) { + throw new ApiError( + uploadResponse.status, + 'Audio upload failed', + ); + } + onProgress?.(1); + return; + } + + // Chunked upload for large files + const reader = blob.stream().getReader(); + const chunks: Uint8Array[] = []; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + uploadedBytes += value.length; + onProgress?.(uploadedBytes / totalSize); + } + + // Concatenate chunks and upload + const combined = new Uint8Array(totalSize); + let offset = 0; + for (const chunk of chunks) { + combined.set(chunk, offset); + offset += chunk.length; + } + + const finalResponse = await fetcher(uploadUrl, { + method: 'PUT', + body: new Blob([combined], { type: mimeType }), + headers: { 'Content-Type': mimeType }, + }); + + if (!finalResponse.ok) { + throw new ApiError( + finalResponse.status, + 'Audio upload failed after chunking', + ); + } + onProgress?.(1); + }; + + /** + * High-level helper: create note + upload audio in one call. + */ + const createAndUpload = async ( + data: CreateNoteRequest, + fileUri: string, + onProgress?: (progress: number) => void, + ): Promise => { + const { note, uploadUrl } = await createNote(data); + await uploadAudio(uploadUrl, fileUri, data.mimeType, onProgress); + return note; + }; + + const deleteNote = async (id: string): Promise => { + return request(config, `/notes/${id}`, { method: 'DELETE' }); + }; + + const archiveNote = async (id: string): Promise => { + return request(config, `/notes/${id}/archive`, { + method: 'PATCH', + }); + }; + + const restoreNote = async (id: string): Promise => { + return request(config, `/notes/${id}/restore`, { + method: 'PATCH', + }); + }; + + // ── Transcription ────────────────────────────────────────────── + + /** Request transcription for a note. Returns immediately; transcription is async. */ + const transcribeNote = async ( + noteId: string, + ): Promise => { + return request( + config, + `/notes/${noteId}/transcribe`, + { method: 'POST' }, + ); + }; + + const getTranscription = async ( + noteId: string, + ): Promise => { + return request( + config, + `/notes/${noteId}/transcription`, + ); + }; + + /** + * Poll for transcription completion. + * Useful when WebSocket is unavailable. Polls every 2s, up to `maxAttempts`. + */ + const pollTranscription = async ( + noteId: string, + maxAttempts = 30, + intervalMs = 2000, + ): Promise => { + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const { transcription } = await getTranscription(noteId); + if (transcription && transcription.status === 'completed') { + return transcription; + } + if (transcription && transcription.status === 'failed') { + throw new Error( + `Transcription failed for note ${noteId}`, + ); + } + await sleep(intervalMs); + } + throw new Error( + `Transcription polling timed out for note ${noteId} after ${maxAttempts} attempts`, + ); + }; + + // ── Signed URL ──────────────────────────────────────────────── + + const getPlaybackUrl = async ( + noteId: string, + ): Promise<{ url: string; expiresAt: string }> => { + return request<{ url: string; expiresAt: string }>( + config, + `/notes/${noteId}/playback-url`, + ); + }; + + // ── WebSocket (transcription streaming) ─────────────────────── + + /** + * Connect to the transcription WebSocket for real-time status updates. + * Returns an unsubscribe function. + */ + const subscribeTranscription = ( + noteId: string, + onEvent: (event: { + type: string; + noteId: string; + progress?: number; + transcription?: Transcription; + error?: string; + }) => void, + ): (() => void) => { + const protocol = config.baseUrl.startsWith('https') ? 'wss' : 'ws'; + const wsUrl = `${protocol}://${config.baseUrl.replace(/^https?:\/\//, '')}/notes/${noteId}/transcribe/ws`; + + const ws = new WebSocket(wsUrl); + let closed = false; + + ws.onopen = () => { + ws.send(JSON.stringify({ type: 'auth', token: config.token })); + }; + + ws.onmessage = (msg) => { + try { + const event = JSON.parse(msg.data); + onEvent(event); + } catch { + // ignore malformed messages + } + }; + + ws.onerror = () => { + if (!closed) { + onEvent({ + type: 'transcription.failed', + noteId, + error: 'WebSocket connection error', + }); + } + }; + + ws.onclose = () => { + closed = true; + }; + + return () => { + closed = true; + ws.close(); + }; + }; + + return { + listNotes, + getNote, + createNote, + uploadAudio, + createAndUpload, + deleteNote, + archiveNote, + restoreNote, + transcribeNote, + getTranscription, + pollTranscription, + getPlaybackUrl, + subscribeTranscription, + }; +}; + +export type ApiClient = ReturnType; + +// ─── Default instance (singleton) ───────────────────────────────────── + +let defaultClient: ApiClient | null = null; + +/** Initialize the default API client (call once at app startup). */ +export const initApi = (config: ApiConfig): ApiClient => { + defaultClient = createApiClient(config); + return defaultClient; +}; + +/** Get the default API client. Throws if `initApi` was not called. */ +export const getApi = (): ApiClient => { + if (!defaultClient) { + throw new Error( + 'API client not initialized. Call `initApi(config)` first.', + ); + } + return defaultClient; +}; diff --git a/bounty-9/components/AudioPlayer.tsx b/bounty-9/components/AudioPlayer.tsx new file mode 100644 index 00000000..b72618dc --- /dev/null +++ b/bounty-9/components/AudioPlayer.tsx @@ -0,0 +1,408 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { + Alert, + Platform, + Pressable, + StyleSheet, + Text, + View, +} from 'react-native'; +import { Audio, AVPlaybackStatus, InterruptionModeIOS } from 'expo-av'; + +import type { PlaybackState, PlaybackStatus } from '../types'; + +// ─── Constants ──────────────────────────────────────────────────────── + +const RATES = [0.5, 1, 1.5, 2] as const; + +const formatTime = (ms: number): string => { + if (!Number.isFinite(ms) || ms < 0) return '0:00'; + const totalSeconds = Math.floor(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${seconds.toString().padStart(2, '0')}`; +}; + +const clamp = (value: number, min: number, max: number) => + Math.min(max, Math.max(min, value)); + +// ─── Props ──────────────────────────────────────────────────────────── + +interface AudioPlayerProps { + /** URL (remote or local) of the audio file to play */ + source: string; + /** Callback when playback finishes naturally */ + onPlaybackComplete?: () => void; + /** Callback on error */ + onError?: (error: string) => void; + /** Initial playback rate (0.5, 1, 1.5, 2) */ + initialRate?: number; + /** Disable controls */ + disabled?: boolean; +} + +// ─── Component ──────────────────────────────────────────────────────── + +export const AudioPlayer: React.FC = ({ + source, + onPlaybackComplete, + onError, + initialRate = 1, + disabled = false, +}) => { + const soundRef = useRef(null); + const isLoadedRef = useRef(false); + + const [state, setState] = useState({ + status: 'idle', + positionMs: 0, + durationMs: 0, + volume: 1, + isMuted: false, + rate: initialRate, + error: null, + }); + + // ── Load sound ─────────────────────────────────────────────────── + + const loadSound = useCallback(async () => { + try { + setState((s) => ({ ...s, status: 'loading', error: null })); + + await Audio.setAudioModeAsync({ + allowsRecordingIOS: false, + staysActiveInBackground: false, + playsInSilentModeIOS: true, + interruptionModeIOS: InterruptionModeIOS.DoNotMix, + shouldDuckAndroid: true, + playThroughEarpieceAndroid: false, + }); + + const { sound } = await Audio.Sound.createAsync( + { uri: source }, + { shouldPlay: false, progressUpdateIntervalMillis: 250 }, + onPlaybackStatusUpdate, + ); + soundRef.current = sound; + isLoadedRef.current = true; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to load audio'; + setState((s) => ({ ...s, status: 'error', error: message })); + onError?.(message); + } + }, [source, onError]); + + // ── Playback status update handler ─────────────────────────────── + + const onPlaybackStatusUpdate = useCallback( + (status: AVPlaybackStatus) => { + if (!status.isLoaded) { + if (status.error) { + setState((s) => ({ + ...s, + status: 'error', + error: status.error, + })); + onError?.(status.error); + } + return; + } + + const playbackStatus: PlaybackState = { + status: status.isPlaying + ? 'playing' + : status.didJustFinish + ? 'finished' + : status.isLoaded + ? 'paused' + : 'idle', + positionMs: status.positionMillis ?? 0, + durationMs: status.durationMillis ?? 0, + volume: status.volume ?? 1, + isMuted: status.isMuted ?? false, + rate: status.rate ?? 1, + error: null, + }; + + setState(playbackStatus); + + if (status.didJustFinish) { + onPlaybackComplete?.(); + } + }, + [onPlaybackComplete, onError], + ); + + // ── Load on mount / source change ──────────────────────────────── + + useEffect(() => { + loadSound(); + return () => { + if (soundRef.current) { + soundRef.current.unloadAsync().catch(() => {}); + soundRef.current = null; + isLoadedRef.current = false; + } + }; + }, [loadSound]); + + // ── Play / pause ───────────────────────────────────────────────── + + const togglePlayPause = useCallback(async () => { + if (!soundRef.current || !isLoadedRef.current) return; + try { + if (state.status === 'playing') { + await soundRef.current.pauseAsync(); + } else { + await soundRef.current.playAsync(); + } + } catch (err) { + const message = err instanceof Error ? err.message : 'Playback error'; + setState((s) => ({ ...s, status: 'error', error: message })); + onError?.(message); + } + }, [state.status, onError]); + + // ── Seek ───────────────────────────────────────────────────────── + + const seek = useCallback( + async (deltaMs: number) => { + if (!soundRef.current || !isLoadedRef.current) return; + const newPosition = clamp( + state.positionMs + deltaMs, + 0, + state.durationMs, + ); + try { + await soundRef.current.setPositionAsync(newPosition); + setState((s) => ({ ...s, positionMs: newPosition })); + } catch { + // best-effort seek + } + }, + [state.positionMs, state.durationMs], + ); + + // ── Cycle playback rate ────────────────────────────────────────── + + const cycleRate = useCallback(async () => { + if (!soundRef.current || !isLoadedRef.current) return; + const currentIndex = RATES.indexOf(state.rate as (typeof RATES)[number]); + const nextRate = RATES[(currentIndex + 1) % RATES.length]; + try { + await soundRef.current.setRateAsync(nextRate, true); + setState((s) => ({ ...s, rate: nextRate })); + } catch { + // best-effort rate change + } + }, [state.rate]); + + // ── Toggle mute ────────────────────────────────────────────────── + + const toggleMute = useCallback(async () => { + if (!soundRef.current || !isLoadedRef.current) return; + const nextMuted = !state.isMuted; + try { + await soundRef.current.setIsMutedAsync(nextMuted); + setState((s) => ({ ...s, isMuted: nextMuted })); + } catch { + // best-effort mute + } + }, [state.isMuted]); + + // ── Error alert ────────────────────────────────────────────────── + + useEffect(() => { + if (state.status === 'error' && state.error) { + Alert.alert('Playback Error', state.error); + } + }, [state.status, state.error]); + + // ── Seek backward / forward ────────────────────────────────────── + + const isPlayable = + state.status !== 'loading' && + state.status !== 'error' && + !disabled; + + return ( + + {/* Waveform / progress bar */} + + + 0 + ? `${(state.positionMs / state.durationMs) * 100}%` + : '0%', + }, + ]} + /> + + + + {/* Time labels */} + + {formatTime(state.positionMs)} + {formatTime(state.durationMs)} + + + {/* Controls */} + + {/* Seek backward 15s */} + seek(-15000)} + disabled={!isPlayable} + accessibilityRole="button" + accessibilityLabel="Seek backward 15 seconds" + > + ↺15 + + + {/* Play / pause */} + + + {state.status === 'loading' ? '⏳' : state.status === 'playing' ? '⏸' : '▶'} + + + + {/* Seek forward 15s */} + seek(15000)} + disabled={!isPlayable} + accessibilityRole="button" + accessibilityLabel="Seek forward 15 seconds" + > + 15↻ + + + + {/* Secondary controls row */} + + {/* Speed */} + + {state.rate}x + + + {/* Mute */} + + + {state.isMuted ? '🔇' : '🔊'} + + + + + ); +}; + +// ─── Styles ─────────────────────────────────────────────────────────── + +const styles = StyleSheet.create({ + container: { + gap: 8, + paddingVertical: 12, + }, + progressContainer: { + height: 24, + justifyContent: 'center', + }, + progressTrack: { + backgroundColor: '#E5E7EB', + borderRadius: 4, + height: 6, + overflow: 'hidden', + width: '100%', + }, + progressFill: { + backgroundColor: '#3B82F6', + borderRadius: 4, + height: '100%', + }, + timeRow: { + flexDirection: 'row', + justifyContent: 'space-between', + }, + timeText: { + color: '#6B7280', + fontFamily: Platform.select({ ios: 'Menlo', default: 'monospace' }), + fontSize: 12, + }, + controls: { + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'center', + gap: 24, + }, + controlButton: { + alignItems: 'center', + borderRadius: 24, + height: 48, + justifyContent: 'center', + width: 48, + }, + controlDisabled: { + opacity: 0.4, + }, + controlIcon: { + color: '#374151', + fontSize: 13, + fontWeight: '700', + }, + playButton: { + alignItems: 'center', + backgroundColor: '#3B82F6', + borderRadius: 32, + height: 64, + justifyContent: 'center', + width: 64, + }, + playIcon: { + color: '#FFFFFF', + fontSize: 24, + }, + secondaryRow: { + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'center', + gap: 16, + }, + secondaryButton: { + alignItems: 'center', + backgroundColor: '#F3F4F6', + borderRadius: 6, + height: 32, + justifyContent: 'center', + minWidth: 48, + paddingHorizontal: 12, + }, + secondaryText: { + color: '#374151', + fontSize: 13, + fontWeight: '600', + }, +}); diff --git a/bounty-9/components/AudioRecorder.tsx b/bounty-9/components/AudioRecorder.tsx new file mode 100644 index 00000000..34d1e7cd --- /dev/null +++ b/bounty-9/components/AudioRecorder.tsx @@ -0,0 +1,497 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { + Alert, + AppState, + AppStateStatus, + Platform, + Pressable, + StyleSheet, + Text, + View, +} from 'react-native'; +import { Audio, AudioMode, InterruptionModeIOS } from 'expo-av'; +import * as FileSystem from 'expo-file-system'; + +import type { AudioMimeType, RecordingState, RecordingStatus } from '../types'; + +// ─── Constants ──────────────────────────────────────────────────────── + +const RECORDING_OPTIONS: Audio.RecordingOptions = { + isMeteringEnabled: true, + android: { + extension: '.webm', + outputFormat: Audio.AndroidOutputFormat.WEBM, + audioEncoder: Audio.AndroidAudioEncoder.OPUS, + sampleRate: 16000, + numberOfChannels: 1, + bitRate: 16000, + }, + ios: { + extension: '.wav', + audioQuality: Audio.IOSAudioQuality.HIGH, + sampleRate: 16000, + numberOfChannels: 1, + bitRate: 16000, + linearPCMBitDepth: 16, + linearPCMIsBigEndian: false, + linearPCMIsFloat: false, + }, +}; + +const getMimeType = (): AudioMimeType => + Platform.OS === 'android' ? 'audio/webm' : 'audio/wav'; + +const formatTime = (ms: number): string => { + const totalSeconds = Math.floor(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${seconds.toString().padStart(2, '0')}`; +}; + +// ─── Props ──────────────────────────────────────────────────────────── + +interface AudioRecorderProps { + /** Called with local file URI after recording + stop */ + onRecordingComplete?: (uri: string, durationMs: number, mimeType: AudioMimeType) => void; + /** Called on error */ + onError?: (error: string) => void; + /** Maximum recording duration in ms (default 10 min) */ + maxDurationMs?: number; + /** Disable the record button */ + disabled?: boolean; +} + +// ─── Hook ───────────────────────────────────────────────────────────── + +const useAudioPermission = () => { + const [permission, setPermission] = useState('permission-pending'); + + const requestPermission = useCallback(async () => { + try { + const { granted } = await Audio.requestPermissionsAsync(); + setPermission(granted ? 'ready' : 'permission-denied'); + return granted; + } catch { + setPermission('permission-denied'); + return false; + } + }, []); + + useEffect(() => { + (async () => { + const { granted } = await Audio.getPermissionsAsync(); + setPermission(granted ? 'ready' : 'permission-pending'); + })(); + }, []); + + return { permission, requestPermission }; +}; + +// ─── Component ──────────────────────────────────────────────────────── + +export const AudioRecorder: React.FC = ({ + onRecordingComplete, + onError, + maxDurationMs = 600_000, + disabled = false, +}) => { + const { permission, requestPermission } = useAudioPermission(); + const [state, setState] = useState({ + status: permission === 'ready' ? 'ready' : 'idle', + durationMs: 0, + fileUri: null, + fileSize: null, + error: null, + }); + + const recordingRef = useRef(null); + const timerRef = useRef | null>(null); + const startTimeRef = useRef(0); + const appStateRef = useRef(AppState.currentState); + + // ── Sync initial permission state ──────────────────────────────── + + useEffect(() => { + if (permission === 'ready' && state.status === 'idle') { + setState((s) => ({ ...s, status: 'ready' })); + } + if (permission === 'permission-denied') { + setState((s) => ({ ...s, status: 'permission-denied' })); + } + }, [permission, state.status]); + + // ── Handle app foregrounding → restart audio session ───────────── + + useEffect(() => { + const sub = AppState.addEventListener('change', (nextState) => { + if ( + appStateRef.current.match(/inactive|background/) && + nextState === 'active' && + recordingRef.current + ) { + Audio.setAudioModeAsync({ + allowsRecordingIOS: true, + playsInSilentModeIOS: true, + staysActiveInBackground: false, + interruptionModeIOS: InterruptionModeIOS.DoNotMix, + shouldDuckAndroid: true, + playThroughEarpieceAndroid: false, + }); + } + appStateRef.current = nextState; + }); + return () => sub.remove(); + }, []); + + // ── Cleanup on unmount ─────────────────────────────────────────── + + useEffect(() => { + return () => { + if (timerRef.current) clearInterval(timerRef.current); + if (recordingRef.current) { + recordingRef.current.stopAndUnloadAsync().catch(() => {}); + } + }; + }, []); + + // ── Duration timer ─────────────────────────────────────────────── + + const startTimer = () => { + startTimeRef.current = Date.now(); + timerRef.current = setInterval(() => { + const elapsed = Date.now() - startTimeRef.current; + setState((s) => ({ ...s, durationMs: elapsed })); + if (elapsed >= maxDurationMs) { + stopRecording(); + } + }, 250); + }; + + const stopTimer = () => { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + }; + + // ── Start recording ────────────────────────────────────────────── + + const startRecording = useCallback(async () => { + try { + if (permission !== 'ready') { + const granted = await requestPermission(); + if (!granted) { + Alert.alert( + 'Microphone Access Required', + 'Enable microphone access in Settings to record audio notes.', + [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Open Settings', onPress: () => {} }, + ], + ); + return; + } + } + + await Audio.setAudioModeAsync({ + allowsRecordingIOS: true, + playsInSilentModeIOS: true, + staysActiveInBackground: false, + interruptionModeIOS: InterruptionModeIOS.DoNotMix, + shouldDuckAndroid: true, + playThroughEarpieceAndroid: false, + }); + + const { recording } = await Audio.Recording.createAsync(RECORDING_OPTIONS); + recordingRef.current = recording; + setState({ + status: 'recording', + durationMs: 0, + fileUri: null, + fileSize: null, + error: null, + }); + startTimer(); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to start recording'; + setState((s) => ({ ...s, status: 'error', error: message })); + onError?.(message); + } + }, [permission, requestPermission, onError, maxDurationMs]); + + // ── Stop recording ─────────────────────────────────────────────── + + const stopRecording = useCallback(async () => { + try { + if (!recordingRef.current) return; + + stopTimer(); + await recordingRef.current.stopAndUnloadAsync(); + const uri = recordingRef.current.getURI(); + recordingRef.current = null; + + if (!uri) { + throw new Error('Recording URI is null after stop'); + } + + const info = await FileSystem.getInfoAsync(uri, { size: true }); + const durationMs = Date.now() - startTimeRef.current; + + setState({ + status: 'stopped', + durationMs, + fileUri: uri, + fileSize: info.exists ? info.size ?? null : null, + error: null, + }); + + onRecordingComplete?.(uri, durationMs, getMimeType()); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to stop recording'; + setState((s) => ({ ...s, status: 'error', error: message })); + onError?.(message); + } + }, [onRecordingComplete, onError]); + + // ── Discard local recording ────────────────────────────────────── + + const discardRecording = useCallback(async () => { + if (state.fileUri) { + try { + await FileSystem.deleteAsync(state.fileUri, { idempotent: true }); + } catch { /* best-effort */ } + } + setState({ + status: 'ready', + durationMs: 0, + fileUri: null, + fileSize: null, + error: null, + }); + }, [state.fileUri]); + + // ── Reset to record again ──────────────────────────────────────── + + const resetRecording = useCallback(() => { + setState({ + status: 'ready', + durationMs: 0, + fileUri: null, + fileSize: null, + error: null, + }); + }, []); + + // ── Render helpers ─────────────────────────────────────────────── + + const isRecording = state.status === 'recording'; + const isStopped = state.status === 'stopped'; + const isBusy = isRecording || state.status === 'uploading'; + const canRecord = !disabled && !isBusy && permission !== 'permission-denied'; + + return ( + + {/* Permission denied banner */} + {permission === 'permission-denied' && ( + + + Microphone access denied. Enable it in Settings. + + + )} + + {/* Timer display */} + + {isStopped ? formatTime(state.durationMs) : formatTime(state.durationMs)} + + + {/* Status label */} + + {state.status === 'recording' && 'Recording…'} + {state.status === 'uploading' && 'Uploading…'} + {state.status === 'stopped' && 'Recording complete'} + {state.status === 'error' && 'Error'} + {state.status === 'ready' && 'Tap to record'} + {state.status === 'idle' && 'Ready'} + + + {/* Record / Stop button */} + [ + styles.recordButton, + isRecording && styles.recordButtonActive, + isStopped && styles.recordButtonStopped, + !canRecord && styles.recordButtonDisabled, + pressed && canRecord && styles.recordButtonPressed, + ]} + onPress={isRecording ? stopRecording : startRecording} + disabled={!canRecord || disabled} + accessibilityRole="button" + accessibilityLabel={isRecording ? 'Stop recording' : 'Start recording'} + > + + + + {/* Action buttons (visible after stop) */} + {isStopped && ( + + + Discard + + + Record New + + + )} + + {/* Error display */} + {state.status === 'error' && state.error && ( + + {state.error} + + Retry + + + )} + + ); +}; + +// ─── Styles ─────────────────────────────────────────────────────────── + +const BUTTON_SIZE = 80; +const ICON_SIZE = 56; + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + gap: 12, + paddingVertical: 24, + }, + banner: { + backgroundColor: '#FEF3CD', + borderRadius: 8, + paddingHorizontal: 16, + paddingVertical: 10, + width: '100%', + }, + bannerText: { + color: '#856404', + fontSize: 14, + textAlign: 'center', + }, + timer: { + color: '#1F2937', + fontFamily: Platform.select({ ios: 'Menlo', default: 'monospace' }), + fontSize: 48, + fontWeight: '200', + letterSpacing: 2, + }, + timerActive: { + color: '#DC2626', + }, + statusLabel: { + color: '#6B7280', + fontSize: 14, + marginTop: -4, + }, + recordButton: { + alignItems: 'center', + backgroundColor: '#EF4444', + borderRadius: BUTTON_SIZE / 2, + height: BUTTON_SIZE, + justifyContent: 'center', + width: BUTTON_SIZE, + }, + recordButtonActive: { + backgroundColor: '#991B1B', + }, + recordButtonStopped: { + backgroundColor: '#10B981', + }, + recordButtonDisabled: { + backgroundColor: '#D1D5DB', + }, + recordButtonPressed: { + opacity: 0.8, + transform: [{ scale: 0.95 }], + }, + recordIcon: { + backgroundColor: '#FFFFFF', + borderRadius: ICON_SIZE / 2, + height: ICON_SIZE, + width: ICON_SIZE, + }, + recordIconActive: { + borderRadius: 8, + height: 24, + width: 24, + }, + recordIconStopped: { + borderRadius: ICON_SIZE / 2, + height: ICON_SIZE, + width: ICON_SIZE, + }, + actions: { + flexDirection: 'row', + gap: 16, + marginTop: 8, + }, + actionButton: { + backgroundColor: '#F3F4F6', + borderRadius: 8, + paddingHorizontal: 24, + paddingVertical: 10, + }, + actionButtonText: { + color: '#374151', + fontSize: 14, + fontWeight: '600', + }, + errorContainer: { + alignItems: 'center', + gap: 8, + }, + errorText: { + color: '#DC2626', + fontSize: 13, + textAlign: 'center', + }, + retryButton: { + backgroundColor: '#FEE2E2', + borderRadius: 6, + paddingHorizontal: 16, + paddingVertical: 6, + }, + retryButtonText: { + color: '#991B1B', + fontSize: 13, + fontWeight: '600', + }, +}); diff --git a/bounty-9/components/TranscriptionView.tsx b/bounty-9/components/TranscriptionView.tsx new file mode 100644 index 00000000..3512275e --- /dev/null +++ b/bounty-9/components/TranscriptionView.tsx @@ -0,0 +1,333 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { + ActivityIndicator, + Platform, + Pressable, + ScrollView, + StyleSheet, + Text, + View, +} from 'react-native'; + +import type { Transcription, TranscriptionStatus } from '../types'; + +// ─── Constants ──────────────────────────────────────────────────────── + +const MAX_PREVIEW_CHARS = 200; + +// ─── Props ──────────────────────────────────────────────────────────── + +interface TranscriptionViewProps { + /** Full transcription data (null while pending / loading) */ + transcription: Transcription | null; + /** Current transcription status */ + status: TranscriptionStatus; + /** Called when user taps "Retry" after a failure */ + onRetry?: () => void; + /** Called when a segment is tapped (seek-to-time) */ + onSegmentPress?: (startTime: number) => void; + /** Whether the view starts collapsed */ + initiallyCollapsed?: boolean; +} + +// ─── Helpers ────────────────────────────────────────────────────────── + +const formatTimestamp = (seconds: number): string => { + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + return `${m}:${s.toString().padStart(2, '0')}`; +}; + +// ─── Segment Row ────────────────────────────────────────────────────── + +interface SegmentRowProps { + segment: { start: number; end: number; text: string }; + onPress?: (start: number) => void; +} + +const SegmentRow: React.FC = React.memo( + ({ segment, onPress }) => ( + [ + styles.segmentRow, + pressed && styles.segmentRowPressed, + ]} + onPress={() => onPress?.(segment.start)} + accessibilityRole="button" + accessibilityLabel={`Go to ${formatTimestamp(segment.start)}`} + > + + {formatTimestamp(segment.start)} + + {segment.text} + + ), +); + +// ─── Loading / Processing States ────────────────────────────────────── + +const ProcessingIndicator: React.FC<{ status: TranscriptionStatus }> = ({ + status, +}) => ( + + {status === 'processing' ? ( + <> + + Transcribing audio… + + ) : ( + Transcription pending + )} + +); + +// ─── Error State ────────────────────────────────────────────────────── + +const ErrorState: React.FC<{ onRetry?: () => void }> = ({ onRetry }) => ( + + Transcription failed + {onRetry && ( + + Tap to retry + + )} + +); + +// ─── Empty State ────────────────────────────────────────────────────── + +const EmptyState: React.FC = () => ( + + No transcription available + +); + +// ─── Component ──────────────────────────────────────────────────────── + +export const TranscriptionView: React.FC = ({ + transcription, + status, + onRetry, + onSegmentPress, + initiallyCollapsed = true, +}) => { + const [expanded, setExpanded] = useState(!initiallyCollapsed); + + const toggleExpanded = useCallback(() => { + setExpanded((prev) => !prev); + }, []); + + // Stable segments reference for memo + const segments = useMemo( + () => transcription?.segments ?? [], + [transcription?.segments], + ); + + const fullText = transcription?.text ?? ''; + + const previewText = useMemo( + () => + fullText.length > MAX_PREVIEW_CHARS + ? fullText.slice(0, MAX_PREVIEW_CHARS) + '…' + : fullText, + [fullText], + ); + + // ── Determine what to render ──────────────────────────────────── + + if (status === 'pending' || status === 'processing') { + return ; + } + + if (status === 'failed') { + return ; + } + + if (status === 'completed' && !transcription) { + return ; + } + + // ── Completed transcription ───────────────────────────────────── + + return ( + + {/* Header with confidence + language badge */} + + Transcription + + {transcription?.language && ( + + + {transcription.language.toUpperCase()} + + + )} + {transcription?.confidence != null && ( + + + {Math.round(transcription.confidence * 100)}% + + + )} + + + + {/* Collapsible preview */} + + + {expanded ? fullText : previewText} + + {fullText.length > MAX_PREVIEW_CHARS && ( + + {expanded ? 'Show less' : 'Show more'} + + )} + + + {/* Segmented view (only when expanded) */} + {expanded && segments.length > 0 && ( + + {segments.map((seg, idx) => ( + + ))} + + )} + + {/* Confidence disclaimer for low-confidence transcripts */} + {transcription?.confidence != null && transcription.confidence < 0.6 && ( + + + Low confidence transcription — accuracy may be reduced. + + + )} + + ); +}; + +// ─── Styles ─────────────────────────────────────────────────────────── + +const styles = StyleSheet.create({ + container: { + backgroundColor: '#F9FAFB', + borderRadius: 12, + gap: 8, + padding: 16, + }, + header: { + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'space-between', + }, + headerTitle: { + color: '#374151', + fontSize: 15, + fontWeight: '600', + }, + badgeRow: { + flexDirection: 'row', + gap: 6, + }, + badge: { + backgroundColor: '#E0E7FF', + borderRadius: 4, + paddingHorizontal: 6, + paddingVertical: 2, + }, + badgeText: { + color: '#4338CA', + fontSize: 11, + fontWeight: '600', + }, + fullText: { + color: '#1F2937', + fontSize: 14, + lineHeight: 22, + }, + expandToggle: { + color: '#3B82F6', + fontSize: 13, + fontWeight: '600', + marginTop: 4, + }, + segmentsContainer: { + maxHeight: 240, + }, + segmentRow: { + borderBottomColor: '#F3F4F6', + borderBottomWidth: 1, + flexDirection: 'row', + gap: 8, + paddingVertical: 8, + }, + segmentRowPressed: { + backgroundColor: '#EFF6FF', + marginHorizontal: -8, + paddingHorizontal: 8, + borderRadius: 6, + }, + segmentTimestamp: { + color: '#6B7280', + fontFamily: Platform.select({ ios: 'Menlo', default: 'monospace' }), + fontSize: 11, + lineHeight: 20, + minWidth: 32, + }, + segmentText: { + color: '#1F2937', + flex: 1, + fontSize: 14, + lineHeight: 20, + }, + statusContainer: { + alignItems: 'center', + gap: 8, + paddingVertical: 24, + }, + statusText: { + color: '#6B7280', + fontSize: 14, + }, + errorText: { + color: '#DC2626', + fontSize: 14, + }, + retryButton: { + backgroundColor: '#FEE2E2', + borderRadius: 6, + paddingHorizontal: 16, + paddingVertical: 6, + }, + retryButtonText: { + color: '#991B1B', + fontSize: 13, + fontWeight: '600', + }, + disclaimer: { + backgroundColor: '#FEF3CD', + borderRadius: 6, + paddingHorizontal: 10, + paddingVertical: 6, + }, + disclaimerText: { + color: '#856404', + fontSize: 12, + }, +}); diff --git a/bounty-9/types.ts b/bounty-9/types.ts new file mode 100644 index 00000000..d211175a --- /dev/null +++ b/bounty-9/types.ts @@ -0,0 +1,188 @@ +// ─── Core domain types ──────────────────────────────────────────────── + +/** Soft-delete / archive status */ +export type ArchiveStatus = 'active' | 'archived'; + +/** Supported audio MIME types */ +export type AudioMimeType = 'audio/webm' | 'audio/wav' | 'audio/mp4'; + +/** Recording lifecycle state on device */ +export type RecordingStatus = + | 'idle' + | 'permission-pending' + | 'permission-denied' + | 'ready' + | 'recording' + | 'stopped' + | 'uploading' + | 'uploaded' + | 'error'; + +/** Playback lifecycle state */ +export type PlaybackStatus = + | 'idle' + | 'loading' + | 'playing' + | 'paused' + | 'finished' + | 'error'; + +/** Transcription lifecycle state */ +export type TranscriptionStatus = + | 'pending' + | 'processing' + | 'completed' + | 'failed'; + +// ─── AudioNote ──────────────────────────────────────────────────────── + +/** A persisted audio note record (matches D1 row) */ +export interface AudioNote { + /** UUID v7 primary key */ + id: string; + /** User-facing title (auto-generated if null) */ + title: string | null; + /** Recording length in milliseconds */ + durationMs: number; + /** R2 object key (e.g. `audio/.webm`) */ + fileKey: string; + /** File size in bytes */ + fileSize: number; + /** MIME type of the audio file */ + mimeType: AudioMimeType; + /** ISO-8601 creation timestamp */ + createdAt: string; + /** ISO-8601 last-updated timestamp */ + updatedAt: string; + /** Soft-delete flag */ + archived: boolean; +} + +// ─── Recording ──────────────────────────────────────────────────────── + +/** Client-side recording state emitted by AudioRecorder */ +export interface RecordingState { + /** Current lifecycle status */ + status: RecordingStatus; + /** Duration of the current recording session (ms) */ + durationMs: number; + /** Local file URI after recording stops */ + fileUri: string | null; + /** File size in bytes (available after stop) */ + fileSize: number | null; + /** Error message when status === 'error' */ + error: string | null; +} + +/** Metadata collected before/after a recording session */ +export interface RecordingMetadata { + /** User-provided or auto-generated title */ + title?: string; + /** Duration in milliseconds */ + durationMs: number; + /** MIME type of the recorded file */ + mimeType: AudioMimeType; + /** File size in bytes */ + fileSize: number; +} + +// ─── Transcription ──────────────────────────────────────────────────── + +/** A single segment (word or phrase group) within a transcript */ +export interface TranscriptSegment { + /** Start time in seconds */ + start: number; + /** End time in seconds */ + end: number; + /** Transcribed text for this segment */ + text: string; + /** Per-segment confidence (0–1) */ + confidence?: number; +} + +/** Full transcription result */ +export interface Transcription { + /** UUID v7 primary key */ + id: string; + /** Parent audio note ID */ + noteId: string; + /** Full concatenated transcript text */ + text: string; + /** Segmented transcription with timing data */ + segments: TranscriptSegment[]; + /** Whisper model variant used */ + model: string; + /** Overall confidence (0–1) */ + confidence: number; + /** Detected language code (BCP-47) */ + language: string; + /** Current processing status */ + status: TranscriptionStatus; + /** ISO-8601 creation timestamp */ + createdAt: string; +} + +// ─── Playback ───────────────────────────────────────────────────────── + +/** Client-side playback state emitted by AudioPlayer */ +export interface PlaybackState { + /** Current lifecycle status */ + status: PlaybackStatus; + /** Current playback position in milliseconds */ + positionMs: number; + /** Total duration in milliseconds (0 until loaded) */ + durationMs: number; + /** Volume level 0.0–1.0 */ + volume: number; + /** Whether the audio is muted */ + isMuted: boolean; + /** Playback rate (0.5, 1.0, 1.5, 2.0) */ + rate: number; + /** Error message when status === 'error' */ + error: string | null; +} + +// ─── API DTOs ───────────────────────────────────────────────────────── + +/** Response from GET /notes */ +export interface ListNotesResponse { + items: AudioNote[]; + cursor: string | null; + total: number; +} + +/** Payload for POST /notes */ +export interface CreateNoteRequest { + title?: string; + durationMs: number; + mimeType: AudioMimeType; + fileSize: number; +} + +/** Response from POST /notes/upload */ +export interface UploadResponse { + note: AudioNote; + /** Pre-signed URL for uploading the audio file */ + uploadUrl: string; + /** Pre-signed URL expiration in seconds */ + expiresIn: number; +} + +/** Response from POST /notes/:id/transcribe */ +export interface TranscribeResponse { + transcription: Transcription; +} + +/** Response from GET /notes/:id/transcription */ +export interface GetTranscriptionResponse { + transcription: Transcription | null; +} + +// ─── Events ─────────────────────────────────────────────────────────── + +/** Events emitted over the transcription WebSocket */ +export type TranscriptionEvent = + | { type: 'transcription.started'; noteId: string } + | { type: 'transcription.progress'; noteId: string; progress: number } + | { type: 'transcription.completed'; noteId: string; transcription: Transcription } + | { type: 'transcription.failed'; noteId: string; error: string };