profileShare

rasmusjy / voicetask

Read-only snapshot

No repository description.

main default branch 105 files Expires Sep 13, 2026, 9:06 AM

Commit

fixed openai talking

commit 7ca02d7

7 changed files with +131 and −16

Jump to a changed file
  1. .env.example +6 −5
  2. README.md +5 −6
  3. package.json +1 −0
  4. scripts/setup.mjs +77 −0
  5. server/app.ts +1 −1
  6. server/index.ts +28 −2
  7. server/providers/sttOpenai.ts +13 −2
modified .env.example +6 −5
@@ -1,16 +1,17 @@
1 -# Copy to .env (or set in your shell) and fill in the keys you need.
1 +# Easiest: run "npm run setup" and it writes .env for you.
2 +# Manual: copy this file to .env and replace the @insert-...@ placeholders with your real keys.
2 3 # With MOCK_PROVIDERS=1 no keys are required at all.
3 4
4 5 # 1 = deterministic offline mocks for STT and LLM (used by tests and CI)
5 6 MOCK_PROVIDERS=
6 7
7 -# Interview + spec generation (Anthropic)
8 -ANTHROPIC_API_KEY=
8 +# Interview + spec generation (Anthropic). Get a key at console.anthropic.com
9 +ANTHROPIC_API_KEY=@insert-anthropic-api-key@
9 10 # Optional. Default: claude-opus-4-8. Cheaper alternative: claude-sonnet-4-6
10 11 ANTHROPIC_MODEL=
11 12
12 -# Speech-to-text (OpenAI)
13 -OPENAI_API_KEY=
13 +# Speech-to-text (OpenAI). Get a key at platform.openai.com
14 +OPENAI_API_KEY=@insert-openai-api-key@
14 15 # Optional. Default: gpt-4o-mini-transcribe. Alternatives: gpt-4o-transcribe, whisper-1
15 16 STT_MODEL=
16 17
modified README.md +5 −6
@@ -6,15 +6,14 @@Talk through what you want to build. An AI interviewer asks one targeted questio
6 6
7 7 ```
8 8 npm install
9 -
10 -# Offline demo, no API keys needed (deterministic mock providers):
11 -MOCK_PROVIDERS=1 npm run dev
12 -
13 -# Real mode:
14 -cp .env.example .env # fill in the keys
9 +npm run setup # interactive: offline demo (no keys) or real APIs, writes .env
15 10 npm run dev
16 11 ```
17 12
13 +`npm run setup` asks whether you want the offline demo (deterministic mock providers, no API keys) or real APIs, and writes a local `.env` for you. It works the same on Windows, macOS, and Linux.
14 +
15 +To configure by hand instead, copy `.env.example` to `.env` and replace the `@insert-anthropic-api-key@` and `@insert-openai-api-key@` placeholders with your real keys. The server refuses to start while a placeholder is still in place, so a half-finished `.env` fails loudly instead of failing on the first API call.
16 +
18 17 Open the printed Vite URL in Chrome or Edge. Hold the mic button (or space) to talk, or type. Say "done" or click the done link to finish, then generate the spec pack.
19 18
20 19 ## APIs and models
modified package.json +1 −0
@@ -7,6 +7,7 @@
7 7 "node": ">=20.12"
8 8 },
9 9 "scripts": {
10 + "setup": "node scripts/setup.mjs",
10 11 "dev": "concurrently -k -n server,client -c blue,green \"npm:dev:server\" \"npm:dev:client\"",
11 12 "dev:server": "tsx watch server/index.ts",
12 13 "dev:client": "vite --config client/vite.config.ts",
added scripts/setup.mjs +77 −0
@@ -0,0 +1,77 @@
1 +import { existsSync, writeFileSync } from 'node:fs'
2 +import { createInterface } from 'node:readline'
3 +
4 +const rl = createInterface({ input: process.stdin, output: process.stdout })
5 +
6 +// Queue lines so answers are not lost between questions when input is piped.
7 +const pending = []
8 +const waiters = []
9 +rl.on('line', (line) => {
10 + const waiter = waiters.shift()
11 + if (waiter) waiter(line)
12 + else pending.push(line)
13 +})
14 +rl.on('close', () => {
15 + for (const waiter of waiters.splice(0)) waiter(null)
16 +})
17 +
18 +async function ask(question) {
19 + process.stdout.write(question)
20 + const line =
21 + pending.length > 0 ? pending.shift() : await new Promise((resolve) => waiters.push(resolve))
22 + if (line === null) {
23 + console.error('\nInput ended before setup finished. Nothing changed.')
24 + process.exit(1)
25 + }
26 + return line.trim()
27 +}
28 +
29 +console.log('VoiceTask setup. Writes a local .env file (gitignored, never committed).\n')
30 +
31 +if (existsSync('.env')) {
32 + const answer = await ask('.env already exists. Overwrite it? [y/N] ')
33 + if (answer.toLowerCase() !== 'y') {
34 + console.log('Keeping the existing .env. Nothing changed.')
35 + rl.close()
36 + process.exit(0)
37 + }
38 +}
39 +
40 +console.log(`Mode:
41 + 1) Real APIs - needs an Anthropic key (interview) and an OpenAI key (speech-to-text)
42 + 2) Offline demo - no keys, deterministic mock providers
43 +`)
44 +
45 +let mode = ''
46 +while (mode !== '1' && mode !== '2') {
47 + mode = await ask('Choose [1/2]: ')
48 +}
49 +
50 +let anthropicKey = ''
51 +let openaiKey = ''
52 +
53 +if (mode === '1') {
54 + console.log('\nKeys are stored only in your local .env. Input is visible while you paste.\n')
55 + while (!anthropicKey) {
56 + anthropicKey = await ask('Anthropic API key (console.anthropic.com): ')
57 + }
58 + if (!anthropicKey.startsWith('sk-ant-')) {
59 + console.log(' Note: Anthropic keys usually start with "sk-ant-". Saving it anyway.')
60 + }
61 + while (!openaiKey) {
62 + openaiKey = await ask('OpenAI API key (platform.openai.com): ')
63 + }
64 + if (!openaiKey.startsWith('sk-')) {
65 + console.log(' Note: OpenAI keys usually start with "sk-". Saving it anyway.')
66 + }
67 +}
68 +
69 +const env = `# Written by npm run setup. Edit freely; see .env.example for all options.
70 +MOCK_PROVIDERS=${mode === '2' ? '1' : ''}
71 +ANTHROPIC_API_KEY=${anthropicKey}
72 +OPENAI_API_KEY=${openaiKey}
73 +`
74 +
75 +writeFileSync('.env', env)
76 +console.log('\nWrote .env. Next: npm run dev, then open the printed URL in Chrome or Edge.')
77 +rl.close()
modified server/app.ts +1 −1
@@ -26,7 +26,7 @@export function buildApp(deps: Partial<AppDeps> = {}): FastifyInstance {
26 26
27 27 const app = Fastify({ logger: false })
28 28 void app.register(cors, { origin: true })
29 - void app.register(multipart)
29 + void app.register(multipart, { limits: { fileSize: 25 * 1024 * 1024 } })
30 30
31 31 app.get('/api/health', async () => ({ ok: true }))
32 32 registerSessionRoutes(app, resolved)
modified server/index.ts +28 −2
@@ -1,13 +1,39 @@
1 -import { existsSync } from 'node:fs'
1 +import { existsSync, readFileSync } from 'node:fs'
2 2 import { buildApp } from './app'
3 3
4 -if (existsSync('.env')) process.loadEnvFile('.env')
4 +// .env wins over inherited shell values, so a stale MOCK_PROVIDERS=1 in some
5 +// old terminal cannot silently force mock mode.
6 +if (existsSync('.env')) {
7 + for (const line of readFileSync('.env', 'utf8').split(/\r?\n/)) {
8 + const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/)
9 + if (!match) continue
10 + const value = match[2].replace(/^(['"])(.*)\1$/, '$2')
11 + if (value === '') delete process.env[match[1]]
12 + else process.env[match[1]] = value
13 + }
14 +}
15 +
16 +if (process.env.MOCK_PROVIDERS !== '1') {
17 + const missing = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY'].filter((key) => {
18 + const value = process.env[key]
19 + return !value || value.startsWith('@insert')
20 + })
21 + if (missing.length > 0) {
22 + console.error(
23 + `Missing ${missing.join(' and ')}. Run "npm run setup", or edit .env and replace the ` +
24 + '@insert-...@ placeholders with real keys. For the offline demo set MOCK_PROVIDERS=1 (no keys needed).',
25 + )
26 + process.exit(1)
27 + }
28 +}
5 29
6 30 const PORT = Number(process.env.PORT ?? 3001)
7 31
8 32 async function main() {
9 33 const app = buildApp()
10 34 await app.listen({ port: PORT, host: '0.0.0.0' })
35 + const mode = process.env.MOCK_PROVIDERS === '1' ? 'MOCK providers (offline demo)' : 'real providers'
36 + console.log(`VoiceTask server listening on :${PORT} using ${mode}`)
11 37 }
12 38
13 39 main().catch((err) => {
modified server/providers/sttOpenai.ts +13 −2
@@ -2,6 +2,15 @@import { EmptyTranscriptError, type SttProvider } from './types'
2 2
3 3 const DEFAULT_STT_MODEL = 'gpt-4o-mini-transcribe'
4 4
5 +// OpenAI decodes by file extension, so the name must match the real container
6 +// format or valid audio is rejected as corrupted.
7 +function audioFileName(mimeType: string): string {
8 + const subtype = mimeType.split('/')[1]?.split(';')[0]?.toLowerCase() ?? 'webm'
9 + const ext = subtype === 'mpeg' ? 'mp3' : subtype === 'x-wav' || subtype === 'wave' ? 'wav' : subtype
10 + const supported = ['flac', 'm4a', 'mp3', 'mp4', 'mpga', 'oga', 'ogg', 'wav', 'webm']
11 + return `audio.${supported.includes(ext) ? ext : 'webm'}`
12 +}
13 +
5 14 export function createSttOpenai(): SttProvider {
6 15 const apiKey = process.env.OPENAI_API_KEY
7 16 if (!apiKey) throw new Error('OPENAI_API_KEY is not set')
@@ -11,7 +20,7 @@export function createSttOpenai(): SttProvider {
11 20 async transcribe(audio: Buffer, mimeType: string): Promise<string> {
12 21 const form = new FormData()
13 22 form.append('model', model)
14 - form.append('file', new Blob([audio], { type: mimeType }), 'audio.webm')
23 + form.append('file', new Blob([audio], { type: mimeType }), audioFileName(mimeType))
15 24
16 25 const res = await fetch('https://api.openai.com/v1/audio/transcriptions', {
17 26 method: 'POST',
@@ -21,7 +30,9 @@export function createSttOpenai(): SttProvider {
21 30
22 31 if (!res.ok) {
23 32 const body = await res.text()
24 - throw new Error(`OpenAI STT request failed (${res.status}): ${body}`)
33 + throw new Error(
34 + `OpenAI STT request failed (${res.status}) [sent ${audioFileName(mimeType)}, type "${mimeType}", ${audio.length} bytes]: ${body}`,
35 + )
25 36 }
26 37
27 38 const data = (await res.json()) as { text?: string }