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 +0 -0
- finchvox/__main__.py +81 -0
- finchvox/audio_recorder.py +278 -0
- finchvox/audio_utils.py +123 -0
- finchvox/cli.py +127 -0
- finchvox/collector/__init__.py +0 -0
- finchvox/collector/__main__.py +22 -0
- finchvox/collector/audio_handler.py +146 -0
- finchvox/collector/collector_routes.py +186 -0
- finchvox/collector/config.py +64 -0
- finchvox/collector/server.py +126 -0
- finchvox/collector/service.py +43 -0
- finchvox/collector/writer.py +86 -0
- finchvox/server.py +201 -0
- finchvox/trace.py +115 -0
- finchvox/ui/css/app.css +774 -0
- finchvox/ui/images/favicon.ico +0 -0
- finchvox/ui/images/finchvox-logo.png +0 -0
- finchvox/ui/js/time-utils.js +97 -0
- finchvox/ui/js/trace_detail.js +1228 -0
- finchvox/ui/js/traces_list.js +26 -0
- finchvox/ui/lib/alpine.min.js +5 -0
- finchvox/ui/lib/wavesurfer.min.js +1 -0
- finchvox/ui/trace_detail.html +313 -0
- finchvox/ui/traces_list.html +63 -0
- finchvox/ui_routes.py +362 -0
- finchvox-0.0.1.dist-info/METADATA +189 -0
- finchvox-0.0.1.dist-info/RECORD +31 -0
- finchvox-0.0.1.dist-info/WHEEL +4 -0
- finchvox-0.0.1.dist-info/entry_points.txt +2 -0
- finchvox-0.0.1.dist-info/licenses/LICENSE +24 -0
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
|
finchvox/audio_utils.py
ADDED
|
@@ -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()
|