Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 164 additions & 29 deletions apps/desktop/src/components/main/body/sessions/outer-header/listen.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useHover } from "@uidotdev/usehooks";
import { MicOff } from "lucide-react";
import { useCallback } from "react";
import { useCallback, useEffect, useRef } from "react";

import { commands as windowsCommands } from "@hypr/plugin-windows";
import { Button } from "@hypr/ui/components/ui/button";
Expand All @@ -13,14 +13,146 @@ import { cn } from "@hypr/utils";

import { useListener } from "../../../../../contexts/listener";
import { useStartListening } from "../../../../../hooks/useStartListening";
import { SoundIndicator } from "../../shared";
import {
ActionableTooltipContent,
RecordingIcon,
useHasTranscript,
useListenButtonState,
} from "../shared";

function ScrollingWaveform({
amplitude,
color = "#e5e5e5",
height = 32,
width = 120,
barWidth = 2,
gap = 1,
minBarHeight = 2,
maxBarHeight,
}: {
amplitude: number;
color?: string;
height?: number;
width?: number;
barWidth?: number;
gap?: number;
minBarHeight?: number;
maxBarHeight?: number;
}) {
const resolvedMaxBarHeight = maxBarHeight ?? height;
const maxBars = Math.floor(width / (barWidth + gap));
const canvasRef = useRef<HTMLCanvasElement>(null);
const amplitudesRef = useRef<number[]>([]);
const amplitudeRef = useRef(amplitude);

amplitudeRef.current = amplitude;

const dprRef = useRef(window.devicePixelRatio || 1);

useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;

const dpr = window.devicePixelRatio || 1;
dprRef.current = dpr;
canvas.width = width * dpr;
canvas.height = height * dpr;

const ctx = canvas.getContext("2d");
if (ctx) {
ctx.scale(dpr, dpr);
}
}, [width, height]);

useEffect(() => {
amplitudesRef.current = [];

const draw = () => {
const amp = amplitudeRef.current;
const linear = amp < 30 ? 0 : Math.min((amp - 30) / 40, 1);
const normalized = Math.pow(linear, 0.6);

amplitudesRef.current.push(normalized);
if (amplitudesRef.current.length > maxBars) {
amplitudesRef.current = amplitudesRef.current.slice(-maxBars);
}

const canvas = canvasRef.current;
if (!canvas) return;

const ctx = canvas.getContext("2d");
if (!ctx) return;

ctx.clearRect(0, 0, width, height);

const amplitudes = amplitudesRef.current;
const startX = width - amplitudes.length * (barWidth + gap);

ctx.fillStyle = color;
amplitudes.forEach((amp, index) => {
const barHeight =
minBarHeight + amp * (resolvedMaxBarHeight - minBarHeight);
const x = startX + index * (barWidth + gap);
const y = (height - barHeight) / 2;

ctx.beginPath();
ctx.roundRect(x, y, barWidth, barHeight, barWidth / 2);
ctx.fill();
});
};

draw();
const interval = setInterval(draw, 100);
return () => clearInterval(interval);
}, [
color,
height,
width,
barWidth,
gap,
minBarHeight,
resolvedMaxBarHeight,
maxBars,
]);

return (
<div
style={{
position: "relative",
width,
height,
minWidth: width,
minHeight: height,
}}
>
<canvas ref={canvasRef} style={{ width, height }} />
<div
style={{
position: "absolute",
top: 0,
left: 0,
width: 12,
height: "100%",
background:
"linear-gradient(to right, rgb(254 242 242), transparent)",
pointerEvents: "none",
}}
/>
<div
style={{
position: "absolute",
top: 0,
right: 0,
width: 12,
height: "100%",
background: "linear-gradient(to left, rgb(254 242 242), transparent)",
pointerEvents: "none",
}}
/>
</div>
);
}

export function ListenButton({ sessionId }: { sessionId: string }) {
const { shouldRender } = useListenButtonState(sessionId);
const hasTranscript = useHasTranscript(sessionId);
Expand Down Expand Up @@ -134,34 +266,37 @@ function InMeetingIndicator({ sessionId }: { sessionId: string }) {
<div className="flex items-center gap-1.5">
<span className="animate-pulse">...</span>
</div>
) : hovered ? (
<div className="flex items-center gap-1.5">
<span className="w-3 h-3 bg-red-500 rounded-none" />
<span>Stop</span>
</div>
) : muted ? (
<div className="flex items-center gap-1.5">
<MicOff size={14} />
<SoundIndicator
value={[amplitude.mic, amplitude.speaker]}
color="#ef4444"
size="long"
height={16}
width={32}
stickWidth={2}
gap={1}
/>
</div>
) : (
<SoundIndicator
value={[amplitude.mic, amplitude.speaker]}
color="#ef4444"
size="long"
height={16}
width={32}
stickWidth={2}
gap={1}
/>
<>
<div
className={cn([
"flex items-center gap-1.5",
hovered ? "hidden" : "flex",
])}
>
{muted && <MicOff size={14} />}
<ScrollingWaveform
amplitude={
((amplitude.mic + amplitude.speaker) / 2 / 65535) * 100 * 1000
}
color="#ef4444"
height={16}
width={muted ? 50 : 75}
barWidth={2}
gap={1}
minBarHeight={2}
/>
</div>
<div
className={cn([
"flex items-center gap-1.5",
hovered ? "flex" : "hidden",
])}
>
<span className="w-3 h-3 bg-red-500 rounded-none" />
<span>Stop</span>
</div>
</>
)}
</Button>
);
Expand Down
45 changes: 1 addition & 44 deletions apps/desktop/src/components/main/body/shared.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { X } from "lucide-react";
import { useEffect, useState } from "react";
import { useState } from "react";

import { ContextMenuItem } from "@hypr/ui/components/ui/context-menu";
import { DancingSticks } from "@hypr/ui/components/ui/dancing-sticks";
import { Kbd, KbdGroup } from "@hypr/ui/components/ui/kbd";
import { cn } from "@hypr/utils";

Expand Down Expand Up @@ -156,45 +155,3 @@ export function TabItemBase({
</div>
);
}

type SoundIndicatorProps = {
value: number | Array<number>;
color?: string;
size?: "default" | "long";
height?: number;
width?: number;
stickWidth?: number;
gap?: number;
};

export function SoundIndicator({
value,
color,
size = "long",
height,
width,
stickWidth,
gap,
}: SoundIndicatorProps) {
const [amplitude, setAmplitude] = useState(0);

const u16max = 65535;
useEffect(() => {
const sample = Array.isArray(value)
? value.reduce((sum, v) => sum + v, 0) / value.length / u16max
: value / u16max;
setAmplitude(Math.min(sample, 1));
}, [value]);

return (
<DancingSticks
amplitude={amplitude}
color={color}
size={size}
height={height}
width={width}
stickWidth={stickWidth}
gap={gap}
/>
);
}
Loading