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
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en" data-theme="dark">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Traces - Finchvox</title>
|
|
7
|
+
<link rel="stylesheet" href="/css/app.css">
|
|
8
|
+
<script src="/js/time-utils.js"></script>
|
|
9
|
+
<script defer src="/lib/alpine.min.js"></script>
|
|
10
|
+
</head>
|
|
11
|
+
<body x-data="tracesListApp()" x-init="init()">
|
|
12
|
+
<div class="page-header">
|
|
13
|
+
<img src="/images/finchvox-logo.png" alt="FinchVox Logo" class="logo">
|
|
14
|
+
<span class="secondary-text">Traces</span> <span>/</span> <span class="trace-count" x-text="`${traces.length} traces`"></span>
|
|
15
|
+
</div>
|
|
16
|
+
<main class="traces-list-view">
|
|
17
|
+
<div x-show="traces.length === 0" class="error" style="margin:100px 2rem 1rem 2rem;">
|
|
18
|
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="error-icon">
|
|
19
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
|
|
20
|
+
</svg>
|
|
21
|
+
<span>
|
|
22
|
+
Is this thing on? No traces have been found in the data directory. See the <a href="https://github.com/itsderek23/finchvox/README.md#troubleshooting" target="_blank">troubleshooting guide</a>.
|
|
23
|
+
<div x-show="dataDir && dataDir.length > 0" class="secondary-text" style="display:block; margin-top:0.5em;">
|
|
24
|
+
Data directory: <span class="mono" x-text="dataDir"></span>
|
|
25
|
+
</div>
|
|
26
|
+
</span>
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
<table class="traces-table" x-show="traces.length > 0">
|
|
30
|
+
<thead>
|
|
31
|
+
<tr>
|
|
32
|
+
<th>Service Name</th>
|
|
33
|
+
<th>Trace ID</th>
|
|
34
|
+
<th>Start Time</th>
|
|
35
|
+
<th>End Time</th>
|
|
36
|
+
<th>Duration</th>
|
|
37
|
+
<th>Spans</th>
|
|
38
|
+
<th></th>
|
|
39
|
+
</tr>
|
|
40
|
+
</thead>
|
|
41
|
+
<tbody>
|
|
42
|
+
<template x-for="trace in traces" :key="trace.trace_id">
|
|
43
|
+
<tr class="trace-row">
|
|
44
|
+
<td x-text="trace.service_name || '-'"></td>
|
|
45
|
+
<td class="trace-id">
|
|
46
|
+
<a class="mono" :href="`/traces/${trace.trace_id}`" x-text="trace.trace_id"></a>
|
|
47
|
+
</td>
|
|
48
|
+
<td x-text="formatDate(trace.start_time)"></td>
|
|
49
|
+
<td x-text="formatDate(trace.end_time)"></td>
|
|
50
|
+
<td x-text="formatDuration(trace.duration_ms)"></td>
|
|
51
|
+
<td x-text="trace.span_count"></td>
|
|
52
|
+
<td>
|
|
53
|
+
|
|
54
|
+
</td>
|
|
55
|
+
</tr>
|
|
56
|
+
</template>
|
|
57
|
+
</tbody>
|
|
58
|
+
</table>
|
|
59
|
+
</main>
|
|
60
|
+
|
|
61
|
+
<script src="/js/traces_list.js"></script>
|
|
62
|
+
</body>
|
|
63
|
+
</html>
|
finchvox/ui_routes.py
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""
|
|
2
|
+
UI routes for FinchVox Trace Viewer.
|
|
3
|
+
|
|
4
|
+
Serves the web UI and provides REST APIs for trace data.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from fastapi import FastAPI, HTTPException, BackgroundTasks
|
|
12
|
+
from fastapi.responses import FileResponse, JSONResponse
|
|
13
|
+
from fastapi.staticfiles import StaticFiles
|
|
14
|
+
from loguru import logger
|
|
15
|
+
|
|
16
|
+
from finchvox.audio_utils import find_chunks, combine_chunks
|
|
17
|
+
from finchvox.trace import Trace
|
|
18
|
+
from finchvox.collector.config import (
|
|
19
|
+
get_traces_base_dir,
|
|
20
|
+
get_trace_dir,
|
|
21
|
+
get_trace_logs_dir,
|
|
22
|
+
get_trace_audio_dir,
|
|
23
|
+
get_trace_exceptions_dir,
|
|
24
|
+
get_default_data_dir
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# UI directory - check package location first (when installed via pip/uv),
|
|
29
|
+
# then fall back to development location
|
|
30
|
+
UI_DIR = Path(__file__).parent / "ui"
|
|
31
|
+
if not UI_DIR.exists():
|
|
32
|
+
# Fall back to development location (ui/ at project root)
|
|
33
|
+
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
|
34
|
+
UI_DIR = PROJECT_ROOT / "ui"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def register_ui_routes(app: FastAPI, data_dir: Path = None):
|
|
38
|
+
"""
|
|
39
|
+
Register UI routes and static file serving on an existing FastAPI app.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
app: Existing FastAPI application to register routes on
|
|
43
|
+
data_dir: Base data directory (default: ~/.finchvox)
|
|
44
|
+
"""
|
|
45
|
+
if data_dir is None:
|
|
46
|
+
data_dir = get_default_data_dir()
|
|
47
|
+
|
|
48
|
+
traces_base_dir = get_traces_base_dir(data_dir)
|
|
49
|
+
# Mount static files FIRST (must be before route handlers)
|
|
50
|
+
app.mount("/css", StaticFiles(directory=str(UI_DIR / "css")), name="css")
|
|
51
|
+
app.mount("/js", StaticFiles(directory=str(UI_DIR / "js")), name="js")
|
|
52
|
+
app.mount("/lib", StaticFiles(directory=str(UI_DIR / "lib")), name="lib")
|
|
53
|
+
app.mount("/images", StaticFiles(directory=str(UI_DIR / "images")), name="images")
|
|
54
|
+
|
|
55
|
+
@app.get("/favicon.ico")
|
|
56
|
+
async def favicon():
|
|
57
|
+
"""Serve the favicon."""
|
|
58
|
+
return FileResponse(str(UI_DIR / "images" / "favicon.ico"))
|
|
59
|
+
|
|
60
|
+
@app.get("/")
|
|
61
|
+
async def index():
|
|
62
|
+
"""Serve the traces list page."""
|
|
63
|
+
return FileResponse(str(UI_DIR / "traces_list.html"))
|
|
64
|
+
|
|
65
|
+
@app.get("/traces/{trace_id}")
|
|
66
|
+
async def trace_detail_page(trace_id: str):
|
|
67
|
+
"""Serve the trace detail page."""
|
|
68
|
+
return FileResponse(str(UI_DIR / "trace_detail.html"))
|
|
69
|
+
|
|
70
|
+
@app.get("/api/traces")
|
|
71
|
+
async def list_traces() -> JSONResponse:
|
|
72
|
+
"""
|
|
73
|
+
List all available traces.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
List of trace metadata including trace_id, service_name, span_count,
|
|
77
|
+
start_time, end_time, and duration_ms
|
|
78
|
+
"""
|
|
79
|
+
traces = []
|
|
80
|
+
|
|
81
|
+
if not traces_base_dir.exists():
|
|
82
|
+
return JSONResponse({"traces": [], "data_dir": str(traces_base_dir)})
|
|
83
|
+
|
|
84
|
+
# Scan trace directories (each directory is a trace)
|
|
85
|
+
for trace_dir in traces_base_dir.iterdir():
|
|
86
|
+
if not trace_dir.is_dir():
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
trace_id = trace_dir.name
|
|
90
|
+
trace_file = trace_dir / f"trace_{trace_id}.jsonl"
|
|
91
|
+
|
|
92
|
+
if not trace_file.exists():
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
# Use Trace class to load metadata
|
|
97
|
+
trace = Trace(trace_file)
|
|
98
|
+
traces.append(trace.to_dict())
|
|
99
|
+
except Exception as e:
|
|
100
|
+
print(f"Error reading trace file {trace_file}: {e}")
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
# Sort by start_time descending (most recent first)
|
|
104
|
+
traces.sort(key=lambda t: t.get("start_time") or 0, reverse=True)
|
|
105
|
+
|
|
106
|
+
return JSONResponse({"traces": traces, "data_dir": str(traces_base_dir)})
|
|
107
|
+
|
|
108
|
+
@app.get("/api/trace/{trace_id}")
|
|
109
|
+
async def get_trace(trace_id: str) -> JSONResponse:
|
|
110
|
+
"""
|
|
111
|
+
Get all spans for a specific trace.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
trace_id: The trace ID
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
JSON with spans array
|
|
118
|
+
"""
|
|
119
|
+
trace_dir = get_trace_dir(data_dir, trace_id)
|
|
120
|
+
trace_file = trace_dir / f"trace_{trace_id}.jsonl"
|
|
121
|
+
|
|
122
|
+
if not trace_file.exists():
|
|
123
|
+
raise HTTPException(status_code=404, detail=f"Trace {trace_id} not found")
|
|
124
|
+
|
|
125
|
+
spans = []
|
|
126
|
+
last_span_time = None
|
|
127
|
+
try:
|
|
128
|
+
with open(trace_file, 'r') as f:
|
|
129
|
+
for line in f:
|
|
130
|
+
if line.strip():
|
|
131
|
+
span = json.loads(line)
|
|
132
|
+
spans.append(span)
|
|
133
|
+
# Track the last span's end time for abandonment detection
|
|
134
|
+
if "end_time_unix_nano" in span:
|
|
135
|
+
last_span_time = span["end_time_unix_nano"]
|
|
136
|
+
except Exception as e:
|
|
137
|
+
raise HTTPException(status_code=500, detail=f"Error reading trace: {str(e)}")
|
|
138
|
+
|
|
139
|
+
return JSONResponse({
|
|
140
|
+
"spans": spans,
|
|
141
|
+
"last_span_time": last_span_time
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
@app.get("/api/trace/{trace_id}/raw")
|
|
145
|
+
async def get_trace_raw(trace_id: str) -> JSONResponse:
|
|
146
|
+
"""
|
|
147
|
+
Get raw trace data as a formatted JSON array.
|
|
148
|
+
|
|
149
|
+
Reads the JSONL file and returns all spans as a single JSON array
|
|
150
|
+
with indentation for easy reading in browser.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
trace_id: The trace ID
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
JSON array of all spans with formatting
|
|
157
|
+
"""
|
|
158
|
+
trace_dir = get_trace_dir(data_dir, trace_id)
|
|
159
|
+
trace_file = trace_dir / f"trace_{trace_id}.jsonl"
|
|
160
|
+
|
|
161
|
+
if not trace_file.exists():
|
|
162
|
+
raise HTTPException(status_code=404, detail=f"Trace {trace_id} not found")
|
|
163
|
+
|
|
164
|
+
spans = []
|
|
165
|
+
try:
|
|
166
|
+
with open(trace_file, 'r') as f:
|
|
167
|
+
for line in f:
|
|
168
|
+
if line.strip():
|
|
169
|
+
span = json.loads(line)
|
|
170
|
+
spans.append(span)
|
|
171
|
+
except Exception as e:
|
|
172
|
+
raise HTTPException(status_code=500, detail=f"Error reading trace: {str(e)}")
|
|
173
|
+
|
|
174
|
+
# Return as formatted JSON with indentation
|
|
175
|
+
return JSONResponse(
|
|
176
|
+
content=spans,
|
|
177
|
+
media_type="application/json",
|
|
178
|
+
headers={
|
|
179
|
+
"Content-Type": "application/json; charset=utf-8"
|
|
180
|
+
}
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
@app.get("/api/logs/{trace_id}")
|
|
184
|
+
async def get_logs(trace_id: str) -> JSONResponse:
|
|
185
|
+
"""
|
|
186
|
+
Get all logs for a specific trace.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
trace_id: The trace ID
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
JSON with logs array
|
|
193
|
+
"""
|
|
194
|
+
logs_dir = get_trace_logs_dir(data_dir, trace_id)
|
|
195
|
+
log_file = logs_dir / f"log_{trace_id}.jsonl"
|
|
196
|
+
|
|
197
|
+
if not log_file.exists():
|
|
198
|
+
return JSONResponse({"logs": []})
|
|
199
|
+
|
|
200
|
+
logs = []
|
|
201
|
+
try:
|
|
202
|
+
with open(log_file, 'r') as f:
|
|
203
|
+
for line in f:
|
|
204
|
+
if line.strip():
|
|
205
|
+
log = json.loads(line)
|
|
206
|
+
logs.append(log)
|
|
207
|
+
except Exception as e:
|
|
208
|
+
raise HTTPException(status_code=500, detail=f"Error reading logs: {str(e)}")
|
|
209
|
+
|
|
210
|
+
return JSONResponse({"logs": logs})
|
|
211
|
+
|
|
212
|
+
@app.get("/api/exceptions/{trace_id}")
|
|
213
|
+
async def get_exceptions(trace_id: str) -> JSONResponse:
|
|
214
|
+
"""
|
|
215
|
+
Get all exceptions for a specific trace.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
trace_id: The trace ID
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
JSON with exceptions array
|
|
222
|
+
"""
|
|
223
|
+
exceptions_dir = get_trace_exceptions_dir(data_dir, trace_id)
|
|
224
|
+
exceptions_file = exceptions_dir / f"exceptions_{trace_id}.jsonl"
|
|
225
|
+
|
|
226
|
+
if not exceptions_file.exists():
|
|
227
|
+
return JSONResponse({"exceptions": []})
|
|
228
|
+
|
|
229
|
+
exceptions = []
|
|
230
|
+
try:
|
|
231
|
+
with open(exceptions_file, 'r') as f:
|
|
232
|
+
for line in f:
|
|
233
|
+
if line.strip():
|
|
234
|
+
exception = json.loads(line)
|
|
235
|
+
exceptions.append(exception)
|
|
236
|
+
except Exception as e:
|
|
237
|
+
raise HTTPException(status_code=500, detail=f"Error reading exceptions: {str(e)}")
|
|
238
|
+
|
|
239
|
+
return JSONResponse({"exceptions": exceptions})
|
|
240
|
+
|
|
241
|
+
@app.get("/api/audio/{trace_id}")
|
|
242
|
+
async def get_audio(trace_id: str, background_tasks: BackgroundTasks):
|
|
243
|
+
"""
|
|
244
|
+
Get combined audio for a specific trace.
|
|
245
|
+
|
|
246
|
+
Combines all audio chunks into a single WAV file on-demand.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
trace_id: The trace ID
|
|
250
|
+
background_tasks: FastAPI background tasks for cleanup
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
Combined WAV file with all audio chunks
|
|
254
|
+
"""
|
|
255
|
+
audio_dir = get_trace_audio_dir(data_dir, trace_id)
|
|
256
|
+
|
|
257
|
+
if not audio_dir.exists():
|
|
258
|
+
raise HTTPException(status_code=404, detail=f"Audio for trace {trace_id} not found")
|
|
259
|
+
|
|
260
|
+
# Find all chunks for this trace
|
|
261
|
+
logger.info(f"Finding audio chunks for trace {trace_id}")
|
|
262
|
+
# Pass the parent of audio_dir (trace dir) since find_chunks expects base and appends trace_id
|
|
263
|
+
chunks = find_chunks(get_traces_base_dir(data_dir), trace_id)
|
|
264
|
+
|
|
265
|
+
if not chunks:
|
|
266
|
+
raise HTTPException(status_code=404, detail=f"No audio chunks found for trace {trace_id}")
|
|
267
|
+
|
|
268
|
+
logger.info(f"Found {len(chunks)} chunks for trace {trace_id}")
|
|
269
|
+
|
|
270
|
+
# Generate combined WAV in temp file
|
|
271
|
+
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp:
|
|
272
|
+
tmp_path = Path(tmp.name)
|
|
273
|
+
|
|
274
|
+
combine_chunks(chunks, tmp_path)
|
|
275
|
+
|
|
276
|
+
# Schedule cleanup after response sent
|
|
277
|
+
background_tasks.add_task(tmp_path.unlink)
|
|
278
|
+
|
|
279
|
+
return FileResponse(
|
|
280
|
+
str(tmp_path),
|
|
281
|
+
media_type="audio/wav",
|
|
282
|
+
headers={
|
|
283
|
+
"Content-Disposition": f"inline; filename=trace_{trace_id}.wav"
|
|
284
|
+
}
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
@app.get("/api/audio/{trace_id}/download")
|
|
288
|
+
async def download_audio(trace_id: str, background_tasks: BackgroundTasks):
|
|
289
|
+
"""
|
|
290
|
+
Download combined audio for a specific trace.
|
|
291
|
+
|
|
292
|
+
Same as get_audio but with Content-Disposition: attachment to trigger download.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
trace_id: The trace ID
|
|
296
|
+
background_tasks: FastAPI background tasks for cleanup
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
Combined WAV file with all audio chunks (as download)
|
|
300
|
+
"""
|
|
301
|
+
audio_dir = get_trace_audio_dir(data_dir, trace_id)
|
|
302
|
+
|
|
303
|
+
if not audio_dir.exists():
|
|
304
|
+
raise HTTPException(status_code=404, detail=f"Audio for trace {trace_id} not found")
|
|
305
|
+
|
|
306
|
+
# Find all chunks for this trace
|
|
307
|
+
logger.info(f"Finding audio chunks for trace {trace_id}")
|
|
308
|
+
chunks = find_chunks(get_traces_base_dir(data_dir), trace_id)
|
|
309
|
+
|
|
310
|
+
if not chunks:
|
|
311
|
+
raise HTTPException(status_code=404, detail=f"No audio chunks found for trace {trace_id}")
|
|
312
|
+
|
|
313
|
+
logger.info(f"Found {len(chunks)} chunks for trace {trace_id}")
|
|
314
|
+
|
|
315
|
+
# Generate combined WAV in temp file
|
|
316
|
+
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp:
|
|
317
|
+
tmp_path = Path(tmp.name)
|
|
318
|
+
|
|
319
|
+
combine_chunks(chunks, tmp_path)
|
|
320
|
+
|
|
321
|
+
# Schedule cleanup after response sent
|
|
322
|
+
background_tasks.add_task(tmp_path.unlink)
|
|
323
|
+
|
|
324
|
+
return FileResponse(
|
|
325
|
+
str(tmp_path),
|
|
326
|
+
media_type="audio/wav",
|
|
327
|
+
headers={
|
|
328
|
+
"Content-Disposition": f"attachment; filename=trace_{trace_id}.wav"
|
|
329
|
+
}
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
@app.get("/api/audio/{trace_id}/status")
|
|
333
|
+
async def get_audio_status(trace_id: str) -> JSONResponse:
|
|
334
|
+
"""
|
|
335
|
+
Get audio metadata without combining chunks.
|
|
336
|
+
|
|
337
|
+
Returns chunk count and last modification time for detecting
|
|
338
|
+
when new audio has been added to a trace.
|
|
339
|
+
|
|
340
|
+
Args:
|
|
341
|
+
trace_id: The trace ID
|
|
342
|
+
|
|
343
|
+
Returns:
|
|
344
|
+
JSON with chunk_count and last_modified timestamp
|
|
345
|
+
"""
|
|
346
|
+
audio_dir = get_trace_audio_dir(data_dir, trace_id)
|
|
347
|
+
|
|
348
|
+
if not audio_dir.exists():
|
|
349
|
+
return JSONResponse({"chunk_count": 0, "last_modified": None})
|
|
350
|
+
|
|
351
|
+
# Find all chunks for this trace
|
|
352
|
+
chunks = find_chunks(get_traces_base_dir(data_dir), trace_id)
|
|
353
|
+
|
|
354
|
+
last_modified = None
|
|
355
|
+
if chunks:
|
|
356
|
+
# Get most recent modification time
|
|
357
|
+
last_modified = max(Path(c).stat().st_mtime for c in chunks)
|
|
358
|
+
|
|
359
|
+
return JSONResponse({
|
|
360
|
+
"chunk_count": len(chunks),
|
|
361
|
+
"last_modified": last_modified
|
|
362
|
+
})
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: finchvox
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Voice AI observability dev tool for Pipecat
|
|
5
|
+
License: Finchvox Source Available License
|
|
6
|
+
|
|
7
|
+
Copyright (c) 2026 Finchvox
|
|
8
|
+
|
|
9
|
+
Permission is granted, free of charge, to any person obtaining a copy of this
|
|
10
|
+
software and associated documentation files (the "Software"), to use, copy,
|
|
11
|
+
modify, and distribute the Software for internal or private use.
|
|
12
|
+
|
|
13
|
+
You may:
|
|
14
|
+
- Run the Software locally or within your organization
|
|
15
|
+
- Modify the Software for your own use
|
|
16
|
+
- Deploy the Software within your own infrastructure
|
|
17
|
+
- Deploy the Software on behalf of a customer, provided the customer operates
|
|
18
|
+
the Software themselves
|
|
19
|
+
|
|
20
|
+
You may NOT:
|
|
21
|
+
- Offer the Software as a hosted service, managed service, or SaaS
|
|
22
|
+
- Provide access to the Software to third parties as a service
|
|
23
|
+
- Resell, rebrand, or commercially exploit the Software as a service
|
|
24
|
+
|
|
25
|
+
This license does not grant you the right to use the Finchvox name, logo, or
|
|
26
|
+
trademarks.
|
|
27
|
+
|
|
28
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Requires-Python: >=3.10
|
|
31
|
+
Requires-Dist: aiofiles>=24.1.0
|
|
32
|
+
Requires-Dist: aiohttp>=3.9.0
|
|
33
|
+
Requires-Dist: fastapi>=0.115.0
|
|
34
|
+
Requires-Dist: grpcio>=1.60.0
|
|
35
|
+
Requires-Dist: loguru>=0.7.0
|
|
36
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-grpc>=1.27.0
|
|
37
|
+
Requires-Dist: opentelemetry-proto>=1.27.0
|
|
38
|
+
Requires-Dist: protobuf<6.0.0,>=4.25.0
|
|
39
|
+
Requires-Dist: python-multipart>=0.0.9
|
|
40
|
+
Requires-Dist: ruff>=0.14.10
|
|
41
|
+
Requires-Dist: uvicorn[standard]>=0.32.0
|
|
42
|
+
Provides-Extra: dev
|
|
43
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
|
|
44
|
+
Requires-Dist: pytest>=7.0.0; extra == 'dev'
|
|
45
|
+
Description-Content-Type: text/markdown
|
|
46
|
+
|
|
47
|
+
# <img src="ui/images/finchvox-logo.png" height=24 /> Finchvox - elevated debuggability for Voice AI apps
|
|
48
|
+
|
|
49
|
+
Do your eyes bleed like a Vecna victim watching Pipecat logs fly by? Do OpenTelemetry traces look impressive … yet explain nothing? If so, meet Finchvox, a local debuggability tool purpose-built for Voice AI apps.
|
|
50
|
+
|
|
51
|
+
Finchvox unifies conversation audio and traces in a single UI, highlighting voice-specific problems like interruptions and high user <-> bot latency. Good luck convincing DataDog to add that!
|
|
52
|
+
|
|
53
|
+
<a href="./docs/demo.gif"><img src="./docs/screenshot.png" /></a>
|
|
54
|
+
|
|
55
|
+
## Table of Contents
|
|
56
|
+
|
|
57
|
+
- [Prerequisites](#prerequisites)
|
|
58
|
+
- [Installation](#installation)
|
|
59
|
+
- [Usage](#usage---finchvox-server)
|
|
60
|
+
- [Setup](#setup)
|
|
61
|
+
- [✨ AI Install](#-let-your-ai-coding-agent-do-the-work)
|
|
62
|
+
- [Step 1 – Enable Tracing in Your Pipecat Application](#step-1---enable-tracing-in-your-pipecat-application)
|
|
63
|
+
- [Step 2 – Enable Audio Recording](#step-2---enable-audio-recording)
|
|
64
|
+
- [Troubleshooting](#troubleshooting)
|
|
65
|
+
|
|
66
|
+
## Prerequisites
|
|
67
|
+
|
|
68
|
+
- Python 3.10 or higher
|
|
69
|
+
- A Pipecat Voice AI application
|
|
70
|
+
|
|
71
|
+
## Installation
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
# uv
|
|
75
|
+
uv add finchvox "pipecat-ai[tracing]"
|
|
76
|
+
|
|
77
|
+
# Or with pip
|
|
78
|
+
pip install finchvox "pipecat-ai[tracing]"
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Usage - Finchvox server
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
uv run finchvox start
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
For the list of available options, run:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
uv run finchvox --help
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Setup
|
|
94
|
+
|
|
95
|
+
### ✨ Let your AI coding agent do the work!
|
|
96
|
+
|
|
97
|
+
Try this starter prompt:
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
Follow the "Setup" instructions at https://github.com/itsderek23/finchvox/blob/main/README.md to setup tracing and audio recording for finchvox.
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Step 1 - Enable Tracing in Your Pipecat Application
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
import os
|
|
107
|
+
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
|
|
108
|
+
from pipecat.utils.tracing.setup import setup_tracing
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
# Step 1: Initialize OpenTelemetry with your chosen exporter
|
|
113
|
+
exporter = OTLPSpanExporter(
|
|
114
|
+
endpoint="http://localhost:4317",
|
|
115
|
+
insecure=True,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
setup_tracing(
|
|
119
|
+
service_name="my-voice-app",
|
|
120
|
+
exporter=exporter,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Step 2: Enable tracing in your PipelineTask
|
|
124
|
+
task = PipelineTask(
|
|
125
|
+
pipeline,
|
|
126
|
+
params=PipelineParams(
|
|
127
|
+
enable_metrics=True
|
|
128
|
+
),
|
|
129
|
+
enable_tracing=True,
|
|
130
|
+
enable_turn_tracking=True
|
|
131
|
+
)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
For the full list of OpenTelemetry setup options, see the [Pipecat OpenTelemetry docs](https://docs.pipecat.ai/server/utilities/opentelemetry#overview).
|
|
135
|
+
|
|
136
|
+
### Step 2 - Enable Audio Recording
|
|
137
|
+
|
|
138
|
+
Import the audio recorder and add it to your pipeline:
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
from finchvox.audio_recorder import ConversationAudioRecorder
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
audio_recorder = ConversationAudioRecorder()
|
|
146
|
+
|
|
147
|
+
pipeline = Pipeline(
|
|
148
|
+
[
|
|
149
|
+
# Other processors, like STT, LLM, TTS, etc.
|
|
150
|
+
audio_recorder.get_processor(),
|
|
151
|
+
# context_aggregator.assistant(),
|
|
152
|
+
]
|
|
153
|
+
)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Start and stop recording on client connect/disconnect events:
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
@transport.event_handler("on_client_connected")
|
|
160
|
+
async def on_client_connected(transport, client):
|
|
161
|
+
await audio_recorder.start_recording()
|
|
162
|
+
|
|
163
|
+
# Other initialization logic...
|
|
164
|
+
|
|
165
|
+
@transport.event_handler("on_client_disconnected")
|
|
166
|
+
async def on_client_disconnected(transport, client):
|
|
167
|
+
await audio_recorder.stop_recording()
|
|
168
|
+
|
|
169
|
+
# Other cleanup logic...
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Troubleshooting
|
|
173
|
+
|
|
174
|
+
### Port already in use
|
|
175
|
+
|
|
176
|
+
If port 4317 is already occupied:
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
# Find process using port
|
|
180
|
+
lsof -i :4317
|
|
181
|
+
|
|
182
|
+
# Kill the process
|
|
183
|
+
kill -9 <PID>
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### No spans being written
|
|
187
|
+
|
|
188
|
+
1. Check collector is running: Look for "OTLP collector listening on port 4317" log message
|
|
189
|
+
2. Verify client endpoint: Ensure Pipecat is configured to send to `http://localhost:4317`
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
finchvox/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
finchvox/__main__.py,sha256=qKFAbLd3VP4KXyzdF-ENX6Lw4DO3ntywSwKCFbViED0,2376
|
|
3
|
+
finchvox/audio_recorder.py,sha256=QyKj25ooKX2N1oF-mL1BGE2qVldyodeXVpo__S4pCCg,10427
|
|
4
|
+
finchvox/audio_utils.py,sha256=k9L4enss9Ckq-rPgrkah0CMpwpOM0Ty_KAhICSoABOs,4500
|
|
5
|
+
finchvox/cli.py,sha256=ScWNcPb3bfX-jA_CfNbxTdHYL3MKxZHDTYwwWHdfAJo,3583
|
|
6
|
+
finchvox/server.py,sha256=BP7zROwvAvdoP8177_jAl0GCCknuCd54AMaJon-3QDs,6794
|
|
7
|
+
finchvox/trace.py,sha256=7bjaQDQvoFWgrmCxLhn1kBe7TIl26Tg7rcXxLi0XCAE,4048
|
|
8
|
+
finchvox/ui_routes.py,sha256=Ibxfu45mKw9GKmBMDXJdPCZvdfXtXMEBcW6g42Y-syM,12090
|
|
9
|
+
finchvox/collector/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
finchvox/collector/__main__.py,sha256=aZE5_CkO9G3U1TeKJWec9Cpx2R3HNfmu90zM-6MePPQ,575
|
|
11
|
+
finchvox/collector/audio_handler.py,sha256=VfZnwsO4aGIN7TTHow4kE37KS_5CV7HtVDalM1RnfJk,4395
|
|
12
|
+
finchvox/collector/collector_routes.py,sha256=ysh8PNYH5gsXGRcMcLozGdyjZP5mhYgz-gModFOUP5w,6671
|
|
13
|
+
finchvox/collector/config.py,sha256=9yj2PQsI8DPSzpKphSqpLrBcInxkouVw4w37XTAFDNg,1896
|
|
14
|
+
finchvox/collector/server.py,sha256=2rNH1pmKQEyaE7vrld5sdWClqXgSwxq5VNYKe-tzmt8,4239
|
|
15
|
+
finchvox/collector/service.py,sha256=t4sceC-kwYQ1yoNrjgM3WHGWthNhAB1u7JzcSvDMRfc,1711
|
|
16
|
+
finchvox/collector/writer.py,sha256=1fjtxuBd3NcwR4Y-jFUJfJdKznHyH37JShSOHuw_nhM,3207
|
|
17
|
+
finchvox/ui/trace_detail.html,sha256=ouAC1Gn_ELqjPK0rqdXOou2KJYlj6X3QbYyJOdBhBDU,19201
|
|
18
|
+
finchvox/ui/traces_list.html,sha256=XjBOydvDfGM4fWvSxQ5GwHeIwXGNGWGK4zKjK47nOks,3000
|
|
19
|
+
finchvox/ui/css/app.css,sha256=rgiEWz7q-XaNsVMlq2JHD6SMfm4yBoItfY6fjPOpJ3M,14361
|
|
20
|
+
finchvox/ui/images/favicon.ico,sha256=j0O9rVzMW78TFsiA8zYelMIHQhN8GcTzbAf9OvjXC5M,15406
|
|
21
|
+
finchvox/ui/images/finchvox-logo.png,sha256=_D7isfCweOAsWoLW0UhGvjZF53MC8OX1CS6AKWecamQ,37048
|
|
22
|
+
finchvox/ui/js/time-utils.js,sha256=jb_vLsMvagTrXMnRCx12WHyh4KjeaZZZFUgz6zCsHIY,3478
|
|
23
|
+
finchvox/ui/js/trace_detail.js,sha256=HDMH4oVaBeyrhBY0DLChvBtImr9y6-qL3MU-jZO9YWc,56247
|
|
24
|
+
finchvox/ui/js/traces_list.js,sha256=FdL_1s-9HttFjom3QiBx_ww3N-6dC58NcBYCYdoaA5w,711
|
|
25
|
+
finchvox/ui/lib/alpine.min.js,sha256=e2nmRsTW_W5F0yF1XHx48Hdf-vCgsat5O3q4YPaizUQ,45764
|
|
26
|
+
finchvox/ui/lib/wavesurfer.min.js,sha256=MHeB40E-2QV3fR_LifZI3x5zpcFQNGuqbGZgMgsWCOM,40117
|
|
27
|
+
finchvox-0.0.1.dist-info/METADATA,sha256=hQsN1WV21j4Z0uJFZQ4EbOPI2WRMmGTa4eraPJ49_uU,5431
|
|
28
|
+
finchvox-0.0.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
29
|
+
finchvox-0.0.1.dist-info/entry_points.txt,sha256=sgwiFNVxRoX7_N2E3mF-gNPJfaeIlejZkrboVPXY0zc,47
|
|
30
|
+
finchvox-0.0.1.dist-info/licenses/LICENSE,sha256=FhYQcbAV4VeqmDcdLwjJeEu6CqIpsKaGXGCUXQYkPmg,912
|
|
31
|
+
finchvox-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
Finchvox Source Available License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Finchvox
|
|
4
|
+
|
|
5
|
+
Permission is granted, free of charge, to any person obtaining a copy of this
|
|
6
|
+
software and associated documentation files (the "Software"), to use, copy,
|
|
7
|
+
modify, and distribute the Software for internal or private use.
|
|
8
|
+
|
|
9
|
+
You may:
|
|
10
|
+
- Run the Software locally or within your organization
|
|
11
|
+
- Modify the Software for your own use
|
|
12
|
+
- Deploy the Software within your own infrastructure
|
|
13
|
+
- Deploy the Software on behalf of a customer, provided the customer operates
|
|
14
|
+
the Software themselves
|
|
15
|
+
|
|
16
|
+
You may NOT:
|
|
17
|
+
- Offer the Software as a hosted service, managed service, or SaaS
|
|
18
|
+
- Provide access to the Software to third parties as a service
|
|
19
|
+
- Resell, rebrand, or commercially exploit the Software as a service
|
|
20
|
+
|
|
21
|
+
This license does not grant you the right to use the Finchvox name, logo, or
|
|
22
|
+
trademarks.
|
|
23
|
+
|
|
24
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
|