Skip to content

Commit 4c76b64

Browse files
committed
Add voice recording, transcription, and audio player components
- Implemented `AudioPlayer` for custom audio playback controls. - Added `useAudioRecorder` hook for managing microphone audio recording with visual feedback. - Introduced audio transcription API integration using OpenAI Whisper. - Created `blobToBase64` utility to encode recorded blobs for API consumption. - Added IndexedDB-based `recording-store` for saving in-progress recordings. - Developed `VoiceAutofillSection` for guided recording, playback, transcription, and editing.
1 parent e8134fc commit 4c76b64

19 files changed

Lines changed: 1589 additions & 42 deletions

android/app/build.gradle

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ android {
1111
applicationId "com.compassconnections.app"
1212
minSdkVersion rootProject.ext.minSdkVersion
1313
targetSdkVersion rootProject.ext.targetSdkVersion
14-
versionCode 131
15-
versionName "1.31.0"
14+
versionCode 132
15+
versionName "1.32.0"
1616
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
1717
aaptOptions {
1818
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

backend/api/src/app.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ import {setLastOnlineTime} from './set-last-online-time'
9292
import {shipProfiles} from './ship-profiles'
9393
import {starProfile} from './star-profile'
9494
import {stats} from './stats'
95+
import {transcribeAudio} from './transcribe-audio'
9596
import {unsubscribe} from './unsubscribe'
9697
import {updateEvent} from './update-event'
9798
import {updateMe} from './update-me'
@@ -653,6 +654,7 @@ const handlers: {[k in APIPath]: APIHandler<k>} = {
653654
vote: vote,
654655
'validate-username': validateUsernameEndpoint,
655656
'llm-extract-profile': llmExtractProfileEndpoint,
657+
'transcribe-audio': transcribeAudio,
656658
// 'user/:username': getUser,
657659
// 'user/:username/lite': getDisplayUser,
658660
// 'user/by-id/:id/lite': getDisplayUser,
@@ -678,7 +680,8 @@ Object.entries(handlers).forEach(([path, handler]) => {
678680

679681
const apiRoute = [
680682
url,
681-
express.json({limit: '1mb'}),
683+
// Endpoints that carry binary payloads (e.g. base64 audio) declare a larger `bodyLimit`.
684+
express.json({limit: (api as any).bodyLimit ?? '1mb'}),
682685
allowCorsUnrestricted,
683686
cache,
684687
typedEndpoint(path as any, handler as any),

backend/api/src/llm-extract-profile.ts

Lines changed: 77 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import {
99
GENDERS,
1010
LANGUAGE_CHOICES,
1111
MBTI_CHOICES,
12+
NEUROTYPE_CHOICES,
13+
ORIENTATION_CHOICES,
1214
POLITICAL_CHOICES,
1315
PSYCHEDELICS_CHOICES,
1416
RACE_CHOICES,
@@ -23,7 +25,7 @@ import {debug} from 'common/logger'
2325
import {ProfileWithoutUser} from 'common/profiles/profile'
2426
import {SITE_ORDER} from 'common/socials'
2527
import {removeNullOrUndefinedProps} from 'common/util/object'
26-
import {parseJsonContentToText} from 'common/util/parse'
28+
import {parseJsonContentToText, textToJSONContent} from 'common/util/parse'
2729
import {HOUR_MS, MINUTE_MS, sleep} from 'common/util/time'
2830
import {createHash} from 'crypto'
2931
import {promises as fs} from 'fs'
@@ -38,18 +40,26 @@ const CACHE_DIR = join(tmpdir(), 'compass-llm-cache')
3840
const CACHE_TTL_MS = 24 * HOUR_MS
3941
const PROCESSING_TTL_MS = 10 * MINUTE_MS
4042

43+
type ExtractSource = 'text' | 'url' | 'voice'
44+
4145
interface ParsedBody {
4246
content?: string
4347
url?: string
4448
locale?: string
49+
source?: ExtractSource
4550
}
4651

52+
// Bump whenever the extraction prompt changes. The cache key is otherwise derived purely from the
53+
// request, so a prompt fix would keep returning the old answer for the 24h TTL — which looks exactly
54+
// like the fix not working.
55+
const PROMPT_VERSION = 2
56+
4757
function getCacheKey(parsedBody: ParsedBody): string {
4858
if (!USE_CACHE) return ''
4959
const hash = createHash('sha256')
5060
// Normalize: sort keys for consistent hashing
5161
const normalized = JSON.stringify(parsedBody, Object.keys(parsedBody).sort())
52-
hash.update(normalized)
62+
hash.update(`v${PROMPT_VERSION}:${normalized}`)
5363
return hash.digest('hex')
5464
}
5565

@@ -79,6 +89,8 @@ async function validateProfileFields(
7989
'cannabis_intention',
8090
'psychedelics_pref',
8191
'cannabis_pref',
92+
'orientation',
93+
'neurotype',
8294
]
8395
for (const key of toArray) {
8496
if (result[key] !== undefined) {
@@ -113,6 +125,10 @@ async function validateProfileFields(
113125
'occupation_title',
114126
'religious_beliefs',
115127
'political_details',
128+
'gender_details',
129+
'orientation_details',
130+
'neurotype_details',
131+
'accessibility_notes',
116132
]
117133
for (const key of toString) {
118134
if (result[key] !== undefined) {
@@ -246,7 +262,10 @@ async function setCachedResult(cacheKey: string, result: any): Promise<void> {
246262
await fs.mkdir(CACHE_DIR, {recursive: true})
247263
const cacheFile = join(CACHE_DIR, `${cacheKey}.json`)
248264
await fs.writeFile(cacheFile, JSON.stringify(result), 'utf-8')
249-
debug('Cached LLM result', {cacheKey: cacheKey.substring(0, 8), result})
265+
debug('Cached LLM result', {
266+
cacheKey: cacheKey.substring(0, 8),
267+
result: JSON.stringify(result),
268+
})
250269
} catch (error) {
251270
log('Failed to write cache', {cacheKey, error})
252271
// Don't throw - caching failure shouldn't break the main flow
@@ -296,11 +315,13 @@ async function processAndCache(
296315
content?: string | undefined,
297316
url?: string | undefined,
298317
locale?: string,
318+
source?: ExtractSource,
299319
): Promise<void> {
300320
log('Extracting profile from content', {
301321
contentLength: content?.length,
302322
url,
303323
locale,
324+
source,
304325
})
305326
try {
306327
let bio: JSONContent | undefined
@@ -309,7 +330,7 @@ async function processAndCache(
309330
debug(JSON.stringify(bio, null, 2))
310331
content = parseJsonContentToText(bio)
311332
}
312-
const profile = await callLLM(content, locale)
333+
const profile = await callLLM(content, locale, source)
313334
if (bio) {
314335
profile.bio = bio
315336
}
@@ -422,7 +443,9 @@ async function _callClaude(text: string) {
422443
export async function callLLM(
423444
content: string,
424445
locale?: string,
446+
source?: ExtractSource,
425447
): Promise<Partial<ProfileWithoutUser>> {
448+
const isVoice = source === 'voice'
426449
const [INTERESTS, CAUSE_AREAS, WORK_AREAS] = await Promise.all([
427450
getOptions('interests', locale),
428451
getOptions('causes', locale),
@@ -451,12 +474,19 @@ export async function callLLM(
451474
cannabis_intention: Object.values(SUBSTANCE_INTENTION_CHOICES),
452475
psychedelics_pref: Object.values(SUBSTANCE_PREFERENCE_CHOICES),
453476
cannabis_pref: Object.values(SUBSTANCE_PREFERENCE_CHOICES),
477+
orientation: Object.values(ORIENTATION_CHOICES),
478+
neurotype: Object.values(NEUROTYPE_CHOICES),
454479
}
455480

456481
const PROFILE_FIELDS: Partial<Record<keyof ProfileWithoutUser, any>> = {
457482
// Basic info
458483
age: 'Number. Age in years (between 18 and 100).',
459484
gender: `String. One of: ${validChoices.pref_gender?.join(', ')}. If multiple mentioned, use the most likely one. Infer if you have enough evidence`,
485+
gender_details:
486+
'String. Free-form elaboration on their gender identity, only if they say more than the label itself.',
487+
orientation: `Array. Any of: ${validChoices.orientation?.join(', ')}. Only if stated — never infer from the gender of a partner or of who they are looking for.`,
488+
orientation_details:
489+
'String. Free-form elaboration on their orientation, only if they say more than the label itself.',
460490
height_in_inches: 'Number. Height converted to inches.',
461491
city: 'String. Current city of residence (English spelling).',
462492
country: 'String. Current country of residence (English spelling).',
@@ -498,6 +528,11 @@ export async function callLLM(
498528
big5_agreeableness: 'Number 0–100. Only if explicitly self-reported, never infer.',
499529
big5_neuroticism: 'Number 0–100. Only if explicitly self-reported, never infer.',
500530

531+
// Neurotype is an identity here, not a diagnosis: only ever take the person's own words for it.
532+
neurotype: `Array. Any of: ${validChoices.neurotype?.join(', ')}. Only if they identify this way themselves — never infer it from how they describe their personality, focus, energy or social life.`,
533+
neurotype_details:
534+
'String. Free-form elaboration on their neurotype, only if they say more than the label itself.',
535+
501536
// Beliefs
502537
religion: `Array. Any of: ${validChoices.religion?.join(', ')}`,
503538
religious_beliefs:
@@ -511,7 +546,7 @@ export async function callLLM(
511546
'Number. Minimum preferred age of match (higher than 18, only if mentioned, do NOT infer).',
512547
pref_age_max:
513548
'Number. Maximum preferred age of match (lower than 100, only if mentioned, do NOT infer).',
514-
pref_gender: `Array. Any of: ${validChoices.pref_gender?.join(', ')}`,
549+
pref_gender: `Array. Any of: ${validChoices.pref_gender?.join(', ')}. Only the genders they actually name as sought. If they say gender does not matter to them, or is unimportant to their attraction, OMIT this field — an omitted field already means "no preference", so listing every option instead is both wrong and unreadable.`,
515550
pref_relation_styles: `Array. Any of: ${validChoices.pref_relation_styles?.join(', ')}`,
516551
pref_romantic_styles: `Array. Any of: ${validChoices.pref_romantic_styles?.join(', ')}`,
517552
relationship_status: `Array. Any of: ${validChoices.relationship_status?.join(', ')}`,
@@ -523,6 +558,8 @@ export async function callLLM(
523558
headline:
524559
'String. Summary of who they are, in their own voice (first person). Maximum 200 characters total. Cannot be null.',
525560
keywords: 'Array of 3–6 short tags summarising the person.',
561+
accessibility_notes:
562+
'String. Practical things that help someone meet them well — access needs, energy levels, sensory preferences, venue preferences. Only if mentioned; never infer a disability, and keep their own framing and wording.',
526563
links: `Object. Key is any of: ${SITE_ORDER.join(', ')}.`,
527564

528565
// Taxonomies — match existing labels first, only add new if truly no close match exists
@@ -531,7 +568,25 @@ export async function callLLM(
531568
work: `Array. Use only existing labels, do not add new if no close match. Any of: ${validChoices.work?.join(', ')}`,
532569
}
533570

534-
const EXTRACTION_PROMPT = `You are a profile information extraction expert analyzing text from a personal webpage, bio, or similar source.
571+
// For text and URL sources the bio is the source material itself, stored verbatim. A speech
572+
// transcript makes a poor bio (filler words, false starts, no paragraphs), so for voice we ask
573+
// the model to write it instead.
574+
if (isVoice) {
575+
PROFILE_FIELDS.bio =
576+
'String. A first-person bio written from what the person said, in their own voice and their ' +
577+
'own language. Keep their wording and personality wherever you can; only clean up filler ' +
578+
'words, false starts, repetitions and transcription noise, and organise it into ' +
579+
'paragraphs. Separate each paragraph from the next with a BLANK LINE, i.e. two newline ' +
580+
'characters ("\\n\\n") — a single newline is not enough. Never add facts, opinions or ' +
581+
'flourishes they did not say, and never write about them in the third person. Plain text ' +
582+
'only — no markdown.'
583+
}
584+
585+
const EXTRACTION_PROMPT = `You are a profile information extraction expert analyzing ${
586+
isVoice
587+
? 'a speech-to-text transcript of someone talking about themselves out loud'
588+
: 'text from a personal webpage, bio, or similar source'
589+
}.
535590
536591
TASK: Extract structured profile data and return it as a single valid JSON object.
537592
@@ -540,12 +595,20 @@ RULES:
540595
- Omit the key in the output for missing fields
541596
- For taxonomy fields (interests, causes, work): match existing labels first; only add a new label if truly no existing one is close
542597
- For big5 scores: only populate if the person explicitly states a test result — never infer from personality description
543-
- Return valid JSON only — no markdown, no explanation, no extra text
598+
- Never answer a multi-choice field by selecting every option it offers. An expression of openness or indifference ("gender doesn't matter to me", "I'm open to anything") is not a selection of all values — omit the field, which already means "no preference"
599+
- Return valid JSON only — no markdown, no explanation, no extra text${
600+
isVoice
601+
? `
602+
- The transcript is spoken language: expect filler words, false starts, self-corrections and speech-recognition errors. Read past them, and when the person corrects themselves keep the corrected version
603+
- Ignore anything the person says to the recorder rather than about themselves (e.g. "let me start over", "what else should I say")
604+
- Spoken numbers, places and names may be mis-transcribed; only fill a field when you are confident what was meant`
605+
: ''
606+
}
544607
545608
SCHEMA (each value describes the expected type and accepted values):
546609
${JSON.stringify(PROFILE_FIELDS, null, 2)}
547610
548-
TEXT TO ANALYZE:
611+
${isVoice ? 'TRANSCRIPT TO ANALYZE' : 'TEXT TO ANALYZE'}:
549612
`
550613
const text = EXTRACTION_PROMPT + content
551614
if (text.length > MAX_CONTEXT_LENGTH) {
@@ -564,6 +627,10 @@ TEXT TO ANALYZE:
564627
try {
565628
parsed = typeof outputText === 'string' ? JSON.parse(outputText) : outputText
566629
parsed = await validateProfileFields(parsed, validChoices)
630+
// The bio column holds rich text; the model answers with plain prose.
631+
if (typeof parsed.bio === 'string') {
632+
parsed.bio = parsed.bio.trim() ? textToJSONContent(parsed.bio) : undefined
633+
}
567634
parsed = removeNullOrUndefinedProps(parsed)
568635
} catch (parseError) {
569636
log('Failed to parse LLM response as JSON', {outputText, parseError})
@@ -647,7 +714,7 @@ export async function fetchOnlineProfile(url: string | undefined): Promise<JSONC
647714
}
648715

649716
export const llmExtractProfileEndpoint: APIHandler<'llm-extract-profile'> = async (parsedBody) => {
650-
const {url, locale} = parsedBody
717+
const {url, locale, source} = parsedBody
651718
const content = parsedBody.content
652719

653720
if (content && url) {
@@ -672,7 +739,7 @@ export const llmExtractProfileEndpoint: APIHandler<'llm-extract-profile'> = asyn
672739
await setProcessing(cacheKey)
673740

674741
// Kick off async processing (don't await)
675-
processAndCache(cacheKey, content, url, locale).catch((err) => {
742+
processAndCache(cacheKey, content, url, locale, source).catch((err) => {
676743
log('Unexpected error in async processing', {cacheKey, error: err})
677744
})
678745

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import {APIErrors, APIHandler} from 'api/helpers/endpoint'
2+
import {debug} from 'common/logger'
3+
import {log} from 'shared/monitoring/log'
4+
5+
// Whisper's own hard limit is 25 MB. We cap below that so we reject with a friendly message rather
6+
// than letting OpenAI 413 on us. Opus runs ~200-400 KB/minute, so this is many minutes of speech.
7+
const MAX_AUDIO_BYTES = 20 * 1024 * 1024
8+
9+
// `gpt-4o-transcribe` is Whisper's successor and the default in our other voice codebase; set
10+
// OPENAI_TRANSCRIBE_MODEL=whisper-1 to fall back to classic Whisper.
11+
const TRANSCRIBE_MODEL = process.env.OPENAI_TRANSCRIBE_MODEL || 'gpt-4o-transcribe'
12+
13+
// Container → file extension. OpenAI infers the codec from the uploaded filename, so this has to be
14+
// right; `;codecs=...` suffixes that browsers append are stripped first.
15+
const EXTENSION_BY_MIME: Record<string, string> = {
16+
'audio/webm': 'webm',
17+
'audio/ogg': 'ogg',
18+
'audio/mp4': 'mp4',
19+
'audio/m4a': 'm4a',
20+
'audio/x-m4a': 'm4a',
21+
'audio/mpeg': 'mp3',
22+
'audio/mp3': 'mp3',
23+
'audio/wav': 'wav',
24+
'audio/x-wav': 'wav',
25+
'audio/flac': 'flac',
26+
}
27+
28+
function parseMimeType(mimeType: string): {contentType: string; extension: string} {
29+
const base = mimeType.split(';')[0].trim().toLowerCase()
30+
const extension = EXTENSION_BY_MIME[base]
31+
if (extension) return {contentType: base, extension}
32+
// Unknown container: webm/opus is what every Chromium browser records, so it is the safest guess.
33+
log('Unsupported audio mime type, falling back to audio/webm', {mimeType})
34+
return {contentType: 'audio/webm', extension: 'webm'}
35+
}
36+
37+
// Whisper takes an ISO-639-1 code; our locales are either 'en' or 'en-US'-shaped.
38+
function toLanguageCode(locale: string | undefined): string | undefined {
39+
const code = locale?.split(/[-_]/)[0]?.toLowerCase()
40+
return code && /^[a-z]{2}$/.test(code) ? code : undefined
41+
}
42+
43+
export const transcribeAudio: APIHandler<'transcribe-audio'> = async (props) => {
44+
const {audio, mimeType, locale} = props
45+
46+
const apiKey = process.env.OPENAI_API_KEY
47+
if (!apiKey) {
48+
log('OPENAI_API_KEY not configured')
49+
throw APIErrors.internalServerError('Voice transcription is not configured')
50+
}
51+
52+
// `audio` is base64: 4 characters encode 3 bytes.
53+
const sizeInBytes = Math.floor((audio.length * 3) / 4)
54+
if (sizeInBytes > MAX_AUDIO_BYTES) {
55+
throw APIErrors.badRequest('Recording is too long. Please record a shorter message.')
56+
}
57+
58+
const {contentType, extension} = parseMimeType(mimeType)
59+
const language = toLanguageCode(locale)
60+
log('Transcribing audio', {mimeType, contentType, sizeInBytes, model: TRANSCRIBE_MODEL, language})
61+
62+
const form = new FormData()
63+
form.append(
64+
'file',
65+
new Blob([Buffer.from(audio, 'base64')], {type: contentType}),
66+
`voice.${extension}`,
67+
)
68+
form.append('model', TRANSCRIBE_MODEL)
69+
// Punctuated, readable text — the extraction step downstream reads much better prose than a
70+
// single unbroken run of words.
71+
form.append('response_format', 'text')
72+
if (language) form.append('language', language)
73+
74+
let response: Response
75+
try {
76+
response = await fetch('https://api.openai.com/v1/audio/transcriptions', {
77+
method: 'POST',
78+
headers: {Authorization: `Bearer ${apiKey}`},
79+
body: form,
80+
})
81+
} catch (error) {
82+
log('OpenAI transcription request failed', {error})
83+
throw APIErrors.internalServerError('Failed to transcribe the recording')
84+
}
85+
86+
if (!response.ok) {
87+
const errorText = await response.text()
88+
log('OpenAI transcription API error', {status: response.status, error: errorText})
89+
throw APIErrors.internalServerError('Failed to transcribe the recording')
90+
}
91+
92+
const transcript = (await response.text()).trim()
93+
debug({transcript})
94+
95+
if (!transcript) {
96+
throw APIErrors.badRequest('We could not hear any speech in that recording. Please try again.')
97+
}
98+
99+
return {transcript}
100+
}

0 commit comments

Comments
 (0)