Skip to content

Commit babd5e6

Browse files
Merge pull request #2244 from CapSoftware/codex/fix-transcription-stream-probe
fix: recover valid audio probes and classify no-speech transcription
2 parents 0cb42df + 94e8838 commit babd5e6

5 files changed

Lines changed: 79 additions & 10 deletions

File tree

apps/media-server/Dockerfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ RUN bun test src/__tests__/lib/audio-quality-policy.test.ts src/__tests__/lib/au
1515
&& bun test src/__tests__/lib/audio-quality-formats.integration.test.ts src/__tests__/lib/audio-quality-benchmark.test.ts \
1616
&& bun test src/__tests__/lib/drive-resumable-upload.test.ts src/__tests__/lib/storage-upload.test.ts src/__tests__/lib/container-memory.test.ts \
1717
&& bun test src/__tests__/lib/media-size.test.ts src/__tests__/lib/media-probe.integration.test.ts \
18+
&& bun test src/__tests__/lib/media-audio.integration.test.ts \
1819
&& bun test src/__tests__/lib/recording-verification.integration.test.ts src/__tests__/lib/job-manager.test.ts \
1920
&& bun test src/__tests__/lib/media-transfer.test.ts \
2021
&& bun test src/__tests__/routes/recording-verification.test.ts \

apps/media-server/src/__tests__/lib/media-audio.integration.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,48 @@ describe("mediaAudio integration tests", () => {
104104
expect(hasAudio).toBe(false);
105105
});
106106

