diff --git a/audiotee-js/.cursorrules b/audiotee-js/.cursorrules new file mode 100644 index 0000000..6efd276 --- /dev/null +++ b/audiotee-js/.cursorrules @@ -0,0 +1,119 @@ +# AudioTee.js - Node.js Audio Streaming Package + +This is a Node.js wrapper for AudioTee that provides a streaming interface to capture macOS system audio using Core Audio taps. + +## Project Context + +- **Purpose**: Node.js package that wraps the AudioTee Swift binary via child processes +- **Target**: macOS 14.2+ with Node.js 14+ +- **Use cases**: Real-time audio processing, ASR integration, Electron apps +- **Distribution**: npm package using node-pre-gyp for binary distribution + +## Architecture + +- `index.js` - Main entry point, uses node-pre-gyp to locate binary +- `lib/AudioTeeStream.js` - Core streaming class that wraps AudioTee process +- `scripts/build.js` - Build script that copies AudioTee binary for distribution +- `test/test.js` - Test suite demonstrating usage + +## Technical Standards + +### Code Style +- Use functional programming patterns where possible +- Prefer `const` over `let`, avoid `var` +- Use arrow functions for callbacks and short functions +- No semicolons at end of lines (per user preference) +- Use template literals for string interpolation +- Prefer British English spelling (colour, realise, etc.) + +### Node.js Specific +- Use EventEmitter pattern for streaming interfaces +- Handle child process lifecycle carefully (spawn, kill, cleanup) +- Use Buffer for binary data, not Uint8Array +- Implement proper error handling with descriptive messages +- Use readline interface for line-based protocol parsing +- Handle both JSON and binary protocol modes correctly + +### Error Handling +- Always emit errors via EventEmitter, don't throw synchronously +- Provide context in error messages (PIDs, file paths, etc.) +- Handle child process errors gracefully +- Validate input parameters and provide helpful error messages + +### Protocol Implementation +- Correctly parse the mixed JSON/binary protocol from AudioTee +- Handle partial reads and buffer management for binary mode +- Emit events in the correct order (metadata → stream_start → audio → stream_stop) +- Preserve AudioTee's timestamp and metadata information + +### Dependencies +- Minimize external dependencies (currently only node-pre-gyp) +- Use only Node.js built-in modules where possible +- Ensure compatibility with Node.js 14+ (no newer APIs) + +### Documentation +- Comprehensive JSDoc comments for public APIs +- Examples in README showing real-world usage patterns +- Clear event documentation with payload structure +- Error scenarios and troubleshooting guidance + +### Testing +- Provide both interactive and automated test modes +- Test should work without requiring audio playback +- Handle permissions issues gracefully in tests +- Verify binary protocol parsing works correctly + +## Binary Distribution + +- Use node-pre-gyp for professional binary distribution +- Support both Intel and Apple Silicon Macs +- Graceful fallback if binary download fails +- Verify binary functionality during build process + +## Development Guidelines + +When making changes: + +1. **Test thoroughly** - Both JSON and binary protocols +2. **Handle edge cases** - Process crashes, permission issues, etc. +3. **Maintain compatibility** - Don't break existing APIs +4. **Update documentation** - Keep README examples current +5. **Follow semantic versioning** - Breaking changes require major version bump + +## Common Patterns + +### Error Handling +```javascript +// Always emit errors, don't throw +this.emit('error', new Error(`Descriptive message: ${details}`)) + +// Provide context in errors +this.emit('error', new Error(`Failed to start AudioTee: ${error.message}`)) +``` + +### Event Emission +```javascript +// Use consistent event structure +this.emit('audio', { + timestamp: new Date(), + duration: number, + peakAmplitude: number, + audioData: Buffer +}) +``` + +### Process Management +```javascript +// Always check process state before operations +if (this.process && !this.process.killed) { + this.process.kill('SIGTERM') +} +``` + +## Future Considerations + +- Support for multiple concurrent streams +- WebSocket streaming interface +- TypeScript definitions +- React/Vue.js integration examples +- Performance monitoring and metrics \ No newline at end of file diff --git a/audiotee-js/.github/workflows/release.yml b/audiotee-js/.github/workflows/release.yml new file mode 100644 index 0000000..1d9f98d --- /dev/null +++ b/audiotee-js/.github/workflows/release.yml @@ -0,0 +1,173 @@ +name: Build and Release AudioTee.js + +on: + release: + types: [published] + workflow_dispatch: + inputs: + version: + description: "Version to build (e.g., v1.0.0)" + required: true + default: "v1.0.0" + +jobs: + build: + strategy: + matrix: + include: + - os: macos-latest + arch: arm64 + node: "18" + - os: macos-13 + arch: x64 + node: "18" + + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout audiotee-js + uses: actions/checkout@v4 + + - name: Checkout AudioTee (parent project) + uses: actions/checkout@v4 + with: + repository: your-org/audiotee + path: audiotee + ref: main + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + registry-url: "https://registry.npmjs.org" + + - name: Setup Swift + uses: swift-actions/setup-swift@v1 + with: + swift-version: "5.9" + + - name: Build AudioTee binary + run: | + cd audiotee + swift build -c release + ls -la .build/release/ + + - name: Install Node.js dependencies + run: | + npm ci + + - name: Build AudioTee.js package + env: + AUDIOTEE_BINARY_PATH: ../audiotee/.build/release/audiotee + run: | + npm run build + ls -la bin/ + + - name: Test package + run: | + # Quick test to ensure the package loads and binary works + timeout 10s npm test quick || true + + - name: Package binary for distribution + run: | + npm run package + + - name: List package contents + run: | + ls -la build/ + + - name: Publish binary to GitHub releases + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + npm run publish-binary + + publish-npm: + needs: build + runs-on: ubuntu-latest + if: github.event_name == 'release' + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "18" + registry-url: "https://registry.npmjs.org" + + - name: Install dependencies + run: npm ci + + - name: Publish to npm + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + # Update version to match release tag + npm version ${{ github.event.release.tag_name }} --no-git-tag-version + npm publish + + test-installation: + needs: publish-npm + runs-on: macos-latest + if: github.event_name == 'release' + + steps: + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "18" + + - name: Test npm installation + run: | + # Test that the package can be installed and works + npm install audiotee-js@${{ github.event.release.tag_name }} + + # Create a simple test + cat > test-install.js << 'EOF' + const { AudioTeeStream } = require('audiotee-js'); + + console.log('✅ Package imported successfully'); + + const stream = new AudioTeeStream({ + format: 'json', + chunkDuration: 0.1 + }); + + console.log('✅ AudioTeeStream created successfully'); + + let metadataReceived = false; + + stream.on('metadata', () => { + metadataReceived = true; + console.log('✅ Metadata received'); + stream.stop(); + }); + + stream.on('error', (error) => { + console.error('❌ Error:', error.message); + process.exit(1); + }); + + stream.on('close', () => { + if (metadataReceived) { + console.log('✅ Installation test passed!'); + process.exit(0); + } else { + console.error('❌ No metadata received'); + process.exit(1); + } + }); + + setTimeout(() => { + console.error('❌ Test timeout'); + stream.stop(); + process.exit(1); + }, 5000); + + console.log('🚀 Starting AudioTee...'); + stream.start(); + EOF + + node test-install.js diff --git a/audiotee-js/.gitignore b/audiotee-js/.gitignore new file mode 100644 index 0000000..904d2d0 --- /dev/null +++ b/audiotee-js/.gitignore @@ -0,0 +1,69 @@ +# Dependencies +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime +*.log +*.pid +*.seed +*.pid.lock + +# Coverage directory used by tools like istanbul +coverage/ +*.lcov + +# nyc test coverage +.nyc_output + +# Compiled binary +bin/ +build/ +lib-cov/ + +# Diagnostic reports +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Optional npm cache directory +.npm + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# Environment variables +.env +.env.test +.env.local +.env.*.local + +# Mac system files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Editor files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Test recordings +*.raw +*.wav +test-recordings/ + +# Temporary files +tmp/ +temp/ \ No newline at end of file diff --git a/audiotee-js/.npmignore b/audiotee-js/.npmignore new file mode 100644 index 0000000..8950bbb --- /dev/null +++ b/audiotee-js/.npmignore @@ -0,0 +1,57 @@ +# Source control +.git/ +.gitignore + +# Development files +.cursorrules +.github/ +test/ +coverage/ +*.test.js + +# Build artifacts (included via files in package.json) +build/ +.build/ + +# Dependencies +node_modules/ + +# Documentation (README.md is included via package.json files) +docs/ +examples/ + +# Environment and config files +.env* +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime +*.pid +*.seed +*.pid.lock + +# Mac files +.DS_Store +.DS_Store? +._* + +# Test output +test-recordings/ +*.raw +*.wav + +# Temporary files +tmp/ +temp/ + +# CI/CD artifacts that aren't needed in the package +.github/workflows/ \ No newline at end of file diff --git a/audiotee-js/DEVELOPMENT.md b/audiotee-js/DEVELOPMENT.md new file mode 100644 index 0000000..93a5336 --- /dev/null +++ b/audiotee-js/DEVELOPMENT.md @@ -0,0 +1,238 @@ +# AudioTee.js Development Guide + +This guide covers setting up the development environment and workflow for AudioTee.js. + +## Project Structure + +``` +audiotee-js/ +├── package.json # npm package configuration with node-pre-gyp +├── index.js # Main entry point, uses node-pre-gyp to find binary +├── lib/ +│ └── AudioTeeStream.js # Core streaming class +├── scripts/ +│ └── build.js # Build script that copies AudioTee binary +├── test/ +│ └── test.js # Interactive and automated tests +├── examples/ +│ └── basic-usage.js # Usage examples and demos +├── .github/workflows/ +│ └── release.yml # CI/CD for automated releases +└── README.md # User documentation +``` + +## Initial Setup + +### 1. Clone and Install Dependencies + +```bash +git clone +cd audiotee-js +npm install +``` + +### 2. Build AudioTee Binary + +You'll need the AudioTee Swift project to build the binary: + +```bash +# Option A: If AudioTee is in parent directory (current setup) +cd ../audiotee +swift build -c release +cd ../audiotee-js + +# Option B: If AudioTee is elsewhere, set the path +export AUDIOTEE_BINARY_PATH=/path/to/audiotee/.build/release/audiotee +``` + +### 3. Build the Package + +```bash +npm run build +``` + +This copies the AudioTee binary to `bin/audiotee` and makes it executable. + +## Development Workflow + +### Testing + +```bash +# Interactive test - requires audio playback +npm test + +# Quick automated test +npm test quick + +# Run examples +node examples/basic-usage.js +node examples/basic-usage.js 2 # Save to file example +``` + +### Building for Different Architectures + +```bash +# For Intel Macs (if you have access) +npm run build + +# For Apple Silicon (if you have access) +npm run build + +# Clean build artifacts +npm run clean +``` + +### Testing the Package Locally + +```bash +# Test the package as if installed from npm +npm pack +npm install -g audiotee-js-1.0.0.tgz + +# Test in another directory +cd /tmp +node -e "const { AudioTeeStream } = require('audiotee-js'); console.log('✅ Works!')" +``` + +## Release Process + +### 1. Prepare Release + +1. Update version in `package.json` +2. Update `CHANGELOG.md` (if you add one) +3. Test thoroughly on both Intel and Apple Silicon if possible +4. Commit changes + +### 2. Create GitHub Release + +```bash +git tag v1.0.0 +git push origin v1.0.0 +``` + +Then create a release on GitHub. This will trigger the automated build process. + +### 3. Automated Release (via GitHub Actions) + +The workflow will: +1. Build AudioTee binary for Intel and Apple Silicon +2. Package binaries using node-pre-gyp +3. Upload binaries to GitHub releases +4. Publish package to npm +5. Test the published package + +### 4. Manual Release (if needed) + +```bash +# Build and package +npm run build +npm run package + +# Publish binary to GitHub releases +npm run publish-binary + +# Publish to npm +npm publish +``` + +## Configuration + +### Environment Variables + +- `AUDIOTEE_BINARY_PATH` - Path to AudioTee binary for building +- `GITHUB_TOKEN` - For publishing binaries to GitHub releases +- `NODE_AUTH_TOKEN` - For publishing to npm + +### node-pre-gyp Configuration + +The binary distribution is configured in `package.json`: + +```json +{ + "binary": { + "module_name": "audiotee", + "module_path": "./bin/", + "remote_path": "v{version}/", + "package_name": "audiotee-v{version}-{platform}-{arch}.tar.gz", + "host": "https://github.com/your-org/audiotee-js/releases/download/" + } +} +``` + +Update the `host` URL to match your repository. + +## Troubleshooting + +### Binary Not Found During Build + +```bash +# Check if AudioTee is built +ls -la ../audiotee/.build/release/audiotee + +# Or set custom path +export AUDIOTEE_BINARY_PATH=/path/to/your/audiotee/binary +npm run build +``` + +### Permission Issues + +```bash +# Make sure binary is executable +chmod +x bin/audiotee + +# Check binary works +./bin/audiotee --help +``` + +### node-pre-gyp Issues + +```bash +# Clear cache +npm run clean +rm -rf node_modules +npm install + +# Debug node-pre-gyp +DEBUG=node-pre-gyp npm run package +``` + +## Code Style + +- Follow the patterns in `.cursorrules` +- Use functional programming where possible +- No semicolons (per project preference) +- Handle errors via EventEmitter, don't throw +- Use British English in documentation +- Comprehensive JSDoc for public APIs + +## Testing Checklist + +Before releasing: + +- [ ] Basic audio capture works +- [ ] Both JSON and binary formats work +- [ ] Sample rate conversion works +- [ ] Process filtering works (if testable) +- [ ] Error handling works (invalid args, missing binary, etc.) +- [ ] Package installs and works on clean system +- [ ] Examples in README work +- [ ] CI/CD builds successfully + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Test thoroughly +5. Update documentation +6. Submit a pull request + +## Publishing Checklist + +- [ ] Version updated in package.json +- [ ] Tests pass +- [ ] Documentation updated +- [ ] GitHub release created +- [ ] CI/CD completed successfully +- [ ] npm package published +- [ ] Installation test passes \ No newline at end of file diff --git a/audiotee-js/LICENSE b/audiotee-js/LICENSE new file mode 100644 index 0000000..431b2d9 --- /dev/null +++ b/audiotee-js/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 AudioTee.js Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/audiotee-js/README.md b/audiotee-js/README.md new file mode 100644 index 0000000..67b95aa --- /dev/null +++ b/audiotee-js/README.md @@ -0,0 +1,297 @@ +# AudioTee.js + +Node.js wrapper for [AudioTee](https://github.com/your-org/audiotee) - capture macOS system audio using Core Audio taps. + +AudioTee.js provides a streaming interface to capture system audio in real-time, perfect for building applications that need to process audio from any running application on macOS. + +## Features + +- 🎵 **Real-time system audio capture** using Core Audio taps +- 📦 **Streaming interface** with Node.js EventEmitter API +- ⚡ **High performance** binary protocol support +- 🎛️ **Flexible configuration** - sample rates, chunk sizes, process filtering +- 🔇 **Process-specific capture** - include/exclude specific applications +- 📊 **Audio metadata** - format information and level monitoring +- 🛡️ **Error handling** - graceful failure and process management + +## Requirements + +- **macOS 14.2+** (Sonoma or later) +- **Node.js 14+** +- **Audio recording permissions** (you'll be prompted on first use) + +## Installation + +```bash +npm install audiotee-js +``` + +The package will automatically download the appropriate AudioTee binary for your system during installation. + +## Quick Start + +```javascript +const { AudioTeeStream } = require('audiotee-js'); + +// Create a stream with 16kHz sample rate (great for ASR) +const stream = new AudioTeeStream({ + sampleRate: 16000, + format: 'binary', + chunkDuration: 0.2 +}); + +// Listen for audio metadata +stream.on('metadata', (metadata) => { + console.log('Audio format:', metadata); +}); + +// Process audio chunks +stream.on('audio', (packet) => { + console.log(`Received ${packet.audioData.length} bytes of audio`); + // packet.audioData is a Buffer containing raw PCM data + // packet.timestamp, packet.duration, packet.peakAmplitude also available +}); + +// Handle errors +stream.on('error', (error) => { + console.error('AudioTee error:', error); +}); + +// Start capturing +stream.start(); + +// Stop when done +// stream.stop(); +``` + +## API Reference + +### AudioTeeStream + +The main class for capturing system audio. + +#### Constructor + +```javascript +new AudioTeeStream(options) +``` + +**Options:** + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `format` | `string` | `'binary'` | Output format: `'json'`, `'binary'`, or `'auto'` | +| `sampleRate` | `number` | `undefined` | Target sample rate (8000, 16000, 22050, 24000, 32000, 44100, 48000) | +| `chunkDuration` | `number` | `0.2` | Audio chunk duration in seconds (max 5.0) | +| `includeProcesses` | `number[]` | `[]` | Process IDs to capture (empty = all processes) | +| `excludeProcesses` | `number[]` | `[]` | Process IDs to exclude | +| `mute` | `boolean` | `false` | Mute processes being captured | +| `binaryPath` | `string` | `auto` | Custom path to AudioTee binary | + +#### Methods + +- **`start()`** - Start audio capture, returns `this` for chaining +- **`stop()`** - Stop audio capture +- **`isActive()`** - Returns `true` if currently capturing +- **`getMetadata()`** - Returns audio metadata (available after `metadata` event) + +#### Events + +- **`metadata`** - Audio format information +- **`stream_start`** - Capture has started +- **`audio`** - Audio data packet +- **`stream_stop`** - Capture has stopped +- **`log`** - Log messages from AudioTee +- **`error`** - Error occurred +- **`close`** - Process has closed + +### Audio Packet Format + +Audio events receive packets with this structure: + +```javascript +{ + timestamp: Date, // When this audio was captured + duration: number, // Duration in seconds + peakAmplitude: number, // Peak amplitude (0.0 - 1.0) + audioData: Buffer // Raw PCM audio data +} +``` + +### Metadata Format + +Metadata events provide audio format information: + +```javascript +{ + sample_rate: number, // e.g. 48000 + channels_per_frame: number,// Always 1 (mono) + bits_per_channel: number, // e.g. 32 + is_float: boolean, // true for float32, false for int16 + encoding: string, // e.g. "pcm_f32le" + capture_mode: string, // "audio" + device_name: string|null, // Audio device name + device_uid: string|null // Audio device UID +} +``` + +## Examples + +### Basic Recording + +```javascript +const { AudioTeeStream } = require('audiotee-js'); + +const stream = new AudioTeeStream(); + +stream.on('metadata', console.log); +stream.on('audio', (packet) => { + console.log(`${packet.audioData.length} bytes, peak: ${packet.peakAmplitude}`); +}); + +stream.start(); +``` + +### Save to WAV File + +```javascript +const fs = require('fs'); +const { AudioTeeStream } = require('audiotee-js'); + +const stream = new AudioTeeStream({ + sampleRate: 44100, + format: 'binary' +}); + +const output = fs.createWriteStream('recording.raw'); + +stream.on('audio', (packet) => { + output.write(packet.audioData); +}); + +stream.start(); + +// Stop after 10 seconds +setTimeout(() => { + stream.stop(); + output.end(); +}, 10000); +``` + +### Process-Specific Capture + +```javascript +const { AudioTeeStream } = require('audiotee-js'); + +// Only capture audio from Spotify (you'd need to find Spotify's PID) +const spotifyPID = 1234; // Use Activity Monitor or `pgrep Spotify` + +const stream = new AudioTeeStream({ + includeProcesses: [spotifyPID], + mute: true // Don't play through speakers +}); + +stream.on('audio', (packet) => { + // Only Spotify's audio will be captured + console.log('Spotify audio:', packet.audioData.length, 'bytes'); +}); + +stream.start(); +``` + +### Real-time ASR Integration + +```javascript +const { AudioTeeStream } = require('audiotee-js'); + +const stream = new AudioTeeStream({ + sampleRate: 16000, // Common ASR sample rate + chunkDuration: 0.1, // Faster chunks for real-time + format: 'binary' +}); + +stream.on('audio', async (packet) => { + // Send to your ASR service + const transcript = await sendToASR(packet.audioData); + if (transcript) { + console.log('Transcription:', transcript); + } +}); + +stream.start(); +``` + +## Testing + +Run the included test to verify everything works: + +```bash +# Basic interactive test +npm test + +# Quick automated test +npm test quick +``` + +The test will capture audio for a few seconds and display statistics. + +## Troubleshooting + +### Permission Denied + +AudioTee requires microphone permissions. You'll see a system dialog on first use - make sure to allow access. + +### Binary Not Found + +If you see "AudioTee binary not found", try rebuilding: + +```bash +npm run build +``` + +### No Audio Captured + +- Check that audio is actually playing on your system +- Verify you have the latest macOS version (14.2+) +- Try running the Swift AudioTee directly to isolate the issue + +## Development + +### Building from Source + +```bash +# Clone and build the parent AudioTee project first +git clone https://github.com/your-org/audiotee.git +cd audiotee +swift build -c release + +# Then build the Node.js package +cd audiotee-js +npm install +npm run build +``` + +### Testing Changes + +```bash +npm test # Run basic test +npm run lint # Check code style +npm run clean # Clean build artifacts +``` + +## Performance Notes + +- **Binary format** is more efficient than JSON for high-throughput applications +- **Lower chunk durations** increase CPU usage but reduce latency +- **Sample rate conversion** adds processing overhead - use native rates when possible +- The AudioTee binary uses real-time audio threads for minimal latency + +## License + +MIT License - see [LICENSE](LICENSE) file. + +## Related Projects + +- [AudioTee](https://github.com/your-org/audiotee) - The underlying Swift CLI tool +- [node-core-audio](https://github.com/ZECTBynmo/node-core-audio) - Alternative Node.js audio library +- [AudioCap](https://github.com/insidegui/AudioCap) - macOS audio capture inspiration \ No newline at end of file diff --git a/audiotee-js/examples/basic-usage.js b/audiotee-js/examples/basic-usage.js new file mode 100644 index 0000000..70938af --- /dev/null +++ b/audiotee-js/examples/basic-usage.js @@ -0,0 +1,196 @@ +#!/usr/bin/env node + +/** + * Basic AudioTee.js Usage Examples + * + * This file demonstrates common patterns for using AudioTee.js + * Run with: node examples/basic-usage.js + */ + +const { AudioTeeStream } = require("../index"); +const fs = require("fs"); + +// Example 1: Basic audio capture with console output +function basicCapture() { + console.log("=== Example 1: Basic Audio Capture ===\n"); + + const stream = new AudioTeeStream({ + format: "binary", + sampleRate: 16000, // Good for speech recognition + chunkDuration: 0.2, + }); + + let packetCount = 0; + let totalBytes = 0; + + stream.on("metadata", (metadata) => { + console.log("🎵 Audio metadata:"); + console.log(` Sample rate: ${metadata.sample_rate} Hz`); + console.log(` Encoding: ${metadata.encoding}`); + console.log(""); + }); + + stream.on("audio", (packet) => { + packetCount++; + totalBytes += packet.audioData.length; + + // Show real-time stats + process.stdout.write( + `\r📊 Packets: ${packetCount}, Bytes: ${totalBytes}, Peak: ${packet.peakAmplitude.toFixed( + 3 + )}` + ); + }); + + stream.on("error", (error) => { + console.error("\n❌ Error:", error.message); + process.exit(1); + }); + + // Auto-stop after 5 seconds for demo + setTimeout(() => { + console.log("\n\n✅ Stopping capture..."); + stream.stop(); + }, 5000); + + console.log("🚀 Starting capture (will run for 5 seconds)..."); + stream.start(); +} + +// Example 2: Save audio to file +function saveToFile() { + console.log("\n=== Example 2: Save Audio to File ===\n"); + + const stream = new AudioTeeStream({ + format: "binary", + sampleRate: 44100, // CD quality + chunkDuration: 0.1, + }); + + const outputFile = "recording.raw"; + const writeStream = fs.createWriteStream(outputFile); + + stream.on("metadata", (metadata) => { + console.log(`💾 Saving ${metadata.encoding} audio to ${outputFile}`); + console.log( + ` Format: ${metadata.sample_rate}Hz, ${metadata.bits_per_channel}-bit` + ); + }); + + stream.on("audio", (packet) => { + // Write raw audio data to file + writeStream.write(packet.audioData); + }); + + stream.on("stream_stop", () => { + writeStream.end(); + console.log(`\n✅ Saved audio to ${outputFile}`); + + // Show file size + const stats = fs.statSync(outputFile); + console.log(`📈 File size: ${(stats.size / 1024).toFixed(1)} KB`); + }); + + // Stop after 3 seconds + setTimeout(() => { + stream.stop(); + }, 3000); + + console.log("🎵 Recording for 3 seconds..."); + stream.start(); +} + +// Example 3: Monitor audio levels (VU meter style) +function audioLevelMonitor() { + console.log("\n=== Example 3: Audio Level Monitor ===\n"); + + const stream = new AudioTeeStream({ + format: "json", // JSON format for this example + chunkDuration: 0.05, // Fast updates for smooth level display + }); + + function drawLevelMeter(level) { + const maxBars = 20; + const bars = Math.floor(level * maxBars); + const meter = "█".repeat(bars) + "░".repeat(maxBars - bars); + const percentage = (level * 100).toFixed(1); + + process.stdout.write(`\r🔊 ${meter} ${percentage}%`); + } + + stream.on("audio", (packet) => { + drawLevelMeter(packet.peakAmplitude); + }); + + // Run for 10 seconds + setTimeout(() => { + console.log("\n\n✅ Level monitoring complete"); + stream.stop(); + }, 10000); + + console.log("🎚️ Audio level monitor (10 seconds):"); + console.log(" Play some music to see the levels!\n"); + stream.start(); +} + +// Example 4: Process-specific capture +function captureSpecificProcess() { + console.log("\n=== Example 4: Process-Specific Capture ===\n"); + + // This would capture only from a specific application + // You'd need to find the PID first: `pgrep "Music"` or Activity Monitor + + const stream = new AudioTeeStream({ + // includeProcesses: [1234], // Uncomment and set real PID + mute: true, // Don't play through speakers + format: "binary", + }); + + stream.on("metadata", () => { + console.log( + "🎯 Capturing from specific process (demo mode - all processes)" + ); + console.log(" To capture from specific app:"); + console.log(' 1. Find PID: pgrep "App Name"'); + console.log(" 2. Uncomment includeProcesses line above"); + }); + + stream.on("audio", (packet) => { + console.log( + `📦 Got ${packet.audioData.length} bytes from targeted process` + ); + }); + + // Stop after 3 seconds + setTimeout(() => { + stream.stop(); + }, 3000); + + stream.start(); +} + +// Run examples based on command line argument +const example = process.argv[2] || "1"; + +switch (example) { + case "1": + basicCapture(); + break; + case "2": + saveToFile(); + break; + case "3": + audioLevelMonitor(); + break; + case "4": + captureSpecificProcess(); + break; + default: + console.log("Usage: node basic-usage.js [1|2|3|4]"); + console.log(""); + console.log("Examples:"); + console.log(" 1 - Basic audio capture"); + console.log(" 2 - Save audio to file"); + console.log(" 3 - Audio level monitor"); + console.log(" 4 - Process-specific capture"); +} diff --git a/audiotee-js/index.js b/audiotee-js/index.js new file mode 100644 index 0000000..9a34ad8 --- /dev/null +++ b/audiotee-js/index.js @@ -0,0 +1,15 @@ +const path = require("path"); +const binary = require("node-pre-gyp"); + +// Get the path to the downloaded binary +const bindingPath = binary.find( + path.resolve(path.join(__dirname, "package.json")) +); +const binaryPath = path.join(path.dirname(bindingPath), "audiotee"); + +const AudioTeeStream = require("./lib/AudioTeeStream"); + +module.exports = { + AudioTeeStream, + getBinaryPath: () => binaryPath, +}; diff --git a/audiotee-js/lib/AudioTeeStream.js b/audiotee-js/lib/AudioTeeStream.js new file mode 100644 index 0000000..3f63108 --- /dev/null +++ b/audiotee-js/lib/AudioTeeStream.js @@ -0,0 +1,298 @@ +const { spawn } = require("child_process"); +const { EventEmitter } = require("events"); +const readline = require("readline"); + +/** + * AudioTeeStream - Node.js wrapper for AudioTee system audio capture + * + * Events: + * - 'metadata': Audio format information + * - 'stream_start': Recording has started + * - 'audio': Audio data chunk { timestamp, duration, peakAmplitude, audioData } + * - 'stream_stop': Recording has stopped + * - 'log': Log messages { level, message, context } + * - 'error': Errors + * - 'close': Process has closed + */ +class AudioTeeStream extends EventEmitter { + constructor(options = {}) { + super(); + + // Binary path - use provided path or get from main module + this.binaryPath = + options.binaryPath || + (() => { + try { + return require("../index").getBinaryPath(); + } catch { + throw new Error( + "AudioTee binary path not available. Ensure package is properly installed." + ); + } + })(); + + // AudioTee options + this.format = options.format || "binary"; // 'json', 'binary', or 'auto' + this.sampleRate = options.sampleRate; + this.chunkDuration = options.chunkDuration || 0.2; + this.includeProcesses = options.includeProcesses || []; + this.excludeProcesses = options.excludeProcesses || []; + this.mute = options.mute || false; + + // Internal state + this.process = null; + this.metadata = null; + this.isStarted = false; + + // Binary format state + this.pendingAudioMeta = null; + this.expectedBytes = 0; + this.binaryBuffer = Buffer.alloc(0); + } + + /** + * Start audio capture + * @returns {AudioTeeStream} this instance for chaining + */ + start() { + if (this.isStarted) { + throw new Error("AudioTeeStream is already started"); + } + + const args = this.buildArguments(); + + try { + this.process = spawn(this.binaryPath, args, { + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (error) { + this.emit( + "error", + new Error(`Failed to start AudioTee: ${error.message}`) + ); + return this; + } + + this.isStarted = true; + this.setupProtocolHandling(); + this.setupProcessHandlers(); + + return this; + } + + /** + * Stop audio capture + */ + stop() { + if (this.process && !this.process.killed) { + this.process.kill("SIGTERM"); + } + } + + /** + * Check if the stream is currently active + * @returns {boolean} + */ + isActive() { + return this.isStarted && this.process && !this.process.killed; + } + + /** + * Get current audio metadata (available after 'metadata' event) + * @returns {Object|null} + */ + getMetadata() { + return this.metadata; + } + + // Private methods + + buildArguments() { + const args = [`--format=${this.format}`]; + + if (this.sampleRate) { + args.push(`--sample-rate=${this.sampleRate}`); + } + + if (this.chunkDuration !== 0.2) { + args.push(`--chunk-duration=${this.chunkDuration}`); + } + + if (this.includeProcesses.length) { + args.push(`--include-processes=${this.includeProcesses.join(" ")}`); + } + + if (this.excludeProcesses.length) { + args.push(`--exclude-processes=${this.excludeProcesses.join(" ")}`); + } + + if (this.mute) { + args.push("--mute"); + } + + return args; + } + + setupProtocolHandling() { + if (this.format === "binary") { + this.setupBinaryProtocol(); + } else { + this.setupJSONProtocol(); + } + } + + setupJSONProtocol() { + const rl = readline.createInterface({ + input: this.process.stdout, + crlfDelay: Infinity, + }); + + rl.on("line", (line) => this.handleJSONLine(line)); + } + + setupBinaryProtocol() { + // For binary format, we need to handle both JSON lines and raw binary data + let lineBuffer = ""; + let inJsonMode = true; + + this.process.stdout.on("data", (chunk) => { + if (inJsonMode) { + // Look for complete JSON lines + lineBuffer += chunk.toString(); + + let newlineIndex; + while ((newlineIndex = lineBuffer.indexOf("\n")) !== -1) { + const line = lineBuffer.slice(0, newlineIndex); + lineBuffer = lineBuffer.slice(newlineIndex + 1); + + try { + const message = JSON.parse(line); + if (message.message_type === "audio" && this.format === "binary") { + // Prepare for binary data + this.expectedBytes = message.data.audio_length; + this.pendingAudioMeta = { + timestamp: new Date(message.data.timestamp), + duration: message.data.duration, + peakAmplitude: message.data.peak_amplitude, + }; + inJsonMode = false; + } else { + this.handleJSONMessage(message); + } + } catch (error) { + this.emit( + "error", + new Error(`Failed to parse JSON: ${error.message}`) + ); + } + } + } else { + // We're expecting binary audio data + this.binaryBuffer = Buffer.concat([this.binaryBuffer, chunk]); + + if (this.binaryBuffer.length >= this.expectedBytes) { + // Extract the audio data + const audioData = this.binaryBuffer.slice(0, this.expectedBytes); + this.binaryBuffer = this.binaryBuffer.slice(this.expectedBytes); + + // Emit the audio event + this.emit("audio", { + ...this.pendingAudioMeta, + audioData, + }); + + // Reset state + this.expectedBytes = 0; + this.pendingAudioMeta = null; + inJsonMode = true; + + // Process any remaining data as JSON + if (this.binaryBuffer.length > 0) { + lineBuffer += this.binaryBuffer.toString(); + this.binaryBuffer = Buffer.alloc(0); + } + } + } + }); + } + + handleJSONLine(line) { + try { + const message = JSON.parse(line); + this.handleJSONMessage(message); + } catch (error) { + this.emit("error", new Error(`Failed to parse JSON: ${error.message}`)); + } + } + + handleJSONMessage(message) { + switch (message.message_type) { + case "metadata": + this.metadata = message.data; + this.emit("metadata", this.metadata); + break; + + case "stream_start": + this.emit("stream_start"); + break; + + case "audio": + if (this.format === "json") { + // JSON format - audio data is base64 encoded + const audioBuffer = Buffer.from(message.data.audio_data, "base64"); + this.emit("audio", { + timestamp: new Date(message.data.timestamp), + duration: message.data.duration, + peakAmplitude: message.data.peak_amplitude, + audioData: audioBuffer, + }); + } + // Binary format audio is handled in setupBinaryProtocol + break; + + case "stream_stop": + this.emit("stream_stop"); + break; + + case "info": + case "error": + case "debug": + this.emit("log", { + level: message.message_type, + message: message.data?.message || "Unknown log message", + context: message.data?.context, + }); + break; + + default: + this.emit("log", { + level: "debug", + message: `Unknown message type: ${message.message_type}`, + context: { raw_message: message }, + }); + } + } + + setupProcessHandlers() { + this.process.stderr.on("data", (data) => { + // AudioTee should not write to stderr in normal operation + this.emit("log", { + level: "error", + message: "AudioTee stderr output", + context: { output: data.toString().trim() }, + }); + }); + + this.process.on("close", (code, signal) => { + this.isStarted = false; + this.emit("close", { code, signal }); + }); + + this.process.on("error", (error) => { + this.isStarted = false; + this.emit("error", new Error(`AudioTee process error: ${error.message}`)); + }); + } +} + +module.exports = AudioTeeStream; diff --git a/audiotee-js/package.json b/audiotee-js/package.json new file mode 100644 index 0000000..6087f68 --- /dev/null +++ b/audiotee-js/package.json @@ -0,0 +1,61 @@ +{ + "name": "audiotee-js", + "version": "1.0.0", + "description": "Node.js wrapper for AudioTee - capture macOS system audio using Core Audio taps", + "main": "index.js", + "scripts": { + "install": "node-pre-gyp install --fallback-to-build", + "build": "node scripts/build.js", + "test": "node test/test.js", + "package": "node-pre-gyp package", + "publish-binary": "node-pre-gyp publish", + "clean": "node-pre-gyp clean", + "lint": "eslint lib/ scripts/ test/ index.js", + "prepack": "npm run build" + }, + "binary": { + "module_name": "audiotee", + "module_path": "./bin/", + "remote_path": "v{version}/", + "package_name": "audiotee-v{version}-{platform}-{arch}.tar.gz", + "host": "https://github.com/your-org/audiotee-js/releases/download/", + "napi_versions": [] + }, + "dependencies": { + "node-pre-gyp": "^0.17.0" + }, + "devDependencies": { + "eslint": "^8.0.0" + }, + "files": [ + "index.js", + "lib/", + "scripts/build.js", + "README.md" + ], + "os": ["darwin"], + "engines": { + "node": ">=14.0.0" + }, + "keywords": [ + "audio", + "macos", + "recording", + "system-audio", + "core-audio", + "streaming", + "real-time", + "asr", + "speech-recognition" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/your-org/audiotee-js.git" + }, + "bugs": { + "url": "https://github.com/your-org/audiotee-js/issues" + }, + "homepage": "https://github.com/your-org/audiotee-js#readme", + "license": "MIT", + "author": "Your Name " +} \ No newline at end of file diff --git a/audiotee-js/scripts/build.js b/audiotee-js/scripts/build.js new file mode 100644 index 0000000..cc5d471 --- /dev/null +++ b/audiotee-js/scripts/build.js @@ -0,0 +1,88 @@ +#!/usr/bin/env node +const fs = require("fs"); +const path = require("path"); +const os = require("os"); + +// Default to parent directory (when in same repo), allow override via env var +const DEFAULT_BINARY_PATH = path.join( + __dirname, + "..", + "..", + ".build", + "release", + "audiotee" +); +const AUDIOTEE_BINARY_PATH = + process.env.AUDIOTEE_BINARY_PATH || DEFAULT_BINARY_PATH; + +function build() { + const arch = os.arch(); + const platform = os.platform(); + + console.log(`Building for platform: ${platform}, architecture: ${arch}`); + + if (platform !== "darwin") { + throw new Error("AudioTee only supports macOS"); + } + + // Create bin directory + const binDir = path.join(__dirname, "..", "bin"); + if (!fs.existsSync(binDir)) { + fs.mkdirSync(binDir, { recursive: true }); + console.log(`Created bin directory: ${binDir}`); + } + + // Resolve the source binary path + const sourcePath = path.resolve(AUDIOTEE_BINARY_PATH); + const targetPath = path.join(binDir, "audiotee"); + + console.log(`Looking for AudioTee binary at: ${sourcePath}`); + + if (!fs.existsSync(sourcePath)) { + console.error(`AudioTee binary not found at: ${sourcePath}`); + console.error("Please ensure AudioTee is built first:"); + console.error(" cd ../audiotee && swift build -c release"); + console.error("Or set AUDIOTEE_BINARY_PATH environment variable"); + throw new Error(`AudioTee binary not found at: ${sourcePath}`); + } + + console.log(`Copying AudioTee binary from ${sourcePath} to ${targetPath}`); + fs.copyFileSync(sourcePath, targetPath); + + // Make executable + fs.chmodSync(targetPath, 0o755); + + // Verify the binary works + console.log("Verifying binary..."); + const { execSync } = require("child_process"); + try { + execSync(`"${targetPath}" --help`, { stdio: "pipe" }); + console.log("Binary verification successful"); + } catch (error) { + console.warn("Binary verification failed, but continuing..."); + } + + console.log("Build completed successfully"); +} + +function clean() { + const binDir = path.join(__dirname, "..", "bin"); + if (fs.existsSync(binDir)) { + fs.rmSync(binDir, { recursive: true, force: true }); + console.log("Cleaned bin directory"); + } +} + +if (require.main === module) { + const command = process.argv[2]; + + switch (command) { + case "clean": + clean(); + break; + default: + build(); + } +} + +module.exports = { build, clean }; diff --git a/audiotee-js/test/test.js b/audiotee-js/test/test.js new file mode 100644 index 0000000..5d99281 --- /dev/null +++ b/audiotee-js/test/test.js @@ -0,0 +1,142 @@ +#!/usr/bin/env node +const { AudioTeeStream } = require("../index"); + +function runBasicTest() { + console.log("=== AudioTee.js Basic Test ===\n"); + + const stream = new AudioTeeStream({ + format: "binary", + sampleRate: 16000, + chunkDuration: 0.1, + }); + + let audioPacketCount = 0; + let totalAudioBytes = 0; + + stream.on("metadata", (metadata) => { + console.log("📊 Audio Metadata:"); + console.log(` Sample Rate: ${metadata.sample_rate} Hz`); + console.log(` Channels: ${metadata.channels_per_frame}`); + console.log(` Bits per Channel: ${metadata.bits_per_channel}`); + console.log(` Encoding: ${metadata.encoding}`); + console.log(` Float: ${metadata.is_float}\n`); + }); + + stream.on("stream_start", () => { + console.log("🎵 Audio stream started\n"); + }); + + stream.on("audio", (packet) => { + audioPacketCount++; + totalAudioBytes += packet.audioData.length; + + process.stdout.write( + `\r📦 Packets: ${audioPacketCount} | Audio bytes: ${totalAudioBytes} | Peak: ${packet.peakAmplitude.toFixed( + 3 + )} | Duration: ${packet.duration.toFixed(3)}s` + ); + }); + + stream.on("stream_stop", () => { + console.log("\n\n🛑 Audio stream stopped"); + console.log( + `📈 Final stats: ${audioPacketCount} packets, ${totalAudioBytes} bytes total\n` + ); + }); + + stream.on("log", (log) => { + if (log.level === "error") { + console.error(`\n❌ ${log.level.toUpperCase()}: ${log.message}`); + if (log.context) { + console.error(` Context:`, log.context); + } + } + }); + + stream.on("error", (error) => { + console.error(`\n💥 Error: ${error.message}`); + process.exit(1); + }); + + stream.on("close", ({ code, signal }) => { + console.log( + `👋 AudioTee process closed (code: ${code}, signal: ${signal})` + ); + process.exit(code || 0); + }); + + // Handle Ctrl+C gracefully + process.on("SIGINT", () => { + console.log("\n\n🛑 Received SIGINT, stopping AudioTee..."); + stream.stop(); + }); + + console.log("🚀 Starting AudioTee stream..."); + console.log("💡 Play some audio and watch the packets stream in!"); + console.log("⏹️ Press Ctrl+C to stop\n"); + + try { + stream.start(); + } catch (error) { + console.error(`Failed to start: ${error.message}`); + process.exit(1); + } +} + +function runQuickTest() { + console.log("=== AudioTee.js Quick Test ===\n"); + + const stream = new AudioTeeStream({ + format: "json", + chunkDuration: 0.2, + }); + + let packetCount = 0; + const maxPackets = 5; + + stream.on("metadata", (metadata) => { + console.log("✅ Received metadata:", metadata); + }); + + stream.on("audio", () => { + packetCount++; + console.log(`✅ Received audio packet ${packetCount}/${maxPackets}`); + + if (packetCount >= maxPackets) { + console.log("✅ Quick test complete!"); + stream.stop(); + } + }); + + stream.on("error", (error) => { + console.error("❌ Test failed:", error.message); + process.exit(1); + }); + + stream.on("close", () => { + console.log("👋 Test finished"); + process.exit(0); + }); + + setTimeout(() => { + console.log("⏰ Test timeout - AudioTee might not be working"); + stream.stop(); + process.exit(1); + }, 10000); + + console.log("🚀 Running quick test (capturing 5 audio packets)..."); + stream.start(); +} + +// Run the appropriate test based on command line args +const testType = process.argv[2] || "basic"; + +switch (testType) { + case "quick": + runQuickTest(); + break; + case "basic": + default: + runBasicTest(); + break; +}