Skip to content

Commit e7d9e91

Browse files
committed
fix: wire up transcription playback
1 parent 17d034b commit e7d9e91

File tree

13 files changed

+781
-542
lines changed

13 files changed

+781
-542
lines changed

apps/desktop/forge.config.ts

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,11 @@ const config: ForgeConfig = {
149149

150150
// Copy the package
151151
console.log(`Copying ${dep}...`);
152-
cpSync(rootDepPath, localDepPath, { recursive: true, dereference: true, force: true });
152+
cpSync(rootDepPath, localDepPath, {
153+
recursive: true,
154+
dereference: true,
155+
force: true,
156+
});
153157
console.log(`✓ Successfully copied ${dep}`);
154158
} catch (error) {
155159
console.error(`Failed to copy ${dep}:`, error);
@@ -160,31 +164,35 @@ const config: ForgeConfig = {
160164
console.log("Checking for symlinks in copied dependencies...");
161165
for (const dep of nativeModuleDependenciesToPackage) {
162166
const localDepPath = join(localNodeModules, dep);
163-
167+
164168
try {
165169
if (existsSync(localDepPath)) {
166170
const stats = lstatSync(localDepPath);
167171
if (stats.isSymbolicLink()) {
168-
console.log(`Found symlink for ${dep}, replacing with dereferenced copy...`);
169-
172+
console.log(
173+
`Found symlink for ${dep}, replacing with dereferenced copy...`,
174+
);
175+
170176
// Read where the symlink points to
171177
const symlinkTarget = readlinkSync(localDepPath);
172178
const absoluteTarget = join(localDepPath, "..", symlinkTarget);
173179
const sourcePath = normalize(absoluteTarget);
174-
180+
175181
console.log(` Symlink points to: ${sourcePath}`);
176-
182+
177183
// Remove the symlink
178184
rmSync(localDepPath, { recursive: true, force: true });
179-
185+
180186
// Copy with dereference to get actual content
181-
cpSync(sourcePath, localDepPath, {
182-
recursive: true,
187+
cpSync(sourcePath, localDepPath, {
188+
recursive: true,
183189
force: true,
184-
dereference: true // Follow symlinks and copy actual content
190+
dereference: true, // Follow symlinks and copy actual content
185191
});
186-
187-
console.log(`✓ Successfully replaced symlink for ${dep} with actual content`);
192+
193+
console.log(
194+
`✓ Successfully replaced symlink for ${dep} with actual content`,
195+
);
188196
}
189197
}
190198
} catch (error) {
@@ -404,8 +412,10 @@ const config: ForgeConfig = {
404412
const scopeDir = dep.split("/")[0]; // @libsql/client -> @libsql
405413
// for workspace packages only keep the actual package
406414
if (scopeDir === "@amical") {
407-
if (filePath.startsWith(`/node_modules/${dep}`) ||
408-
filePath === `/node_modules/${scopeDir}`) {
415+
if (
416+
filePath.startsWith(`/node_modules/${dep}`) ||
417+
filePath === `/node_modules/${scopeDir}`
418+
) {
409419
KEEP_FILE.keep = true;
410420
KEEP_FILE.log = true;
411421
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { useRef, useState, useCallback, useEffect } from "react";
2+
3+
interface UseAudioPlayerReturn {
4+
isPlaying: boolean;
5+
currentPlayingId: number | null;
6+
play: (
7+
audioData: ArrayBuffer,
8+
transcriptionId: number,
9+
mimeType?: string,
10+
) => void;
11+
pause: () => void;
12+
stop: () => void;
13+
toggle: (
14+
audioData: ArrayBuffer,
15+
transcriptionId: number,
16+
mimeType?: string,
17+
) => void;
18+
}
19+
20+
export function useAudioPlayer(): UseAudioPlayerReturn {
21+
const audioRef = useRef<HTMLAudioElement | null>(null);
22+
const currentBlobUrlRef = useRef<string | null>(null);
23+
const [isPlaying, setIsPlaying] = useState(false);
24+
const [currentPlayingId, setCurrentPlayingId] = useState<number | null>(null);
25+
26+
const cleanup = useCallback(() => {
27+
if (audioRef.current) {
28+
audioRef.current.pause();
29+
audioRef.current.src = "";
30+
}
31+
if (currentBlobUrlRef.current) {
32+
URL.revokeObjectURL(currentBlobUrlRef.current);
33+
currentBlobUrlRef.current = null;
34+
}
35+
setIsPlaying(false);
36+
setCurrentPlayingId(null);
37+
}, []);
38+
39+
const play = useCallback(
40+
(
41+
audioData: ArrayBuffer,
42+
transcriptionId: number,
43+
mimeType: string = "audio/wav",
44+
) => {
45+
cleanup();
46+
47+
const blob = new Blob([audioData], { type: mimeType });
48+
const blobUrl = URL.createObjectURL(blob);
49+
currentBlobUrlRef.current = blobUrl;
50+
51+
if (!audioRef.current) {
52+
audioRef.current = new Audio();
53+
}
54+
55+
audioRef.current.src = blobUrl;
56+
audioRef.current.onended = () => {
57+
setIsPlaying(false);
58+
setCurrentPlayingId(null);
59+
};
60+
61+
audioRef.current
62+
.play()
63+
.then(() => {
64+
setIsPlaying(true);
65+
setCurrentPlayingId(transcriptionId);
66+
})
67+
.catch((error) => {
68+
console.error("Failed to play audio:", error);
69+
cleanup();
70+
});
71+
},
72+
[cleanup],
73+
);
74+
75+
const pause = useCallback(() => {
76+
if (audioRef.current && !audioRef.current.paused) {
77+
audioRef.current.pause();
78+
setIsPlaying(false);
79+
}
80+
}, []);
81+
82+
const stop = useCallback(() => {
83+
cleanup();
84+
}, [cleanup]);
85+
86+
const toggle = useCallback(
87+
(audioData: ArrayBuffer, transcriptionId: number, mimeType?: string) => {
88+
if (currentPlayingId === transcriptionId && isPlaying) {
89+
pause();
90+
} else {
91+
play(audioData, transcriptionId, mimeType);
92+
}
93+
},
94+
[currentPlayingId, isPlaying, pause, play],
95+
);
96+
97+
useEffect(() => {
98+
return () => {
99+
cleanup();
100+
};
101+
}, [cleanup]);
102+
103+
return {
104+
isPlaying,
105+
currentPlayingId,
106+
play,
107+
pause,
108+
stop,
109+
toggle,
110+
};
111+
}

apps/desktop/src/renderer/main/pages/transcriptions/components/TranscriptionsList.tsx

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Button } from "@/components/ui/button";
44
import { Card, CardContent } from "@/components/ui/card";
55
import { Badge } from "@/components/ui/badge";
66
import { api } from "@/trpc/react";
7+
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
78

89
import {
910
Tooltip,
@@ -14,8 +15,8 @@ import {
1415
import {
1516
Copy,
1617
Play,
18+
Pause,
1719
Trash2,
18-
Download,
1920
FileText,
2021
Search,
2122
MoreHorizontal,
@@ -35,6 +36,7 @@ import {
3536
export const TranscriptionsList: React.FC = () => {
3637
const [searchTerm, setSearchTerm] = useState("");
3738
const [openDropdownId, setOpenDropdownId] = useState<number | null>(null);
39+
const audioPlayer = useAudioPlayer();
3840

3941
// Get shortcuts data
4042
const shortcutsQuery = api.settings.getShortcuts.useQuery();
@@ -85,6 +87,34 @@ export const TranscriptionsList: React.FC = () => {
8587
},
8688
});
8789

90+
// Using mutation for fetching audio data instead of query to:
91+
// - Prevent caching of large binary audio files in memory
92+
// - Avoid automatic refetching behaviors (window focus, network reconnect)
93+
// - Clearly indicate this is a user-triggered action (play button click)
94+
// - Track loading state per transcription ID efficiently
95+
const getAudioFileMutation = api.transcriptions.getAudioFile.useMutation({
96+
onSuccess: (data, variables) => {
97+
if (data?.data) {
98+
// Decode base64 to ArrayBuffer
99+
const base64 = data.data;
100+
const binaryString = atob(base64);
101+
const bytes = new Uint8Array(binaryString.length);
102+
for (let i = 0; i < binaryString.length; i++) {
103+
bytes[i] = binaryString.charCodeAt(i);
104+
}
105+
// Pass the MIME type from the server response
106+
audioPlayer.toggle(
107+
bytes.buffer,
108+
variables.transcriptionId,
109+
data.mimeType,
110+
);
111+
}
112+
},
113+
onError: (error) => {
114+
console.error("Error fetching audio file:", error);
115+
},
116+
});
117+
88118
const transcriptions = transcriptionsQuery.data || [];
89119
const totalCount = transcriptionsCountQuery.data || 0;
90120
const loading =
@@ -103,9 +133,15 @@ export const TranscriptionsList: React.FC = () => {
103133
deleteTranscriptionMutation.mutate({ id });
104134
};
105135

106-
const handlePlayAudio = (audioFile: string) => {
107-
// Implement audio playback functionality
108-
console.log("Playing audio:", audioFile);
136+
const handlePlayAudio = (transcriptionId: number) => {
137+
if (
138+
audioPlayer.currentPlayingId === transcriptionId &&
139+
audioPlayer.isPlaying
140+
) {
141+
audioPlayer.stop();
142+
} else {
143+
getAudioFileMutation.mutate({ transcriptionId });
144+
}
109145
};
110146

111147
const handleDownloadAudio = async (transcriptionId: number) => {
@@ -219,12 +255,27 @@ export const TranscriptionsList: React.FC = () => {
219255
variant="ghost"
220256
size="sm"
221257
className="h-8 w-8 p-0"
222-
onClick={() => handlePlayAudio(transcription.audioFile!)}
258+
onClick={() => handlePlayAudio(transcription.id)}
259+
disabled={
260+
getAudioFileMutation.isPending &&
261+
getAudioFileMutation.variables?.transcriptionId ===
262+
transcription.id
263+
}
223264
>
224-
<Play className="h-4 w-4" />
265+
{audioPlayer.currentPlayingId === transcription.id &&
266+
audioPlayer.isPlaying ? (
267+
<Pause className="h-4 w-4" />
268+
) : (
269+
<Play className="h-4 w-4" />
270+
)}
225271
</Button>
226272
</TooltipTrigger>
227-
<TooltipContent>Play audio</TooltipContent>
273+
<TooltipContent>
274+
{audioPlayer.currentPlayingId === transcription.id &&
275+
audioPlayer.isPlaying
276+
? "Pause audio"
277+
: "Play audio"}
278+
</TooltipContent>
228279
</Tooltip>
229280
</TooltipProvider>
230281
)}

apps/desktop/src/trpc/routers/transcriptions.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -120,10 +120,15 @@ export const transcriptionsRouter = createRouter({
120120
return result;
121121
}),
122122

123-
// Get audio file for download
123+
// Get audio file for playback
124+
// Implemented as mutation instead of query because:
125+
// 1. Large binary data (audio files) shouldn't be cached by React Query
126+
// 2. Prevents automatic refetching on window focus/network reconnect
127+
// 3. Represents an explicit user action (clicking play), not passive data fetching
128+
// 4. Avoids memory overhead from React Query's caching system
124129
getAudioFile: procedure
125130
.input(z.object({ transcriptionId: z.number() }))
126-
.query(async ({ input, ctx }) => {
131+
.mutation(async ({ input, ctx }) => {
127132
const transcription = await getTranscriptionById(input.transcriptionId);
128133

129134
if (!transcription?.audioFile) {
@@ -138,10 +143,28 @@ export const transcriptionsRouter = createRouter({
138143
const audioData = await fs.promises.readFile(transcription.audioFile);
139144
const filename = path.basename(transcription.audioFile);
140145

146+
// Detect MIME type based on file extension
147+
const ext = path.extname(transcription.audioFile).toLowerCase();
148+
let mimeType = "audio/wav"; // Default for our WAV files
149+
150+
// Map common audio extensions to MIME types
151+
const mimeTypes: Record<string, string> = {
152+
".wav": "audio/wav",
153+
".mp3": "audio/mpeg",
154+
".webm": "audio/webm",
155+
".ogg": "audio/ogg",
156+
".m4a": "audio/mp4",
157+
".flac": "audio/flac",
158+
};
159+
160+
if (ext in mimeTypes) {
161+
mimeType = mimeTypes[ext];
162+
}
163+
141164
return {
142-
data: audioData,
165+
data: audioData.toString("base64"),
143166
filename,
144-
mimeType: "audio/webm",
167+
mimeType,
145168
};
146169
} catch (error) {
147170
const logger = ctx.serviceManager.getLogger();
@@ -155,6 +178,8 @@ export const transcriptionsRouter = createRouter({
155178
}),
156179

157180
// Download audio file with save dialog
181+
// Mutation because this triggers a system dialog and file write operation
182+
// Not a query since it has side effects beyond just fetching data
158183
downloadAudioFile: procedure
159184
.input(z.object({ transcriptionId: z.number() }))
160185
.mutation(async ({ input, ctx }) => {

0 commit comments

Comments
 (0)