Skip to content

Commit 91044a1

Browse files
XS⚠️ ◾ Check if video has audio before processing (#695)
* check if video has audio before processing * fix biome format * clean up
1 parent 794b537 commit 91044a1

File tree

9 files changed

+240
-1
lines changed

9 files changed

+240
-1
lines changed

src/backend/ipc/channels.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export const IPC_CHANNELS = {
2424
SHOW_CONTROL_BAR: "show-control-bar",
2525
HIDE_CONTROL_BAR: "hide-control-bar",
2626
RECORDING_TIME_UPDATE: "recording-time-update",
27+
CHECK_VIDEO_HAS_AUDIO: "check-video-has-audio",
2728
MINIMIZE_MAIN_WINDOW: "minimize-main-window",
2829
RESTORE_MAIN_WINDOW: "restore-main-window",
2930
OPEN_SOURCE_PICKER: "open-source-picker",

src/backend/ipc/process-video-handlers.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,16 @@ export class ProcessVideoIPCHandlers {
240240
this.lastVideoFilePath = filePath;
241241
workflowManager.startStage(WorkflowProgressStage.CONVERTING_AUDIO);
242242
notify(ProgressStage.CONVERTING_AUDIO);
243+
244+
const hasAudio = await this.ffmpegService.hasAudibleAudio(filePath);
245+
if (!hasAudio) {
246+
const errorMessage =
247+
"No audio detected in this video. Please re-record and make sure the correct microphone is selected and unmuted.";
248+
workflowManager.failStage(WorkflowProgressStage.CONVERTING_AUDIO, errorMessage);
249+
notify(ProgressStage.ERROR, { error: errorMessage });
250+
return { success: false, error: errorMessage };
251+
}
252+
243253
const mp3FilePath = await this.convertVideoToMp3(filePath);
244254

245255
workflowManager.completeStage(WorkflowProgressStage.CONVERTING_AUDIO);
@@ -251,6 +261,14 @@ export class ProcessVideoIPCHandlers {
251261
const transcript = await transcriptionModelProvider.transcribeAudio(mp3FilePath);
252262
const transcriptText = transcript.map((seg) => seg.text).join("");
253263

264+
if (!transcriptText.trim()) {
265+
const errorMessage =
266+
"No speech detected in this recording. Please re-record and check your microphone and audio levels.";
267+
workflowManager.failStage(WorkflowProgressStage.TRANSCRIBING, errorMessage);
268+
notify(ProgressStage.ERROR, { error: errorMessage });
269+
return { success: false, error: errorMessage };
270+
}
271+
254272
notify(ProgressStage.TRANSCRIPTION_COMPLETED, { transcript });
255273

256274
workflowManager.completeStage(WorkflowProgressStage.TRANSCRIBING, transcriptText);

src/backend/ipc/screen-recording-handlers.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
import { ipcMain } from "electron";
22
import { getMainWindow } from "../index";
3+
import { FFmpegService } from "../services/ffmpeg/ffmpeg-service";
34
import { CameraWindow } from "../services/recording/camera-window";
45
import { RecordingControlBarWindow } from "../services/recording/control-bar-window";
56
import { CountdownWindow } from "../services/recording/countdown-window";
67
import { RecordingService } from "../services/recording/recording-service";
8+
import { formatErrorMessage } from "../utils/error-utils";
79
import { IPC_CHANNELS } from "./channels";
810

911
export class ScreenRecordingIPCHandlers {
1012
private service = RecordingService.getInstance();
1113
private controlBar = RecordingControlBarWindow.getInstance();
1214
private cameraWindow = CameraWindow.getInstance();
1315
private countdownWindow = CountdownWindow.getInstance();
16+
private ffmpegService = FFmpegService.getInstance();
1417

1518
constructor() {
1619
const handlers = {
@@ -28,6 +31,14 @@ export class ScreenRecordingIPCHandlers {
2831
[IPC_CHANNELS.STOP_RECORDING_FROM_CONTROL_BAR]: () => this.stopRecordingFromControlBar(),
2932
[IPC_CHANNELS.MINIMIZE_MAIN_WINDOW]: () => this.minimizeMainWindow(),
3033
[IPC_CHANNELS.RESTORE_MAIN_WINDOW]: () => this.restoreMainWindow(),
34+
[IPC_CHANNELS.CHECK_VIDEO_HAS_AUDIO]: async (_: unknown, filePath: string) => {
35+
try {
36+
const hasAudio = await this.ffmpegService.hasAudibleAudio(filePath);
37+
return { success: true, hasAudio };
38+
} catch (error) {
39+
return { success: false, error: formatErrorMessage(error) };
40+
}
41+
},
3142
};
3243

3344
for (const [channel, handler] of Object.entries(handlers)) {

src/backend/preload.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ const IPC_CHANNELS = {
4242
ENABLE_LOOPBACK_AUDIO: "enable-loopback-audio",
4343
DISABLE_LOOPBACK_AUDIO: "disable-loopback-audio",
4444
HIDE_CONTROL_BAR: "hide-control-bar",
45+
CHECK_VIDEO_HAS_AUDIO: "check-video-has-audio",
4546
MINIMIZE_MAIN_WINDOW: "minimize-main-window",
4647
RESTORE_MAIN_WINDOW: "restore-main-window",
4748
OPEN_SOURCE_PICKER: "open-source-picker",
@@ -174,6 +175,8 @@ const electronAPI = {
174175
listSources: () => ipcRenderer.invoke(IPC_CHANNELS.LIST_SCREEN_SOURCES),
175176
cleanupTempFile: (filePath: string) =>
176177
ipcRenderer.invoke(IPC_CHANNELS.CLEANUP_TEMP_FILE, filePath),
178+
hasAudio: (filePath: string) =>
179+
ipcRenderer.invoke(IPC_CHANNELS.CHECK_VIDEO_HAS_AUDIO, filePath),
177180
showControlBar: (cameraDeviceId?: string) =>
178181
ipcRenderer.invoke(IPC_CHANNELS.SHOW_CONTROL_BAR, cameraDeviceId),
179182
hideControlBar: () => ipcRenderer.invoke(IPC_CHANNELS.HIDE_CONTROL_BAR),

src/backend/services/ffmpeg/ffmpeg-service.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,79 @@ describe("FFmpegService", () => {
207207
});
208208
});
209209

210+
describe("hasAudibleAudio", () => {
211+
it("should resolve false when file has no audio stream", async () => {
212+
const service = new FFmpegService("/mock/ffmpeg", mockFileSystem, mockProcessSpawner);
213+
214+
const resultPromise = service.hasAudibleAudio("/input/video.mp4");
215+
216+
setImmediate(() => {
217+
mockChildProcess.stderr?.emit(
218+
"data",
219+
Buffer.from("Stream map '0:a:0' matches no streams.\n"),
220+
);
221+
mockChildProcess.emit("close", 1);
222+
});
223+
224+
await expect(resultPromise).resolves.toBe(false);
225+
});
226+
227+
it("should resolve false when max_volume is -inf dB", async () => {
228+
const service = new FFmpegService("/mock/ffmpeg", mockFileSystem, mockProcessSpawner);
229+
230+
const resultPromise = service.hasAudibleAudio("/input/video.mp4");
231+
232+
setImmediate(() => {
233+
mockChildProcess.stderr?.emit("data", Buffer.from("max_volume: -inf dB\n"));
234+
mockChildProcess.emit("close", 0);
235+
});
236+
237+
await expect(resultPromise).resolves.toBe(false);
238+
});
239+
240+
it("should resolve false when max_volume is below threshold", async () => {
241+
const service = new FFmpegService("/mock/ffmpeg", mockFileSystem, mockProcessSpawner);
242+
243+
const resultPromise = service.hasAudibleAudio("/input/video.mp4", -50);
244+
245+
setImmediate(() => {
246+
mockChildProcess.stderr?.emit("data", Buffer.from("max_volume: -80.0 dB\n"));
247+
mockChildProcess.emit("close", 0);
248+
});
249+
250+
await expect(resultPromise).resolves.toBe(false);
251+
});
252+
253+
it("should resolve true when max_volume meets threshold", async () => {
254+
const service = new FFmpegService("/mock/ffmpeg", mockFileSystem, mockProcessSpawner);
255+
256+
const resultPromise = service.hasAudibleAudio("/input/video.mp4", -50);
257+
258+
setImmediate(() => {
259+
mockChildProcess.stderr?.emit("data", Buffer.from("max_volume: -10.0 dB\n"));
260+
mockChildProcess.emit("close", 0);
261+
});
262+
263+
await expect(resultPromise).resolves.toBe(true);
264+
});
265+
266+
it("should reject when ffmpeg audible-audio check fails for other reasons", async () => {
267+
const service = new FFmpegService("/mock/ffmpeg", mockFileSystem, mockProcessSpawner);
268+
269+
const resultPromise = service.hasAudibleAudio("/input/video.mp4");
270+
271+
setImmediate(() => {
272+
mockChildProcess.stderr?.emit(
273+
"data",
274+
Buffer.from("Invalid data found when processing input\n"),
275+
);
276+
mockChildProcess.emit("close", 1);
277+
});
278+
279+
await expect(resultPromise).rejects.toThrow("FFmpeg audible-audio check failed with code 1");
280+
});
281+
});
282+
210283
describe("getInstance", () => {
211284
it("should return the same instance (singleton)", () => {
212285
const instance1 = FFmpegService.getInstance();

src/backend/services/ffmpeg/ffmpeg-service.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,81 @@ export class FFmpegService {
111111
});
112112
}
113113

114+
async hasAudibleAudio(inputPath: string, minMaxVolumeDb = -50): Promise<boolean> {
115+
await this.ensureInputFileExists(inputPath);
116+
await this.ensureFfmpegExists();
117+
118+
return new Promise((resolve, reject) => {
119+
const args = [
120+
"-hide_banner",
121+
"-nostats",
122+
"-i",
123+
inputPath,
124+
"-map",
125+
"0:a:0",
126+
"-vn",
127+
"-af",
128+
"volumedetect",
129+
"-f",
130+
"null",
131+
"-",
132+
];
133+
134+
const ffmpegProcess = this.processSpawner.spawn(this.ffmpegPath, args);
135+
let stderr = "";
136+
137+
ffmpegProcess.stderr?.on("data", (data: Buffer) => {
138+
stderr += data.toString();
139+
});
140+
141+
ffmpegProcess.on("close", (code: number | null) => {
142+
if (code !== 0) {
143+
const normalized = stderr.toLowerCase();
144+
const indicatesNoAudio =
145+
normalized.includes("matches no streams") ||
146+
normalized.includes("does not contain any stream") ||
147+
normalized.includes("stream map");
148+
149+
if (indicatesNoAudio) {
150+
resolve(false);
151+
return;
152+
}
153+
154+
reject(
155+
new Error(
156+
`FFmpeg audible-audio check failed with code ${code ?? "unknown"}. Error: ${stderr || "Unknown error"}`,
157+
),
158+
);
159+
return;
160+
}
161+
162+
const maxVolumeMatch = stderr.match(/max_volume:\s*(-?inf|[-\d.]+)\s*dB/i);
163+
if (!maxVolumeMatch) {
164+
resolve(false);
165+
return;
166+
}
167+
168+
const raw = maxVolumeMatch[1].toLowerCase();
169+
if (raw === "-inf" || raw === "inf") {
170+
resolve(false);
171+
return;
172+
}
173+
174+
const maxVolumeDb = Number.parseFloat(raw);
175+
if (Number.isNaN(maxVolumeDb)) {
176+
resolve(false);
177+
return;
178+
}
179+
180+
resolve(maxVolumeDb >= minMaxVolumeDb);
181+
});
182+
183+
ffmpegProcess.on("error", (error: Error) => {
184+
reject(new Error(`Failed to start FFmpeg: ${error.message}`));
185+
});
186+
});
187+
}
188+
114189
async captureNthFrame(
115190
inputPath: string,
116191
outputImagePath: string,

src/ui/src/components/recording/ScreenRecorder.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,14 @@ export function ScreenRecorder() {
136136
}
137137

138138
const { filePath, fileName } = recordedVideo;
139+
const audioCheck = await window.electronAPI.screenRecording.hasAudio(filePath);
140+
if (audioCheck?.success && audioCheck.hasAudio === false) {
141+
toast.error(
142+
"No audio detected in this recording. Please re-record and make sure the correct microphone is selected and unmuted.",
143+
);
144+
return;
145+
}
146+
139147
resetPreview();
140148

141149
try {

src/ui/src/components/recording/VideoPreviewModal.tsx

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ export function VideoPreviewModal({
4141
}: VideoPreviewModalProps) {
4242
const [videoUrl, setVideoUrl] = useState("");
4343
const [showConfirmExit, setShowConfirmExit] = useState(false);
44+
const [audioCheck, setAudioCheck] = useState<
45+
| { status: "idle" }
46+
| { status: "checking" }
47+
| { status: "has_audio" }
48+
| { status: "no_audio" }
49+
| { status: "error"; error: string }
50+
>({ status: "idle" });
4451

4552
useEffect(() => {
4653
if (!videoBlob) return;
@@ -49,6 +56,32 @@ export function VideoPreviewModal({
4956
return () => URL.revokeObjectURL(url);
5057
}, [videoBlob]);
5158

59+
useEffect(() => {
60+
if (!open) {
61+
setAudioCheck({ status: "idle" });
62+
return;
63+
}
64+
65+
let cancelled = false;
66+
const run = async () => {
67+
setAudioCheck({ status: "checking" });
68+
const result = await window.electronAPI.screenRecording.hasAudio(videoFilePath);
69+
if (cancelled) return;
70+
71+
if (!result?.success) {
72+
setAudioCheck({ status: "error", error: result?.error || "Audio check failed" });
73+
return;
74+
}
75+
76+
setAudioCheck(result.hasAudio ? { status: "has_audio" } : { status: "no_audio" });
77+
};
78+
79+
run();
80+
return () => {
81+
cancelled = true;
82+
};
83+
}, [open, videoFilePath]);
84+
5285
const cleanupFile = () => window.electronAPI.screenRecording.cleanupTempFile(videoFilePath);
5386

5487
const confirmExit = async () => {
@@ -78,12 +111,26 @@ export function VideoPreviewModal({
78111
/>
79112
)}
80113

114+
{audioCheck.status === "checking" && (
115+
<p className="text-sm text-muted-foreground">Checking audio…</p>
116+
)}
117+
{audioCheck.status === "no_audio" && (
118+
<div className="rounded-md border border-ssw-red/30 bg-ssw-red/10 px-3 py-2 text-sm text-ssw-red-foreground">
119+
No audio detected in this recording. Please re-record and make sure the correct
120+
microphone is selected and unmuted.
121+
</div>
122+
)}
123+
81124
<DialogFooter className="gap-2">
82125
<Button variant="outline" onClick={handleRetry}>
83126
<RotateCcw className="w-4 h-4" />
84127
Re-record
85128
</Button>
86-
<Button variant="default" onClick={onContinue}>
129+
<Button
130+
variant="default"
131+
onClick={onContinue}
132+
disabled={audioCheck.status === "checking" || audioCheck.status === "no_audio"}
133+
>
87134
<ArrowRight className="w-4 h-4" />
88135
Continue
89136
</Button>

src/ui/src/services/ipc-client.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,9 @@ declare global {
8383
stop: (videoData: Uint8Array) => Promise<ScreenRecordingStopResult>;
8484
listSources: () => Promise<ScreenSource[]>;
8585
cleanupTempFile: (filePath: string) => Promise<void>;
86+
hasAudio: (
87+
filePath: string,
88+
) => Promise<{ success: boolean; hasAudio?: boolean; error?: string }>;
8689
showControlBar: (cameraDeviceId?: string) => Promise<{ success: boolean }>;
8790
hideControlBar: () => Promise<{ success: boolean }>;
8891
stopFromControlBar: () => Promise<{ success: boolean }>;

0 commit comments

Comments
 (0)