@lalternative/tornade-sdk-react 0.1.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/README.md ADDED
@@ -0,0 +1,25 @@
1
+ # @lalternative/tornade-sdk-react
2
+
3
+ Plays a tornade reading in the browser, straight from tornade, on a URL the
4
+ application's server signed. The server never relays audio: it primes the
5
+ opening when the text is produced, signs one URL per press, and the bytes go
6
+ from tornade to the listener.
7
+
8
+ ```tsx
9
+ import { speakSource, useVoicePlayback } from '@lalternative/tornade-sdk-react'
10
+
11
+ function PlayButton({ messageId }: { messageId: string }) {
12
+ const resolve = useCallback(async () => {
13
+ const res = await fetch(`${API}/messages/${messageId}/audio`, { credentials: 'include' })
14
+ const { url, text } = await res.json()
15
+ return speakSource({ url, text, scope: 'chat-message', id: messageId })
16
+ }, [messageId])
17
+ const { state, toggle } = useVoicePlayback(resolve)
18
+ if (state === 'unavailable') return null
19
+ return <button onClick={toggle}>{state === 'playing' ? 'Pause' : 'Play'}</button>
20
+ }
21
+ ```
22
+
23
+ The server side is the Go `client` package of this module: `client.New` with
24
+ an `AppKey` for `PrimeOpening`, and `signed.NewSigner` for the URL the
25
+ endpoint above returns.
@@ -0,0 +1,54 @@
1
+ /**
2
+ * A streamed reading is a sequence of independently decodable mp3 pieces,
3
+ * each length-prefixed in the response body: a big-endian uint32 byte count
4
+ * followed by that many bytes. Mirrors go/audioreader's FramesContentType.
5
+ */
6
+ declare const FRAMES_CONTENT_TYPE = "application/x-lalter-audio-frames";
7
+ /**
8
+ * Reads length-prefixed frames off a byte stream as they arrive, handing
9
+ * each complete frame to onFrame as soon as it is fully buffered. A frame can
10
+ * straddle several network chunks, or a chunk can hold several frames.
11
+ */
12
+ declare function readFrames(reader: ReadableStreamDefaultReader<Uint8Array>, onFrame: (frame: Uint8Array) => void, signal: AbortSignal): Promise<void>;
13
+
14
+ /** Where a reading may be fetched, and what to ask for once there. */
15
+ type VoiceSource = {
16
+ url: string;
17
+ body: unknown;
18
+ };
19
+ /** The reading an application's server signed a URL for. */
20
+ type SignedReading = {
21
+ /** The signed URL onto tornade's /speak, as handed out by the application. */
22
+ url: string;
23
+ /** The exact text the signature covers: what the server read aloud, not the raw content. */
24
+ text: string;
25
+ scope: string;
26
+ id: string;
27
+ };
28
+ /**
29
+ * Builds the request the browser sends tornade for a signed reading. Always
30
+ * streamed: an opening the application primed is only served on the
31
+ * streaming path, and only a stream starts playing before the end is read.
32
+ */
33
+ declare function speakSource(reading: SignedReading): VoiceSource;
34
+
35
+ type VoicePlaybackState = 'idle' | 'loading' | 'playing' | 'unavailable';
36
+ /**
37
+ * Plays a reading aloud piece by piece as tornade streams it, so listening
38
+ * starts on the first piece instead of after the whole synthesis.
39
+ *
40
+ * Decoded with Web Audio rather than MediaSource: mp3 in a SourceBuffer is
41
+ * inconsistent across desktop browsers, and MediaSource is unavailable on
42
+ * iOS Safari and in a Tauri webview, while decodeAudioData works everywhere.
43
+ *
44
+ * resolve is called on each press rather than once: the URL it returns is
45
+ * signed and expires, so one resolved when the text was rendered would have
46
+ * gone stale by the time someone presses play.
47
+ */
48
+ declare function useVoicePlayback(resolve: () => Promise<VoiceSource>): {
49
+ state: VoicePlaybackState;
50
+ toggle: () => void;
51
+ stop: () => void;
52
+ };
53
+
54
+ export { FRAMES_CONTENT_TYPE, type SignedReading, type VoicePlaybackState, type VoiceSource, readFrames, speakSource, useVoicePlayback };
package/dist/index.js ADDED
@@ -0,0 +1,132 @@
1
+ // src/frames.ts
2
+ var FRAMES_CONTENT_TYPE = "application/x-lalter-audio-frames";
3
+ async function readFrames(reader, onFrame, signal) {
4
+ let buffer = new Uint8Array(0);
5
+ const append = (chunk) => {
6
+ const next = new Uint8Array(buffer.length + chunk.length);
7
+ next.set(buffer);
8
+ next.set(chunk, buffer.length);
9
+ buffer = next;
10
+ };
11
+ for (; ; ) {
12
+ if (signal.aborted) return;
13
+ const { done, value } = await reader.read();
14
+ if (value) append(value);
15
+ if (done) return;
16
+ for (; ; ) {
17
+ if (buffer.length < 4) break;
18
+ const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.length);
19
+ const frameLength = view.getUint32(0);
20
+ const total = 4 + frameLength;
21
+ if (buffer.length < total) break;
22
+ onFrame(buffer.subarray(4, total));
23
+ buffer = buffer.subarray(total);
24
+ }
25
+ }
26
+ }
27
+
28
+ // src/speak.ts
29
+ function speakSource(reading) {
30
+ return {
31
+ url: reading.url,
32
+ body: { text: reading.text, scope: reading.scope, id: reading.id, stream: true }
33
+ };
34
+ }
35
+
36
+ // src/useVoicePlayback.ts
37
+ import { useCallback, useRef, useState } from "react";
38
+ function useVoicePlayback(resolve) {
39
+ const [state, setState] = useState("idle");
40
+ const contextRef = useRef(null);
41
+ const abortRef = useRef(null);
42
+ const stopRef = useRef(null);
43
+ const stop = useCallback(() => {
44
+ stopRef.current?.();
45
+ stopRef.current = null;
46
+ abortRef.current?.abort();
47
+ abortRef.current = null;
48
+ setState((s) => s === "unavailable" ? s : "idle");
49
+ }, []);
50
+ const play = useCallback(() => {
51
+ setState("loading");
52
+ const controller = new AbortController();
53
+ abortRef.current = controller;
54
+ const audioContext = new AudioContext();
55
+ contextRef.current = audioContext;
56
+ let cancelled = false;
57
+ let nextStartAt = 0;
58
+ let scheduled = 0;
59
+ let sourcesDone = 0;
60
+ let streamDone = false;
61
+ const finishIfDone = () => {
62
+ if (streamDone && scheduled === sourcesDone) {
63
+ setState((s) => s === "playing" ? "idle" : s);
64
+ }
65
+ };
66
+ stopRef.current = () => {
67
+ cancelled = true;
68
+ audioContext.close().catch(() => {
69
+ });
70
+ };
71
+ (async () => {
72
+ try {
73
+ const reading = await resolve();
74
+ const res = await fetch(reading.url, {
75
+ method: "POST",
76
+ signal: controller.signal,
77
+ headers: { "Content-Type": "application/json" },
78
+ body: JSON.stringify(reading.body)
79
+ });
80
+ if (!res.ok || !res.body || res.headers.get("Content-Type") !== FRAMES_CONTENT_TYPE) {
81
+ throw new Error(`unexpected response (${res.status})`);
82
+ }
83
+ await readFrames(
84
+ res.body.getReader(),
85
+ (frame) => {
86
+ if (cancelled) return;
87
+ scheduled += 1;
88
+ const bytes = frame.slice().buffer;
89
+ audioContext.decodeAudioData(bytes).then((buffer) => {
90
+ if (cancelled) return;
91
+ const source = audioContext.createBufferSource();
92
+ source.buffer = buffer;
93
+ source.connect(audioContext.destination);
94
+ const startAt = Math.max(nextStartAt, audioContext.currentTime);
95
+ source.start(startAt);
96
+ nextStartAt = startAt + buffer.duration;
97
+ source.onended = () => {
98
+ sourcesDone += 1;
99
+ finishIfDone();
100
+ };
101
+ if (scheduled === 1) setState("playing");
102
+ }).catch(() => {
103
+ sourcesDone += 1;
104
+ finishIfDone();
105
+ });
106
+ },
107
+ controller.signal
108
+ );
109
+ streamDone = true;
110
+ finishIfDone();
111
+ if (scheduled === 0) setState("unavailable");
112
+ } catch {
113
+ if (!cancelled) setState("unavailable");
114
+ }
115
+ })();
116
+ }, [resolve]);
117
+ const toggle = useCallback(() => {
118
+ if (state === "playing" || state === "loading") {
119
+ stop();
120
+ return;
121
+ }
122
+ play();
123
+ }, [state, play, stop]);
124
+ return { state, toggle, stop };
125
+ }
126
+ export {
127
+ FRAMES_CONTENT_TYPE,
128
+ readFrames,
129
+ speakSource,
130
+ useVoicePlayback
131
+ };
132
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/frames.ts","../src/speak.ts","../src/useVoicePlayback.ts"],"sourcesContent":["/**\n * A streamed reading is a sequence of independently decodable mp3 pieces,\n * each length-prefixed in the response body: a big-endian uint32 byte count\n * followed by that many bytes. Mirrors go/audioreader's FramesContentType.\n */\nexport const FRAMES_CONTENT_TYPE = 'application/x-lalter-audio-frames'\n\n/**\n * Reads length-prefixed frames off a byte stream as they arrive, handing\n * each complete frame to onFrame as soon as it is fully buffered. A frame can\n * straddle several network chunks, or a chunk can hold several frames.\n */\nexport async function readFrames(\n reader: ReadableStreamDefaultReader<Uint8Array>,\n onFrame: (frame: Uint8Array) => void,\n signal: AbortSignal,\n): Promise<void> {\n let buffer = new Uint8Array(0)\n\n const append = (chunk: Uint8Array) => {\n const next = new Uint8Array(buffer.length + chunk.length)\n next.set(buffer)\n next.set(chunk, buffer.length)\n buffer = next\n }\n\n for (;;) {\n if (signal.aborted) return\n const { done, value } = await reader.read()\n if (value) append(value)\n if (done) return\n\n for (;;) {\n if (buffer.length < 4) break\n const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.length)\n const frameLength = view.getUint32(0)\n const total = 4 + frameLength\n if (buffer.length < total) break\n onFrame(buffer.subarray(4, total))\n buffer = buffer.subarray(total)\n }\n }\n}\n","/** Where a reading may be fetched, and what to ask for once there. */\nexport type VoiceSource = {\n url: string\n body: unknown\n}\n\n/** The reading an application's server signed a URL for. */\nexport type SignedReading = {\n /** The signed URL onto tornade's /speak, as handed out by the application. */\n url: string\n /** The exact text the signature covers: what the server read aloud, not the raw content. */\n text: string\n scope: string\n id: string\n}\n\n/**\n * Builds the request the browser sends tornade for a signed reading. Always\n * streamed: an opening the application primed is only served on the\n * streaming path, and only a stream starts playing before the end is read.\n */\nexport function speakSource(reading: SignedReading): VoiceSource {\n return {\n url: reading.url,\n body: { text: reading.text, scope: reading.scope, id: reading.id, stream: true },\n }\n}\n","import { useCallback, useRef, useState } from 'react'\nimport { FRAMES_CONTENT_TYPE, readFrames } from './frames'\nimport type { VoiceSource } from './speak'\n\nexport type VoicePlaybackState = 'idle' | 'loading' | 'playing' | 'unavailable'\n\n/**\n * Plays a reading aloud piece by piece as tornade streams it, so listening\n * starts on the first piece instead of after the whole synthesis.\n *\n * Decoded with Web Audio rather than MediaSource: mp3 in a SourceBuffer is\n * inconsistent across desktop browsers, and MediaSource is unavailable on\n * iOS Safari and in a Tauri webview, while decodeAudioData works everywhere.\n *\n * resolve is called on each press rather than once: the URL it returns is\n * signed and expires, so one resolved when the text was rendered would have\n * gone stale by the time someone presses play.\n */\nexport function useVoicePlayback(resolve: () => Promise<VoiceSource>) {\n const [state, setState] = useState<VoicePlaybackState>('idle')\n const contextRef = useRef<AudioContext | null>(null)\n const abortRef = useRef<AbortController | null>(null)\n const stopRef = useRef<(() => void) | null>(null)\n\n const stop = useCallback(() => {\n stopRef.current?.()\n stopRef.current = null\n abortRef.current?.abort()\n abortRef.current = null\n setState((s) => (s === 'unavailable' ? s : 'idle'))\n }, [])\n\n const play = useCallback(() => {\n setState('loading')\n const controller = new AbortController()\n abortRef.current = controller\n\n const audioContext = new AudioContext()\n contextRef.current = audioContext\n\n let cancelled = false\n let nextStartAt = 0\n let scheduled = 0\n let sourcesDone = 0\n let streamDone = false\n\n const finishIfDone = () => {\n if (streamDone && scheduled === sourcesDone) {\n setState((s) => (s === 'playing' ? 'idle' : s))\n }\n }\n\n stopRef.current = () => {\n cancelled = true\n audioContext.close().catch(() => {})\n }\n\n ;(async () => {\n try {\n const reading = await resolve()\n // No credentials: the audio comes from another origin, and what\n // authorises the request is the signature already in the URL.\n const res = await fetch(reading.url, {\n method: 'POST',\n signal: controller.signal,\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(reading.body),\n })\n if (!res.ok || !res.body || res.headers.get('Content-Type') !== FRAMES_CONTENT_TYPE) {\n throw new Error(`unexpected response (${res.status})`)\n }\n\n await readFrames(\n res.body.getReader(),\n (frame) => {\n if (cancelled) return\n scheduled += 1\n // decodeAudioData detaches the buffer it is given, which would\n // corrupt later frames sharing the same backing ArrayBuffer.\n const bytes = frame.slice().buffer\n audioContext\n .decodeAudioData(bytes)\n .then((buffer) => {\n if (cancelled) return\n const source = audioContext.createBufferSource()\n source.buffer = buffer\n source.connect(audioContext.destination)\n const startAt = Math.max(nextStartAt, audioContext.currentTime)\n source.start(startAt)\n nextStartAt = startAt + buffer.duration\n source.onended = () => {\n sourcesDone += 1\n finishIfDone()\n }\n if (scheduled === 1) setState('playing')\n })\n .catch(() => {\n sourcesDone += 1\n finishIfDone()\n })\n },\n controller.signal,\n )\n streamDone = true\n finishIfDone()\n if (scheduled === 0) setState('unavailable')\n } catch {\n if (!cancelled) setState('unavailable')\n }\n })()\n }, [resolve])\n\n const toggle = useCallback(() => {\n if (state === 'playing' || state === 'loading') {\n stop()\n return\n }\n play()\n }, [state, play, stop])\n\n return { state, toggle, stop }\n}\n"],"mappings":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@lalternative/tornade-sdk-react",
3
+ "version": "0.1.0",
4
+ "description": "React hook that plays a tornade reading in the browser, straight from tornade on a URL the application signed",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "scripts": {
18
+ "build": "tsup",
19
+ "dev": "tsup --watch",
20
+ "typecheck": "tsc --noEmit",
21
+ "prepublishOnly": "pnpm build",
22
+ "test": "node --experimental-strip-types --test \"src/**/*.test.ts\""
23
+ },
24
+ "peerDependencies": {
25
+ "react": ">=18"
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "^22.0.0",
29
+ "@types/react": "^19.0.0",
30
+ "react": "^19.0.0",
31
+ "tsup": "^8.0.0",
32
+ "typescript": "^5.7.0"
33
+ },
34
+ "keywords": [
35
+ "tornade",
36
+ "tts",
37
+ "audio",
38
+ "react"
39
+ ],
40
+ "license": "MIT",
41
+ "publishConfig": {
42
+ "registry": "https://registry.npmjs.org",
43
+ "access": "public"
44
+ },
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "https://github.com/lalternativefabrique/tornade.git",
48
+ "directory": "sdk-react"
49
+ }
50
+ }