From 6a498fef0670af5cef661f695697f093c808c795 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Thu, 19 Mar 2026 14:28:01 -0700 Subject: [PATCH] speak --- .gitignore | 34 ++++++++++++++++ .npmrc | 1 + .rev/config | 6 +++ CLAUDE.md | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 27 +++++++++++++ SPEC.md | 12 ++++++ bun.lock | 26 +++++++++++++ index.ts | 54 +++++++++++++++++++++++++ package.json | 15 +++++++ tsconfig.json | 29 ++++++++++++++ 10 files changed, 310 insertions(+) create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 .rev/config create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 SPEC.md create mode 100644 bun.lock create mode 100755 index.ts create mode 100644 package.json create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..6c57d5c --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +registry=https://npm.nose.space diff --git a/.rev/config b/.rev/config new file mode 100644 index 0000000..5d92359 --- /dev/null +++ b/.rev/config @@ -0,0 +1,6 @@ +{ + "id": 3, + "repo": "speak", + "server": "http://rev.toes.local", + "type": "timeline" +} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..764c1dd --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,106 @@ + +Default to using Bun instead of Node.js. + +- Use `bun ` instead of `node ` or `ts-node ` +- Use `bun test` instead of `jest` or `vitest` +- Use `bun build ` instead of `webpack` or `esbuild` +- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` +- Use `bun run + + +``` + +With the following `frontend.tsx`: + +```tsx#frontend.tsx +import React from "react"; +import { createRoot } from "react-dom/client"; + +// import .css files directly and it works +import './index.css'; + +const root = createRoot(document.body); + +export default function Frontend() { + return

Hello, world!

; +} + +root.render(); +``` + +Then, run index.ts + +```sh +bun --hot ./index.ts +``` + +For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c62f2f4 --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# speak + +Like macOS `say`, but using an ElevenLabs voice. + +## Setup + +Add your ElevenLabs credentials to `~/.env`: + +``` +ELEVENLABS_API_KEY=your_key +ELEVENLABS_VOICE_ID=your_voice_id +``` + +## Usage + +Run directly: + +```sh +bun run index.ts Hey there cowboy. +``` + +Install globally: + +```sh +bun link +speak "Oh my, well hey there partner" +``` diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..f39a294 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,12 @@ +# speak + +`speak` is a cli that looks in ~/.env for `ELEVENLABS_VOICE_ID` +and talks in whatever voice you've setup there. + + $ speak Hey there cowboy. + $ speak 'Oh my. Well hey there, *partner*.' + +Like Apple's `say`, but more fabulous. + +[0]: https://elevenlabs.io/api + diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..d1318d4 --- /dev/null +++ b/bun.lock @@ -0,0 +1,26 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "speak", + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.3.11", "https://npm.nose.space/@types/bun/-/bun-1.3.11.tgz", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + + "@types/node": ["@types/node@25.5.0", "https://npm.nose.space/@types/node/-/node-25.5.0.tgz", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], + + "bun-types": ["bun-types@1.3.11", "https://npm.nose.space/bun-types/-/bun-types-1.3.11.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + + "typescript": ["typescript@5.9.3", "https://npm.nose.space/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.18.2", "https://npm.nose.space/undici-types/-/undici-types-7.18.2.tgz", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + } +} diff --git a/index.ts b/index.ts new file mode 100755 index 0000000..7014683 --- /dev/null +++ b/index.ts @@ -0,0 +1,54 @@ +#!/usr/bin/env bun + +import { $ } from "bun"; + +// Parse ~/.env for ElevenLabs credentials +const envText = await Bun.file(`${process.env.HOME}/.env`).text(); +const env: Record = {}; +for (const line of envText.split("\n")) { + const match = line.match(/^([^#=]+)=(.*)$/); + if (match) env[match[1].trim()] = match[2].trim(); +} + +const apiKey = env.ELEVENLABS_API_KEY; +const voiceId = env.ELEVENLABS_VOICE_ID; + +if (!apiKey || !voiceId) { + console.error("Missing ELEVENLABS_API_KEY or ELEVENLABS_VOICE_ID in ~/.env"); + process.exit(1); +} + +// Grab text from argv (everything after the script name) +const text = Bun.argv.slice(2).join(" "); +if (!text) { + console.error("Usage: speak "); + process.exit(1); +} + +// Call ElevenLabs TTS API +const response = await fetch( + `https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`, + { + method: "POST", + headers: { + "xi-api-key": apiKey, + "Content-Type": "application/json", + }, + body: JSON.stringify({ text }), + } +); + +if (!response.ok) { + console.error(`ElevenLabs API error: ${response.status} ${response.statusText}`); + process.exit(1); +} + +// Write MP3 to temp file, play it, clean up +const tmpFile = `${Bun.env.TMPDIR || "/tmp"}/speak-${Date.now()}.mp3`; +await Bun.write(tmpFile, response); + +try { + await $`afplay ${tmpFile}`.quiet(); +} finally { + await $`rm ${tmpFile}`.quiet(); +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..dafe66a --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "@because/speak", + "version": "0.0.0", + "module": "index.ts", + "type": "module", + "bin": { + "speak": "index.ts" + }, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..bfa0fea --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "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, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}