107+
test.each([
108+
[90, true],
109+
[-90, true],
110+
[180, true],
111+
[90, false],
112+
] as const)(
113+
"detects audio with display rotation %i and audio=%s",
114+
async (rotation, hasAudio) => {
115+
const dirPath = await mkdtemp(join(tmpdir(), "cap-audio-rotation-"));
116+
const outputPath = join(dirPath, "rotated.mp4");
117+
try {
118+
const proc = Bun.spawn({
119+
cmd: [
120+
"ffmpeg",
121+
"-v",
122+
"error",
123+
"-display_rotation",
124+
String(rotation),
125+
"-i",
126+
hasAudio ? TEST_VIDEO_WITH_AUDIO : TEST_VIDEO_NO_AUDIO,
127+
"-c",
128+
"copy",
129+
outputPath,
130+
],
131+
stdout: "ignore",
132+
stderr: "pipe",
133+
});
134+
const [stderr, exitCode] = await Promise.all([
135+
new Response(proc.stderr).text(),
136+
proc.exited,
137+
]);
138+
expect(stderr).toBe("");
139+
expect(exitCode).toBe(0);
140+
expect(await checkHasAudioTrack(`file://${outputPath}`)).toBe(
141+
hasAudio,
142+
);
143+
} finally {
144+
await rm(dirPath, { recursive: true, force: true });
145+
}
146+
},
147+
);
148+
107149
test("inherits signed queries for relative HLS segments", async () => {
108150
const dirPath = await createHlsFixture();
109151
const requests: string[] = [];
@@ -134,6 +176,21 @@ describe("mediaAudio integration tests", () => {
134176
}
135177
});
136178

179+
test("still rejects audio-only inputs without leaking an operation", async () => {
180+
const beforeCount = getActiveProcessCount();
181+
const dirPath = await mkdtemp(join(tmpdir(), "cap-audio-only-"));
182+
const outputPath = join(dirPath, "audio.mp3");
183+
try {
184+
await Bun.write(outputPath, await extractAudio(TEST_VIDEO_WITH_AUDIO));
185+
await expect(
186+
checkHasAudioTrack(`file://${outputPath}`),
187+
).rejects.toThrow("No video stream found");
188+
await waitForAudioOperations(beforeCount);
189+
} finally {
190+
await rm(dirPath, { recursive: true, force: true });
191+
}
192+
});
193+
137194
test("contains upstream failures without leaking the active operation", async () => {
138195
const beforeCount = getActiveProcessCount();
139196
const server = Bun.serve({

apps/media-server/src/lib/media-audio.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { mkdtemp, rm } from "node:fs/promises";
22
import { join } from "node:path";
33
import { type Subprocess, spawn } from "bun";
4+
import { z } from "zod";
45
import { withTimeout } from "./media-common";
56
import {
67
canAcceptNewAudioOperation,
@@ -31,6 +32,9 @@ const MAX_AUDIO_SIZE_BYTES = 100 * 1024 * 1024;
3132
const MAX_STDERR_BYTES = 64 * 1024;
3233
const AUDIO_PROBE_MAX_ATTEMPTS = 3;
3334
const AUDIO_PROBE_RETRY_BASE_MS = 250;
35+
const AUDIO_PROBE_SCHEMA = z.object({
36+
streams: z.array(z.object({ codec_type: z.string().optional() })),
37+
});
3438

3539
const DEFAULT_OPTIONS: Required<AudioExtractionOptions> = {
3640
format: "mp3",
@@ -144,7 +148,7 @@ function getAudioProbeArgs(inputPath: string): string[] {
144148
"0",
145149
);
146150
}
147-
args.push("-show_entries", "stream=codec_type", "-of", "csv=p=0", inputPath);
151+
args.push("-show_entries", "stream=codec_type", "-of", "json", inputPath);
148152
return args;
149153
}
150154

@@ -224,10 +228,8 @@ async function probeAudioTracks(
224228
const safeStderrText = redactProcessOutput(stderrText, sourceUrl);
225229

226230
if (exitCode === 0) {
227-
const trackTypes = stdoutText
228-
.split(/\r?\n/)
229-
.map((value) => value.trim())
230-
.filter(Boolean);
231+
const { streams } = AUDIO_PROBE_SCHEMA.parse(JSON.parse(stdoutText));
232+
const trackTypes = streams.map((stream) => stream.codec_type);
231233
if (!trackTypes.includes("video")) {
232234
throw new Error("No video stream found");
233235
}

apps/web/__tests__/integration/transcribe-workflow.test.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,12 @@ vi.mock("drizzle-orm", () => ({
9898

9999
vi.mock("server-only", () => ({}));
100100

101-
vi.mock("workflow", () => ({
102-
FatalError: class FatalError extends Error {},
103-
}));
101+
vi.mock("workflow", async () => {
102+
const { runInNewContext } = await import("node:vm");
103+
return {
104+
FatalError: runInNewContext("(class FatalError extends Error {})"),
105+
};
106+
});
104107

105108
vi.mock("workflow/api", () => ({
106109
start: vi.fn(),
@@ -245,7 +248,9 @@ describe("transcribeVideoWorkflow", () => {
245248
expect(mocks.updates.at(-1)).toEqual({ transcriptionStatus: "COMPLETE" });
246249
});
247250

248-
it("marks audio without speech as skipped without retrying transcription", async () => {
251+
it("handles no-speech errors across workflow realms without retrying transcription", async () => {
252+
const { FatalError } = await import("workflow");
253+
expect(new FatalError("no spoken audio")).not.toBeInstanceOf(Error);
249254
mocks.transcribe.mockResolvedValueOnce({
250255
id: "silent-transcript",
251256
status: "error",
@@ -268,6 +273,7 @@ describe("transcribeVideoWorkflow", () => {
268273
expect(mocks.updates).toContainEqual({ transcriptionStatus: "NO_AUDIO" });
269274
expect(mocks.updates).not.toContainEqual({ transcriptionStatus: "ERROR" });
270275
expect(mocks.startAiGeneration).not.toHaveBeenCalled();
276+
expect(mocks.deleteObject).toHaveBeenCalledTimes(1);
271277
});
272278

273279
it("preserves transcription failures unrelated to missing speech", async () => {

apps/web/workflows/transcribe.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,10 @@ export async function transcribeVideoWorkflow(
162162
await saveTranscription(videoId, userId, videoData.video, transcription);
163163
} catch (error) {
164164
if (
165-
error instanceof Error &&
165+
typeof error === "object" &&
166+
error !== null &&
167+
"message" in error &&
168+
typeof error.message === "string" &&
166169
error.message.toLowerCase().includes("no spoken audio")
167170
) {
168171
await markNoAudio(videoId);

0 commit comments

Comments
 (0)