This commit is contained in:
Chris Wanstrath 2025-06-16 22:15:23 -07:00
parent b403c6d1d4
commit 1d6a73676b
9 changed files with 509 additions and 0 deletions

15
packages/iago/README.md Normal file
View File

@ -0,0 +1,15 @@
# iago
To install dependencies:
```bash
bun install
```
To run:
```bash
bun run index.ts
```
This project was created using `bun init` in bun v1.2.13. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.

25
packages/iago/bun.lock Normal file
View File

@ -0,0 +1,25 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "iago",
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"packages": {
"@types/bun": ["@types/bun@1.2.16", "", { "dependencies": { "bun-types": "1.2.16" } }, "sha512-1aCZJ/6nSiViw339RsaNhkNoEloLaPzZhxMOYEa7OzRzO41IGg5n/7I43/ZIAW/c+Q6cT12Vf7fOZOoVIzb5BQ=="],
"@types/node": ["@types/node@24.0.3", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-R4I/kzCYAdRLzfiCabn9hxWfbuHS573x+r0dJMkkzThEa7pbrcDWK+9zu3e7aBOouf+rQAciqPFMnxwr0aWgKg=="],
"bun-types": ["bun-types@1.2.16", "", { "dependencies": { "@types/node": "*" } }, "sha512-ciXLrHV4PXax9vHvUrkvun9VPVGOVwbbbBF/Ev1cXz12lyEZMoJpIJABOfPcN9gDJRaiKF9MVbSygLg4NXu3/A=="],
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="],
}
}

View File

@ -0,0 +1,16 @@
{
"name": "@workshop/iago",
"module": "index.ts",
"type": "module",
"private": true,
"scripts": {
"dev": "bun run --hot server.ts",
"start": "bun run server.ts"
},
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
}
}

BIN
packages/iago/photo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 383 KiB

View File

