Skip to content

Commit

Permalink
feat: add audio to timeline
Browse files Browse the repository at this point in the history
  • Loading branch information
louis030195 committed Nov 6, 2024
1 parent 4a1950f commit eedfa70
Show file tree
Hide file tree
Showing 5 changed files with 266 additions and 6 deletions.
14 changes: 12 additions & 2 deletions screenpipe-app-tauri/app/timeline/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
TimelineDockIcon,
TimelineIconsSection,
} from "@/components/timeline/timeline-dock";
import { AudioTranscript } from "@/components/timeline/audio-transcript";

export interface StreamTimeSeriesResponse {
timestamp: string;
Expand All @@ -46,7 +47,7 @@ interface DeviceMetadata {
timestamp: string;
}

interface AudioData {
export interface AudioData {
device_name: string;
is_input: boolean;
transcription: string;
Expand Down Expand Up @@ -302,7 +303,9 @@ export default function Timeline() {

const handleScroll = (e: React.WheelEvent<HTMLDivElement>) => {
const isWithinAiPanel = aiPanelRef.current?.contains(e.target as Node);
if (isWithinAiPanel) {
const isWithinAudioPanel = document.querySelector('.audio-transcript-panel')?.contains(e.target as Node);

if (isWithinAiPanel || isWithinAudioPanel) {
return;
}

Expand Down Expand Up @@ -754,6 +757,13 @@ export default function Timeline() {
alt="Current frame"
/>
)}
{currentFrame && (
<AudioTranscript
frames={frames}
currentIndex={currentIndex}
groupingWindowMs={30000} // 30 seconds window
/>
)}
</div>

<div className="w-4/5 mx-auto my-8 relative select-none">
Expand Down
253 changes: 253 additions & 0 deletions screenpipe-app-tauri/components/timeline/audio-transcript.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
import { useState, useEffect, useRef } from "react";
import { AudioData, StreamTimeSeriesResponse } from "@/app/timeline/page";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Play, Pause, Volume2, GripHorizontal, X } from "lucide-react";
import { VideoComponent } from "@/components/video";

interface AudioGroup {
deviceName: string;
isInput: boolean;
audioItems: AudioData[];
startTime: Date;
endTime: Date;
}

interface AudioTranscriptProps {
frames: StreamTimeSeriesResponse[];
currentIndex: number;
groupingWindowMs?: number; // how many ms to group audio files together
onClose?: () => void;
}

export function AudioTranscript({
frames,
currentIndex,
groupingWindowMs = 30000,
onClose,
}: AudioTranscriptProps) {
const [audioGroups, setAudioGroups] = useState<AudioGroup[]>([]);
const [playing, setPlaying] = useState<string | null>(null);
const [position, setPosition] = useState({
x: window.innerWidth - 320,
y: 100,
});
const [isDragging, setIsDragging] = useState(false);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const [windowSize, setWindowSize] = useState({ width: 300, height: 500 });
const resizerRef = useRef<HTMLDivElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);

// Group audio files from nearby frames
useEffect(() => {
if (!frames.length) return;

const currentFrame = frames[currentIndex];
const currentTime = new Date(currentFrame.timestamp);
const windowStart = new Date(currentTime.getTime() - groupingWindowMs);
const windowEnd = new Date(currentTime.getTime() + groupingWindowMs);

// Get frames within our time window
const nearbyFrames = frames.filter((frame) => {
const frameTime = new Date(frame.timestamp);
return frameTime >= windowStart && frameTime <= windowEnd;
});

// Group audio by device
const groups = new Map<string, AudioGroup>();

nearbyFrames.forEach((frame) => {
frame.devices.forEach((device) => {
device.audio.forEach((audio) => {
const key = `${audio.device_name}-${audio.is_input}`;

if (!groups.has(key)) {
groups.set(key, {
deviceName: audio.device_name,
isInput: audio.is_input,
audioItems: [],
startTime: new Date(frame.timestamp),
endTime: new Date(frame.timestamp),
});
}

const group = groups.get(key)!;
group.audioItems.push(audio);

// Update time range
const frameTime = new Date(frame.timestamp);
if (frameTime < group.startTime) group.startTime = frameTime;
if (frameTime > group.endTime) group.endTime = frameTime;
});
});
});

setAudioGroups(Array.from(groups.values()));
}, [frames, currentIndex, groupingWindowMs]);

const handlePlay = (audioPath: string) => {
if (playing === audioPath) {
setPlaying(null);
} else {
setPlaying(audioPath);
}
};

const handlePanelMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
setIsDragging(true);
setDragOffset({
x: e.clientX - position.x,
y: e.clientY - position.y,
});
};

const handlePanelMouseMove = (e: React.MouseEvent) => {
if (isDragging) {
setPosition({
x: e.clientX - dragOffset.x,
y: e.clientY - dragOffset.y,
});
}
};

const handlePanelMouseUp = () => {
if (isDragging) {
setIsDragging(false);
}
};

const handleResizeMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
const startX = e.clientX;
const startY = e.clientY;
const startWidth = windowSize.width;
const startHeight = windowSize.height;

const handleMouseMove = (moveEvent: MouseEvent) => {
const newWidth = Math.max(200, startWidth + moveEvent.clientX - startX);
const newHeight = Math.max(200, startHeight + moveEvent.clientY - startY);
setWindowSize({ width: newWidth, height: newHeight });
};

const handleMouseUp = () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};

document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
};

// Modify the content scroll handler
const handleContentScroll = (e: React.WheelEvent<HTMLDivElement>) => {
e.stopPropagation(); // Stop the event from reaching the timeline
};

return (
<div
ref={panelRef}
style={{
position: "fixed",
left: position.x,
top: position.y,
width: windowSize.width,
height: windowSize.height,
cursor: isDragging ? "grabbing" : "default",
}}
className="audio-transcript-panel bg-background/80 backdrop-blur border border-muted-foreground rounded-lg shadow-lg z-[100] overflow-hidden"
>
<div
className="select-none cursor-grab active:cursor-grabbing p-2 border-b border-muted-foreground"
onMouseDown={handlePanelMouseDown}
onMouseMove={handlePanelMouseMove}
onMouseUp={handlePanelMouseUp}
onMouseLeave={handlePanelMouseUp}
>
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<GripHorizontal className="w-4 h-4" />
<span>audio transcripts</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={(e) => {
e.stopPropagation(); // Stop event from bubbling up
onClose?.();
}}
>
<X className="h-3 w-3" />
</Button>
</div>
</div>

<div
className="space-y-2 p-2 overflow-y-auto"
style={{
height: "calc(100% - 37px)",
overscrollBehavior: "contain", // Prevent scroll chaining
WebkitOverflowScrolling: "touch", // Smooth scrolling on iOS
}}
>
{audioGroups.map((group, groupIndex) => (
<Card key={groupIndex} className="p-4 bg-background/80 backdrop-blur">
<div className="text-xs text-muted-foreground mb-2">
{group.deviceName} ({group.isInput ? "input" : "output"})
<div className="text-[10px]">
{group.startTime.toLocaleTimeString()} -{" "}
{group.endTime.toLocaleTimeString()}
</div>
</div>

{group.audioItems.map((audio, index) => (
<div key={index} className="space-y-2 mb-2 last:mb-0">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => handlePlay(audio.audio_file_path)}
>
{playing === audio.audio_file_path ? (
<Pause className="h-3 w-3" />
) : (
<Play className="h-3 w-3" />
)}
</Button>
<div className="flex items-center gap-1 text-xs">
<Volume2 className="h-3 w-3" />
<span>{Math.round(audio.duration_secs)}s</span>
</div>
</div>

{audio.transcription && (
<div className="text-xs pl-8 text-muted-foreground">
{audio.transcription}
</div>
)}

{playing === audio.audio_file_path && (
<div className="pl-8">
<VideoComponent filePath={audio.audio_file_path} />
</div>
)}
</div>
))}
</Card>
))}
</div>

<div
ref={resizerRef}
onMouseDown={handleResizeMouseDown}
className="absolute right-0 bottom-0 w-4 h-4 cursor-se-resize bg-transparent"
style={{
borderTopLeftRadius: "4px",
borderBottomRightRadius: "4px",
cursor: "se-resize",
}}
/>
</div>
);
}
3 changes: 0 additions & 3 deletions screenpipe-app-tauri/components/timeline/timeline-block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,6 @@ export function TimelineBlocks({ frames, timeRange }: TimelineBlocksProps) {
return blocks;
}, [frames, timeRange]);

// Debug output
console.log("timeline blocks:", blocks);

return (
<div className="absolute inset-0 flex flex-col">
{blocks.map((block, index) => {
Expand Down
1 change: 1 addition & 0 deletions screenpipe-app-tauri/components/timeline/timeline-dock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ export function TimelineIconsSection({

return (
<div className="absolute -top-12 inset-x-0 pointer-events-none h-8">

{processedBlocks.map((block, i) => (
<div
key={`${block.appName}-${i}`}
Expand Down
1 change: 0 additions & 1 deletion screenpipe-app-tauri/components/video.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,6 @@ export const VideoComponent = memo(function VideoComponent({
<div className="w-full max-w-2xl text-center">
{isAudio ? (
<div className="bg-gray-100 p-4 rounded-md">
<p className="mb-2 text-gray-700">Audio Device Recording</p>
<audio controls className="w-full">
<source src={mediaSrc} type="audio/mpeg" />
Your browser does not support the audio element.
Expand Down

0 comments on commit eedfa70

Please sign in to comment.