@alexkroman1/aai-cli 6.10.0 → 6.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/scaffold/CLAUDE.md +58 -0
- package/dist/scaffold/package.json +3 -3
- package/dist/scaffold/server.mjs +12 -3
- package/dist/scaffold/vite.config.ts +1 -1
- package/dist/templates/call-audit/agent.test.ts +965 -0
- package/dist/templates/call-audit/agent.ts +158 -0
- package/dist/templates/call-audit/client.tsx +235 -0
- package/dist/templates/call-audit/workflows/audit.ts +305 -0
- package/dist/templates/call-audit/workflows/ingest.ts +259 -0
- package/dist/templates/call-audit/workflows/media.ts +647 -0
- package/dist/templates/call-audit/workflows/summarize.ts +206 -0
- package/dist/templates/call-audit/workflows/sync-api.ts +44 -0
- package/dist/templates/call-audit/workflows/temp-media.ts +138 -0
- package/dist/templates/recap-workflow/agent.test.ts +11 -3
- package/dist/templates/recap-workflow/workflows/recap.ts +19 -8
- package/dist/templates/spoken-summary/agent.test.ts +343 -0
- package/dist/templates/spoken-summary/agent.ts +142 -0
- package/dist/templates/spoken-summary/client.tsx +225 -0
- package/dist/templates/spoken-summary/workflows/summarize.ts +242 -0
- package/dist/templates/spoken-summary/workflows/transcribe.ts +145 -0
- package/dist/templates/transcription-workflow/agent.test.ts +241 -18
- package/dist/templates/transcription-workflow/agent.ts +20 -6
- package/dist/templates/transcription-workflow/workflows/batch.ts +75 -173
- package/dist/templates/transcription-workflow/workflows/normalize.ts +343 -0
- package/dist/templates/transcription-workflow/workflows/stream.ts +6 -4
- package/dist/templates/transcription-workflow/workflows/sync-api.ts +26 -94
- package/dist/templates/transcription-workflow/workflows/transcribe.ts +23 -14
- package/dist/templates/transcription-workflow/workflows/wav.ts +31 -0
- package/package.json +3 -3
|
@@ -17,14 +17,18 @@
|
|
|
17
17
|
* the decoder happily transcribes into confident nonsense.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
import { readdir } from "node:fs/promises";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { FfmpegError } from "@alexkroman1/aai/ffmpeg";
|
|
20
23
|
import { stubReporter, stubStepFetch, stubUploads } from "@alexkroman1/aai/testing";
|
|
21
24
|
import { omitUndefined, readUpload } from "@alexkroman1/aai/utils";
|
|
22
25
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
23
26
|
import { FatalError, RetryableError } from "workflow";
|
|
24
27
|
import { z } from "zod";
|
|
25
28
|
import agentDef, { transcribe, transcribeBatch, transcribeStream } from "./agent.ts";
|
|
26
|
-
import { createJob, pollTranscript,
|
|
27
|
-
import {
|
|
29
|
+
import { createJob, pollTranscript, uploadToProvider } from "./workflows/batch.ts";
|
|
30
|
+
import { classifyFfmpeg, cuttable, normalizeRecording } from "./workflows/normalize.ts";
|
|
31
|
+
import { expectedSegments, planStreamed, probeUpload } from "./workflows/stream.ts";
|
|
28
32
|
import {
|
|
29
33
|
clock,
|
|
30
34
|
mergeTranscript,
|
|
@@ -819,45 +823,59 @@ describe("the async flow", () => {
|
|
|
819
823
|
stubBatch(() => ({ body: { status: "error", error: "audio too quiet" } }));
|
|
820
824
|
// The provider has decided; no number of polls changes it, so this must not come
|
|
821
825
|
// back as "not done yet".
|
|
822
|
-
await expect(pollTranscript("tr_1")).rejects.toBeInstanceOf(FatalError);
|
|
826
|
+
await expect(pollTranscript(UPLOAD_ID, "tr_1", STARTED_AT)).rejects.toBeInstanceOf(FatalError);
|
|
823
827
|
});
|
|
824
828
|
|
|
825
829
|
test("pollTranscript answers `done` on completed and not before", async () => {
|
|
826
830
|
publishRecording(new Uint8Array(10));
|
|
827
831
|
stubBatch(() => ({ body: { status: "processing" } }));
|
|
828
|
-
await expect(pollTranscript("tr_1")).resolves.toEqual({ done: false
|
|
832
|
+
await expect(pollTranscript(UPLOAD_ID, "tr_1", STARTED_AT)).resolves.toEqual({ done: false });
|
|
829
833
|
});
|
|
830
834
|
|
|
831
835
|
test("an unknown status is NOT done, so a new one cannot end a run early", async () => {
|
|
832
836
|
publishRecording(new Uint8Array(10));
|
|
833
837
|
stubBatch(() => ({ body: {} }));
|
|
834
|
-
await expect(pollTranscript("tr_1")).resolves.toMatchObject({
|
|
838
|
+
await expect(pollTranscript(UPLOAD_ID, "tr_1", STARTED_AT)).resolves.toMatchObject({
|
|
839
|
+
done: false,
|
|
840
|
+
});
|
|
835
841
|
});
|
|
836
842
|
|
|
837
|
-
test("
|
|
843
|
+
test("a completed poll carries the transcript — ONE request, not two", async () => {
|
|
838
844
|
publishRecording(new Uint8Array(10), "standup.wav");
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
845
|
+
// It used to poll for a status and then fetch the identical URL again for the
|
|
846
|
+
// text the poll already had in its hand.
|
|
847
|
+
const calls = stubBatch(() => ({
|
|
848
|
+
body: { status: "completed", text: " hello there ", audio_duration: 12.5 },
|
|
849
|
+
}));
|
|
850
|
+
await expect(pollTranscript(UPLOAD_ID, "tr_1", STARTED_AT)).resolves.toMatchObject({
|
|
851
|
+
done: true,
|
|
852
|
+
transcript: {
|
|
853
|
+
source: "standup.wav",
|
|
854
|
+
// Not a fudge: the async API transcribed the recording in one piece, which is
|
|
855
|
+
// the difference this flow is here to show.
|
|
856
|
+
segments: 1,
|
|
857
|
+
durationMs: 12_500,
|
|
858
|
+
words: 2,
|
|
859
|
+
transcript: "hello there",
|
|
860
|
+
},
|
|
848
861
|
});
|
|
862
|
+
expect(calls).toHaveLength(1);
|
|
849
863
|
});
|
|
850
864
|
|
|
851
865
|
test("a rate limit is RETRYABLE, so a busy minute does not fail the run", async () => {
|
|
852
866
|
publishRecording(new Uint8Array(10));
|
|
853
867
|
stubBatch(() => ({ status: 429, body: { error: "slow down" } }));
|
|
854
|
-
await expect(pollTranscript("tr_1")).rejects.toBeInstanceOf(
|
|
868
|
+
await expect(pollTranscript(UPLOAD_ID, "tr_1", STARTED_AT)).rejects.toBeInstanceOf(
|
|
869
|
+
RetryableError,
|
|
870
|
+
);
|
|
855
871
|
});
|
|
856
872
|
|
|
857
873
|
test("all three flows report the same SHAPE, which is what lets one page render any", async () => {
|
|
858
874
|
publishRecording(new Uint8Array(10), "standup.wav");
|
|
859
|
-
stubBatch(() => ({ body: { text: "hi", audio_duration: 1 } }));
|
|
860
|
-
const
|
|
875
|
+
stubBatch(() => ({ body: { status: "completed", text: "hi", audio_duration: 1 } }));
|
|
876
|
+
const progress = await pollTranscript(UPLOAD_ID, "tr_1", STARTED_AT);
|
|
877
|
+
if (!progress.done) return expect.fail("the stub reports a completed job");
|
|
878
|
+
const batched = progress.transcript;
|
|
861
879
|
// One key set, so the page's summary line renders every flow's output. A field on
|
|
862
880
|
// one flow and not the others is a panel that shows it for some runs and not
|
|
863
881
|
// others, with nothing saying why.
|
|
@@ -873,3 +891,208 @@ describe("the async flow", () => {
|
|
|
873
891
|
expect(batched.elapsedMs).toBeGreaterThan(0);
|
|
874
892
|
});
|
|
875
893
|
});
|
|
894
|
+
|
|
895
|
+
describe("normalizing the recording", () => {
|
|
896
|
+
/**
|
|
897
|
+
* The CONVERSION is not driven here, and the reason is the tier rather than the
|
|
898
|
+
* code: it spawns ffmpeg and writes a temp file, neither of which a unit test
|
|
899
|
+
* may do. What is reachable is everything that DECIDES — whether a file needs
|
|
900
|
+
* converting at all, and how a conversion's failure is classified — and those
|
|
901
|
+
* are the two places a mistake is silent. A file wrongly passed through fails
|
|
902
|
+
* later in `splitRecording` with a message about a header; a `timeout`
|
|
903
|
+
* classified as fatal is a run that gives up on work that would have finished.
|
|
904
|
+
*/
|
|
905
|
+
test("a canonical WAV is cuttable, so the desk converts nothing", () => {
|
|
906
|
+
expect(cuttable(wavFile(MONO_16K, 32_000), 44 + 32_000)).toBe(true);
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
test("an extra chunk before the samples is still cuttable", () => {
|
|
910
|
+
// The `LIST`-chunk case, which is the reason the probe window is 64 KB rather
|
|
911
|
+
// than 44 bytes: a file the walk CAN read must not be re-encoded.
|
|
912
|
+
const head = wavFile(MONO_16K, 32_000, { extraChunk: "recorder" });
|
|
913
|
+
expect(cuttable(head, head.length + 32_000)).toBe(true);
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
test("an m4a is not, which is what puts ffmpeg in the path", () => {
|
|
917
|
+
// An MPEG-4 `ftyp` box — what a phone recording really starts with.
|
|
918
|
+
const m4a = new Uint8Array([
|
|
919
|
+
0, 0, 0, 0x20, 0x66, 0x74, 0x79, 0x70, 0x4d, 0x34, 0x41, 0x20, 0, 0, 0, 0,
|
|
920
|
+
]);
|
|
921
|
+
expect(cuttable(m4a, 4_000_000)).toBe(false);
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
test("a WAV whose encoding is not linear PCM is not", () => {
|
|
925
|
+
// The case an `ffprobe`-based check gets WRONG, which is why the check is
|
|
926
|
+
// `parseWav` itself: this file reports a PCM codec to ffprobe and is refused
|
|
927
|
+
// by the parser, so a probe would pass it through and the cut would fail.
|
|
928
|
+
const extensible = wavFile(MONO_16K, 32_000);
|
|
929
|
+
new DataView(extensible.buffer).setUint16(20, 0xff_fe, true);
|
|
930
|
+
expect(cuttable(extensible, 44 + 32_000)).toBe(false);
|
|
931
|
+
});
|
|
932
|
+
|
|
933
|
+
test("a WAV too dense to cut is not, and downsampling is what repairs it", () => {
|
|
934
|
+
// Past `MAX_BYTES_PER_SECOND`, so `parseWav` refuses it — and a conversion to
|
|
935
|
+
// 16 kHz mono makes it cuttable, which is a fix the desk gets for free from
|
|
936
|
+
// asking the parser rather than asking about the codec.
|
|
937
|
+
const dense = wavFile({ sampleRate: 4_000_000, channels: 8, bitsPerSample: 32 }, 32_000);
|
|
938
|
+
expect(cuttable(dense, 44 + 32_000)).toBe(false);
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
test("an already-cuttable recording keeps the id it came in under", async () => {
|
|
942
|
+
// The property that matters: no second upload, so the fan-out reads the file
|
|
943
|
+
// the caller stored. A step that copied it would double the storage every run
|
|
944
|
+
// pays for and would still report success.
|
|
945
|
+
publishRecording(wavFile(MONO_16K, 32_000), "standup.wav");
|
|
946
|
+
const reporter = stubReporter();
|
|
947
|
+
try {
|
|
948
|
+
await expect(normalizeRecording(UPLOAD_ID)).resolves.toEqual({
|
|
949
|
+
recording: UPLOAD_ID,
|
|
950
|
+
converted: false,
|
|
951
|
+
});
|
|
952
|
+
expect(reporter.lines.join(" ")).toContain("already linear-PCM WAV");
|
|
953
|
+
} finally {
|
|
954
|
+
reporter.restore();
|
|
955
|
+
}
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
test("a conversion that ffmpeg REFUSED is fatal, so it is not attempted five times", () => {
|
|
959
|
+
// `exit` means ffmpeg read the file and would read it the same way again.
|
|
960
|
+
expect(() =>
|
|
961
|
+
classifyFfmpeg(
|
|
962
|
+
new FfmpegError({
|
|
963
|
+
kind: "exit",
|
|
964
|
+
message: "Invalid data found when processing input",
|
|
965
|
+
binary: "ffmpeg",
|
|
966
|
+
argv: [],
|
|
967
|
+
exitCode: 1,
|
|
968
|
+
}),
|
|
969
|
+
),
|
|
970
|
+
).toThrow(FatalError);
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
test("a conversion that ran out of time keeps its retries, and its argv", () => {
|
|
974
|
+
// Rethrown UNCHANGED rather than wrapped, which is what `toStepError` does
|
|
975
|
+
// with an error carrying no verdict — and the DevKit's default for anything
|
|
976
|
+
// that is not a `FatalError` is to retry. Asserting the class survives is
|
|
977
|
+
// asserting the diagnosis does: `argv` is the command you paste into a shell,
|
|
978
|
+
// and a `new RetryableError(message)` here would throw it away.
|
|
979
|
+
const timedOut = new FfmpegError({
|
|
980
|
+
kind: "timeout",
|
|
981
|
+
message: "timed out",
|
|
982
|
+
binary: "ffmpeg",
|
|
983
|
+
argv: ["-i", "source"],
|
|
984
|
+
});
|
|
985
|
+
expect(() => classifyFfmpeg(timedOut)).toThrow(timedOut);
|
|
986
|
+
expect(() => classifyFfmpeg(timedOut)).not.toThrow(FatalError);
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
test("no ffmpeg at all is fatal — a retry cannot install one", () => {
|
|
990
|
+
// The `aai dev` case. Fatal deliberately: the message already carries the
|
|
991
|
+
// install instructions, and four more attempts only delay a person reading it.
|
|
992
|
+
expect(() =>
|
|
993
|
+
classifyFfmpeg(
|
|
994
|
+
new FfmpegError({
|
|
995
|
+
kind: "missing-binary",
|
|
996
|
+
message: "ffmpeg is not installed",
|
|
997
|
+
binary: "ffmpeg",
|
|
998
|
+
argv: [],
|
|
999
|
+
}),
|
|
1000
|
+
),
|
|
1001
|
+
).toThrow(FatalError);
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
test("something that is not an ffmpeg failure at all is fatal", () => {
|
|
1005
|
+
// The store rejecting a write, say. Fatal rather than retryable because this
|
|
1006
|
+
// step's own `maxRetries` exists for the I/O halves that report themselves as
|
|
1007
|
+
// transient; an unrecognized error has said nothing about being worth another
|
|
1008
|
+
// attempt, and guessing yes is how a run burns its budget before failing.
|
|
1009
|
+
expect(() => classifyFfmpeg(new Error("no space left on device"))).toThrow(FatalError);
|
|
1010
|
+
});
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
describe("expectedSegments", () => {
|
|
1014
|
+
/** A plan over 16 kHz mono, cut into three 90-second segments. */
|
|
1015
|
+
const PLAN = {
|
|
1016
|
+
format: { ...MONO_16K, dataStart: 44, dataEnd: 44 + 270 * 32_000 },
|
|
1017
|
+
segments: [0, 1, 2].map((index) => ({
|
|
1018
|
+
index,
|
|
1019
|
+
start: 44 + index * 90 * 32_000,
|
|
1020
|
+
end: 44 + (index + 1) * 90 * 32_000,
|
|
1021
|
+
startMs: index * 90_000,
|
|
1022
|
+
endMs: (index + 1) * 90_000,
|
|
1023
|
+
})),
|
|
1024
|
+
};
|
|
1025
|
+
|
|
1026
|
+
test("counts every segment once the whole recording has arrived", () => {
|
|
1027
|
+
expect(expectedSegments(PLAN, PLAN.format.dataEnd)).toBe(3);
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
test("ignores segments that start past the end of a SHORT upload", () => {
|
|
1031
|
+
// The failure this guards is a run that never ENDS rather than one that fails:
|
|
1032
|
+
// the plan came from the header's declared length, so a recording that came up
|
|
1033
|
+
// short has segments beginning past the last byte, and waiting for them is
|
|
1034
|
+
// waiting for audio nobody is going to send.
|
|
1035
|
+
expect(expectedSegments(PLAN, 44 + 100 * 32_000)).toBe(2);
|
|
1036
|
+
expect(expectedSegments(PLAN, 44 + 1)).toBe(1);
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
test("an upload with nothing in it expects no segments at all", () => {
|
|
1040
|
+
expect(expectedSegments(PLAN, 0)).toBe(0);
|
|
1041
|
+
});
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
describe("the conversion, up to the spawn", () => {
|
|
1045
|
+
/**
|
|
1046
|
+
* Reaches the point where ffmpeg would run and stops there, DETERMINISTICALLY.
|
|
1047
|
+
*
|
|
1048
|
+
* `AAI_FFPROBE_PATH` names the binary the SDK resolves, so pointing it at a path
|
|
1049
|
+
* that does not exist produces `kind: "missing-binary"` on every machine — one
|
|
1050
|
+
* where ffmpeg is installed, one where it is not, and CI's Linux leg alike. A
|
|
1051
|
+
* test that instead relied on ffmpeg being ABSENT would pass on a laptop and
|
|
1052
|
+
* behave differently in CI.
|
|
1053
|
+
*
|
|
1054
|
+
* What it covers is the whole step up to the subprocess: reading the header,
|
|
1055
|
+
* deciding the file needs converting, and materializing it to a temp file. Plus
|
|
1056
|
+
* the behaviour a developer actually meets — `aai dev` with no ffmpeg is the one
|
|
1057
|
+
* place dev/prod parity is partial, and it must fail FATALLY with an installable
|
|
1058
|
+
* remedy rather than burn five attempts on a binary that will not appear.
|
|
1059
|
+
*/
|
|
1060
|
+
test("materializes the recording, then fails fatally with no ffprobe", async () => {
|
|
1061
|
+
vi.stubEnv("AAI_FFPROBE_PATH", "/nonexistent/aai-test/ffprobe");
|
|
1062
|
+
vi.stubEnv("AAI_FFMPEG_PATH", "/nonexistent/aai-test/ffmpeg");
|
|
1063
|
+
// An m4a `ftyp` box, so `cuttable` says no and the conversion path is taken.
|
|
1064
|
+
publishRecording(
|
|
1065
|
+
new Uint8Array([0, 0, 0, 0x20, 0x66, 0x74, 0x79, 0x70, 0x4d, 0x34, 0x41, 0x20]),
|
|
1066
|
+
"standup.m4a",
|
|
1067
|
+
);
|
|
1068
|
+
const reporter = stubReporter();
|
|
1069
|
+
try {
|
|
1070
|
+
// Fatal, not retryable: four more attempts find the same missing binary, and
|
|
1071
|
+
// the message already carries the install instructions.
|
|
1072
|
+
await expect(normalizeRecording(UPLOAD_ID)).rejects.toThrow(/ffprobe/);
|
|
1073
|
+
await expect(normalizeRecording(UPLOAD_ID)).rejects.toBeInstanceOf(FatalError);
|
|
1074
|
+
// It got as far as deciding the file needs converting — the failure is the
|
|
1075
|
+
// binary, not the input.
|
|
1076
|
+
expect(reporter.lines.join(" ")).toContain("standup.m4a");
|
|
1077
|
+
expect(normalizeRecording.maxRetries).toBe(5);
|
|
1078
|
+
} finally {
|
|
1079
|
+
reporter.restore();
|
|
1080
|
+
}
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
test("leaves no temp directory behind when the conversion fails", async () => {
|
|
1084
|
+
// The `finally`, on the path that matters: a guest's disk is small, and a step
|
|
1085
|
+
// that leaked a directory per failed run would fill it.
|
|
1086
|
+
vi.stubEnv("AAI_FFPROBE_PATH", "/nonexistent/aai-test/ffprobe");
|
|
1087
|
+
const leaked = (names: string[]) => names.filter((n) => n.startsWith("aai-normalize-"));
|
|
1088
|
+
const before = leaked(await readdir(tmpdir()));
|
|
1089
|
+
publishRecording(new Uint8Array([0, 0, 0, 0x20, 0x66, 0x74, 0x79, 0x70]), "standup.m4a");
|
|
1090
|
+
const reporter = stubReporter();
|
|
1091
|
+
try {
|
|
1092
|
+
await expect(normalizeRecording(UPLOAD_ID)).rejects.toBeInstanceOf(FatalError);
|
|
1093
|
+
expect(leaked(await readdir(tmpdir()))).toEqual(before);
|
|
1094
|
+
} finally {
|
|
1095
|
+
reporter.restore();
|
|
1096
|
+
}
|
|
1097
|
+
});
|
|
1098
|
+
});
|
|
@@ -21,10 +21,14 @@
|
|
|
21
21
|
* `DATABASE_URL` under `aai dev`). REQUIRED here, unlike most workflow apps:
|
|
22
22
|
* a run survives without it, but an UPLOAD's record is a row, so the form
|
|
23
23
|
* below refuses by name until storage is on.
|
|
24
|
-
* - **
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
24
|
+
* - **ffmpeg, under `aai dev` only.** A deployed guest's image installs it; on a
|
|
25
|
+
* laptop it is whatever is on `PATH` (or `AAI_FFMPEG_PATH`). The `transcribe`
|
|
26
|
+
* flow needs it for anything that is not already a linear-PCM WAV, because the
|
|
27
|
+
* cutting is arithmetic over byte offsets and that is only possible on
|
|
28
|
+
* uncompressed audio — `workflows/normalize.ts` is the conversion and
|
|
29
|
+
* `workflows/wav.ts` is why it has to happen. A WAV needs no ffmpeg at all,
|
|
30
|
+
* and `transcribeStream` never uses it: a recording that is still uploading is
|
|
31
|
+
* not something a decoder can be pointed at.
|
|
28
32
|
*
|
|
29
33
|
* ## The recording is UPLOADED, and the run carries its id
|
|
30
34
|
*
|
|
@@ -49,7 +53,8 @@
|
|
|
49
53
|
* | client sends | `POST /uploads` | `PUT /uploads/<id>` | `POST /uploads` |
|
|
50
54
|
* | shape | plan, fan out, merge | poll, fan out, merge | submit, poll, read |
|
|
51
55
|
* | client hook | `useWorkflowSubmit` | `useWorkflowStream` | `useWorkflowSubmit` |
|
|
52
|
-
* | accepts |
|
|
56
|
+
* | accepts | **any audio** | linear-PCM WAV | **any audio** |
|
|
57
|
+
* | converts first | when it must | never | not needed |
|
|
53
58
|
* | segments | 7 for 10 minutes | 7 | **1** |
|
|
54
59
|
*
|
|
55
60
|
* The first two are the same fan-out arranged two ways, and the difference between
|
|
@@ -102,7 +107,12 @@ export const transcribe = workflow({
|
|
|
102
107
|
input: z.object({
|
|
103
108
|
// A plain string, because an upload id is what the run really receives. What
|
|
104
109
|
// makes it a file picker rather than a text box is the `uploads` line below.
|
|
105
|
-
|
|
110
|
+
//
|
|
111
|
+
// It says "any recording" now, and that is the whole of what
|
|
112
|
+
// `workflows/normalize.ts` bought: this used to name linear-PCM WAV and mean
|
|
113
|
+
// it, so the desk's real front door was a sentence telling the caller to run
|
|
114
|
+
// ffmpeg themselves. The cut still needs WAV — the run makes one.
|
|
115
|
+
recording: z.string().describe("Any recording — WAV, MP3, M4A, or a video's audio track"),
|
|
106
116
|
}),
|
|
107
117
|
// The one line that makes the form take a file: `<WorkflowFields>` renders a
|
|
108
118
|
// picker for this property, `useWorkflowSubmit` stores the chosen file, and
|
|
@@ -128,6 +138,10 @@ export const transcribe = workflow({
|
|
|
128
138
|
export const transcribeStream = workflow({
|
|
129
139
|
description: "Transcribe a recording while it is still uploading",
|
|
130
140
|
input: z.object({
|
|
141
|
+
// Still WAV, and this is the one flow where that is not a limitation to be
|
|
142
|
+
// fixed: it cuts the recording while the bytes are arriving, and a partial
|
|
143
|
+
// file is not something ffmpeg can transcode. `transcribe` converts because
|
|
144
|
+
// it has the whole file before it plans anything.
|
|
131
145
|
recording: z.string().describe("A linear-PCM WAV recording (16-bit or 8-bit, any rate)"),
|
|
132
146
|
}),
|
|
133
147
|
uploads: ["recording"],
|