@ -0,0 +1,263 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Voice Recorder</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
background: white;
border-radius: 20px;
padding: 40px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
text-align: center;
max-width: 400px;
width: 100%;
}
h1 {
color: #333;
margin-bottom: 30px;
font-size: 2rem;
font-weight: 600;
}
.record-button {
width: 80px;
height: 80px;
border-radius: 50%;
border: none;
background: #ff4757;
color: white;
font-size: 1.5rem;
cursor: pointer;
transition: all 0.3s ease;
margin-bottom: 20px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 20px;
}
.record-button:hover {
transform: scale(1.05);
box-shadow: 0 10px 20px rgba(255, 71, 87, 0.3);
}
.record-button.recording {
background: #2ed573;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0% { transform: scale(1); }
50% { transform: scale(1.1); }
100% { transform: scale(1); }
}
.status {
margin: 20px 0;
padding: 10px;
border-radius: 10px;
font-weight: 500;
}
.status.recording {
background: #ffeaa7;
color: #d63031;
}
.status.ready {
background: #55a3ff;
color: white;
}
.audio-player {
margin-top: 20px;
width: 100%;
}
.transcribe-button {
background: #3742fa;
color: white;
border: none;
padding: 12px 24px;
border-radius: 10px;
cursor: pointer;
font-size: 1rem;
margin-top: 15px;
transition: background 0.3s ease;
}
.transcribe-button:hover {
background: #2f3542;
}
.transcribe-button:disabled {
background: #ddd;
cursor: not-allowed;
}
.transcription {
margin-top: 20px;
padding: 15px;
background: #f8f9fa;
border-radius: 10px;
text-align: left;
min-height: 60px;
}
.hidden {
display: none;
}
</style>
</head>
<body>
<div class="container">
<h1>Voice Recorder</h1>
<button id="recordButton" class="record-button">
🎙️
</button>
<div id="status" class="status ready">
Click the mic to start recording
</div>
<audio id="audioPlayer" class="audio-player hidden" controls></audio>
<button id="transcribeButton" class="transcribe-button hidden">
Transcribe Audio
</button>
<div id="transcription" class="transcription hidden">
<strong>Transcription:</strong><br>
<span id="transcriptionText"></span>
</div>
</div>
<script>
let mediaRecorder;
let audioChunks = [];
let isRecording = false;
const recordButton = document.getElementById('recordButton');
const status = document.getElementById('status');
const audioPlayer = document.getElementById('audioPlayer');
const transcribeButton = document.getElementById('transcribeButton');
const transcription = document.getElementById('transcription');
const transcriptionText = document.getElementById('transcriptionText');
recordButton.addEventListener('click', toggleRecording);
async function toggleRecording() {
if (!isRecording) {
await startRecording();
} else {
stopRecording();
}
}
async function startRecording() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorder = new MediaRecorder(stream);
audioChunks = [];
mediaRecorder.ondataavailable = (event) => {
audioChunks.push(event.data);
};
mediaRecorder.onstop = () => {
const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });
const audioUrl = URL.createObjectURL(audioBlob);
audioPlayer.src = audioUrl;
audioPlayer.classList.remove('hidden');
transcribeButton.classList.remove('hidden');
// Store the blob for transcription
window.audioBlob = audioBlob;
};
mediaRecorder.start();
isRecording = true;
recordButton.classList.add('recording');
recordButton.textContent = '⏹️';
status.textContent = 'Recording... Click to stop';
status.className = 'status recording';
} catch (error) {
console.error('Error accessing microphone:', error);
status.textContent = 'Error: Could not access microphone';
status.className = 'status recording';
}
}
function stopRecording() {
if (mediaRecorder && isRecording) {
mediaRecorder.stop();
mediaRecorder.stream.getTracks().forEach(track => track.stop());
isRecording = false;
recordButton.classList.remove('recording');
recordButton.textContent = '🎙️';
status.textContent = 'Recording complete!';
status.className = 'status ready';
}
}
transcribeButton.addEventListener('click', async () => {
if (!window.audioBlob) {
alert('No audio to transcribe');
return;
}
transcribeButton.disabled = true;
transcribeButton.textContent = 'Transcribing...';
transcription.classList.remove('hidden');
transcriptionText.textContent = 'Processing...';
try {
const formData = new FormData();
formData.append('audio', window.audioBlob, 'recording.webm');
const response = await fetch('/transcribe', {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error('Transcription failed');
}
const result = await response.json();
transcriptionText.textContent = result.transcription || 'No transcription available';
} catch (error) {
console.error('Transcription error:', error);
transcriptionText.textContent = 'Error: Could not transcribe audio';
} finally {
transcribeButton.disabled = false;
transcribeButton.textContent = 'Transcribe Audio';
}
});
</script>
</body>
</html>

146
packages/iago/server.ts Normal file
View File

@ -0,0 +1,146 @@
import { $ } from "bun"
import path from "path"
import { Hono } from "hono"
import { openai, createFile } from "./src/openai"
import { serveStatic } from "hono/bun"
import fs from "fs"
const CAMERA = process.env.IAGO_CAMERA || "Set IAGO_CAMERA environment variable"
const PROMPT = "What text do you see in this image?"
const IMAGE_PATH = "./photo.jpg"
const app = new Hono()
// Serve static files from public directory
app.use("/*", serveStatic({ root: "./public" }))
app.get("/", (c) => {
return c.json({ message: "Hello World" })
})
// you know
app.get("/status", async (c) => {
if (!(await checkImagesnap()))
return c.json({ status: "imagesnap not found", devices: [], camera: CAMERA })
const devices = await getDevices()
return c.json({ devices, camera: CAMERA, status: devices.includes(CAMERA) ? "camera found" : "camera not found" })
})
// take a picture with the camera
app.get("/capture", async (c) => {
try {
await runImagesnap()
const image = await Bun.file(IMAGE_PATH).arrayBuffer()
return new Response(image, {
headers: { "Content-Type": "image/jpeg" },
})
} catch (err: any) {
return c.json({ error: err.message }, 500)
}
})
// capture and analyze image
app.get("/analyze", async (c) => {
try {
// await runImagesnap()
const fileId = await createFile(IMAGE_PATH)
const result = await openai.responses.create({
model: "gpt-4o",
input: [
{
role: "user",
content: [
{ type: "input_text", text: PROMPT },
{ type: "input_image", file_id: fileId, detail: "high" }
]
}
]
})
return c.json({ result: result.output_text })
} catch (err: any) {
return c.json({ error: err.message }, 500)
}
})
app.get("/speak", async (c) => {
const mp3 = await openai.audio.speech.create({
model: "gpt-4o-mini-tts",
voice: "ash",
instructions: "Speak in an upbeat and goofy tone!",
input: "I put the MAN in MAN-UH!"
})
const buffer = Buffer.from(await mp3.arrayBuffer())
return new Response(buffer, {
headers: {
"Content-Type": "audio/mpeg",
"Content-Disposition": "inline; filename=speak.mp3"
},
})
})
app.get("/talk", async (c) => {
return c.redirect("/talk.html")
})
app.post("/transcribe", async (c) => {
try {
const formData = await c.req.formData()
const audioFile = formData.get("audio") as File
if (!audioFile) {
return c.json({ error: "No audio file provided" }, 400)
}
// Convert File to Buffer
const arrayBuffer = await audioFile.arrayBuffer()
const buffer = Buffer.from(arrayBuffer)
// Create a temporary file
const tempPath = `./temp_audio_${Date.now()}.webm`
await Bun.write(tempPath, buffer)
const transcription = await openai.audio.transcriptions.create({
file: Bun.file(tempPath),
model: "gpt-4o-transcribe",
response_format: "text",
})
// Clean up temporary file
await fs.promises.unlink(tempPath)
console.log(transcription)
return c.json({ transcription })
} catch (err: any) {
console.error("Transcription error:", err)
return c.json({ error: err.message }, 500)
}
})
// Capture image using Bun.spawn
async function runImagesnap(): Promise<void> {
const proc = await $`imagesnap -d ${CAMERA} ${IMAGE_PATH}`
if (proc.exitCode !== 0) throw new Error("imagesnap failed")
}
// Check if imagesnap exists using Bun.spawnSync
async function checkImagesnap(): Promise<boolean> {
const result = await $`which imagesnap`
return result.exitCode === 0
}
// Get devices using imagesnap
async function getDevices(): Promise<string[]> {
const result = await $`imagesnap -l`
return result.stdout.toString().split("\n").filter(line => line.startsWith("=> ")).map(line => line.replace("=> ", ""))
}
export default {
port: 3000,
fetch: app.fetch,
}

View File

@ -0,0 +1,16 @@
import OpenAI from "openai"
export const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY || "",
})
// Function to create a file with the Files API
export async function createFile(filePath: string) {
const fileContent = Bun.file(filePath)
const result = await openai.files.create({
file: fileContent,
purpose: "vision",
})
return result.id
}

BIN
packages/iago/test.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 383 KiB

View File

@ -0,0 +1,28 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "ESNext",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}