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
5 changes: 5 additions & 0 deletions electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,10 @@ interface Window {
message?: string;
error?: string;
}>;
getAudioPeaks: (
filePath: string,
durationSec: number,
) => Promise<import("./media/audioPeaks").AudioPeaksResult>;
readFileChunk: (
filePath: string,
offset: number,
Expand Down Expand Up @@ -390,6 +394,7 @@ interface Window {
transcribe: (
request: import("./stt/transcriptionContract").SttTranscribeRequest,
) => Promise<import("./stt/transcriptionContract").SttTranscribeResponse>;
cancel: () => Promise<void>;
onStatus: (
callback: (event: import("./stt/transcriptionContract").SttStatusEvent) => void,
) => () => void;
Expand Down
26 changes: 26 additions & 0 deletions electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import { LlmConfigStore } from "../ai-edition/llm-config-store";
import { mainLogBuffer } from "../diagnostics/main-log-buffer";
import { mainT } from "../i18n";
import { RECORDINGS_DIR } from "../main";
import { type AudioPeaksResult, getAudioPeaks } from "../media/audioPeaks";
import {
readCursorRecordingFile as readCursorRecordingFileFrom,
readCursorSidecar,
Expand Down Expand Up @@ -3093,6 +3094,31 @@ export function registerIpcHandlers(
}
});

// Waveform peaks for a timeline clip, decoded natively (see media/audioPeaks).
// The renderer's own pipelines take ~12s on a 32-minute recording because they
// decode the whole track in Chromium; ffmpeg does the same work in ~2s off the
// UI process, and the result is cached on disk so it is paid once per file.
// `peaks: null` means "no native path available" — the caller falls back to
// its own decoding rather than losing the waveform.
ipcMain.handle(
"get-audio-peaks",
async (_, filePath: string, durationSec: number): Promise<AudioPeaksResult> => {
try {
// Same approval gate as every other read of a renderer-supplied path.
const normalizedPath = await approveReadableVideoPath(filePath);
if (!normalizedPath) {
return { success: false, message: "File path is not approved" };
}
const peaks = await getAudioPeaks(normalizedPath, durationSec);
return { success: true, peaks };
} catch (error) {
// A clip with no audio track lands here. Degrade quietly: the renderer
// draws no waveform, which is correct, and logs its own warning.
return { success: false, message: String(error) };
}
},
);

// Cap renderer-requested chunk sizes so a buggy or compromised renderer
// cannot make the main process allocate an arbitrarily large buffer.
const MAX_IPC_CHUNK_BYTES = 64 * 1024 * 1024;
Expand Down
Binary file added electron/media/__fixtures__/peaks-sample.m4a
Binary file not shown.
100 changes: 100 additions & 0 deletions electron/media/audioPeaks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// @vitest-environment node
import { existsSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { ffmpegCandidates, peakBlockCount, resolveFfmpeg } from "./audioPeaks";

const ROOT = path.resolve(__dirname, "..", "..");

describe("peakBlockCount", () => {
it("matches the browser pipelines' block maths", () => {
// Same formula as audioPeaksWorker.ts / streamingAudioPeaks.ts: a clip must
// not change shape depending on which pipeline drew it.
expect(peakBlockCount(10)).toBe(2000);
expect(peakBlockCount(60)).toBe(12000);
// Capped, so a 30-minute recording costs the same DOM/array budget as a
// 2-minute one.
expect(peakBlockCount(1951)).toBe(24000);
expect(peakBlockCount(99999)).toBe(24000);
});

it("never returns zero blocks for a sliver of audio", () => {
expect(peakBlockCount(0.001)).toBe(1);
});
});

describe("ffmpeg resolution", () => {
it("prefers the shared build the installer actually ships", () => {
const candidates = ffmpegCandidates(ROOT);
const shared = candidates.findIndex((c) => c.endsWith("ffmpeg-shared.exe"));
const vendorTree = candidates.findIndex((c) => c.includes("lgpl-shared"));
if (process.platform === "win32") {
expect(shared).toBeGreaterThanOrEqual(0);
// The static ffmpeg.exe is excluded from the Windows installer
// ("!win32-*/ffmpeg.exe"), so resolving to it would work in dev and fail
// in production. It must not be a candidate at all.
expect(
candidates.some((c) => c.endsWith(`bin${path.sep}win32-x64${path.sep}ffmpeg.exe`)),
).toBe(false);
expect(shared).toBeLessThan(vendorTree);
}
});

it("honours the env override first", () => {
process.env.OPENSCREEN_FFMPEG_PATH = "/custom/ffmpeg";
try {
expect(ffmpegCandidates(ROOT)[0]).toBe("/custom/ffmpeg");
} finally {
process.env.OPENSCREEN_FFMPEG_PATH = undefined;
}
});

it("returns null rather than throwing when nothing is staged", () => {
expect(resolveFfmpeg(path.join(ROOT, "does", "not", "exist"))).toBeNull();
});
});

// Only runs where the binary is actually staged; skipped elsewhere rather than
// failing a checkout that has not run scripts/fetch-ffmpeg.mjs.
const staged = resolveFfmpeg(ROOT);
describe.runIf(staged)("decoding a real file", () => {
it("produces peaks in range, with real signal in them", async () => {
// A synthetic 5s 440 Hz tone from ffmpeg's own lavfi source — no user
// recording in the repo, and a signal whose shape is known rather than
// "whatever this capture happened to contain".
const fixture = path.join(ROOT, "electron", "media", "__fixtures__", "peaks-sample.m4a");
if (!existsSync(fixture)) return;
const { getAudioPeaks } = await import("./audioPeaks");
const peaks = await getAudioPeaks(fixture, 5);
expect(peaks).not.toBeNull();
if (!peaks) return;
expect(peaks.length).toBe(peakBlockCount(5) * 2);
// [min, max] pairs, both inside [-1, 1], min <= 0 <= max (the folder starts
// each block at the silence baseline, like the worker does).
for (let i = 0; i < peaks.length; i += 2) {
expect(peaks[i]).toBeLessThanOrEqual(0);
expect(peaks[i + 1]).toBeGreaterThanOrEqual(0);
expect(peaks[i]).toBeGreaterThanOrEqual(-1);
expect(peaks[i + 1]).toBeLessThanOrEqual(1);
}
// Not all silence — otherwise everything above would pass on a pipeline
// that returned a zeroed array.
//
// The bound is tight rather than "> 0" because loose is the same as
// absent here: the mistakes worth catching are all scale errors — int16
// divided by 65536 instead of 32768, a stereo downmix halving the signal,
// a block whose samples never get compared — and every one of them is a
// factor of two. 0.214 is what ffmpeg itself decodes this fixture to
// (verified with `-f s16le` straight to a file), so this asserts the fold
// agrees with the decoder rather than restating the fixture's nominal
// amplitude, which lavfi's volume filter does not actually deliver.
let mn = 0;
let mx = 0;
for (const v of peaks) {
if (v < mn) mn = v;
if (v > mx) mx = v;
}
expect(mx).toBeCloseTo(0.214, 1);
expect(mn).toBeCloseTo(-0.205, 1);
}, 120_000);
});
Loading
Loading