Skip to content

Commit 7cdfc1d

Browse files
committed
feat: add audio recording with security improvements
- Add stereo WAV recording functionality for conversations - Implement recording settings page with auto-save configuration - Add recording indicator component showing duration and status - Display audio player on conversation pages for playback Security & Performance Improvements: - Add path traversal protection with filename sanitization - Implement bounds checking for buffer writes (10MB max) - Add disk space validation (100MB minimum required) - Add file existence validation in backend - Enhance error recovery with user-friendly messages - Use synchronous file operations for stability Database Changes: - Add recording fields to conversation_sessions table - Track recording path, duration, size, and status UI Changes: - Add recording toggle in conversation UI - Show recording status in real-time - Display audio player for recorded conversations - Remove settings link from title bar as requested
1 parent 8f8aa99 commit 7cdfc1d

File tree

17 files changed

+1288
-8
lines changed

17 files changed

+1288
-8
lines changed

app/Http/Controllers/ConversationController.php

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,4 +282,44 @@ public function destroy(ConversationSession $session)
282282
return redirect()->route('conversations.index')
283283
->with('message', 'Conversation deleted successfully');
284284
}
285+
286+
/**
287+
* Update recording information for a conversation session.
288+
*/
289+
public function updateRecording(ConversationSession $session, Request $request)
290+
{
291+
// No auth check needed for single-user desktop app
292+
293+
$validated = $request->validate([
294+
'has_recording' => 'required|boolean',
295+
'recording_path' => 'required|string',
296+
'recording_duration' => 'required|integer|min:0',
297+
'recording_size' => 'required|integer|min:0',
298+
]);
299+
300+
// Validate that the recording file actually exists
301+
if ($validated['has_recording'] && $validated['recording_path']) {
302+
if (!file_exists($validated['recording_path'])) {
303+
return response()->json([
304+
'message' => 'Recording file not found at specified path',
305+
], 422);
306+
}
307+
308+
// Verify file size matches
309+
$actualSize = filesize($validated['recording_path']);
310+
if ($actualSize !== $validated['recording_size']) {
311+
\Log::warning('Recording file size mismatch', [
312+
'session_id' => $session->id,
313+
'expected_size' => $validated['recording_size'],
314+
'actual_size' => $actualSize,
315+
]);
316+
}
317+
}
318+
319+
$session->update($validated);
320+
321+
return response()->json([
322+
'message' => 'Recording information updated successfully',
323+
]);
324+
}
285325
}

app/Models/ConversationSession.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ class ConversationSession extends Model
3131
'total_action_items',
3232
'ai_summary',
3333
'user_notes',
34+
'has_recording',
35+
'recording_path',
36+
'recording_duration',
37+
'recording_size',
3438
];
3539

3640
protected $casts = [
@@ -42,6 +46,9 @@ class ConversationSession extends Model
4246
'total_topics' => 'integer',
4347
'total_commitments' => 'integer',
4448
'total_action_items' => 'integer',
49+
'has_recording' => 'boolean',
50+
'recording_duration' => 'integer',
51+
'recording_size' => 'integer',
4552
];
4653

4754
public function user(): BelongsTo
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration
8+
{
9+
/**
10+
* Run the migrations.
11+
*/
12+
public function up(): void
13+
{
14+
Schema::table('conversation_sessions', function (Blueprint $table) {
15+
$table->boolean('has_recording')->default(false)->after('ended_at');
16+
$table->string('recording_path')->nullable()->after('has_recording');
17+
$table->integer('recording_duration')->nullable()->comment('Duration in seconds')->after('recording_path');
18+
$table->bigInteger('recording_size')->nullable()->comment('Size in bytes')->after('recording_duration');
19+
});
20+
}
21+
22+
/**
23+
* Reverse the migrations.
24+
*/
25+
public function down(): void
26+
{
27+
Schema::table('conversation_sessions', function (Blueprint $table) {
28+
$table->dropColumn(['has_recording', 'recording_path', 'recording_duration', 'recording_size']);
29+
});
30+
}
31+
};

