refactor: rebrand project from voice-dev to paseo across all packages and configs

This commit is contained in:
Mohamed Boudra
2025-11-15 20:45:09 +01:00
parent b39497d08f
commit b046dae799
16 changed files with 298 additions and 205 deletions
+5 -5
View File
@@ -1,11 +1,11 @@
{
"expo": {
"name": "Voice Dev",
"slug": "voice-dev",
"name": "Paseo",
"slug": "paseo",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "voicedev",
"scheme": "paseo",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"runtimeVersion": {
@@ -19,7 +19,7 @@
"infoPlist": {
"NSMicrophoneUsageDescription": "This app needs access to the microphone for voice commands."
},
"bundleIdentifier": "com.moboudra.voicedev"
"bundleIdentifier": "com.moboudra.paseo"
},
"android": {
"adaptiveIcon": {
@@ -34,7 +34,7 @@
"android.permission.RECORD_AUDIO",
"android.permission.MODIFY_AUDIO_SETTINGS"
],
"package": "com.moboudra.voicedev"
"package": "com.moboudra.paseo"
},
"web": {
"output": "static",
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "@voice-dev/app",
"name": "@paseo/app",
"main": "index.ts",
"version": "1.0.0",
"scripts": {
+1 -1
View File
@@ -129,7 +129,7 @@ export function AgentList({ agents }: AgentListProps) {
{actionAgent?.title || "Delete agent"}
</Text>
<Text style={styles.sheetSubtitle}>
Removing this agent only deletes it from Voice Dev. Claude/Codex keeps the original project.
Removing this agent only deletes it from Paseo. Claude/Codex keeps the original project.
</Text>
<Pressable
style={[styles.sheetButton, styles.sheetDeleteButton]}
@@ -15,7 +15,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
import { StyleSheet } from "react-native-unistyles";
import type { UseWebSocketReturn } from "../hooks/use-websocket";
const STORAGE_KEY = "@voice-dev:conversation-id";
const STORAGE_KEY = "@paseo:conversation-id";
interface Conversation {
id: string;
+1 -1
View File
@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
const STORAGE_KEY = '@voice-dev:recent-paths';
const STORAGE_KEY = '@paseo:recent-paths';
const MAX_RECENT_PATHS = 3;
export interface UseRecentPathsReturn {
+1 -1
View File
@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
const STORAGE_KEY = '@voice-dev:settings';
const STORAGE_KEY = '@paseo:settings';
export interface Settings {
serverUrl: string;
@@ -9,7 +9,7 @@ import {
} from "@boudra/expo-two-way-audio";
export interface SpeechmaticsAudioConfig {
onAudioSegment?: (audioData: string) => void;
onAudioSegment?: (segment: { audioData: string; isLast: boolean }) => void;
onSpeechStart?: () => void;
onSpeechEnd?: () => void;
onError?: (error: Error) => void;
@@ -31,79 +31,6 @@ export interface SpeechmaticsAudio {
segmentDuration: number;
}
/**
* Convert raw PCM data to WAV format by adding WAV headers
*/
function pcmToWav(
pcmBase64: string,
sampleRate: number = 16000,
channels: number = 1,
bitsPerSample: number = 16
): string {
const pcmBinary = atob(pcmBase64);
const pcmLength = pcmBinary.length;
const byteRate = sampleRate * channels * (bitsPerSample / 8);
const blockAlign = channels * (bitsPerSample / 8);
const dataSize = pcmLength;
const fileSize = 44 + dataSize;
const wavBuffer = new Uint8Array(fileSize);
const view = new DataView(wavBuffer.buffer);
let offset = 0;
// RIFF chunk descriptor
writeString(view, offset, "RIFF");
offset += 4;
view.setUint32(offset, fileSize - 8, true);
offset += 4;
writeString(view, offset, "WAVE");
offset += 4;
// fmt sub-chunk
writeString(view, offset, "fmt ");
offset += 4;
view.setUint32(offset, 16, true);
offset += 4;
view.setUint16(offset, 1, true);
offset += 2;
view.setUint16(offset, channels, true);
offset += 2;
view.setUint32(offset, sampleRate, true);
offset += 4;
view.setUint32(offset, byteRate, true);
offset += 4;
view.setUint16(offset, blockAlign, true);
offset += 2;
view.setUint16(offset, bitsPerSample, true);
offset += 2;
// data sub-chunk
writeString(view, offset, "data");
offset += 4;
view.setUint32(offset, dataSize, true);
offset += 4;
// Copy PCM data
for (let i = 0; i < pcmLength; i++) {
wavBuffer[offset + i] = pcmBinary.charCodeAt(i);
}
// Convert to base64
let binary = "";
for (let i = 0; i < wavBuffer.length; i++) {
binary += String.fromCharCode(wavBuffer[i]);
}
return btoa(binary);
}
function writeString(view: DataView, offset: number, str: string): void {
for (let i = 0; i < str.length; i++) {
view.setUint8(offset + i, str.charCodeAt(i));
}
}
function uint8ArrayToBase64(bytes: Uint8Array): string {
let binary = "";
for (let i = 0; i < bytes.length; i++) {
@@ -143,6 +70,38 @@ export function useSpeechmaticsAudio(
const speechDetectionStartRef = useRef<number | null>(null);
const speechConfirmedRef = useRef(false);
const detectionSilenceStartRef = useRef<number | null>(null);
const bufferedBytesRef = useRef(0);
const PCM_SAMPLE_RATE = 16000;
const PCM_CHANNELS = 1;
const PCM_BITS_PER_SAMPLE = 16;
const PCM_BYTES_PER_MS =
(PCM_SAMPLE_RATE * PCM_CHANNELS * (PCM_BITS_PER_SAMPLE / 8)) / 1000;
const MIN_CHUNK_DURATION_MS = 1000;
const MIN_CHUNK_BYTES = Math.round(
PCM_BYTES_PER_MS * MIN_CHUNK_DURATION_MS
);
const flushBufferedAudio = useCallback(
(isLast: boolean) => {
if (audioBufferRef.current.length === 0) {
bufferedBytesRef.current = 0;
return;
}
const combinedBinary = concatenateUint8Arrays(audioBufferRef.current);
const pcmBase64 = uint8ArrayToBase64(combinedBinary);
config.onAudioSegment?.({
audioData: pcmBase64,
isLast,
});
audioBufferRef.current = [];
bufferedBytesRef.current = 0;
},
[config]
);
const VOLUME_THRESHOLD = config.volumeThreshold;
const SILENCE_DURATION_MS = config.silenceDuration;
@@ -178,9 +137,17 @@ export function useSpeechmaticsAudio(
// Start buffering from first spike to capture beginning of speech
if (speechDetectionStartRef.current !== null || isSpeakingRef.current) {
audioBufferRef.current.push(pcmData);
bufferedBytesRef.current += pcmData.length;
if (
speechConfirmedRef.current &&
bufferedBytesRef.current >= MIN_CHUNK_BYTES
) {
flushBufferedAudio(false);
}
}
},
[isActive, isMuted]
[isActive, isMuted, flushBufferedAudio, MIN_CHUNK_BYTES]
)
);
@@ -215,6 +182,7 @@ export function useSpeechmaticsAudio(
speechDetectionStartRef.current = Date.now();
detectionSilenceStartRef.current = null;
audioBufferRef.current = [];
bufferedBytesRef.current = 0;
setIsDetecting(true);
} else {
// Volume is back above threshold - reset grace period
@@ -264,6 +232,7 @@ export function useSpeechmaticsAudio(
speechDetectionStartRef.current = null;
detectionSilenceStartRef.current = null;
audioBufferRef.current = [];
bufferedBytesRef.current = 0;
setIsDetecting(false);
}
}
@@ -292,15 +261,7 @@ export function useSpeechmaticsAudio(
config.onSpeechEnd?.();
// Send buffered audio segment
if (audioBufferRef.current.length > 0) {
const combinedBinary = concatenateUint8Arrays(
audioBufferRef.current
);
const combinedPcmBase64 = uint8ArrayToBase64(combinedBinary);
const wavAudio = pcmToWav(combinedPcmBase64, 16000, 1, 16);
config.onAudioSegment?.(wavAudio);
audioBufferRef.current = [];
}
flushBufferedAudio(true);
}
}
}
@@ -313,6 +274,7 @@ export function useSpeechmaticsAudio(
SPEECH_CONFIRMATION_MS,
DETECTION_GRACE_PERIOD_MS,
config,
flushBufferedAudio,
]
)
);
@@ -365,6 +327,7 @@ export function useSpeechmaticsAudio(
// Reset state
audioBufferRef.current = [];
bufferedBytesRef.current = 0;
isSpeakingRef.current = false;
speechConfirmedRef.current = false;
speechDetectionStartRef.current = null;
@@ -387,6 +350,7 @@ export function useSpeechmaticsAudio(
if (newMuted) {
// Clear any ongoing speech detection/speaking state
audioBufferRef.current = [];
bufferedBytesRef.current = 0;
isSpeakingRef.current = false;
speechConfirmedRef.current = false;
speechDetectionStartRef.current = null;
+3 -3
View File
@@ -441,14 +441,14 @@ function testToolCallParsedPayloadHydration() {
tool_use_id: commandCallId,
result: {
command: 'pwd',
output: '/Users/dev/voice-dev',
output: '/Users/dev/paseo',
},
metadata: { exit_code: 0 },
},
output: {
result: {
command: 'pwd',
output: '/Users/dev/voice-dev',
output: '/Users/dev/paseo',
},
metadata: { exit_code: 0 },
},
@@ -477,7 +477,7 @@ function testToolCallParsedPayloadHydration() {
const commandPass = Boolean(
commandEntry?.payload.data.parsedCommand &&
commandEntry.payload.data.parsedCommand.command === 'pwd' &&
commandEntry.payload.data.parsedCommand.output?.includes('/voice-dev')
commandEntry.payload.data.parsedCommand.output?.includes('/paseo')
);
assert.ok(readPass, 'Read payload should persist across hydration');
+6 -6
View File
@@ -285,11 +285,11 @@ When Claude Code presents a plan in plan mode, forward it to user's screen:
```javascript
// User mentions project
User: "Create a terminal for the web project"
create-terminal(name="web", workingDirectory="~/dev/voice-dev/packages/web")
create-terminal(name="web", workingDirectory="~/dev/paseo/packages/web")
// User says "another terminal here"
// Look at current terminal's working directory, use same path
create-terminal(name="tests", workingDirectory="~/dev/voice-dev/packages/web")
create-terminal(name="tests", workingDirectory="~/dev/paseo/packages/web")
// No context - list terminals first to see what they're working on
User: "Create a terminal"
@@ -430,7 +430,7 @@ Multi-step process:
```javascript
// Step 1
create_terminal((name = "fix-auth"), (workingDirectory = "~/dev/voice-dev"));
create_terminal((name = "fix-auth"), (workingDirectory = "~/dev/paseo"));
// Step 2
send_text(
@@ -455,7 +455,7 @@ send_text((terminalName = "fix-auth"), (text = "claude"), (pressEnter = true));
**Terminal naming:**
- No worktree: Use project name ("faro", "voice-dev")
- No worktree: Use project name ("faro", "paseo")
- With worktree: Use worktree name ("fix-auth", "feature-export")
**When user says "launch Claude in [project]":**
@@ -494,9 +494,9 @@ Already authenticated. Use for:
All projects in `~/dev`:
**voice-dev**
**paseo**
- Location: `~/dev/voice-dev`
- Location: `~/dev/paseo`
- Packages: `voice-assistant`
**Faro** (Autonomous Competitive Intelligence)
+5 -5
View File
@@ -191,12 +191,12 @@ We only have two coding agents. Do not call tools to discover them—treat this
### Creating Agents
**Confirm creation only when intent is unclear.** If the user gives a direct imperative (“spin up a new planner agent in voice-dev”), acknowledge and create immediately; otherwise, ask.
**Confirm creation only when intent is unclear.** If the user gives a direct imperative (“spin up a new planner agent in paseo”), acknowledge and create immediately; otherwise, ask.
```javascript
// Claude Code with planning
create_coding_agent({
cwd: "~/dev/voice-dev",
cwd: "~/dev/paseo",
agentType: "claude",
initialPrompt: "add dark mode toggle to settings page",
initialMode: "plan"
@@ -204,7 +204,7 @@ create_coding_agent({
// Codex for quick edits
create_coding_agent({
cwd: "~/dev/voice-dev",
cwd: "~/dev/paseo",
agentType: "codex",
initialPrompt: "clean up the logging",
initialMode: "auto"
@@ -328,8 +328,8 @@ Already authenticated. Use for:
All projects in `~/dev`:
**voice-dev**
- Location: `~/dev/voice-dev`
**paseo**
- Location: `~/dev/paseo`
- Packages: `voice-assistant`
**Faro** (Autonomous Competitive Intelligence)
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@voice-dev/server",
"name": "@paseo/server",
"version": "0.1.0",
"description": "Voice assistant backend server",
"description": "Paseo backend server",
"type": "module",
"scripts": {
"dev": "NODE_ENV=development node --watch --import tsx src/server/index.ts",
@@ -22,7 +22,7 @@ async function getTerminalMcpClient(): Promise<McpClient> {
}
// Create Terminal MCP server
const server = await createTerminalMcpServer({ sessionName: "__voice-dev" });
const server = await createTerminalMcpServer({ sessionName: "__paseo" });
// Create linked transport pair
const [clientTransport, serverTransport] =
+187 -59
View File
@@ -57,6 +57,16 @@ const ACTIVE_TITLE_GENERATIONS = new Set<string>();
type ProcessingPhase = "idle" | "transcribing" | "llm";
const PCM_SAMPLE_RATE = 16000;
const PCM_CHANNELS = 1;
const PCM_BITS_PER_SAMPLE = 16;
const PCM_BYTES_PER_MS =
(PCM_SAMPLE_RATE * PCM_CHANNELS * (PCM_BITS_PER_SAMPLE / 8)) / 1000;
const MIN_STREAMING_SEGMENT_DURATION_MS = 1000;
const MIN_STREAMING_SEGMENT_BYTES = Math.round(
PCM_BYTES_PER_MS * MIN_STREAMING_SEGMENT_DURATION_MS
);
/**
* Type for present_artifact tool arguments
*/
@@ -69,6 +79,42 @@ interface PresentArtifactArgs {
title: string;
}
interface AudioBufferState {
chunks: Buffer[];
format: string;
isPCM: boolean;
totalPCMBytes: number;
}
function convertPCMToWavBuffer(
pcmBuffer: Buffer,
sampleRate: number,
channels: number,
bitsPerSample: number
): Buffer {
const headerSize = 44;
const wavBuffer = Buffer.alloc(headerSize + pcmBuffer.length);
const byteRate = (sampleRate * channels * bitsPerSample) / 8;
const blockAlign = (channels * bitsPerSample) / 8;
wavBuffer.write("RIFF", 0);
wavBuffer.writeUInt32LE(36 + pcmBuffer.length, 4);
wavBuffer.write("WAVE", 8);
wavBuffer.write("fmt ", 12);
wavBuffer.writeUInt32LE(16, 16);
wavBuffer.writeUInt16LE(1, 20);
wavBuffer.writeUInt16LE(channels, 22);
wavBuffer.writeUInt32LE(sampleRate, 24);
wavBuffer.writeUInt32LE(byteRate, 28);
wavBuffer.writeUInt16LE(blockAlign, 32);
wavBuffer.writeUInt16LE(bitsPerSample, 34);
wavBuffer.write("data", 36);
wavBuffer.writeUInt32LE(pcmBuffer.length, 40);
pcmBuffer.copy(wavBuffer, 44);
return wavBuffer;
}
/**
* Session represents a single client conversation session.
* It owns all state management, orchestration logic, and message processing.
@@ -91,7 +137,7 @@ export class Session {
// Audio buffering for interruption handling
private pendingAudioSegments: Array<{ audio: Buffer; format: string }> = [];
private bufferTimeout: NodeJS.Timeout | null = null;
private audioBuffer: { chunks: Buffer[]; format: string } | null = null;
private audioBuffer: AudioBufferState | null = null;
// Conversation history
private messages: ModelMessage[] = [];
@@ -1499,73 +1545,151 @@ export class Session {
): Promise<void> {
await this.handleRealtimeSpeechStart();
// Decode base64
const audioBuffer = Buffer.from(msg.audio, "base64");
const chunkBuffer = Buffer.from(msg.audio, "base64");
const chunkFormat = msg.format || "audio/wav";
const isPCMChunk = chunkFormat.toLowerCase().includes("pcm");
if (!msg.isLast) {
// Buffer the chunk
if (!this.audioBuffer) {
this.audioBuffer = { chunks: [], format: msg.format };
}
this.audioBuffer.chunks.push(audioBuffer);
if (!this.audioBuffer) {
this.audioBuffer = {
chunks: [],
format: chunkFormat,
isPCM: isPCMChunk,
totalPCMBytes: 0,
};
}
// If the format changes mid-stream, flush what we have first
if (this.audioBuffer.isPCM !== isPCMChunk) {
console.log(
`[Session ${this.clientId}] Buffered audio chunk (${audioBuffer.length} bytes, total: ${this.audioBuffer.chunks.length})`
`[Session ${this.clientId}] Audio format changed mid-stream (${this.audioBuffer.isPCM ? "pcm" : this.audioBuffer.format} → ${chunkFormat}), flushing current buffer`
);
const finalized = this.finalizeBufferedAudio();
if (finalized) {
await this.processCompletedAudio(finalized.audio, finalized.format);
}
this.audioBuffer = {
chunks: [],
format: chunkFormat,
isPCM: isPCMChunk,
totalPCMBytes: 0,
};
} else if (!this.audioBuffer.isPCM) {
// Keep latest format info for non-PCM blobs
this.audioBuffer.format = chunkFormat;
}
this.audioBuffer.chunks.push(chunkBuffer);
if (this.audioBuffer.isPCM) {
this.audioBuffer.totalPCMBytes += chunkBuffer.length;
}
console.log(
`[Session ${this.clientId}] Buffered audio chunk (${chunkBuffer.length} bytes, chunks: ${this.audioBuffer.chunks.length}${this.audioBuffer.isPCM ? `, PCM bytes: ${this.audioBuffer.totalPCMBytes}` : ""})`
);
const reachedStreamingThreshold =
this.audioBuffer.isPCM &&
this.audioBuffer.totalPCMBytes >= MIN_STREAMING_SEGMENT_BYTES;
if (!msg.isLast && !reachedStreamingThreshold) {
return;
}
const bufferedState = this.audioBuffer;
const finalized = this.finalizeBufferedAudio();
if (!finalized) {
return;
}
if (!msg.isLast && reachedStreamingThreshold) {
console.log(
`[Session ${this.clientId}] Minimum chunk duration reached (~${MIN_STREAMING_SEGMENT_DURATION_MS}ms, ${
bufferedState?.totalPCMBytes ?? 0
} PCM bytes) – triggering STT`
);
} else {
// Complete segment received
const allChunks = this.audioBuffer
? [...this.audioBuffer.chunks, audioBuffer]
: [audioBuffer];
const format = this.audioBuffer?.format || msg.format;
const currentSegmentAudio = Buffer.concat(allChunks);
console.log(
`[Session ${this.clientId}] Complete audio segment (${currentSegmentAudio.length} bytes, ${allChunks.length} chunks)`
`[Session ${this.clientId}] Complete audio segment (${finalized.audio.length} bytes, ${bufferedState?.chunks.length ?? 0} chunk(s))`
);
}
await this.processCompletedAudio(finalized.audio, finalized.format);
}
private finalizeBufferedAudio():
| { audio: Buffer; format: string }
| null {
if (!this.audioBuffer) {
return null;
}
const bufferState = this.audioBuffer;
this.audioBuffer = null;
if (bufferState.isPCM) {
const pcmBuffer = Buffer.concat(bufferState.chunks);
const wavBuffer = convertPCMToWavBuffer(
pcmBuffer,
PCM_SAMPLE_RATE,
PCM_CHANNELS,
PCM_BITS_PER_SAMPLE
);
return {
audio: wavBuffer,
format: "audio/wav",
};
}
return {
audio: Buffer.concat(bufferState.chunks),
format: bufferState.format,
};
}
private async processCompletedAudio(
audio: Buffer,
format: string
): Promise<void> {
const shouldBuffer =
this.processingPhase === "transcribing" &&
this.pendingAudioSegments.length === 0;
if (shouldBuffer) {
console.log(
`[Session ${this.clientId}] Buffering audio segment (phase: ${this.processingPhase})`
);
this.pendingAudioSegments.push({
audio,
format,
});
this.setBufferTimeout();
return;
}
if (this.pendingAudioSegments.length > 0) {
this.pendingAudioSegments.push({
audio,
format,
});
console.log(
`[Session ${this.clientId}] Processing ${this.pendingAudioSegments.length} buffered segments together`
);
// Clear chunk buffer
this.audioBuffer = null;
const pendingSegments = [...this.pendingAudioSegments];
this.pendingAudioSegments = [];
this.clearBufferTimeout();
// Decision: buffer or process?
const shouldBuffer =
this.processingPhase === "transcribing" &&
this.pendingAudioSegments.length === 0;
const combinedAudio = Buffer.concat(
pendingSegments.map((segment) => segment.audio)
);
const combinedFormat =
pendingSegments[pendingSegments.length - 1].format;
if (shouldBuffer) {
// Currently transcribing first segment - buffer this one
console.log(
`[Session ${this.clientId}] Buffering audio segment (phase: ${this.processingPhase})`
);
this.pendingAudioSegments.push({
audio: currentSegmentAudio,
format,
});
this.setBufferTimeout();
} else if (this.pendingAudioSegments.length > 0) {
// We have buffered segments - add this one and process all together
this.pendingAudioSegments.push({
audio: currentSegmentAudio,
format,
});
console.log(
`[Session ${this.clientId}] Processing ${this.pendingAudioSegments.length} buffered segments together`
);
// Clear pending segments and timeout
const pendingSegments = [...this.pendingAudioSegments];
this.pendingAudioSegments = [];
this.clearBufferTimeout();
// Concatenate all segments
const allSegmentAudios = pendingSegments.map((s) => s.audio);
const combinedAudio = Buffer.concat(allSegmentAudios);
await this.processAudio(combinedAudio, format);
} else {
// Normal flow - no buffering needed
await this.processAudio(currentSegmentAudio, format);
}
await this.processAudio(combinedAudio, combinedFormat);
return;
}
await this.processAudio(audio, format);
}
/**
@@ -2086,7 +2210,11 @@ export class Session {
if (this.audioBuffer) {
console.log(
`[Session ${this.clientId}] Clearing partial audio buffer (${this.audioBuffer.chunks.length} chunk(s))`
`[Session ${this.clientId}] Clearing partial audio buffer (${this.audioBuffer.chunks.length} chunk(s)${
this.audioBuffer.isPCM
? `, ${this.audioBuffer.totalPCMBytes} PCM bytes`
: ""
})`
);
this.audioBuffer = null;
}