finchvox 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
finchvox/__init__.py ADDED
File without changes
finchvox/__main__.py ADDED
@@ -0,0 +1,81 @@
1
+ """
2
+ FinchVox entry point - starts unified server with collector and UI.
3
+
4
+ Usage:
5
+ python -m finchvox # Start with default port 3000
6
+ python -m finchvox --port 8000 # Start with custom port
7
+ python -m finchvox --help # Show options
8
+ """
9
+
10
+ import argparse
11
+ from pathlib import Path
12
+ from finchvox.server import UnifiedServer
13
+ from finchvox.collector.config import GRPC_PORT, get_default_data_dir
14
+
15
+
16
+ def main():
17
+ """Main entry point."""
18
+ parser = argparse.ArgumentParser(
19
+ description="FinchVox unified server for voice AI observability",
20
+ formatter_class=argparse.RawDescriptionHelpFormatter,
21
+ epilog="""
22
+ Examples:
23
+ python -m finchvox # Start with default ports
24
+ python -m finchvox --port 8000 # Use custom HTTP port
25
+ python -m finchvox --grpc-port 4318 # Use custom gRPC port
26
+ python -m finchvox --data-dir ./my-data # Use custom data directory
27
+ """
28
+ )
29
+ parser.add_argument(
30
+ "--port",
31
+ type=int,
32
+ default=3000,
33
+ help="HTTP server port (default: 3000)"
34
+ )
35
+ parser.add_argument(
36
+ "--grpc-port",
37
+ type=int,
38
+ default=GRPC_PORT,
39
+ help=f"gRPC server port (default: {GRPC_PORT})"
40
+ )
41
+ parser.add_argument(
42
+ "--host",
43
+ type=str,
44
+ default="0.0.0.0",
45
+ help="Host to bind to (default: 0.0.0.0)"
46
+ )
47
+ parser.add_argument(
48
+ "--data-dir",
49
+ type=str,
50
+ default=None,
51
+ help="Data directory for traces/logs/audio/exceptions (default: ~/.finchvox)"
52
+ )
53
+
54
+ args = parser.parse_args()
55
+
56
+ # Resolve data directory
57
+ if args.data_dir:
58
+ data_dir = Path(args.data_dir).expanduser().resolve()
59
+ else:
60
+ data_dir = get_default_data_dir()
61
+
62
+ print("Starting FinchVox Unified Server...")
63
+ print("=" * 50)
64
+ print(f"HTTP Server: http://{args.host}:{args.port}")
65
+ print(f" - UI: http://{args.host}:{args.port}")
66
+ print(f" - Collector: http://{args.host}:{args.port}/collector")
67
+ print(f"gRPC Server: {args.host}:{args.grpc_port}")
68
+ print(f"Data Directory: {data_dir}")
69
+ print("=" * 50)
70
+
71
+ server = UnifiedServer(
72
+ port=args.port,
73
+ grpc_port=args.grpc_port,
74
+ host=args.host,
75
+ data_dir=data_dir
76
+ )
77
+ server.run()
78
+
79
+
80
+ if __name__ == "__main__":
81
+ main()
@@ -0,0 +1,278 @@
1
+ """
2
+ Audio recording with chunked stereo capture and timing metadata.
3
+
4
+ Records conversation audio with:
5
+ - Stereo format: user audio on left channel, bot audio on right channel
6
+ - Chunked recording every 5-10 seconds for continuous streaming
7
+ - Timing events for latency calculation
8
+ - Association with OpenTelemetry trace IDs
9
+ - Direct upload to configured endpoint (no local file storage)
10
+ """
11
+
12
+ import asyncio
13
+ import io
14
+ import json
15
+ import wave
16
+ from datetime import datetime
17
+ from typing import Optional
18
+
19
+ import aiohttp
20
+ from loguru import logger
21
+ from opentelemetry import trace
22
+ from pipecat.processors.audio.audio_buffer_processor import AudioBufferProcessor
23
+ # Add Pipecat's conversation context provider
24
+ from pipecat.utils.tracing.conversation_context_provider import (
25
+ ConversationContextProvider
26
+ )
27
+
28
+
29
+ class ConversationAudioRecorder:
30
+ """
31
+ Records conversation audio with timing metadata for latency analysis.
32
+
33
+ Audio chunks are uploaded directly to a configured endpoint with no local file storage.
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ chunk_duration_seconds: int = 5,
39
+ sample_rate: int = 16000,
40
+ endpoint: Optional[str] = "http://localhost:3000",
41
+ ):
42
+ """
43
+ Initialize audio recorder.
44
+
45
+ Args:
46
+ chunk_duration_seconds: Duration of each audio chunk (5-10 seconds recommended)
47
+ sample_rate: Audio sample rate (default 16kHz)
48
+ endpoint: URL of finchvox HTTP server (default: "http://localhost:3000")
49
+ If None, recording will fail when started
50
+ """
51
+ self.chunk_duration = chunk_duration_seconds
52
+ self.sample_rate = sample_rate
53
+ self.endpoint = endpoint
54
+
55
+ # Create AudioBufferProcessor with stereo configuration
56
+ self.audio_buffer = AudioBufferProcessor(
57
+ sample_rate=self.sample_rate, # Explicit sample rate (16000 Hz)
58
+ num_channels=2, # Stereo: user left, bot right
59
+ buffer_size=320000, # ~10 seconds at 16kHz, 16-bit
60
+ enable_turn_audio=False, # Continuous recording, not per-turn
61
+ )
62
+
63
+ # Timing events for latency calculation
64
+ self.timing_events = []
65
+ self.current_trace_id: Optional[str] = None
66
+ self.conversation_start_time: Optional[datetime] = None
67
+ self.chunk_counter = 0
68
+
69
+ self._setup_event_handlers()
70
+
71
+ def _setup_event_handlers(self):
72
+ """Set up audio buffer event handlers for chunked recording."""
73
+
74
+ @self.audio_buffer.event_handler("on_audio_data")
75
+ async def on_audio_data(buffer, audio, sample_rate, num_channels):
76
+ """Handle audio data chunks (called every chunk_duration seconds)."""
77
+ try:
78
+ # Get trace ID from Pipecat's conversation context provider
79
+ trace_id = None
80
+
81
+ # First, try to get trace_id from active conversation span
82
+ context_provider = ConversationContextProvider.get_instance()
83
+ conversation_context = context_provider.get_current_conversation_context()
84
+
85
+ if conversation_context:
86
+ # Extract span context from conversation context
87
+ span = trace.get_current_span(conversation_context)
88
+ span_context = span.get_span_context()
89
+ if span_context.trace_id != 0:
90
+ trace_id = format(span_context.trace_id, "032x")
91
+
92
+ # Fallback to manually set trace_id (for backwards compatibility)
93
+ if not trace_id:
94
+ trace_id = self.current_trace_id or "no_trace"
95
+
96
+ # Prepare metadata
97
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
98
+ metadata = {
99
+ "trace_id": trace_id,
100
+ "chunk_number": self.chunk_counter,
101
+ "timestamp": timestamp,
102
+ "sample_rate": sample_rate,
103
+ "num_channels": num_channels,
104
+ "channels": {
105
+ "0": "user",
106
+ "1": "bot"
107
+ },
108
+ "timing_events": self.timing_events,
109
+ "conversation_start": (
110
+ self.conversation_start_time.isoformat()
111
+ if self.conversation_start_time
112
+ else None
113
+ ),
114
+ }
115
+
116
+ # Upload to endpoint
117
+ upload_success = await self.upload_chunk(
118
+ trace_id=trace_id,
119
+ chunk_number=self.chunk_counter,
120
+ audio_data=audio,
121
+ metadata=metadata
122
+ )
123
+
124
+ if upload_success:
125
+ logger.info(
126
+ f"Uploaded audio chunk {self.chunk_counter} for trace {trace_id[:8]}... "
127
+ f"({len(self.timing_events)} timing events)"
128
+ )
129
+ else:
130
+ logger.error(
131
+ f"Failed to upload chunk {self.chunk_counter} for trace {trace_id[:8]}..."
132
+ )
133
+
134
+ self.chunk_counter += 1
135
+
136
+ # Clear old timing events from previous chunks (keep recent for context)
137
+ if len(self.timing_events) > 100:
138
+ self.timing_events = self.timing_events[-50:]
139
+
140
+ except Exception as e:
141
+ logger.error(f"Failed to process audio chunk: {e}", exc_info=True)
142
+
143
+ async def upload_chunk(
144
+ self,
145
+ trace_id: str,
146
+ chunk_number: int,
147
+ audio_data: bytes,
148
+ metadata: dict
149
+ ) -> bool:
150
+ """
151
+ Upload audio chunk to endpoint via HTTP POST.
152
+
153
+ Args:
154
+ trace_id: OpenTelemetry trace ID
155
+ chunk_number: Sequential chunk number
156
+ audio_data: Raw audio bytes (WAV format)
157
+ metadata: Metadata dictionary
158
+
159
+ Returns:
160
+ True if upload succeeded, False otherwise
161
+ """
162
+ try:
163
+ url = f"{self.endpoint}/collector/audio/{trace_id}/chunk"
164
+
165
+ # Create WAV file in memory
166
+ wav_buffer = io.BytesIO()
167
+ with wave.open(wav_buffer, 'wb') as wav_file:
168
+ wav_file.setnchannels(metadata['num_channels'])
169
+ wav_file.setsampwidth(2) # 16-bit audio
170
+ wav_file.setframerate(metadata['sample_rate'])
171
+ wav_file.writeframes(audio_data)
172
+
173
+ wav_buffer.seek(0)
174
+
175
+ async with aiohttp.ClientSession() as session:
176
+ form = aiohttp.FormData()
177
+ form.add_field(
178
+ 'audio',
179
+ wav_buffer,
180
+ filename=f"chunk_{chunk_number:04d}.wav",
181
+ content_type='audio/wav'
182
+ )
183
+ form.add_field(
184
+ 'metadata',
185
+ json.dumps(metadata),
186
+ content_type='application/json'
187
+ )
188
+
189
+ # Upload with timeout
190
+ async with session.post(
191
+ url,
192
+ data=form,
193
+ timeout=aiohttp.ClientTimeout(total=30)
194
+ ) as response:
195
+ if response.status == 201:
196
+ result = await response.json()
197
+ logger.debug(
198
+ f"Uploaded chunk {chunk_number} for trace {trace_id[:8]}... "
199
+ f"to endpoint: {result.get('file_path')}"
200
+ )
201
+ return True
202
+ else:
203
+ error_text = await response.text()
204
+ logger.error(
205
+ f"Failed to upload chunk {chunk_number}: "
206
+ f"HTTP {response.status}: {error_text}"
207
+ )
208
+ return False
209
+
210
+ except asyncio.TimeoutError:
211
+ logger.error(f"Timeout uploading chunk {chunk_number} to endpoint")
212
+ return False
213
+ except Exception as e:
214
+ logger.error(
215
+ f"Error uploading chunk {chunk_number} to endpoint: {e}",
216
+ exc_info=True
217
+ )
218
+ return False
219
+
220
+
221
+ async def start_recording(self, trace_id: Optional[str] = None):
222
+ """
223
+ Start recording audio for a conversation.
224
+
225
+ Args:
226
+ trace_id: Optional trace ID hint. If not provided or unavailable,
227
+ the recorder will automatically extract trace_id from
228
+ the active conversation span during chunk capture.
229
+
230
+ Raises:
231
+ ValueError: If endpoint is not configured
232
+ """
233
+ if not self.endpoint:
234
+ raise ValueError(
235
+ "Cannot start recording: endpoint is not configured. "
236
+ "Provide an endpoint URL when initializing ConversationAudioRecorder."
237
+ )
238
+
239
+ self.current_trace_id = trace_id
240
+ self.conversation_start_time = datetime.now()
241
+ self.chunk_counter = 0
242
+ self.timing_events = []
243
+
244
+ await self.audio_buffer.start_recording()
245
+ logger.info(f"Started audio recording for trace {trace_id}")
246
+
247
+ async def stop_recording(self):
248
+ """Stop recording audio."""
249
+ await self.audio_buffer.stop_recording()
250
+ logger.info(
251
+ f"Stopped audio recording. Captured {self.chunk_counter} chunks "
252
+ f"with {len(self.timing_events)} timing events"
253
+ )
254
+
255
+ def add_timing_event(self, event_type: str, metadata: dict = None):
256
+ """
257
+ Add a timing event for latency calculation.
258
+
259
+ Args:
260
+ event_type: Type of event (e.g., 'user_stopped', 'bot_started', 'bot_stopped')
261
+ metadata: Additional metadata for the event
262
+ """
263
+ event = {
264
+ "type": event_type,
265
+ "timestamp": datetime.now().isoformat(),
266
+ "relative_time": (
267
+ (datetime.now() - self.conversation_start_time).total_seconds()
268
+ if self.conversation_start_time
269
+ else 0
270
+ ),
271
+ "metadata": metadata or {},
272
+ }
273
+ self.timing_events.append(event)
274
+ logger.debug(f"Timing event: {event_type}")
275
+
276
+ def get_processor(self) -> AudioBufferProcessor:
277
+ """Get the AudioBufferProcessor to add to pipeline."""
278
+ return self.audio_buffer
@@ -0,0 +1,123 @@
1
+ """
2
+ Audio utilities for FinchVox trace viewer.
3
+
4
+ This module provides functions for finding and combining audio chunks
5
+ from voice conversation traces.
6
+ """
7
+
8
+ import wave
9
+ from pathlib import Path
10
+ from typing import List, Tuple
11
+
12
+ from loguru import logger
13
+
14
+
15
+ def find_chunks(audio_dir: Path, trace_id: str) -> List[Tuple[int, Path]]:
16
+ """
17
+ Find all audio chunks for a given trace_id.
18
+
19
+ Args:
20
+ audio_dir: Directory containing audio chunks (can be old or new structure)
21
+ trace_id: Trace ID to search for
22
+
23
+ Returns:
24
+ List of (chunk_number, chunk_path) tuples, sorted by chunk number
25
+ """
26
+ chunks = []
27
+
28
+ # New structure: traces/{trace_id}/audio/chunk_XXXX.wav
29
+ new_structure_dir = audio_dir / trace_id / "audio"
30
+ if new_structure_dir.exists():
31
+ for chunk_file in new_structure_dir.glob("chunk_*.wav"):
32
+ # Extract chunk number from filename: chunk_0001.wav -> 1
33
+ try:
34
+ chunk_num = int(chunk_file.stem.split("_")[1])
35
+ chunks.append((chunk_num, chunk_file))
36
+ except (IndexError, ValueError) as e:
37
+ logger.warning(f"Could not parse chunk number from {chunk_file}: {e}")
38
+
39
+ # Old structure (for backward compatibility): audio/{trace_id}/chunk_XXXX.wav
40
+ old_structure_dir = audio_dir / trace_id
41
+ if old_structure_dir.exists() and old_structure_dir != new_structure_dir.parent:
42
+ for chunk_file in old_structure_dir.glob("chunk_*.wav"):
43
+ # Extract chunk number from filename: chunk_0001.wav -> 1
44
+ try:
45
+ chunk_num = int(chunk_file.stem.split("_")[1])
46
+ chunks.append((chunk_num, chunk_file))
47
+ except (IndexError, ValueError) as e:
48
+ logger.warning(f"Could not parse chunk number from {chunk_file}: {e}")
49
+
50
+ # Also check local fallback format: audio_{trace_id}_..._chunkXXXX.wav
51
+ if audio_dir.exists():
52
+ for chunk_file in audio_dir.glob(f"audio_{trace_id}*_chunk*.wav"):
53
+ try:
54
+ # Extract chunk number from filename
55
+ chunk_part = chunk_file.stem.split("_chunk")[1]
56
+ chunk_num = int(chunk_part)
57
+ chunks.append((chunk_num, chunk_file))
58
+ except (IndexError, ValueError) as e:
59
+ logger.warning(f"Could not parse chunk number from {chunk_file}: {e}")
60
+
61
+ # Sort by chunk number and remove duplicates
62
+ chunks = list(set(chunks))
63
+ chunks.sort(key=lambda x: x[0])
64
+ return chunks
65
+
66
+
67
+ def combine_chunks(chunks: List[Tuple[int, Path]], output_file: Path) -> None:
68
+ """
69
+ Combine audio chunks into a single WAV file.
70
+
71
+ Args:
72
+ chunks: List of (chunk_number, chunk_path) tuples
73
+ output_file: Path to write combined WAV file
74
+ """
75
+ if not chunks:
76
+ logger.error("No chunks to combine")
77
+ return
78
+
79
+ # Get audio parameters from first chunk
80
+ first_chunk = chunks[0][1]
81
+ with wave.open(str(first_chunk), "rb") as wf:
82
+ sample_rate = wf.getframerate()
83
+ num_channels = wf.getnchannels()
84
+ sample_width = wf.getsampwidth()
85
+
86
+ logger.info(
87
+ f"Combining {len(chunks)} chunks: "
88
+ f"{sample_rate}Hz, {num_channels} channels, {sample_width*8}-bit"
89
+ )
90
+
91
+ # Open output file
92
+ with wave.open(str(output_file), "wb") as out_wf:
93
+ out_wf.setnchannels(num_channels)
94
+ out_wf.setsampwidth(sample_width)
95
+ out_wf.setframerate(sample_rate)
96
+
97
+ # Append each chunk
98
+ total_frames = 0
99
+ for chunk_num, chunk_path in chunks:
100
+ logger.debug(f"Adding chunk {chunk_num}: {chunk_path.name}")
101
+
102
+ with wave.open(str(chunk_path), "rb") as in_wf:
103
+ # Verify parameters match
104
+ if (
105
+ in_wf.getframerate() != sample_rate
106
+ or in_wf.getnchannels() != num_channels
107
+ or in_wf.getsampwidth() != sample_width
108
+ ):
109
+ logger.warning(
110
+ f"Chunk {chunk_num} has different audio parameters, skipping"
111
+ )
112
+ continue
113
+
114
+ # Read and write all frames
115
+ frames = in_wf.readframes(in_wf.getnframes())
116
+ out_wf.writeframes(frames)
117
+ total_frames += in_wf.getnframes()
118
+
119
+ duration_seconds = total_frames / sample_rate
120
+ logger.info(
121
+ f"Combined {len(chunks)} chunks into {output_file.name} "
122
+ f"({duration_seconds:.1f} seconds)"
123
+ )
finchvox/cli.py ADDED
@@ -0,0 +1,127 @@
1
+ """
2
+ FinchVox CLI - Command-line interface for the finchvox package.
3
+
4
+ Provides subcommands:
5
+ - finchvox start: Start the unified server
6
+ - finchvox version: Display version information
7
+ """
8
+
9
+ import argparse
10
+ import sys
11
+ from pathlib import Path
12
+ from finchvox.server import UnifiedServer
13
+ from finchvox.collector.config import GRPC_PORT
14
+
15
+
16
+ def get_version() -> str:
17
+ """Get the package version."""
18
+ # Read version from pyproject.toml or package metadata
19
+ try:
20
+ from importlib.metadata import version
21
+ return version("finchvox")
22
+ except Exception:
23
+ return "0.0.1" # Fallback version
24
+
25
+
26
+ def cmd_version(args):
27
+ """Handle the 'version' subcommand."""
28
+ print(f"finchvox version {get_version()}")
29
+ print(f"Python {sys.version}")
30
+
31
+
32
+ def cmd_start(args):
33
+ """Handle the 'start' subcommand."""
34
+ # Resolve data directory
35
+ if args.data_dir:
36
+ data_dir = Path(args.data_dir).expanduser().resolve()
37
+ else:
38
+ data_dir = Path.home() / ".finchvox"
39
+
40
+ print("Starting FinchVox Unified Server...")
41
+ print("=" * 50)
42
+ print(f"HTTP Server: http://{args.host}:{args.port}")
43
+ print(f" - UI: http://{args.host}:{args.port}")
44
+ print(f" - Collector: http://{args.host}:{args.port}/collector")
45
+ print(f"gRPC Server: {args.host}:{args.grpc_port}")
46
+ print(f"Data Directory: {data_dir}")
47
+ print("=" * 50)
48
+
49
+ server = UnifiedServer(
50
+ port=args.port,
51
+ grpc_port=args.grpc_port,
52
+ host=args.host,
53
+ data_dir=data_dir
54
+ )
55
+ server.run()
56
+
57
+
58
+ def main():
59
+ """Main CLI entry point."""
60
+ parser = argparse.ArgumentParser(
61
+ prog="finchvox",
62
+ description="FinchVox - Voice AI observability dev tool for Pipecat",
63
+ formatter_class=argparse.RawDescriptionHelpFormatter,
64
+ )
65
+
66
+ subparsers = parser.add_subparsers(
67
+ title="commands",
68
+ description="Available commands",
69
+ dest="command",
70
+ required=True
71
+ )
72
+
73
+ # 'start' subcommand
74
+ start_parser = subparsers.add_parser(
75
+ "start",
76
+ help="Start the unified server",
77
+ description="Start the FinchVox unified server (gRPC + HTTP)",
78
+ formatter_class=argparse.RawDescriptionHelpFormatter,
79
+ epilog="""
80
+ Examples:
81
+ finchvox start # Start with defaults
82
+ finchvox start --port 8000 # Custom HTTP port
83
+ finchvox start --grpc-port 4318 # Custom gRPC port
84
+ finchvox start --data-dir ./my-data # Custom data directory
85
+ """
86
+ )
87
+ start_parser.add_argument(
88
+ "--port",
89
+ type=int,
90
+ default=3000,
91
+ help="HTTP server port (default: 3000)"
92
+ )
93
+ start_parser.add_argument(
94
+ "--grpc-port",
95
+ type=int,
96
+ default=GRPC_PORT,
97
+ help=f"gRPC server port (default: {GRPC_PORT})"
98
+ )
99
+ start_parser.add_argument(
100
+ "--host",
101
+ type=str,
102
+ default="0.0.0.0",
103
+ help="Host to bind to (default: 0.0.0.0)"
104
+ )
105
+ start_parser.add_argument(
106
+ "--data-dir",
107
+ type=str,
108
+ default=None,
109
+ help="Data directory for traces/logs/audio/exceptions (default: ~/.finchvox)"
110
+ )
111
+ start_parser.set_defaults(func=cmd_start)
112
+
113
+ # 'version' subcommand
114
+ version_parser = subparsers.add_parser(
115
+ "version",
116
+ help="Display version information",
117
+ description="Display FinchVox version and Python version"
118
+ )
119
+ version_parser.set_defaults(func=cmd_version)
120
+
121
+ # Parse arguments and dispatch to handler
122
+ args = parser.parse_args()
123
+ args.func(args)
124
+
125
+
126
+ if __name__ == "__main__":
127
+ main()
File without changes
@@ -0,0 +1,22 @@
1
+ import sys
2
+ from loguru import logger
3
+ from .server import run_server
4
+ from .config import LOG_LEVEL
5
+
6
+
7
+ def main():
8
+ """Main entry point for the OTLP collector."""
9
+ # Configure loguru
10
+ logger.remove() # Remove default handler
11
+ logger.add(
12
+ sink=sys.stderr,
13
+ level=LOG_LEVEL,
14
+ format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan> - <level>{message}</level>"
15
+ )
16
+
17
+ logger.info("Starting FinchVox OTLP Collector")
18
+ run_server()
19
+
20
+
21
+ if __name__ == "__main__":
22
+ main()