package-lock.json

Lines changed: 3 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

resources/js/components/RealtimeAgent/Navigation/MobileMenu.vue

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@
115115

116116
<script setup lang="ts">
117117
import { computed } from 'vue';
118+
import { router } from '@inertiajs/vue3';
118119
import { useRealtimeAgentStore } from '@/stores/realtimeAgent';
119120
import { useSettingsStore } from '@/stores/settings';
120121
import ConnectionStatus from './ConnectionStatus.vue';
@@ -179,6 +180,11 @@ const handleDashboardClick = () => {
179180
settingsStore.closeAllDropdowns();
180181
};
181182
183+
const handleSettingsClick = () => {
184+
router.visit('/settings/recording');
185+
settingsStore.closeAllDropdowns();
186+
};
187+
182188
183189
const getIconEmoji = (icon?: string) => {
184190
if (!icon) return '📋';

resources/js/components/RealtimeAgent/Navigation/TitleBar.vue

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@
4141

4242
<!-- Connection Status -->
4343
<ConnectionStatus />
44+
45+
<!-- Recording Indicator Slot -->
46+
<slot name="recording-indicator" />
4447

4548
<!-- Divider -->
4649
<div class="h-4 w-px bg-gray-300 dark:bg-gray-600"></div>
@@ -134,6 +137,7 @@
134137

135138
<script setup lang="ts">
136139
import { computed, ref, onMounted } from 'vue';
140+
import { router } from '@inertiajs/vue3';
137141
import { useRealtimeAgentStore } from '@/stores/realtimeAgent';
138142
import { useSettingsStore } from '@/stores/settings';
139143
import CoachSelector from './CoachSelector.vue';
@@ -223,6 +227,10 @@ const handleDashboardClick = () => {
223227
emit('dashboardClick');
224228
};
225229
230+
const handleSettingsClick = () => {
231+
router.visit('/settings/recording');
232+
};
233+
226234
const toggleSession = () => {
227235
emit('toggleSession');
228236
};

resources/js/components/RealtimeAgent/OnboardingModal.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<template>
22
<Dialog v-model:open="isOpen" @update:open="handleOpenChange">
3-
<DialogContent class="max-w-md p-0" :closeable="false">
3+
<DialogContent class="max-w-md p-0">
44
<!-- Logo and App Name Header -->
55
<div class="p-6 pb-0 text-center">
66
<div class="mb-4 flex items-center justify-center">
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
<template>
2+
<div v-if="isRecording" class="flex items-center gap-2">
3+
<!-- Recording dot animation -->
4+
<div class="relative">
5+
<div class="h-2 w-2 rounded-full bg-red-500 animate-pulse"></div>
6+
<div class="absolute inset-0 h-2 w-2 rounded-full bg-red-500 animate-ping"></div>
7+
</div>
8+
9+
<!-- Recording timer -->
10+
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">
11+
REC {{ formattedDuration }}
12+
</span>
13+
14+
<!-- File size (optional) -->
15+
<span v-if="showFileSize && fileSize > 0" class="text-xs text-gray-600 dark:text-gray-400">
16+
({{ formatFileSize(fileSize) }})
17+
</span>
18+
19+
<!-- Pause/Resume button -->
20+
<button
21+
v-if="showControls"
22+
@click="togglePause"
23+
class="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
24+
:title="isPaused ? 'Resume recording' : 'Pause recording'"
25+
>
26+
<svg v-if="!isPaused" class="h-3 w-3 text-gray-600 dark:text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
27+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z" />
28+
</svg>
29+
<svg v-else class="h-3 w-3 text-gray-600 dark:text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
30+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
31+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
32+
</svg>
33+
</button>
34+
</div>
35+
</template>
36+
37+
<script setup lang="ts">
38+
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
39+
40+
interface Props {
41+
isRecording: boolean;
42+
isPaused?: boolean;
43+
duration?: number; // in milliseconds
44+
fileSize?: number; // in bytes
45+
showFileSize?: boolean;
46+
showControls?: boolean;
47+
}
48+
49+
const props = withDefaults(defineProps<Props>(), {
50+
isPaused: false,
51+
duration: 0,
52+
fileSize: 0,
53+
showFileSize: false,
54+
showControls: false
55+
});
56+
57+
const emit = defineEmits<{
58+
'toggle-pause': [];
59+
}>();
60+
61+
// Local duration tracking for smooth updates
62+
const localDuration = ref(props.duration);
63+
let intervalId: NodeJS.Timeout | null = null;
64+
65+
// Format duration as MM:SS
66+
const formattedDuration = computed(() => {
67+
const totalSeconds = Math.floor(localDuration.value / 1000);
68+
const minutes = Math.floor(totalSeconds / 60);
69+
const seconds = totalSeconds % 60;
70+
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
71+
});
72+
73+
// Start/stop duration counter
74+
const startDurationCounter = () => {
75+
if (intervalId) {
76+
clearInterval(intervalId);
77+
}
78+
79+
intervalId = setInterval(() => {
80+
if (props.isRecording && !props.isPaused) {
81+
localDuration.value += 1000;
82+
}
83+
}, 1000);
84+
};
85+
86+
const stopDurationCounter = () => {
87+
if (intervalId) {
88+
clearInterval(intervalId);
89+
intervalId = null;
90+
}
91+
};
92+
93+
// Format file size
94+
const formatFileSize = (bytes: number): string => {
95+
if (bytes === 0) return '0 B';
96+
97+
const k = 1024;
98+
const sizes = ['B', 'KB', 'MB', 'GB'];
99+
const i = Math.floor(Math.log(bytes) / Math.log(k));
100+
101+
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
102+
};
103+
104+
// Toggle pause
105+
const togglePause = () => {
106+
emit('toggle-pause');
107+
};
108+
109+
// Watch for recording state changes
110+
onMounted(() => {
111+
if (props.isRecording) {
112+
startDurationCounter();
113+
}
114+
});
115+
116+
onUnmounted(() => {
117+
stopDurationCounter();
118+
});
119+
120+
// Update local duration when prop changes
121+
watch(() => props.duration, (newDuration) => {
122+
localDuration.value = newDuration;
123+
});
124+
125+
// Start/stop counter when recording state changes
126+
watch(() => props.isRecording, (isRecording) => {
127+
if (isRecording) {
128+
startDurationCounter();
129+
} else {
130+
stopDurationCounter();
131+
localDuration.value = 0;
132+
}
133+
});
134+
</script>
135+
136+
<style scoped>
137+
@keyframes ping {
138+
75%, 100% {
139+
transform: scale(2);
140+
opacity: 0;
141+
}
142+
}
143+
144+
.animate-ping {
145+
animation: ping 2s cubic-bezier(0, 0, 0.2, 1) infinite;
146+
}
147+
</style>
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<template>
2+
<button
3+
type="button"
4+
role="switch"
5+
:aria-checked="modelValue"
6+
@click="toggle"
7+
v-bind="$attrs"
8+
>
9+
<slot />
10+
</button>
11+
</template>
12+
13+
<script setup lang="ts">
14+
import { defineEmits, defineProps } from 'vue';
15+
16+
// Props
17+
const props = defineProps<{
18+
modelValue: boolean;
19+
}>();
20+
21+
// Emits
22+
const emit = defineEmits<{
23+
'update:modelValue': [value: boolean];
24+
}>();
25+
26+
// Methods
27+
const toggle = () => {
28+
emit('update:modelValue', !props.modelValue);
29+
};
30+
</script>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default as Switch } from './Switch.vue';

0 commit comments

Comments
 (0)