@alexkroman1/aai-cli 6.10.1 → 7.0.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/{_agent-DpH2pBJd.mjs → _agent-BTwZJu4P.mjs} +2 -2
- package/dist/{_bundler-C2BDwU7r.mjs → _bundler-B4RqNF5Z.mjs} +2 -2
- package/dist/_dev-env.d.ts +22 -2
- package/dist/{_dev-server-LLLWnFBs.mjs → _dev-server-CiKFH9qw.mjs} +38 -47
- package/dist/_dev-server.d.ts +0 -17
- package/dist/{_init-C57bWAQ8.mjs → _init-CE4HKdgV.mjs} +3 -2
- package/dist/{_preflight-BtfaYtbE.mjs → _preflight-Co1G9Jww.mjs} +1 -1
- package/dist/_preflight.d.ts +1 -1
- package/dist/{_slug-api-BxrsJXVI.mjs → _slug-api-nRFaBEPJ.mjs} +2 -2
- package/dist/{_studio-U3jhKlA3.mjs → _studio-DR5yzZ1T.mjs} +2 -1
- package/dist/{_templates-Dxb_P2Wz.mjs → _templates-FI3xPEMj.mjs} +1 -1
- package/dist/_workflow-scan.d.ts +108 -0
- package/dist/{build-Dfza2pRI.mjs → build-6jfaakgc.mjs} +5 -2
- package/dist/cli.mjs +30 -25
- package/dist/{client-bundler-BEIqgOtd.mjs → client-bundler-CWnG42cU.mjs} +2 -1
- package/dist/client-bundler.mjs +1 -1
- package/dist/{delete-vbpjGzqs.mjs → delete-Dl_LSfvI.mjs} +3 -3
- package/dist/{deploy-B8lubiRT.mjs → deploy-DvYeD8ia.mjs} +5 -5
- package/dist/{dev-Uq5ujP8a.mjs → dev-B59JIfWO.mjs} +1 -1
- package/dist/{eject-31gjtaHF.mjs → eject-C9WJyyr2.mjs} +1 -1
- package/dist/{init-l6xfU4xX.mjs → init-CpJVjEg0.mjs} +3 -3
- package/dist/{login-C71-qz8F.mjs → login-BBuM1sxH.mjs} +3 -4
- package/dist/{logs-BWVGSjLR.mjs → logs-9q-psa4q.mjs} +2 -2
- package/dist/project-config.mjs +1 -1
- package/dist/scaffold/CLAUDE.md +93 -14
- package/dist/scaffold/package.json +5 -3
- package/dist/scaffold/server.mjs +13 -4
- package/dist/scaffold/vite.config.ts +1 -1
- package/dist/{secret-BuMuFR4B.mjs → secret-CVvSLIDV.mjs} +2 -2
- package/dist/{storage-DzRZ-eCw.mjs → storage-BvUrnvM3.mjs} +2 -2
- package/dist/{studio-BCNUpDgP.mjs → studio--MUV0cid.mjs} +4 -4
- package/dist/templates/call-audit/agent.test.ts +818 -0
- package/dist/templates/call-audit/agent.ts +171 -0
- package/dist/templates/call-audit/client.tsx +198 -0
- package/dist/templates/call-audit/workflows/audit.ts +298 -0
- package/dist/templates/call-audit/workflows/ingest.ts +251 -0
- package/dist/templates/call-audit/workflows/media.ts +637 -0
- package/dist/templates/call-audit/workflows/summarize.ts +201 -0
- package/dist/templates/call-audit/workflows/sync-api.ts +44 -0
- package/dist/templates/dispatch-center/agent.test.ts +149 -25
- package/dist/templates/dispatch-center/client.tsx +239 -129
- package/dist/templates/dispatch-center/shared.ts +99 -1
- package/dist/templates/dispatch-center/system-prompt.md +3 -1
- package/dist/templates/dispatch-center/tools/incident_add_note.ts +16 -13
- package/dist/templates/dispatch-center/tools/incident_create.ts +19 -1
- package/dist/templates/dispatch-center/tools/incident_escalate.ts +68 -59
- package/dist/templates/dispatch-center/tools/incident_triage.ts +57 -43
- package/dist/templates/dispatch-center/tools/incident_update_status.ts +49 -40
- package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +8 -2
- package/dist/templates/dispatch-center/tools/resources_dispatch.ts +69 -52
- package/dist/templates/dispatch-center/tools/resources_update_status.ts +45 -41
- package/dist/templates/embedded-assets/agent.test.ts +17 -14
- package/dist/templates/health-assistant/agent.test.ts +22 -4
- package/dist/templates/infocom-adventure/agent.test.ts +4 -6
- package/dist/templates/infocom-adventure/client.tsx +246 -164
- package/dist/templates/link-digest/agent.test.ts +24 -19
- package/dist/templates/link-digest/client.tsx +47 -61
- package/dist/templates/link-digest/workflows/digest.ts +19 -31
- package/dist/templates/night-owl/agent.test.ts +70 -19
- package/dist/templates/night-owl/agent.ts +5 -0
- package/dist/templates/night-owl/client.tsx +56 -56
- package/dist/templates/night-owl/shared.ts +24 -0
- package/dist/templates/night-owl/tools/recommend.ts +18 -6
- package/dist/templates/pizza-ordering/agent.test.ts +27 -27
- package/dist/templates/pizza-ordering/client.tsx +9 -26
- package/dist/templates/plan-and-execute/agent.test.ts +99 -58
- package/dist/templates/plan-and-execute/agent.ts +1 -1
- package/dist/templates/plan-and-execute/client.tsx +12 -15
- package/dist/templates/plan-and-execute/shared.ts +71 -2
- package/dist/templates/plan-and-execute/tools/plan_status.ts +18 -6
- package/dist/templates/plan-and-execute/tools/revise_plan.ts +18 -10
- package/dist/templates/plan-and-execute/tools/start_plan.ts +13 -2
- package/dist/templates/plan-and-execute/tools/work_next_step.ts +70 -22
- package/dist/templates/podcast-digest/agent.test.ts +746 -0
- package/dist/templates/podcast-digest/agent.ts +139 -0
- package/dist/templates/podcast-digest/client.tsx +154 -0
- package/dist/templates/podcast-digest/workflows/digest.ts +411 -0
- package/dist/templates/podcast-digest/workflows/feeds.ts +507 -0
- package/dist/templates/podcast-digest/workflows/slack.ts +209 -0
- package/dist/templates/recap-workflow/agent.test.ts +107 -96
- package/dist/templates/recap-workflow/tools/recap_progress.ts +9 -12
- package/dist/templates/recap-workflow/tools/recap_status.ts +8 -9
- package/dist/templates/recap-workflow/tools/request_recap.ts +2 -1
- package/dist/templates/recap-workflow/workflows/recap.ts +48 -41
- package/dist/templates/redline/agent.test.ts +17 -15
- package/dist/templates/redline/client.tsx +12 -12
- package/dist/templates/redline/workflows/redline.ts +19 -31
- package/dist/templates/research-workflow/agent.test.ts +60 -59
- package/dist/templates/research-workflow/tools/research_progress.ts +9 -12
- package/dist/templates/research-workflow/tools/research_status.ts +9 -11
- package/dist/templates/research-workflow/workflows/research.ts +44 -61
- package/dist/templates/retail/agent.test.ts +26 -23
- package/dist/templates/retail/client.tsx +226 -117
- package/dist/templates/retail/registry.test.ts +38 -6
- package/dist/templates/retail/store.test.ts +82 -15
- package/dist/templates/retail/store.ts +174 -47
- package/dist/templates/retail/system-prompt.md +11 -2
- package/dist/templates/retail/tools/cancel_pending_order.ts +2 -2
- package/dist/templates/retail/tools/exchange_delivered_order_items.ts +2 -2
- package/dist/templates/retail/tools/find_user_id_by_email.ts +8 -5
- package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +4 -5
- package/dist/templates/retail/tools/get_item_details.ts +3 -4
- package/dist/templates/retail/tools/get_order_details.ts +3 -4
- package/dist/templates/retail/tools/get_product_details.ts +3 -4
- package/dist/templates/retail/tools/get_user_details.ts +2 -2
- package/dist/templates/retail/tools/list_all_product_types.ts +5 -5
- package/dist/templates/retail/tools/modify_pending_order_address.ts +2 -2
- package/dist/templates/retail/tools/modify_pending_order_items.ts +2 -3
- package/dist/templates/retail/tools/modify_pending_order_payment.ts +2 -2
- package/dist/templates/retail/tools/modify_user_address.ts +2 -2
- package/dist/templates/retail/tools/return_delivered_order_items.ts +2 -2
- package/dist/templates/retail/tools/transfer_to_human_agents.ts +8 -4
- package/dist/templates/solo-rpg/agent.test.ts +227 -48
- package/dist/templates/solo-rpg/client.tsx +38 -37
- package/dist/templates/solo-rpg/shared.ts +145 -19
- package/dist/templates/solo-rpg/system-prompt.md +3 -2
- package/dist/templates/solo-rpg/tools/action_roll.ts +82 -63
- package/dist/templates/solo-rpg/tools/burn_momentum.ts +85 -50
- package/dist/templates/solo-rpg/tools/check_state.ts +24 -7
- package/dist/templates/solo-rpg/tools/load_game.ts +13 -1
- package/dist/templates/solo-rpg/tools/save_game.ts +16 -7
- package/dist/templates/solo-rpg/tools/setup_character.ts +22 -2
- package/dist/templates/solo-rpg/tools/update_state.ts +113 -100
- package/dist/templates/spoken-summary/agent.test.ts +314 -0
- package/dist/templates/spoken-summary/agent.ts +155 -0
- package/dist/templates/spoken-summary/client.tsx +185 -0
- package/dist/templates/spoken-summary/workflows/summarize.ts +237 -0
- package/dist/templates/spoken-summary/workflows/transcribe.ts +138 -0
- package/dist/templates/support-line/agent.test.ts +11 -16
- package/dist/templates/support-line/agent.ts +1 -1
- package/dist/templates/support-line/client.tsx +9 -9
- package/dist/templates/support-line/nodes.ts +100 -0
- package/dist/templates/support-line/procedure.ts +407 -0
- package/dist/templates/support-line/tools/answer_question.ts +17 -4
- package/dist/templates/transcription-workflow/agent.test.ts +246 -123
- package/dist/templates/transcription-workflow/agent.ts +21 -7
- package/dist/templates/transcription-workflow/client.tsx +17 -42
- package/dist/templates/transcription-workflow/workflows/batch.ts +79 -180
- package/dist/templates/transcription-workflow/workflows/normalize.ts +247 -0
- package/dist/templates/transcription-workflow/workflows/stitch.ts +0 -11
- package/dist/templates/transcription-workflow/workflows/stream.ts +10 -9
- package/dist/templates/transcription-workflow/workflows/sync-api.ts +26 -94
- package/dist/templates/transcription-workflow/workflows/transcribe.ts +55 -40
- package/dist/templates/transcription-workflow/workflows/wav.ts +31 -31
- package/dist/templates/travel-concierge/agent.test.ts +64 -33
- package/dist/templates/travel-concierge/client.tsx +11 -23
- package/dist/templates/travel-concierge/routing.ts +34 -15
- package/dist/templates/travel-concierge/shared.ts +70 -3
- package/dist/templates/travel-concierge/tools/book_car_rental.ts +2 -2
- package/dist/templates/travel-concierge/tools/book_excursion.ts +2 -2
- package/dist/templates/travel-concierge/tools/book_hotel.ts +2 -2
- package/dist/templates/travel-concierge/tools/cancel_ticket.ts +2 -2
- package/dist/templates/travel-concierge/tools/update_ticket.ts +2 -2
- package/dist/{worker-bundler-CGD4r8Kc.mjs → worker-bundler-COxnqstQ.mjs} +221 -3
- package/dist/worker-bundler.mjs +1 -1
- package/dist/{workflow-CFpxOFfQ.mjs → workflow-D2AQf2Pl.mjs} +28 -1
- package/dist/workflow-bundler.d.ts +6 -1
- package/dist/workflow.d.ts +1 -1
- package/package.json +5 -4
- package/dist/templates/support-line/graph.ts +0 -224
- package/dist/{_config-D_s09e7g.mjs → _config-CmJOFsAP.mjs} +1 -1
- /package/dist/templates/plan-and-execute/{graph.ts → procedure.ts} +0 -0
|
@@ -17,16 +17,23 @@
|
|
|
17
17
|
* the decoder happily transcribes into confident nonsense.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
20
|
+
import { readdir } from "node:fs/promises";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { readUpload } from "@alexkroman1/aai/step";
|
|
23
|
+
import {
|
|
24
|
+
installStubReporter,
|
|
25
|
+
installStubStepFetch,
|
|
26
|
+
installStubTranscribe,
|
|
27
|
+
installStubUploads,
|
|
28
|
+
} from "@alexkroman1/aai/testing/vitest";
|
|
29
|
+
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
23
30
|
import { FatalError, RetryableError } from "workflow";
|
|
24
31
|
import { z } from "zod";
|
|
25
32
|
import agentDef, { transcribe, transcribeBatch, transcribeStream } from "./agent.ts";
|
|
26
|
-
import { createJob, pollTranscript,
|
|
27
|
-
import {
|
|
33
|
+
import { createJob, pollTranscript, uploadToProvider } from "./workflows/batch.ts";
|
|
34
|
+
import { cuttable, normalizeRecording } from "./workflows/normalize.ts";
|
|
35
|
+
import { expectedSegments, planStreamed, probeUpload } from "./workflows/stream.ts";
|
|
28
36
|
import {
|
|
29
|
-
clock,
|
|
30
37
|
mergeTranscript,
|
|
31
38
|
splitRecording,
|
|
32
39
|
stitchChunks,
|
|
@@ -45,12 +52,8 @@ import {
|
|
|
45
52
|
SEGMENT_SECONDS,
|
|
46
53
|
UnsupportedRecordingError,
|
|
47
54
|
type WavFormat,
|
|
48
|
-
wavWithHeader,
|
|
49
55
|
} from "./workflows/wav.ts";
|
|
50
56
|
|
|
51
|
-
/** Where the sync endpoint lives — the one URL these stubs answer differently. */
|
|
52
|
-
const SYNC_ORIGIN = "https://sync.assemblyai.com";
|
|
53
|
-
|
|
54
57
|
/** The id every spec below uploads under. */
|
|
55
58
|
const UPLOAD_ID = "upl_test";
|
|
56
59
|
|
|
@@ -72,16 +75,12 @@ const STARTED_AT = 1_000_000;
|
|
|
72
75
|
* bytes with no server, no database and no HTTP.
|
|
73
76
|
*/
|
|
74
77
|
function publishRecording(bytes: Uint8Array, name = "standup.wav") {
|
|
75
|
-
|
|
78
|
+
// `installStubUploads` rather than `stubUploads`: the fake registers its own
|
|
79
|
+
// `onTestFinished`, which is what replaced the three hand-kept restore
|
|
80
|
+
// registries this file used to carry.
|
|
81
|
+
installStubUploads({ [UPLOAD_ID]: { bytes, name, type: "audio/wav" } });
|
|
76
82
|
}
|
|
77
83
|
|
|
78
|
-
/** Unpublished between specs — a slot left behind reaches the next file. */
|
|
79
|
-
let restore: (() => void) | undefined;
|
|
80
|
-
afterEach(() => {
|
|
81
|
-
restore?.();
|
|
82
|
-
restore = undefined;
|
|
83
|
-
});
|
|
84
|
-
|
|
85
84
|
/** 16 kHz mono 16-bit — one second of audio is 32,000 bytes. */
|
|
86
85
|
const MONO_16K = { sampleRate: 16_000, channels: 1, bitsPerSample: 16 } as const;
|
|
87
86
|
|
|
@@ -143,8 +142,8 @@ describe("the agent declares its three workflows and nothing else", () => {
|
|
|
143
142
|
// every flow, and the streaming one differs only in that the CLIENT chose the id
|
|
144
143
|
// and PUT the file to it. A divergence here would mean the form had to ask a
|
|
145
144
|
// person how the bytes should travel.
|
|
146
|
-
for (const
|
|
147
|
-
expect(
|
|
145
|
+
for (const wf of [transcribe, transcribeStream, transcribeBatch]) {
|
|
146
|
+
expect(wf.uploads).toEqual(["recording"]);
|
|
148
147
|
}
|
|
149
148
|
});
|
|
150
149
|
|
|
@@ -371,24 +370,6 @@ describe("planSegments decides the fan-out's width", () => {
|
|
|
371
370
|
});
|
|
372
371
|
});
|
|
373
372
|
|
|
374
|
-
describe("wavWithHeader", () => {
|
|
375
|
-
test("writes a header the endpoint can read the rate back out of", () => {
|
|
376
|
-
const samples = new Uint8Array(3200).fill(7);
|
|
377
|
-
const out = wavWithHeader({ ...MONO_16K, dataStart: 44, dataEnd: 3244 }, samples);
|
|
378
|
-
const view = new DataView(out.buffer);
|
|
379
|
-
|
|
380
|
-
expect(String.fromCharCode(...out.subarray(0, 4))).toBe("RIFF");
|
|
381
|
-
expect(String.fromCharCode(...out.subarray(8, 12))).toBe("WAVE");
|
|
382
|
-
expect(view.getUint32(24, true)).toBe(MONO_16K.sampleRate);
|
|
383
|
-
expect(view.getUint16(22, true)).toBe(MONO_16K.channels);
|
|
384
|
-
// The two lengths, which are what a decoder trusts: RIFF counts everything
|
|
385
|
-
// after itself, `data` counts only the samples.
|
|
386
|
-
expect(view.getUint32(4, true)).toBe(36 + samples.length);
|
|
387
|
-
expect(view.getUint32(40, true)).toBe(samples.length);
|
|
388
|
-
expect(out.subarray(44)).toEqual(samples);
|
|
389
|
-
});
|
|
390
|
-
});
|
|
391
|
-
|
|
392
373
|
describe("stitchTranscript", () => {
|
|
393
374
|
test("removes the words the overlap made duplicates", () => {
|
|
394
375
|
expect(
|
|
@@ -468,13 +449,6 @@ describe("stitchChunks — what the PAGE renders while a run is going", () => {
|
|
|
468
449
|
});
|
|
469
450
|
});
|
|
470
451
|
|
|
471
|
-
describe("clock", () => {
|
|
472
|
-
test("renders a position a reader can find in the recording", () => {
|
|
473
|
-
expect(clock(0)).toBe("0:00");
|
|
474
|
-
expect(clock(65_000)).toBe("1:05");
|
|
475
|
-
});
|
|
476
|
-
});
|
|
477
|
-
|
|
478
452
|
describe("splitRecording", () => {
|
|
479
453
|
test("plans the segments and reports the duration", async () => {
|
|
480
454
|
const seconds = 200;
|
|
@@ -524,49 +498,40 @@ describe("transcribeSegment", () => {
|
|
|
524
498
|
/**
|
|
525
499
|
* Publishes the recording and answers the sync endpoint.
|
|
526
500
|
*
|
|
527
|
-
* `
|
|
528
|
-
*
|
|
529
|
-
* `
|
|
530
|
-
*
|
|
531
|
-
*
|
|
532
|
-
*
|
|
501
|
+
* `installStubTranscribe` routes AssemblyAI's four legs off the SDK's own
|
|
502
|
+
* endpoint constants, so this file no longer re-types the wire — and it fills a
|
|
503
|
+
* published `stepFetch` rather than `vi.stubGlobal("fetch", …)`, because the
|
|
504
|
+
* step calls `stepFetch`, which reaches a published slot rather than the global
|
|
505
|
+
* (see `sdk/step-fetch.ts` for why it has to: HTTP/1.1, so a batch of segments
|
|
506
|
+
* gets a socket each instead of N streams on one connection). Stubbing the
|
|
507
|
+
* global still passes, because an unpublished slot falls back to it, and would
|
|
508
|
+
* be asserting against a path production does not take.
|
|
509
|
+
*
|
|
510
|
+
* A refusal is staged as a STATUS, which is what makes the classification specs
|
|
511
|
+
* below a test of the SDK's reading of it rather than of an error a fake minted.
|
|
533
512
|
*/
|
|
534
|
-
function stubProvider(
|
|
535
|
-
sync: { status?: number; body?: unknown; headers?: Record<string, string> } = {},
|
|
536
|
-
) {
|
|
513
|
+
function stubProvider(failure?: { status: number; message: string; retryAfterSeconds?: number }) {
|
|
537
514
|
publishRecording(new Uint8Array(FORMAT.dataEnd));
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
}));
|
|
543
|
-
stubs.push(stub.restore);
|
|
544
|
-
return stub.calls;
|
|
515
|
+
return installStubTranscribe({
|
|
516
|
+
text: "hello there",
|
|
517
|
+
failure: failure === undefined ? undefined : { leg: "sync", ...failure },
|
|
518
|
+
}).calls;
|
|
545
519
|
}
|
|
546
520
|
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
test("sends the segment as a WAV, with the key and the model header", async () => {
|
|
521
|
+
test("sends the segment as a WAV named after its index", async () => {
|
|
522
|
+
// What the SYNC endpoint's request looks like — the URL, the raw-key auth, the
|
|
523
|
+
// multipart envelope — is the SDK's contract and `sdk/step-transcribe*.test.ts`
|
|
524
|
+
// owns it. What is left here is this template's: it re-attaches a header to a
|
|
525
|
+
// window it read, and it names the part after the segment.
|
|
554
526
|
const calls = stubProvider();
|
|
555
527
|
const result = await transcribeSegment(UPLOAD_ID, FORMAT, SEGMENT);
|
|
556
528
|
|
|
557
529
|
expect(result).toEqual({ index: 0, text: "hello there" });
|
|
558
|
-
const
|
|
559
|
-
//
|
|
560
|
-
//
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
// BYTES, and multipart — `stepFetch` takes no `FormData`, which is the
|
|
564
|
-
// point: a branded object handed to a fetch from another undici realm goes
|
|
565
|
-
// out as the string `[object FormData]`. `multipartBody` builds the envelope.
|
|
566
|
-
expect(sync?.headers["Content-Type"]).toMatch(/^multipart\/form-data; boundary=/);
|
|
567
|
-
const body = sync?.body;
|
|
568
|
-
expect(body).toBeInstanceOf(Uint8Array);
|
|
569
|
-
const decoded = new TextDecoder().decode(body as Uint8Array);
|
|
530
|
+
const sent = calls.find((call) => call.leg === "sync")?.body;
|
|
531
|
+
// Narrowed by a failing assertion rather than a cast: `expect.fail` returns
|
|
532
|
+
// `never`, and a body that is not bytes is a finding rather than a type error.
|
|
533
|
+
if (!(sent instanceof Uint8Array)) return expect.fail("the sync leg carries bytes");
|
|
534
|
+
const decoded = new TextDecoder().decode(sent);
|
|
570
535
|
expect(decoded).toContain('name="audio"; filename="segment-0.wav"');
|
|
571
536
|
// The WAV really rides in the part, header and all.
|
|
572
537
|
expect(decoded).toContain("RIFF");
|
|
@@ -577,8 +542,7 @@ describe("transcribeSegment", () => {
|
|
|
577
542
|
// page stitches whatever has arrived, so the transcript renders growing
|
|
578
543
|
// instead of appearing when the last segment does. The reporter is the SDK's
|
|
579
544
|
// published slot, which is the same seam `report()` goes through.
|
|
580
|
-
const reported =
|
|
581
|
-
stubs.push(reported.restore);
|
|
545
|
+
const reported = installStubReporter();
|
|
582
546
|
stubProvider();
|
|
583
547
|
|
|
584
548
|
await transcribeSegment(UPLOAD_ID, FORMAT, SEGMENT);
|
|
@@ -608,7 +572,7 @@ describe("transcribeSegment", () => {
|
|
|
608
572
|
// `RetryableError` carrying `retryAfter` is the difference between draining
|
|
609
573
|
// the 429s and re-collecting them `SEGMENT_CONCURRENCY` at a time on a
|
|
610
574
|
// backoff the server did not choose.
|
|
611
|
-
stubProvider({ status: 429,
|
|
575
|
+
stubProvider({ status: 429, message: "slow down", retryAfterSeconds: 30 });
|
|
612
576
|
const failure = await transcribeSegment(UPLOAD_ID, FORMAT, SEGMENT).catch(
|
|
613
577
|
(err: unknown) => err,
|
|
614
578
|
);
|
|
@@ -620,14 +584,14 @@ describe("transcribeSegment", () => {
|
|
|
620
584
|
});
|
|
621
585
|
|
|
622
586
|
test("retries a rate limit that named no delay", async () => {
|
|
623
|
-
stubProvider({ status: 429,
|
|
587
|
+
stubProvider({ status: 429, message: "slow down" });
|
|
624
588
|
await expect(transcribeSegment(UPLOAD_ID, FORMAT, SEGMENT)).rejects.toBeInstanceOf(
|
|
625
589
|
RetryableError,
|
|
626
590
|
);
|
|
627
591
|
});
|
|
628
592
|
|
|
629
593
|
test("fails FATALLY on a rejected request, naming what the endpoint said", async () => {
|
|
630
|
-
stubProvider({ status: 400,
|
|
594
|
+
stubProvider({ status: 400, message: "too short" });
|
|
631
595
|
await expect(transcribeSegment(UPLOAD_ID, FORMAT, SEGMENT)).rejects.toThrow(
|
|
632
596
|
/HTTP 400 — too short/,
|
|
633
597
|
);
|
|
@@ -687,7 +651,7 @@ describe("the streaming flow", () => {
|
|
|
687
651
|
function publishPartial(stored: number, declared: number, complete = false) {
|
|
688
652
|
const bytes = new Uint8Array(44 + stored);
|
|
689
653
|
bytes.set(wavFile(MONO_16K, declared), 0);
|
|
690
|
-
|
|
654
|
+
installStubUploads({
|
|
691
655
|
[UPLOAD_ID]: { bytes, name: "standup.wav", type: "audio/wav", complete },
|
|
692
656
|
});
|
|
693
657
|
}
|
|
@@ -721,12 +685,12 @@ describe("the streaming flow", () => {
|
|
|
721
685
|
// the file has finished — which is exactly what `transcribe` is for.
|
|
722
686
|
const bytes = new Uint8Array(44 + 100);
|
|
723
687
|
bytes.set(wavFile(MONO_16K, 100, { declaredDataSize: 0 }), 0);
|
|
724
|
-
|
|
688
|
+
installStubUploads({ [UPLOAD_ID]: { bytes, complete: false } });
|
|
725
689
|
await expect(planStreamed(UPLOAD_ID)).rejects.toThrow(/declares no data length/);
|
|
726
690
|
});
|
|
727
691
|
|
|
728
692
|
test("planStreamed refuses a file that is not a WAV, terminally", async () => {
|
|
729
|
-
|
|
693
|
+
installStubUploads({ [UPLOAD_ID]: { bytes: new Uint8Array(2000), complete: false } });
|
|
730
694
|
// Fatal, not retryable: three more attempts read the same bytes.
|
|
731
695
|
await expect(planStreamed(UPLOAD_ID)).rejects.toBeInstanceOf(FatalError);
|
|
732
696
|
});
|
|
@@ -757,21 +721,20 @@ describe("the async flow", () => {
|
|
|
757
721
|
vi.stubEnv("ASSEMBLYAI_API_KEY", "sk-test");
|
|
758
722
|
});
|
|
759
723
|
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
return stub.calls;
|
|
724
|
+
/**
|
|
725
|
+
* Answer the async API, recording what was sent.
|
|
726
|
+
*
|
|
727
|
+
* `installStubTranscribe` routes the three legs off the SDK's own endpoint
|
|
728
|
+
* constants — so a spec cannot pass because the fake and the step agree on a
|
|
729
|
+
* typo — and it restores itself when the test ends.
|
|
730
|
+
*/
|
|
731
|
+
function stubBatch(options: Parameters<typeof installStubTranscribe>[0] = {}) {
|
|
732
|
+
return installStubTranscribe({ audioUrl: "https://cdn.example/abc", ...options }).calls;
|
|
770
733
|
}
|
|
771
734
|
|
|
772
735
|
test("uploadToProvider streams the file and answers with the provider's URL", async () => {
|
|
773
736
|
publishRecording(new Uint8Array(5000), "standup.wav");
|
|
774
|
-
const calls = stubBatch(
|
|
737
|
+
const calls = stubBatch();
|
|
775
738
|
await expect(uploadToProvider(UPLOAD_ID)).resolves.toEqual({
|
|
776
739
|
audioUrl: "https://cdn.example/abc",
|
|
777
740
|
});
|
|
@@ -780,9 +743,9 @@ describe("the async flow", () => {
|
|
|
780
743
|
|
|
781
744
|
test("the file is STREAMED, so a step never holds a whole recording", async () => {
|
|
782
745
|
publishRecording(new Uint8Array(5000), "standup.wav");
|
|
783
|
-
const calls = stubBatch(
|
|
746
|
+
const calls = stubBatch();
|
|
784
747
|
await uploadToProvider(UPLOAD_ID);
|
|
785
|
-
//
|
|
748
|
+
// The fake drains a streaming body into bytes, so what this asserts is that
|
|
786
749
|
// every byte went out — the streaming is what keeps a gigabyte off the heap, and
|
|
787
750
|
// the bytes arriving intact is what says the windowing is right.
|
|
788
751
|
const sent = calls[0]?.body;
|
|
@@ -795,7 +758,7 @@ describe("the async flow", () => {
|
|
|
795
758
|
// thing five times and re-uploaded 24 MB on each attempt. The split is what makes
|
|
796
759
|
// a retry of the cheap half cost the cheap half.
|
|
797
760
|
publishRecording(new Uint8Array(5000));
|
|
798
|
-
const calls = stubBatch(
|
|
761
|
+
const calls = stubBatch({ failure: { leg: "submit", status: 400, message: "bad field" } });
|
|
799
762
|
await expect(createJob("https://cdn.example/abc")).rejects.toBeInstanceOf(FatalError);
|
|
800
763
|
// One call, and it is not the upload.
|
|
801
764
|
expect(calls).toHaveLength(1);
|
|
@@ -804,7 +767,7 @@ describe("the async flow", () => {
|
|
|
804
767
|
|
|
805
768
|
test("createJob asks for `speech_models`, plural — the singular field is a 400", async () => {
|
|
806
769
|
publishRecording(new Uint8Array(10));
|
|
807
|
-
const calls = stubBatch(
|
|
770
|
+
const calls = stubBatch({ jobIdPrefix: "tr_" });
|
|
808
771
|
await expect(createJob("https://cdn.example/abc")).resolves.toEqual({ id: "tr_1" });
|
|
809
772
|
const sent = JSON.parse(String(calls[0]?.body)) as Record<string, unknown>;
|
|
810
773
|
// The async API deprecated `speech_model` and answers 400 for any current model
|
|
@@ -816,48 +779,62 @@ describe("the async flow", () => {
|
|
|
816
779
|
|
|
817
780
|
test("a job the provider gave up on is TERMINAL, not polled forever", async () => {
|
|
818
781
|
publishRecording(new Uint8Array(10));
|
|
819
|
-
stubBatch(
|
|
782
|
+
stubBatch({ jobError: "audio too quiet" });
|
|
820
783
|
// The provider has decided; no number of polls changes it, so this must not come
|
|
821
784
|
// back as "not done yet".
|
|
822
|
-
await expect(pollTranscript("tr_1")).rejects.toBeInstanceOf(FatalError);
|
|
785
|
+
await expect(pollTranscript(UPLOAD_ID, "tr_1", STARTED_AT)).rejects.toBeInstanceOf(FatalError);
|
|
823
786
|
});
|
|
824
787
|
|
|
825
788
|
test("pollTranscript answers `done` on completed and not before", async () => {
|
|
826
789
|
publishRecording(new Uint8Array(10));
|
|
827
|
-
stubBatch(
|
|
828
|
-
await expect(pollTranscript("tr_1")).resolves.toEqual({ done: false
|
|
790
|
+
stubBatch({ pendingPolls: 1 });
|
|
791
|
+
await expect(pollTranscript(UPLOAD_ID, "tr_1", STARTED_AT)).resolves.toEqual({ done: false });
|
|
829
792
|
});
|
|
830
793
|
|
|
831
794
|
test("an unknown status is NOT done, so a new one cannot end a run early", async () => {
|
|
832
795
|
publishRecording(new Uint8Array(10));
|
|
833
|
-
|
|
834
|
-
|
|
796
|
+
// Raw rather than `stubTranscribe`: a body with no status at all is not a
|
|
797
|
+
// shape the fake can stage, and it is the whole point of this spec.
|
|
798
|
+
installStubStepFetch(() => ({ body: {} }));
|
|
799
|
+
await expect(pollTranscript(UPLOAD_ID, "tr_1", STARTED_AT)).resolves.toMatchObject({
|
|
800
|
+
done: false,
|
|
801
|
+
});
|
|
835
802
|
});
|
|
836
803
|
|
|
837
|
-
test("
|
|
804
|
+
test("a completed poll carries the transcript — ONE request, not two", async () => {
|
|
838
805
|
publishRecording(new Uint8Array(10), "standup.wav");
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
806
|
+
// It used to poll for a status and then fetch the identical URL again for the
|
|
807
|
+
// text the poll already had in its hand.
|
|
808
|
+
const calls = stubBatch({ text: " hello there ", durationSec: 12.5 });
|
|
809
|
+
await expect(pollTranscript(UPLOAD_ID, "tr_1", STARTED_AT)).resolves.toMatchObject({
|
|
810
|
+
done: true,
|
|
811
|
+
transcript: {
|
|
812
|
+
source: "standup.wav",
|
|
813
|
+
// Not a fudge: the async API transcribed the recording in one piece, which is
|
|
814
|
+
// the difference this flow is here to show.
|
|
815
|
+
segments: 1,
|
|
816
|
+
durationMs: 12_500,
|
|
817
|
+
words: 2,
|
|
818
|
+
transcript: "hello there",
|
|
819
|
+
},
|
|
848
820
|
});
|
|
821
|
+
expect(calls).toHaveLength(1);
|
|
849
822
|
});
|
|
850
823
|
|
|
851
824
|
test("a rate limit is RETRYABLE, so a busy minute does not fail the run", async () => {
|
|
852
825
|
publishRecording(new Uint8Array(10));
|
|
853
|
-
stubBatch(
|
|
854
|
-
await expect(pollTranscript("tr_1")).rejects.toBeInstanceOf(
|
|
826
|
+
stubBatch({ failure: { leg: "poll", status: 429, message: "slow down" } });
|
|
827
|
+
await expect(pollTranscript(UPLOAD_ID, "tr_1", STARTED_AT)).rejects.toBeInstanceOf(
|
|
828
|
+
RetryableError,
|
|
829
|
+
);
|
|
855
830
|
});
|
|
856
831
|
|
|
857
832
|
test("all three flows report the same SHAPE, which is what lets one page render any", async () => {
|
|
858
833
|
publishRecording(new Uint8Array(10), "standup.wav");
|
|
859
|
-
stubBatch(
|
|
860
|
-
const
|
|
834
|
+
stubBatch({ text: "hi", durationSec: 1 });
|
|
835
|
+
const progress = await pollTranscript(UPLOAD_ID, "tr_1", STARTED_AT);
|
|
836
|
+
if (!progress.done) return expect.fail("the stub reports a completed job");
|
|
837
|
+
const batched = progress.transcript;
|
|
861
838
|
// One key set, so the page's summary line renders every flow's output. A field on
|
|
862
839
|
// one flow and not the others is a panel that shows it for some runs and not
|
|
863
840
|
// others, with nothing saying why.
|
|
@@ -873,3 +850,149 @@ describe("the async flow", () => {
|
|
|
873
850
|
expect(batched.elapsedMs).toBeGreaterThan(0);
|
|
874
851
|
});
|
|
875
852
|
});
|
|
853
|
+
|
|
854
|
+
describe("normalizing the recording", () => {
|
|
855
|
+
/**
|
|
856
|
+
* The CONVERSION is not driven here, and the reason is the tier rather than the
|
|
857
|
+
* code: it spawns ffmpeg and writes a temp file, neither of which a unit test
|
|
858
|
+
* may do. What is reachable is everything that DECIDES — whether a file needs
|
|
859
|
+
* converting at all, and how a conversion's failure is classified — and those
|
|
860
|
+
* are the two places a mistake is silent. A file wrongly passed through fails
|
|
861
|
+
* later in `splitRecording` with a message about a header; a `timeout`
|
|
862
|
+
* classified as fatal is a run that gives up on work that would have finished.
|
|
863
|
+
*/
|
|
864
|
+
test("a canonical WAV is cuttable, so the desk converts nothing", () => {
|
|
865
|
+
expect(cuttable(wavFile(MONO_16K, 32_000), 44 + 32_000)).toBe(true);
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
test("an extra chunk before the samples is still cuttable", () => {
|
|
869
|
+
// The `LIST`-chunk case, which is the reason the probe window is 64 KB rather
|
|
870
|
+
// than 44 bytes: a file the walk CAN read must not be re-encoded.
|
|
871
|
+
const head = wavFile(MONO_16K, 32_000, { extraChunk: "recorder" });
|
|
872
|
+
expect(cuttable(head, head.length + 32_000)).toBe(true);
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
test("an m4a is not, which is what puts ffmpeg in the path", () => {
|
|
876
|
+
// An MPEG-4 `ftyp` box — what a phone recording really starts with.
|
|
877
|
+
const m4a = new Uint8Array([
|
|
878
|
+
0, 0, 0, 0x20, 0x66, 0x74, 0x79, 0x70, 0x4d, 0x34, 0x41, 0x20, 0, 0, 0, 0,
|
|
879
|
+
]);
|
|
880
|
+
expect(cuttable(m4a, 4_000_000)).toBe(false);
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
test("a WAV whose encoding is not linear PCM is not", () => {
|
|
884
|
+
// The case an `ffprobe`-based check gets WRONG, which is why the check is
|
|
885
|
+
// `parseWav` itself: this file reports a PCM codec to ffprobe and is refused
|
|
886
|
+
// by the parser, so a probe would pass it through and the cut would fail.
|
|
887
|
+
const extensible = wavFile(MONO_16K, 32_000);
|
|
888
|
+
new DataView(extensible.buffer).setUint16(20, 0xff_fe, true);
|
|
889
|
+
expect(cuttable(extensible, 44 + 32_000)).toBe(false);
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
test("a WAV too dense to cut is not, and downsampling is what repairs it", () => {
|
|
893
|
+
// Past `MAX_BYTES_PER_SECOND`, so `parseWav` refuses it — and a conversion to
|
|
894
|
+
// 16 kHz mono makes it cuttable, which is a fix the desk gets for free from
|
|
895
|
+
// asking the parser rather than asking about the codec.
|
|
896
|
+
const dense = wavFile({ sampleRate: 4_000_000, channels: 8, bitsPerSample: 32 }, 32_000);
|
|
897
|
+
expect(cuttable(dense, 44 + 32_000)).toBe(false);
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
test("an already-cuttable recording keeps the id it came in under", async () => {
|
|
901
|
+
// The property that matters: no second upload, so the fan-out reads the file
|
|
902
|
+
// the caller stored. A step that copied it would double the storage every run
|
|
903
|
+
// pays for and would still report success.
|
|
904
|
+
publishRecording(wavFile(MONO_16K, 32_000), "standup.wav");
|
|
905
|
+
const reporter = installStubReporter();
|
|
906
|
+
await expect(normalizeRecording(UPLOAD_ID)).resolves.toEqual({
|
|
907
|
+
recording: UPLOAD_ID,
|
|
908
|
+
converted: false,
|
|
909
|
+
});
|
|
910
|
+
expect(reporter.lines.join(" ")).toContain("already linear-PCM WAV");
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
// The ffmpeg VERDICT is no longer tested here, and its absence is the change
|
|
914
|
+
// rather than a gap: `throwFfmpegStepError` on `@alexkroman1/aai/step-errors`
|
|
915
|
+
// owns it now, with every arm pinned in `sdk/step-errors.test.ts` — a refused
|
|
916
|
+
// file, a missing binary, a timeout rethrown UNCHANGED so its `argv` survives,
|
|
917
|
+
// and a cause that is not an ffmpeg failure at all. What stays above is what is
|
|
918
|
+
// still this desk's: WHICH files it decides to convert.
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
describe("expectedSegments", () => {
|
|
922
|
+
/** A plan over 16 kHz mono, cut into three 90-second segments. */
|
|
923
|
+
const PLAN = {
|
|
924
|
+
format: { ...MONO_16K, dataStart: 44, dataEnd: 44 + 270 * 32_000 },
|
|
925
|
+
segments: [0, 1, 2].map((index) => ({
|
|
926
|
+
index,
|
|
927
|
+
start: 44 + index * 90 * 32_000,
|
|
928
|
+
end: 44 + (index + 1) * 90 * 32_000,
|
|
929
|
+
startMs: index * 90_000,
|
|
930
|
+
endMs: (index + 1) * 90_000,
|
|
931
|
+
})),
|
|
932
|
+
};
|
|
933
|
+
|
|
934
|
+
test("counts every segment once the whole recording has arrived", () => {
|
|
935
|
+
expect(expectedSegments(PLAN, PLAN.format.dataEnd)).toBe(3);
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
test("ignores segments that start past the end of a SHORT upload", () => {
|
|
939
|
+
// The failure this guards is a run that never ENDS rather than one that fails:
|
|
940
|
+
// the plan came from the header's declared length, so a recording that came up
|
|
941
|
+
// short has segments beginning past the last byte, and waiting for them is
|
|
942
|
+
// waiting for audio nobody is going to send.
|
|
943
|
+
expect(expectedSegments(PLAN, 44 + 100 * 32_000)).toBe(2);
|
|
944
|
+
expect(expectedSegments(PLAN, 44 + 1)).toBe(1);
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
test("an upload with nothing in it expects no segments at all", () => {
|
|
948
|
+
expect(expectedSegments(PLAN, 0)).toBe(0);
|
|
949
|
+
});
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
describe("the conversion, up to the spawn", () => {
|
|
953
|
+
/**
|
|
954
|
+
* Reaches the point where ffmpeg would run and stops there, DETERMINISTICALLY.
|
|
955
|
+
*
|
|
956
|
+
* `AAI_FFPROBE_PATH` names the binary the SDK resolves, so pointing it at a path
|
|
957
|
+
* that does not exist produces `kind: "missing-binary"` on every machine — one
|
|
958
|
+
* where ffmpeg is installed, one where it is not, and CI's Linux leg alike. A
|
|
959
|
+
* test that instead relied on ffmpeg being ABSENT would pass on a laptop and
|
|
960
|
+
* behave differently in CI.
|
|
961
|
+
*
|
|
962
|
+
* What it covers is the whole step up to the subprocess: reading the header,
|
|
963
|
+
* deciding the file needs converting, and materializing it to a temp file. Plus
|
|
964
|
+
* the behaviour a developer actually meets — `aai dev` with no ffmpeg is the one
|
|
965
|
+
* place dev/prod parity is partial, and it must fail FATALLY with an installable
|
|
966
|
+
* remedy rather than burn five attempts on a binary that will not appear.
|
|
967
|
+
*/
|
|
968
|
+
test("materializes the recording, then fails fatally with no ffprobe", async () => {
|
|
969
|
+
vi.stubEnv("AAI_FFPROBE_PATH", "/nonexistent/aai-test/ffprobe");
|
|
970
|
+
vi.stubEnv("AAI_FFMPEG_PATH", "/nonexistent/aai-test/ffmpeg");
|
|
971
|
+
// An m4a `ftyp` box, so `cuttable` says no and the conversion path is taken.
|
|
972
|
+
publishRecording(
|
|
973
|
+
new Uint8Array([0, 0, 0, 0x20, 0x66, 0x74, 0x79, 0x70, 0x4d, 0x34, 0x41, 0x20]),
|
|
974
|
+
"standup.m4a",
|
|
975
|
+
);
|
|
976
|
+
const reporter = installStubReporter();
|
|
977
|
+
// Fatal, not retryable: four more attempts find the same missing binary, and
|
|
978
|
+
// the message already carries the install instructions.
|
|
979
|
+
await expect(normalizeRecording(UPLOAD_ID)).rejects.toThrow(/ffprobe/);
|
|
980
|
+
await expect(normalizeRecording(UPLOAD_ID)).rejects.toBeInstanceOf(FatalError);
|
|
981
|
+
// It got as far as deciding the file needs converting — the failure is the
|
|
982
|
+
// binary, not the input.
|
|
983
|
+
expect(reporter.lines.join(" ")).toContain("standup.m4a");
|
|
984
|
+
expect(normalizeRecording.maxRetries).toBe(5);
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
test("leaves no temp directory behind when the conversion fails", async () => {
|
|
988
|
+
// The `finally`, on the path that matters: a guest's disk is small, and a step
|
|
989
|
+
// that leaked a directory per failed run would fill it.
|
|
990
|
+
vi.stubEnv("AAI_FFPROBE_PATH", "/nonexistent/aai-test/ffprobe");
|
|
991
|
+
const leaked = (names: string[]) => names.filter((n) => n.startsWith("aai-normalize-"));
|
|
992
|
+
const before = leaked(await readdir(tmpdir()));
|
|
993
|
+
publishRecording(new Uint8Array([0, 0, 0, 0x20, 0x66, 0x74, 0x79, 0x70]), "standup.m4a");
|
|
994
|
+
installStubReporter();
|
|
995
|
+
await expect(normalizeRecording(UPLOAD_ID)).rejects.toBeInstanceOf(FatalError);
|
|
996
|
+
expect(leaked(await readdir(tmpdir()))).toEqual(before);
|
|
997
|
+
});
|
|
998
|
+
});
|
|
@@ -16,15 +16,19 @@
|
|
|
16
16
|
* - **`ASSEMBLYAI_API_KEY` in the agent env** — `.env` under `aai dev`,
|
|
17
17
|
* `aai secret put ASSEMBLYAI_API_KEY` once deployed. `requiredEnv` below is
|
|
18
18
|
* what makes a deploy check for it rather than letting the first run find out.
|
|
19
|
-
* A step reads it with `requireStepEnv`; see `@alexkroman1/aai/
|
|
19
|
+
* A step reads it with `requireStepEnv`; see `@alexkroman1/aai/step`.
|
|
20
20
|
* - **Storage** (`aai storage enable`, Settings → Database in the studio, or
|
|
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"],
|