giggy 0.1.2__tar.gz

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.
@@ -0,0 +1,36 @@
1
+ name: Python SDK CI
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ jobs:
11
+ test:
12
+ runs-on: ubuntu-latest
13
+ strategy:
14
+ matrix:
15
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - uses: actions/setup-python@v5
19
+ with:
20
+ python-version: ${{ matrix.python-version }}
21
+ - name: Install
22
+ run: |
23
+ python -m pip install --upgrade pip
24
+ python -m pip install -e .
25
+ - name: Test
26
+ run: python -m unittest discover -s tests -v
27
+ - name: Build package
28
+ run: |
29
+ python -m pip install build twine
30
+ python -m build
31
+ python -m twine check dist/*
32
+ - name: Verify installed wheel contains STT
33
+ run: |
34
+ python -m pip install --force-reinstall --no-deps dist/*.whl
35
+ cd "$(mktemp -d)"
36
+ python -c "from giggy import Giggy; c=Giggy(api_key='test-key'); assert all(hasattr(c.transcriptions, m) for m in ['submit','transcribe','get','result','stream','live','send_live_frame'])"
@@ -0,0 +1,48 @@
1
+ name: Publish Python SDK
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "py-v*"
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ build:
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: actions/setup-python@v5
17
+ with:
18
+ python-version: "3.12"
19
+ - name: Verify tag matches package version
20
+ run: |
21
+ VERSION="$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')"
22
+ test "$GITHUB_REF_NAME" = "py-v$VERSION"
23
+ - name: Install build tools
24
+ run: python -m pip install build twine
25
+ - name: Run tests
26
+ run: |
27
+ python -m pip install -e .
28
+ python -m unittest discover -s tests -v
29
+ - name: Build
30
+ run: python -m build
31
+ - name: Check distributions
32
+ run: python -m twine check dist/*
33
+ - uses: actions/upload-artifact@v4
34
+ with:
35
+ name: python-distributions
36
+ path: dist/
37
+ publish:
38
+ needs: build
39
+ runs-on: ubuntu-latest
40
+ environment: pypi
41
+ permissions:
42
+ id-token: write
43
+ steps:
44
+ - uses: actions/download-artifact@v4
45
+ with:
46
+ name: python-distributions
47
+ path: dist/
48
+ - uses: pypa/gh-action-pypi-publish@release/v1
giggy-0.1.2/.gitignore ADDED
@@ -0,0 +1,13 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .wheel-venv/
8
+ .env
9
+ .env.*
10
+ speech.mp3
11
+ speech.pcm
12
+ speech.wav
13
+ venv/
giggy-0.1.2/LICENSE ADDED
@@ -0,0 +1,7 @@
1
+ ISC License
2
+
3
+ Copyright 2026 Giggy
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
6
+
7
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
giggy-0.1.2/PKG-INFO ADDED
@@ -0,0 +1,216 @@
1
+ Metadata-Version: 2.5
2
+ Name: giggy
3
+ Version: 0.1.2
4
+ Summary: Official Python SDK for Giggy text-to-speech and speech-to-text.
5
+ Project-URL: Documentation, https://giggy.ai/docs/speech-api
6
+ Project-URL: Repository, https://github.com/giggy-ai/giggy-python
7
+ Project-URL: Issues, https://github.com/giggy-ai/giggy-python/issues
8
+ Author: Giggy
9
+ License-Expression: ISC
10
+ License-File: LICENSE
11
+ Keywords: giggy,speech,speech-to-text,stt,text-to-speech,tts,voice-ai
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+
21
+ # Giggy Python SDK
22
+
23
+ Official Python SDK for the Giggy speech API: text-to-speech and recorded-audio transcription.
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ python -m pip install giggy==0.1.2
29
+ ```
30
+
31
+ Check `python -m pip index versions giggy` for release availability. The source installation below is also available for development.
32
+
33
+ ## Install from source
34
+
35
+ ```bash
36
+ git clone https://github.com/giggy-ai/giggy-python.git
37
+ cd giggy-python
38
+ python -m pip install .
39
+ ```
40
+
41
+ Requires Python 3.10 or newer. Create a Giggy API key in your Giggy account. The account voice endpoint `GET https://giggy.ai/v1/voices` requires that key as a bearer token; use `voices[].voice_id` as the `voice_id` UUID. Keep API keys server-side.
42
+
43
+ ```bash
44
+ export GIGGY_API_KEY="giggy_sk_..."
45
+ export GIGGY_VOICE_ID="your-voice-uuid"
46
+ ```
47
+
48
+ ## Generate speech
49
+
50
+ ```python
51
+ import os
52
+ from pathlib import Path
53
+ from giggy import Giggy
54
+
55
+ giggy = Giggy(api_key=os.environ["GIGGY_API_KEY"])
56
+ audio = giggy.speech.create(
57
+ text="Hello from Giggy.",
58
+ voice_id=os.environ["GIGGY_VOICE_ID"],
59
+ )
60
+ Path("speech.mp3").write_bytes(audio)
61
+ ```
62
+
63
+ ## Stream speech
64
+
65
+ ```python
66
+ with giggy.speech.stream(
67
+ text="Streaming from Giggy.",
68
+ voice_id=os.environ["GIGGY_VOICE_ID"],
69
+ ) as response:
70
+ with open("speech.pcm", "wb") as output:
71
+ while chunk := response.read(65536):
72
+ output.write(chunk)
73
+ ```
74
+
75
+ Streaming requests do not send an idempotency header. The SDK has no automatic retries.
76
+
77
+ ## Transcribe a recording in one SDK call
78
+
79
+ Prepare a WAV file with signed PCM16, mono, 16 kHz audio. Batch is free for 0.5–60 seconds; Fast accepts 0.5 seconds–12 hours and uses credits. The SDK reads the WAV in bounded chunks instead of loading the whole recording into memory.
80
+
81
+ ```python
82
+ import os
83
+ from giggy import Giggy
84
+
85
+ giggy = Giggy(api_key=os.environ["GIGGY_API_KEY"])
86
+ print(giggy.transcriptions.transcribe("recording.wav", mode="batch")["text"])
87
+ ```
88
+
89
+ `transcribe()` submits once, polls the durable generation, then fetches its signed transcript URL without forwarding your API key. It stops on failure or after a configurable `timeout` (default 30 minutes); its failure message includes the generation UUID so you can inspect the operation. For submit-now/fetch-later workflows, use `submit()`, `get()`, and `result()` separately. A completed generation can have no downloadable result after retention cleanup. For a paid recording, pass `mode="fast"`. Pass `language="en"` when you know the language; omit it for automatic detection. `client_request_id` accepts a caller-supplied UUID but does not make ambiguous upload retries safe. The SDK does not retry automatically.
90
+
91
+ ### Prepare a compatible WAV
92
+
93
+ The API accepts signed 16-bit PCM, mono, 16 kHz WAV for this path. For MP3, M4A, stereo, or another sample rate, convert before calling the SDK; this is a local preparation step, not another Giggy endpoint:
94
+
95
+ ```bash
96
+ ffmpeg -i input.mp3 -ac 1 -ar 16000 -c:a pcm_s16le recording.wav
97
+ ffprobe -v error -show_entries stream=codec_name,sample_rate,channels -of default=noprint_wrappers=1 recording.wav
98
+ ```
99
+
100
+ Check that `ffprobe` reports `pcm_s16le`, `16000`, and `1`. Use a 0.5–60 second file for Batch. If the server rejects the upload, check the WAV format and duration first; a file renamed `.wav` is not converted. Keep the input recording and the returned generation UUID until the result has been verified.
101
+
102
+ ## Stream transcript revisions from an uploaded WAV
103
+
104
+ Uploaded-recording Streaming uses the same WAV requirements and is paid. It sends cumulative provisional text over server-sent events. Replace displayed text with each newer `transcript_chunk`; do not append it. Completion must be checked through the durable generation before using the canonical result.
105
+
106
+ ```python
107
+ with giggy.transcriptions.stream("recording.wav") as events:
108
+ generation_uuid = None
109
+ for event, data in events:
110
+ if event == "generation_created":
111
+ generation_uuid = data["generation"]["generation_uuid"]
112
+ elif event == "transcript_chunk":
113
+ print("Replace preview:", data["text"])
114
+ if not generation_uuid:
115
+ raise RuntimeError("No admitted generation")
116
+ print("Final:", giggy.transcriptions.result(generation_uuid)["text"])
117
+ ```
118
+
119
+ `stream()` parses SSE frames and closes the response when its context exits. An interrupted or failed stream must be reconciled through `get()`.
120
+
121
+ ## Live microphone PCM session
122
+
123
+ `live()` opens the existing paid Streaming SSE admission. This complete example records ten seconds from a microphone that supports mono 16 kHz PCM16. Install `sounddevice` (`python -m pip install sounddevice`) and allow microphone access first. The API key stays in the server-side Python process. The sender runs concurrently with the event reader, waits for `microphone_ready`, sends 100 ms frames in order, and sends a final empty frame. Run this with `GIGGY_API_KEY` set:
124
+
125
+ ```python
126
+ import os
127
+ from threading import Thread
128
+ import sounddevice as sd
129
+ from giggy import Giggy
130
+
131
+ giggy = Giggy(api_key=os.environ["GIGGY_API_KEY"])
132
+ errors = []
133
+ sender = None
134
+ generation_uuid = None
135
+
136
+ with giggy.transcriptions.live() as events:
137
+ for event, data in events:
138
+ if event == "generation_created":
139
+ generation_uuid = data["generation"]["generation_uuid"]
140
+ elif event == "microphone_ready":
141
+ ready = dict(data)
142
+
143
+ def send_audio():
144
+ try:
145
+ with sd.RawInputStream(samplerate=16000, channels=1, dtype="int16", blocksize=1600) as microphone:
146
+ for sequence in range(100):
147
+ pcm, overflowed = microphone.read(1600)
148
+ if overflowed:
149
+ raise RuntimeError("Microphone capture overflowed")
150
+ giggy.transcriptions.send_live_frame(
151
+ generation_uuid=ready["generation_uuid"], input_token=ready["input_token"],
152
+ sequence=sequence, pcm=bytes(pcm))
153
+ giggy.transcriptions.send_live_frame(
154
+ generation_uuid=ready["generation_uuid"], input_token=ready["input_token"],
155
+ sequence=100, pcm=b"", complete=True)
156
+ except Exception as error:
157
+ errors.append(error)
158
+
159
+ sender = Thread(target=send_audio)
160
+ sender.start()
161
+ elif event == "transcript_chunk":
162
+ print("Replace preview:", data["text"])
163
+
164
+ if sender:
165
+ sender.join()
166
+ if errors:
167
+ raise errors[0]
168
+ if not generation_uuid:
169
+ raise RuntimeError("No admitted generation")
170
+ print("Final:", giggy.transcriptions.result(generation_uuid)["text"])
171
+ ```
172
+
173
+ Each frame is at most 32,000 bytes. Await acceptance before incrementing `sequence`. Closing the live stream before completion cancels unfinished work. Do not reconnect or retry an ambiguously accepted frame. See the [full live input contract](https://giggy.ai/docs/speech-api#api-stt-streaming) for readiness, framing, duration, cancellation, and billing. The SDK does not implement microphone capture or turn detection; `sounddevice` is an example-only dependency.
174
+
175
+ ## Related resources
176
+
177
+ - [JavaScript/TypeScript SDK](https://github.com/giggy-ai/giggy-js)
178
+ - [Runnable integration examples](https://github.com/giggy-ai/giggy-examples)
179
+ - [Giggy MCP](https://github.com/giggy-ai/giggy-mcp)
180
+
181
+ ## Transcription method reference
182
+
183
+ All methods belong to `giggy.transcriptions`. SDK defaults select free Batch; the REST endpoint defaults to paid Fast.
184
+
185
+ | Method | Inputs and defaults | Returns |
186
+ | --- | --- | --- |
187
+ | `transcribe(audio_path, *, mode="batch", language=None, client_request_id=None, poll_interval=2.5, timeout=1800)` | Local WAV; Batch or Fast; finite positive seconds | Final transcript dictionary after one submission |
188
+ | `submit(audio_path, *, mode="batch", language=None, client_request_id=None, timeout=1800)` | Local WAV; one admission; network inactivity timeout in seconds | Admission dictionary containing `generation.generation_uuid` |
189
+ | `get(generation_uuid, *, timeout=30)` | UUID; one status request | Generation dictionary with `status` and optional `result.url` |
190
+ | `result(generation_uuid, *, timeout=30)` | A completed generation; status and signed-artifact retrieval share the timeout budget | Transcript dictionary; key is not sent to the artifact host |
191
+ | `stream(audio_path, *, language=None, client_request_id=None)` | Completed WAV; paid Streaming | Context manager yielding `(event, data)` pairs |
192
+ | `live(*, client_request_id=None)` | Paid live admission; automatic language detection | Context manager yielding the same event pairs |
193
+ | `send_live_frame(*, generation_uuid, input_token, sequence, pcm, complete=False)` | Ordered PCM16 bytes; maximum 32,000 bytes, even length; empty only for completion | Accepted response dictionary |
194
+
195
+ `transcribe(timeout=...)` starts its wall-clock deadline before upload and bounds waiting for submission, status and artifact I/O. Python uses one bounded I/O thread for this method; on timeout it stops waiting and cancels pending calls, while an in-flight blocking request may finish later. A timeout does not cancel an admitted recording. The exception includes its UUID when admission was received. Individual `submit/get/result` network timeouts also bound socket inactivity; use `transcribe` when you need a deadline for the complete workflow.
196
+
197
+ The existing events are `generation_created` (admitted generation), `microphone_ready` (UUID, attempt and private input token), `transcript_chunk` (cumulative text, sequence, replace=true and complete), and `generation_updated` (status and optional generation). An event's `complete` flag is not durable generation completion. After EOF, inspect `get()` and fetch `result()` only after `status=completed`.
198
+ These pairs are annotated with the exported `TranscriptionEvent` union and `MicrophoneReady`, `TranscriptChunk`, `GenerationUpdated`, and `TranscriptionAdmission` TypedDict payloads. Unknown named events raise an explicit contract error; heartbeat comments are ignored.
199
+
200
+ ## Troubleshooting and migration
201
+
202
+ | Symptom | Action |
203
+ | --- | --- |
204
+ | HTTP 401/403 | Check the key and its owning Personal space or Workspace. Never substitute another user's key. |
205
+ | HTTP 429 | Stop new submissions and honor the server response. An unambiguous rejection is different from an upload whose acceptance is unknown. |
206
+ | Timeout before a UUID | Keep the original WAV and request identity; inspect History before another submission. `client_request_id` is not an idempotency guarantee. |
207
+ | Timeout or SSE EOF after a UUID | Call `get(uuid)`; the recording may still be queued or processing. Do not upload it again automatically. |
208
+ | Completed generation without a result URL, or expired signed URL | Read current status again using the owning key. Use a refreshed result URL if one exists; retention/deletion can make the content permanently unavailable. |
209
+ | Live sender fails | Exit the live context to close its SSE connection, then reconcile the UUID. Closing unfinished live input invokes canonical cancellation; closing uploaded-WAV SSE does not. |
210
+ | Empty final text | The valid recording contained no detected speech. Check the recording; do not invent a transcript. |
211
+
212
+ HTTP failures raise `GiggyAPIError` with status and raw response body. Invalid parameters raise `ValueError`. Deadline expiry raises `TimeoutError`; malformed responses, unavailable results and durable failed/cancelled/admission_failed operations raise `RuntimeError`. Avoid logging live input tokens or signed URLs.
213
+
214
+ When migrating from AssemblyAI, replace `transcriber.transcribe(audio)` with `giggy.transcriptions.transcribe(wav_path)` and `transcript.text` with `result["text"]`. Convert MP3/video/URL inputs locally to PCM16 mono 16 kHz WAV before submitting. Speaker labels, word times, confidence, subtitle exports and speech-analysis options have no equivalent in this transcript contract; remove those options and explain the difference to your application users. Map provisional turn text to cumulative `transcript_chunk` revisions, and explicitly finish each live input within Giggy's 30-second audio/35-second readiness limit.
215
+
216
+ For complete commands and recording-to-text applications, see the [Python recording walkthrough](https://github.com/giggy-ai/giggy-examples/tree/main/python/basic-tts#transcribe-a-recording) and [framework STT guide](https://github.com/giggy-ai/giggy-examples#stt-in-voice-agent-frameworks).
giggy-0.1.2/README.md ADDED
@@ -0,0 +1,196 @@
1
+ # Giggy Python SDK
2
+
3
+ Official Python SDK for the Giggy speech API: text-to-speech and recorded-audio transcription.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ python -m pip install giggy==0.1.2
9
+ ```
10
+
11
+ Check `python -m pip index versions giggy` for release availability. The source installation below is also available for development.
12
+
13
+ ## Install from source
14
+
15
+ ```bash
16
+ git clone https://github.com/giggy-ai/giggy-python.git
17
+ cd giggy-python
18
+ python -m pip install .
19
+ ```
20
+
21
+ Requires Python 3.10 or newer. Create a Giggy API key in your Giggy account. The account voice endpoint `GET https://giggy.ai/v1/voices` requires that key as a bearer token; use `voices[].voice_id` as the `voice_id` UUID. Keep API keys server-side.
22
+
23
+ ```bash
24
+ export GIGGY_API_KEY="giggy_sk_..."
25
+ export GIGGY_VOICE_ID="your-voice-uuid"
26
+ ```
27
+
28
+ ## Generate speech
29
+
30
+ ```python
31
+ import os
32
+ from pathlib import Path
33
+ from giggy import Giggy
34
+
35
+ giggy = Giggy(api_key=os.environ["GIGGY_API_KEY"])
36
+ audio = giggy.speech.create(
37
+ text="Hello from Giggy.",
38
+ voice_id=os.environ["GIGGY_VOICE_ID"],
39
+ )
40
+ Path("speech.mp3").write_bytes(audio)
41
+ ```
42
+
43
+ ## Stream speech
44
+
45
+ ```python
46
+ with giggy.speech.stream(
47
+ text="Streaming from Giggy.",
48
+ voice_id=os.environ["GIGGY_VOICE_ID"],
49
+ ) as response:
50
+ with open("speech.pcm", "wb") as output:
51
+ while chunk := response.read(65536):
52
+ output.write(chunk)
53
+ ```
54
+
55
+ Streaming requests do not send an idempotency header. The SDK has no automatic retries.
56
+
57
+ ## Transcribe a recording in one SDK call
58
+
59
+ Prepare a WAV file with signed PCM16, mono, 16 kHz audio. Batch is free for 0.5–60 seconds; Fast accepts 0.5 seconds–12 hours and uses credits. The SDK reads the WAV in bounded chunks instead of loading the whole recording into memory.
60
+
61
+ ```python
62
+ import os
63
+ from giggy import Giggy
64
+
65
+ giggy = Giggy(api_key=os.environ["GIGGY_API_KEY"])
66
+ print(giggy.transcriptions.transcribe("recording.wav", mode="batch")["text"])
67
+ ```
68
+
69
+ `transcribe()` submits once, polls the durable generation, then fetches its signed transcript URL without forwarding your API key. It stops on failure or after a configurable `timeout` (default 30 minutes); its failure message includes the generation UUID so you can inspect the operation. For submit-now/fetch-later workflows, use `submit()`, `get()`, and `result()` separately. A completed generation can have no downloadable result after retention cleanup. For a paid recording, pass `mode="fast"`. Pass `language="en"` when you know the language; omit it for automatic detection. `client_request_id` accepts a caller-supplied UUID but does not make ambiguous upload retries safe. The SDK does not retry automatically.
70
+
71
+ ### Prepare a compatible WAV
72
+
73
+ The API accepts signed 16-bit PCM, mono, 16 kHz WAV for this path. For MP3, M4A, stereo, or another sample rate, convert before calling the SDK; this is a local preparation step, not another Giggy endpoint:
74
+
75
+ ```bash
76
+ ffmpeg -i input.mp3 -ac 1 -ar 16000 -c:a pcm_s16le recording.wav
77
+ ffprobe -v error -show_entries stream=codec_name,sample_rate,channels -of default=noprint_wrappers=1 recording.wav
78
+ ```
79
+
80
+ Check that `ffprobe` reports `pcm_s16le`, `16000`, and `1`. Use a 0.5–60 second file for Batch. If the server rejects the upload, check the WAV format and duration first; a file renamed `.wav` is not converted. Keep the input recording and the returned generation UUID until the result has been verified.
81
+
82
+ ## Stream transcript revisions from an uploaded WAV
83
+
84
+ Uploaded-recording Streaming uses the same WAV requirements and is paid. It sends cumulative provisional text over server-sent events. Replace displayed text with each newer `transcript_chunk`; do not append it. Completion must be checked through the durable generation before using the canonical result.
85
+
86
+ ```python
87
+ with giggy.transcriptions.stream("recording.wav") as events:
88
+ generation_uuid = None
89
+ for event, data in events:
90
+ if event == "generation_created":
91
+ generation_uuid = data["generation"]["generation_uuid"]
92
+ elif event == "transcript_chunk":
93
+ print("Replace preview:", data["text"])
94
+ if not generation_uuid:
95
+ raise RuntimeError("No admitted generation")
96
+ print("Final:", giggy.transcriptions.result(generation_uuid)["text"])
97
+ ```
98
+
99
+ `stream()` parses SSE frames and closes the response when its context exits. An interrupted or failed stream must be reconciled through `get()`.
100
+
101
+ ## Live microphone PCM session
102
+
103
+ `live()` opens the existing paid Streaming SSE admission. This complete example records ten seconds from a microphone that supports mono 16 kHz PCM16. Install `sounddevice` (`python -m pip install sounddevice`) and allow microphone access first. The API key stays in the server-side Python process. The sender runs concurrently with the event reader, waits for `microphone_ready`, sends 100 ms frames in order, and sends a final empty frame. Run this with `GIGGY_API_KEY` set:
104
+
105
+ ```python
106
+ import os
107
+ from threading import Thread
108
+ import sounddevice as sd
109
+ from giggy import Giggy
110
+
111
+ giggy = Giggy(api_key=os.environ["GIGGY_API_KEY"])
112
+ errors = []
113
+ sender = None
114
+ generation_uuid = None
115
+
116
+ with giggy.transcriptions.live() as events:
117
+ for event, data in events:
118
+ if event == "generation_created":
119
+ generation_uuid = data["generation"]["generation_uuid"]
120
+ elif event == "microphone_ready":
121
+ ready = dict(data)
122
+
123
+ def send_audio():
124
+ try:
125
+ with sd.RawInputStream(samplerate=16000, channels=1, dtype="int16", blocksize=1600) as microphone:
126
+ for sequence in range(100):
127
+ pcm, overflowed = microphone.read(1600)
128
+ if overflowed:
129
+ raise RuntimeError("Microphone capture overflowed")
130
+ giggy.transcriptions.send_live_frame(
131
+ generation_uuid=ready["generation_uuid"], input_token=ready["input_token"],
132
+ sequence=sequence, pcm=bytes(pcm))
133
+ giggy.transcriptions.send_live_frame(
134
+ generation_uuid=ready["generation_uuid"], input_token=ready["input_token"],
135
+ sequence=100, pcm=b"", complete=True)
136
+ except Exception as error:
137
+ errors.append(error)
138
+
139
+ sender = Thread(target=send_audio)
140
+ sender.start()
141
+ elif event == "transcript_chunk":
142
+ print("Replace preview:", data["text"])
143
+
144
+ if sender:
145
+ sender.join()
146
+ if errors:
147
+ raise errors[0]
148
+ if not generation_uuid:
149
+ raise RuntimeError("No admitted generation")
150
+ print("Final:", giggy.transcriptions.result(generation_uuid)["text"])
151
+ ```
152
+
153
+ Each frame is at most 32,000 bytes. Await acceptance before incrementing `sequence`. Closing the live stream before completion cancels unfinished work. Do not reconnect or retry an ambiguously accepted frame. See the [full live input contract](https://giggy.ai/docs/speech-api#api-stt-streaming) for readiness, framing, duration, cancellation, and billing. The SDK does not implement microphone capture or turn detection; `sounddevice` is an example-only dependency.
154
+
155
+ ## Related resources
156
+
157
+ - [JavaScript/TypeScript SDK](https://github.com/giggy-ai/giggy-js)
158
+ - [Runnable integration examples](https://github.com/giggy-ai/giggy-examples)
159
+ - [Giggy MCP](https://github.com/giggy-ai/giggy-mcp)
160
+
161
+ ## Transcription method reference
162
+
163
+ All methods belong to `giggy.transcriptions`. SDK defaults select free Batch; the REST endpoint defaults to paid Fast.
164
+
165
+ | Method | Inputs and defaults | Returns |
166
+ | --- | --- | --- |
167
+ | `transcribe(audio_path, *, mode="batch", language=None, client_request_id=None, poll_interval=2.5, timeout=1800)` | Local WAV; Batch or Fast; finite positive seconds | Final transcript dictionary after one submission |
168
+ | `submit(audio_path, *, mode="batch", language=None, client_request_id=None, timeout=1800)` | Local WAV; one admission; network inactivity timeout in seconds | Admission dictionary containing `generation.generation_uuid` |
169
+ | `get(generation_uuid, *, timeout=30)` | UUID; one status request | Generation dictionary with `status` and optional `result.url` |
170
+ | `result(generation_uuid, *, timeout=30)` | A completed generation; status and signed-artifact retrieval share the timeout budget | Transcript dictionary; key is not sent to the artifact host |
171
+ | `stream(audio_path, *, language=None, client_request_id=None)` | Completed WAV; paid Streaming | Context manager yielding `(event, data)` pairs |
172
+ | `live(*, client_request_id=None)` | Paid live admission; automatic language detection | Context manager yielding the same event pairs |
173
+ | `send_live_frame(*, generation_uuid, input_token, sequence, pcm, complete=False)` | Ordered PCM16 bytes; maximum 32,000 bytes, even length; empty only for completion | Accepted response dictionary |
174
+
175
+ `transcribe(timeout=...)` starts its wall-clock deadline before upload and bounds waiting for submission, status and artifact I/O. Python uses one bounded I/O thread for this method; on timeout it stops waiting and cancels pending calls, while an in-flight blocking request may finish later. A timeout does not cancel an admitted recording. The exception includes its UUID when admission was received. Individual `submit/get/result` network timeouts also bound socket inactivity; use `transcribe` when you need a deadline for the complete workflow.
176
+
177
+ The existing events are `generation_created` (admitted generation), `microphone_ready` (UUID, attempt and private input token), `transcript_chunk` (cumulative text, sequence, replace=true and complete), and `generation_updated` (status and optional generation). An event's `complete` flag is not durable generation completion. After EOF, inspect `get()` and fetch `result()` only after `status=completed`.
178
+ These pairs are annotated with the exported `TranscriptionEvent` union and `MicrophoneReady`, `TranscriptChunk`, `GenerationUpdated`, and `TranscriptionAdmission` TypedDict payloads. Unknown named events raise an explicit contract error; heartbeat comments are ignored.
179
+
180
+ ## Troubleshooting and migration
181
+
182
+ | Symptom | Action |
183
+ | --- | --- |
184
+ | HTTP 401/403 | Check the key and its owning Personal space or Workspace. Never substitute another user's key. |
185
+ | HTTP 429 | Stop new submissions and honor the server response. An unambiguous rejection is different from an upload whose acceptance is unknown. |
186
+ | Timeout before a UUID | Keep the original WAV and request identity; inspect History before another submission. `client_request_id` is not an idempotency guarantee. |
187
+ | Timeout or SSE EOF after a UUID | Call `get(uuid)`; the recording may still be queued or processing. Do not upload it again automatically. |
188
+ | Completed generation without a result URL, or expired signed URL | Read current status again using the owning key. Use a refreshed result URL if one exists; retention/deletion can make the content permanently unavailable. |
189
+ | Live sender fails | Exit the live context to close its SSE connection, then reconcile the UUID. Closing unfinished live input invokes canonical cancellation; closing uploaded-WAV SSE does not. |
190
+ | Empty final text | The valid recording contained no detected speech. Check the recording; do not invent a transcript. |
191
+
192
+ HTTP failures raise `GiggyAPIError` with status and raw response body. Invalid parameters raise `ValueError`. Deadline expiry raises `TimeoutError`; malformed responses, unavailable results and durable failed/cancelled/admission_failed operations raise `RuntimeError`. Avoid logging live input tokens or signed URLs.
193
+
194
+ When migrating from AssemblyAI, replace `transcriber.transcribe(audio)` with `giggy.transcriptions.transcribe(wav_path)` and `transcript.text` with `result["text"]`. Convert MP3/video/URL inputs locally to PCM16 mono 16 kHz WAV before submitting. Speaker labels, word times, confidence, subtitle exports and speech-analysis options have no equivalent in this transcript contract; remove those options and explain the difference to your application users. Map provisional turn text to cumulative `transcript_chunk` revisions, and explicitly finish each live input within Giggy's 30-second audio/35-second readiness limit.
195
+
196
+ For complete commands and recording-to-text applications, see the [Python recording walkthrough](https://github.com/giggy-ai/giggy-examples/tree/main/python/basic-tts#transcribe-a-recording) and [framework STT guide](https://github.com/giggy-ai/giggy-examples#stt-in-voice-agent-frameworks).
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.26"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "giggy"
7
+ version = "0.1.2"
8
+ description = "Official Python SDK for Giggy text-to-speech and speech-to-text."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "ISC"
12
+ authors = [{ name = "Giggy" }]
13
+ keywords = ["giggy", "text-to-speech", "speech-to-text", "tts", "stt", "speech", "voice-ai"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.10",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Operating System :: OS Independent",
21
+ ]
22
+ dependencies = []
23
+
24
+ [project.urls]
25
+ Documentation = "https://giggy.ai/docs/speech-api"
26
+ Repository = "https://github.com/giggy-ai/giggy-python"
27
+ Issues = "https://github.com/giggy-ai/giggy-python/issues"
28
+
29
+ [tool.hatch.build.targets.wheel]
30
+ packages = ["src/giggy"]
@@ -0,0 +1,4 @@
1
+ from .client import Giggy, GenerationUpdated, MicrophoneReady, TranscriptChunk, TranscriptionAdmission, TranscriptionEvent
2
+ from .errors import GiggyAPIError
3
+
4
+ __all__ = ["Giggy", "GiggyAPIError", "GenerationUpdated", "MicrophoneReady", "TranscriptChunk", "TranscriptionAdmission", "TranscriptionEvent"]
@@ -0,0 +1,366 @@
1
+ from collections.abc import Iterator
2
+ from base64 import b64encode
3
+ from contextlib import contextmanager
4
+ from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeoutError
5
+ import json
6
+ import math
7
+ from pathlib import Path
8
+ import re
9
+ import time
10
+ from typing import BinaryIO, Literal, TypeAlias, TypedDict, cast
11
+ from urllib.error import HTTPError
12
+ from urllib.parse import urlencode
13
+ from urllib.parse import urlsplit, urlunsplit
14
+ from urllib.request import Request, urlopen
15
+ from uuid import RFC_4122, UUID, uuid4
16
+
17
+ from .errors import GiggyAPIError
18
+
19
+ class MicrophoneReady(TypedDict):
20
+ generation_uuid: str
21
+ attempt: int
22
+ input_token: str
23
+
24
+ class TranscriptChunk(TypedDict):
25
+ generation_uuid: str
26
+ attempt: int
27
+ text: str
28
+ sequence: int
29
+ replace: Literal[True]
30
+ complete: bool
31
+
32
+ class GenerationUpdated(TypedDict, total=False):
33
+ generation_uuid: str
34
+ status: str
35
+ attempt: int
36
+ generation: dict
37
+
38
+ class TranscriptionAdmission(TypedDict):
39
+ generation: dict
40
+
41
+ TranscriptionEvent: TypeAlias = (
42
+ tuple[Literal["generation_created"], TranscriptionAdmission]
43
+ | tuple[Literal["microphone_ready"], MicrophoneReady]
44
+ | tuple[Literal["transcript_chunk"], TranscriptChunk]
45
+ | tuple[Literal["generation_updated"], GenerationUpdated]
46
+ )
47
+
48
+ class Speech:
49
+ def __init__(self, *, api_key: str, base_url: str) -> None:
50
+ self._api_key = api_key
51
+ self._base_url = base_url.rstrip("/")
52
+
53
+ def _request(
54
+ self,
55
+ *,
56
+ text: str,
57
+ voice_id: str,
58
+ mode: str,
59
+ output_format: str,
60
+ speed: float | None,
61
+ idempotency_key: str | None = None,
62
+ ):
63
+ if not text.strip():
64
+ raise ValueError("text must not be empty")
65
+ if not voice_id.strip():
66
+ raise ValueError("voice_id must not be empty")
67
+
68
+ payload: dict[str, object] = {
69
+ "text": text,
70
+ "voice_id": voice_id,
71
+ "model_id": "giggyspeech",
72
+ "mode": mode,
73
+ "output_format": output_format,
74
+ }
75
+ if speed is not None:
76
+ payload["voice_settings"] = {"speed": speed}
77
+
78
+ headers = {
79
+ "xi-api-key": self._api_key,
80
+ "content-type": "application/json",
81
+ }
82
+ if idempotency_key is not None:
83
+ headers["idempotency-key"] = idempotency_key
84
+
85
+ request = Request(
86
+ self._base_url + "/text-to-speech",
87
+ data=json.dumps(payload).encode("utf-8"),
88
+ headers=headers,
89
+ method="POST",
90
+ )
91
+ try:
92
+ return urlopen(request, timeout=120)
93
+ except HTTPError as error:
94
+ body = error.read().decode("utf-8", errors="replace")
95
+ raise GiggyAPIError(error.code, body) from error
96
+
97
+ def create(
98
+ self,
99
+ *,
100
+ text: str,
101
+ voice_id: str,
102
+ mode: Literal["batch", "fast"] = "batch",
103
+ output_format: str = "mp3_24000_160",
104
+ speed: float | None = None,
105
+ idempotency_key: str | None = None,
106
+ ) -> bytes:
107
+ response = self._request(
108
+ text=text,
109
+ voice_id=voice_id,
110
+ mode=mode,
111
+ output_format=output_format,
112
+ speed=speed,
113
+ idempotency_key=(idempotency_key if idempotency_key is not None else str(uuid4())),
114
+ )
115
+ with response:
116
+ return response.read()
117
+
118
+ @contextmanager
119
+ def stream(
120
+ self,
121
+ *,
122
+ text: str,
123
+ voice_id: str,
124
+ output_format: str = "pcm_24000",
125
+ speed: float | None = None,
126
+ ) -> Iterator[BinaryIO]:
127
+ response = self._request(
128
+ text=text,
129
+ voice_id=voice_id,
130
+ mode="streaming",
131
+ output_format=output_format,
132
+ speed=speed,
133
+ )
134
+ try:
135
+ yield response
136
+ finally:
137
+ response.close()
138
+
139
+
140
+ class Transcriptions:
141
+ def __init__(self, *, api_key: str, base_url: str) -> None:
142
+ self._api_key = api_key
143
+ self._base_url = base_url.rstrip("/")
144
+
145
+ def _upload(self, audio_path: str | Path, *, mode: str, language: str | None,
146
+ client_request_id: str | None, timeout: float = 1800):
147
+ if not math.isfinite(timeout) or timeout <= 0:
148
+ raise ValueError("timeout must be finite and positive")
149
+ if mode not in ("batch", "fast", "streaming"):
150
+ raise ValueError("mode must be batch, fast, or streaming")
151
+ if language is not None and not language.strip():
152
+ raise ValueError("language must not be blank")
153
+ if client_request_id is not None:
154
+ parsed_id = UUID(client_request_id)
155
+ if parsed_id.variant != RFC_4122 or parsed_id.version not in (1, 2, 3, 4, 5):
156
+ raise ValueError("client_request_id must be an RFC 4122 UUID v1–v5")
157
+ client_request_id = str(parsed_id)
158
+ query = {"mode": mode}
159
+ if language is not None:
160
+ query["language"] = language
161
+ if client_request_id is not None:
162
+ query["client_request_id"] = client_request_id
163
+ path = Path(audio_path)
164
+ with path.open("rb") as audio:
165
+ request = Request(
166
+ self._base_url + "/audio/transcriptions?" + urlencode(query),
167
+ data=iter(lambda: audio.read(1024 * 1024), b""),
168
+ headers={"xi-api-key": self._api_key, "Content-Type": "audio/wav",
169
+ "Content-Length": str(path.stat().st_size)},
170
+ method="POST",
171
+ )
172
+ try:
173
+ return urlopen(request, timeout=timeout)
174
+ except HTTPError as error:
175
+ raise GiggyAPIError(error.code, error.read().decode("utf-8", errors="replace")) from error
176
+
177
+ def submit(self, audio_path: str | Path, *, mode: Literal["batch", "fast"] = "batch",
178
+ language: str | None = None, client_request_id: str | None = None,
179
+ timeout: float = 1800) -> dict:
180
+ """Admit a WAV recording; retrieve its transcript after durable completion."""
181
+ with self._upload(audio_path, mode=mode, language=language,
182
+ client_request_id=client_request_id, timeout=timeout) as response:
183
+ if response.status != 202:
184
+ raise RuntimeError(f"Expected HTTP 202 admission, got {response.status}")
185
+ admission = json.load(response)
186
+ if not isinstance(admission, dict) or not isinstance(admission.get("generation"), dict) or not isinstance(admission["generation"].get("generation_uuid"), str) or not isinstance(admission["generation"].get("status"), str):
187
+ raise RuntimeError("Giggy returned an invalid transcription admission")
188
+ return admission
189
+
190
+ def transcribe(self, audio_path: str | Path, *, mode: Literal["batch", "fast"] = "batch",
191
+ language: str | None = None, client_request_id: str | None = None,
192
+ poll_interval: float = 2.5, timeout: float = 1800) -> dict:
193
+ """Submit once, wait for the durable generation, and read its transcript."""
194
+ if not math.isfinite(poll_interval) or not math.isfinite(timeout) or poll_interval <= 0 or timeout <= 0:
195
+ raise ValueError("poll_interval and timeout must be finite and positive")
196
+ deadline = time.monotonic() + timeout
197
+ generation_uuid = None
198
+ executor = ThreadPoolExecutor(max_workers=1)
199
+ try:
200
+ admission = executor.submit(self.submit, audio_path, mode=mode, language=language,
201
+ client_request_id=client_request_id, timeout=timeout).result(timeout=timeout)
202
+ generation_uuid = admission["generation"]["generation_uuid"]
203
+ while True:
204
+ remaining = deadline - time.monotonic()
205
+ if remaining <= 0:
206
+ raise TimeoutError()
207
+ generation = executor.submit(self.get, generation_uuid, timeout=min(30, remaining)).result(timeout=remaining)
208
+ remaining = deadline - time.monotonic()
209
+ if remaining <= 0:
210
+ raise TimeoutError()
211
+ if generation["status"] == "completed":
212
+ return executor.submit(self.result, generation_uuid, timeout=remaining).result(timeout=remaining)
213
+ if generation["status"] in ("failed", "cancelled", "admission_failed"):
214
+ raise RuntimeError(f"Transcription {generation_uuid} is {generation['status']}")
215
+ time.sleep(min(poll_interval, remaining))
216
+ except (TimeoutError, FutureTimeoutError) as error:
217
+ identity = generation_uuid or "admission (generation UUID not received)"
218
+ if time.monotonic() < deadline:
219
+ raise TimeoutError(f"Transcription {identity} network I/O timed out; reconcile the existing operation before resubmitting") from error
220
+ raise TimeoutError(f"Transcription {identity} did not complete within {timeout} seconds; do not resubmit an ambiguous upload") from error
221
+ finally:
222
+ executor.shutdown(wait=False, cancel_futures=True)
223
+
224
+ def get(self, generation_uuid: str, *, timeout: float = 30) -> dict:
225
+ """Read the canonical generation status without polling or retrying."""
226
+ if not math.isfinite(timeout) or timeout <= 0:
227
+ raise ValueError("timeout must be finite and positive")
228
+ generation_uuid = str(UUID(generation_uuid))
229
+ request = Request(self._base_url + "/generations/" + generation_uuid,
230
+ headers={"xi-api-key": self._api_key})
231
+ try:
232
+ with urlopen(request, timeout=timeout) as response:
233
+ payload = json.load(response)
234
+ except HTTPError as error:
235
+ raise GiggyAPIError(error.code, error.read().decode("utf-8", errors="replace")) from error
236
+ generation = payload.get("generation") if isinstance(payload, dict) else None
237
+ if not isinstance(generation, dict) or generation.get("generation_uuid") != generation_uuid or not isinstance(generation.get("status"), str):
238
+ raise RuntimeError("Giggy returned an invalid generation")
239
+ return generation
240
+
241
+ def result(self, generation_uuid: str, *, timeout: float = 30) -> dict:
242
+ """Fetch the signed transcript only after the generation completed."""
243
+ if not math.isfinite(timeout) or timeout <= 0:
244
+ raise ValueError("timeout must be finite and positive")
245
+ deadline = time.monotonic() + timeout
246
+ generation = self.get(generation_uuid, timeout=timeout)
247
+ if generation.get("status") != "completed":
248
+ raise RuntimeError(f"Transcription is {generation.get('status')}")
249
+ url = (generation.get("result") or {}).get("url")
250
+ if not isinstance(url, str) or not url.startswith("https://"):
251
+ raise RuntimeError("Transcript is no longer available")
252
+ try:
253
+ remaining = deadline - time.monotonic()
254
+ if remaining <= 0:
255
+ raise TimeoutError("Transcript retrieval timed out")
256
+ with urlopen(Request(url), timeout=remaining) as response:
257
+ transcript = json.load(response)
258
+ except HTTPError as error:
259
+ raise GiggyAPIError(error.code, error.read().decode("utf-8", errors="replace")) from error
260
+ if not isinstance(transcript, dict) or not isinstance(transcript.get("text"), str):
261
+ raise RuntimeError("Giggy returned an invalid transcript")
262
+ return transcript
263
+
264
+ @contextmanager
265
+ def stream(self, audio_path: str | Path, *, language: str | None = None,
266
+ client_request_id: str | None = None) -> Iterator[Iterator[TranscriptionEvent]]:
267
+ """Stream parsed SSE events while processing an uploaded WAV recording."""
268
+ response = self._upload(audio_path, mode="streaming", language=language,
269
+ client_request_id=client_request_id)
270
+ try:
271
+ if response.status != 200 or "text/event-stream" not in response.headers.get("content-type", ""):
272
+ raise RuntimeError("Giggy did not return a transcript event stream")
273
+ yield self._events(response)
274
+ finally:
275
+ response.close()
276
+
277
+ @contextmanager
278
+ def live(self, *, client_request_id: str | None = None) -> Iterator[Iterator[TranscriptionEvent]]:
279
+ """Open a paid live microphone SSE session; send frames separately after readiness."""
280
+ if client_request_id is not None:
281
+ parsed_id = UUID(client_request_id)
282
+ if parsed_id.variant != RFC_4122 or parsed_id.version not in (1, 2, 3, 4, 5):
283
+ raise ValueError("client_request_id must be an RFC 4122 UUID v1–v5")
284
+ client_request_id = str(parsed_id)
285
+ payload = {"mode": "streaming", "microphone": True}
286
+ if client_request_id is not None:
287
+ payload["client_request_id"] = client_request_id
288
+ request = Request(
289
+ self._base_url + "/audio/transcriptions",
290
+ data=json.dumps(payload).encode("utf-8"),
291
+ headers={"xi-api-key": self._api_key, "Content-Type": "application/json"},
292
+ method="POST",
293
+ )
294
+ try:
295
+ response = urlopen(request, timeout=150)
296
+ except HTTPError as error:
297
+ raise GiggyAPIError(error.code, error.read().decode("utf-8", errors="replace")) from error
298
+ try:
299
+ if response.status != 200 or "text/event-stream" not in response.headers.get("content-type", ""):
300
+ raise RuntimeError("Giggy did not return a live transcript event stream")
301
+ yield self._events(response)
302
+ finally:
303
+ response.close()
304
+
305
+ def send_live_frame(self, *, generation_uuid: str, input_token: str, sequence: int,
306
+ pcm: bytes, complete: bool = False) -> None:
307
+ """Send one ordered PCM16 frame; the caller must await its acceptance before the next."""
308
+ generation_uuid = str(UUID(generation_uuid))
309
+ if not re.fullmatch(r"[0-9a-f]{64}", input_token):
310
+ raise ValueError("input_token must be the microphone_ready capability")
311
+ if not isinstance(sequence, int) or isinstance(sequence, bool) or sequence < 0:
312
+ raise ValueError("sequence must be a nonnegative integer")
313
+ if not isinstance(pcm, bytes) or len(pcm) > 32000 or len(pcm) % 2 or (not pcm and not complete):
314
+ raise ValueError("pcm must be an even PCM16 byte frame of at most one second")
315
+ parsed = urlsplit(self._base_url)
316
+ if parsed.path.rstrip("/") != "/v1":
317
+ raise ValueError("base_url must end with /v1 for live input")
318
+ origin = urlunsplit((parsed.scheme, parsed.netloc, "", "", ""))
319
+ request = Request(
320
+ origin + "/api/generations/" + generation_uuid + "/stt-input",
321
+ data=json.dumps({"input_token": input_token, "sequence": sequence,
322
+ "pcm_base64": b64encode(pcm).decode("ascii"), "complete": complete}).encode("utf-8"),
323
+ headers={"Content-Type": "application/json", "User-Agent": "giggy-python"},
324
+ method="POST",
325
+ )
326
+ try:
327
+ with urlopen(request, timeout=10) as response:
328
+ accepted = json.load(response)
329
+ except HTTPError as error:
330
+ raise GiggyAPIError(error.code, error.read().decode("utf-8", errors="replace")) from error
331
+ if not isinstance(accepted, dict) or accepted.get("ok") is not True:
332
+ raise RuntimeError("Giggy did not accept the PCM frame")
333
+
334
+ def _events(self, response) -> Iterator[TranscriptionEvent]:
335
+ event = ""
336
+ data: list[str] = []
337
+ for raw in response:
338
+ line = raw.decode("utf-8").rstrip("\r\n")
339
+ if line == "":
340
+ if data:
341
+ payload = json.loads("\n".join(data))
342
+ if not isinstance(payload, dict):
343
+ raise RuntimeError("Giggy returned an invalid transcript event")
344
+ if event not in ("generation_created", "microphone_ready", "transcript_chunk", "generation_updated"):
345
+ raise RuntimeError(f"Unexpected transcript event: {event or '(unnamed)'}; reconcile the generation through get()")
346
+ yield cast(TranscriptionEvent, (event, payload))
347
+ event, data = "", []
348
+ elif line.startswith("event:"):
349
+ event = line[6:].strip()
350
+ elif line.startswith("data:"):
351
+ data.append(line[5:].lstrip(" "))
352
+ if data:
353
+ raise RuntimeError("Transcript event stream ended mid-event")
354
+
355
+
356
+ class Giggy:
357
+ def __init__(
358
+ self,
359
+ *,
360
+ api_key: str,
361
+ base_url: str = "https://giggy.ai/v1",
362
+ ) -> None:
363
+ if not api_key.strip():
364
+ raise ValueError("api_key must not be empty")
365
+ self.speech = Speech(api_key=api_key, base_url=base_url)
366
+ self.transcriptions = Transcriptions(api_key=api_key, base_url=base_url)
@@ -0,0 +1,7 @@
1
+ class GiggyAPIError(Exception):
2
+ """An HTTP error returned by the Giggy API."""
3
+
4
+ def __init__(self, status_code: int, body: str) -> None:
5
+ self.status_code = status_code
6
+ self.body = body
7
+ super().__init__(f"Giggy API returned HTTP {status_code}: {body}")
File without changes
@@ -0,0 +1,179 @@
1
+ import json
2
+ import time
3
+ import unittest
4
+ from io import BytesIO
5
+ from unittest.mock import patch
6
+ from urllib.error import HTTPError
7
+
8
+ from giggy import Giggy, GiggyAPIError
9
+
10
+
11
+ class Response(BytesIO):
12
+ status = 202
13
+
14
+ def __enter__(self):
15
+ return self
16
+
17
+ def __exit__(self, *args):
18
+ self.close()
19
+
20
+
21
+ class GiggyClientTests(unittest.TestCase):
22
+ def setUp(self):
23
+ self.client = Giggy(api_key="test-key")
24
+
25
+ def test_missing_api_key(self):
26
+ with self.assertRaisesRegex(ValueError, "api_key"):
27
+ Giggy(api_key=" ")
28
+
29
+ def _check_create(self, mode="batch", idempotency_key=None):
30
+ response = Response(b"audio")
31
+ with patch("giggy.client.urlopen", return_value=response) as mocked:
32
+ result = self.client.speech.create(
33
+ text="hello", voice_id="voice", mode=mode,
34
+ idempotency_key=idempotency_key,
35
+ )
36
+ req = mocked.call_args.args[0]
37
+ payload = json.loads(req.data)
38
+ return result, req, payload
39
+
40
+ def test_batch_request_mapping_and_automatic_idempotency(self):
41
+ result, req, payload = self._check_create()
42
+ self.assertEqual(result, b"audio")
43
+ self.assertEqual(req.full_url, "https://giggy.ai/v1/text-to-speech")
44
+ self.assertEqual(payload["mode"], "batch")
45
+ self.assertEqual(payload["voice_id"], "voice")
46
+ self.assertTrue(req.get_header("Idempotency-key"))
47
+
48
+ def test_fast_request_mapping(self):
49
+ _, _, payload = self._check_create(mode="fast")
50
+ self.assertEqual(payload["mode"], "fast")
51
+
52
+ def test_supplied_idempotency_key(self):
53
+ _, req, _ = self._check_create(idempotency_key="my-key")
54
+ self.assertEqual(req.get_header("Idempotency-key"), "my-key")
55
+
56
+ def test_streaming_request_has_no_idempotency_header_and_closes(self):
57
+ response = Response(b"pcm")
58
+ with patch("giggy.client.urlopen", return_value=response) as mocked:
59
+ with self.client.speech.stream(text="hello", voice_id="voice") as body:
60
+ self.assertEqual(body.read(), b"pcm")
61
+ req = mocked.call_args.args[0]
62
+ self.assertEqual(json.loads(req.data)["mode"], "streaming")
63
+ self.assertIsNone(req.get_header("Idempotency-key"))
64
+ self.assertTrue(response.closed)
65
+
66
+ def test_http_400_raises_structured_error(self):
67
+ error = HTTPError("url", 400, "bad request", {}, BytesIO(b"invalid"))
68
+ with patch("giggy.client.urlopen", side_effect=error):
69
+ with self.assertRaises(GiggyAPIError) as raised:
70
+ self.client.speech.create(text="hello", voice_id="voice")
71
+ self.assertEqual(raised.exception.status_code, 400)
72
+ self.assertIn("invalid", raised.exception.body)
73
+
74
+ def test_transcription_submission_streams_wav_and_maps_options(self):
75
+ from pathlib import Path
76
+ path = Path("pyproject.toml")
77
+ generation_id = "00000000-0000-4000-8000-000000000001"
78
+ captured = {}
79
+
80
+ def respond(request, timeout):
81
+ captured["url"] = request.full_url
82
+ captured["headers"] = request.headers
83
+ captured["body"] = b"".join(request.data)
84
+ return Response(json.dumps({"generation": {"generation_uuid": generation_id, "status": "queued"}}).encode())
85
+
86
+ with patch("giggy.client.urlopen", side_effect=respond):
87
+ admission = self.client.transcriptions.submit(
88
+ path, mode="fast", language="en", client_request_id=generation_id,
89
+ )
90
+ self.assertEqual(admission["generation"]["generation_uuid"], generation_id)
91
+ self.assertIn("/audio/transcriptions?mode=fast&language=en&client_request_id=", captured["url"])
92
+ self.assertEqual(captured["headers"]["Xi-api-key"], "test-key")
93
+ self.assertEqual(captured["body"], path.read_bytes())
94
+
95
+ def test_transcription_result_requires_durable_success_and_unsigned_artifact_request(self):
96
+ generation_id = "00000000-0000-4000-8000-000000000001"
97
+ artifact_url = "https://example.test/transcript.json"
98
+ requests = []
99
+
100
+ def respond(request, timeout):
101
+ requests.append(request)
102
+ if len(requests) == 1:
103
+ return Response(json.dumps({"generation": {
104
+ "generation_uuid": generation_id, "status": "completed", "result": {"url": artifact_url}
105
+ }}).encode())
106
+ return Response(b'{"text":"hello","languages":["English"]}')
107
+
108
+ with patch("giggy.client.urlopen", side_effect=respond):
109
+ result = self.client.transcriptions.result(generation_id)
110
+ self.assertEqual(result["text"], "hello")
111
+ self.assertEqual(requests[0].get_header("Xi-api-key"), "test-key")
112
+ self.assertIsNone(requests[1].get_header("Xi-api-key"))
113
+
114
+ def test_transcribe_submits_once_and_waits_for_durable_result(self):
115
+ generation_id = "00000000-0000-4000-8000-000000000001"
116
+ responses = iter([
117
+ Response(json.dumps({"generation": {"generation_uuid": generation_id, "status": "queued"}}).encode()),
118
+ Response(json.dumps({"generation": {"generation_uuid": generation_id, "status": "processing"}}).encode()),
119
+ Response(json.dumps({"generation": {"generation_uuid": generation_id, "status": "completed"}}).encode()),
120
+ Response(json.dumps({"generation": {"generation_uuid": generation_id, "status": "completed", "result": {"url": "https://example.test/transcript.json"}}}).encode()),
121
+ Response(b'{"text":"ready"}'),
122
+ ])
123
+ with patch("giggy.client.urlopen", side_effect=lambda request, timeout: next(responses)) as opened, patch("giggy.client.time.sleep") as slept:
124
+ result = self.client.transcriptions.transcribe("pyproject.toml", poll_interval=0.1)
125
+ self.assertEqual(result["text"], "ready")
126
+ self.assertEqual(opened.call_count, 5)
127
+ self.assertEqual(slept.call_count, 1)
128
+ started = time.monotonic()
129
+ with patch.object(self.client.transcriptions, "submit", side_effect=lambda *args, **kwargs: time.sleep(0.15)) as submitted:
130
+ with self.assertRaisesRegex(TimeoutError, "generation UUID not received"):
131
+ self.client.transcriptions.transcribe("pyproject.toml", timeout=0.02)
132
+ self.assertLess(time.monotonic() - started, 0.12)
133
+ self.assertEqual(submitted.call_count, 1)
134
+ for invalid in (float("nan"), float("inf"), 0, -1):
135
+ with self.assertRaises(ValueError):
136
+ self.client.transcriptions.transcribe("pyproject.toml", timeout=invalid)
137
+
138
+ def test_transcription_stream_parses_split_events_and_revisions(self):
139
+ path = "pyproject.toml"
140
+ response = Response(b'event: transcript_chunk\r\ndata: {"text":"one","replace":true}\r\n\r\nevent: transcript_chunk\r\ndata: {"text":"one two","replace":true}\r\n\r\n')
141
+ response.status = 200
142
+ response.headers = {"content-type": "text/event-stream"}
143
+ with patch("giggy.client.urlopen", return_value=response):
144
+ with self.client.transcriptions.stream(path) as events:
145
+ items = list(events)
146
+ self.assertEqual([item[1]["text"] for item in items], ["one", "one two"])
147
+ self.assertTrue(response.closed)
148
+
149
+ def test_live_transcription_admission_and_ordered_frame_request(self):
150
+ generation_id = "00000000-0000-4000-8000-000000000001"
151
+ token = "a" * 64
152
+ calls = []
153
+ event_body = (f'event: microphone_ready\ndata: {{"generation_uuid":"{generation_id}","attempt":1,"input_token":"{token}"}}\n\n').encode()
154
+ live_response = Response(event_body)
155
+ live_response.status = 200
156
+ live_response.headers = {"content-type": "text/event-stream"}
157
+
158
+ def respond(request, timeout):
159
+ calls.append(request)
160
+ return live_response if len(calls) == 1 else Response(b'{"ok":true}')
161
+
162
+ with patch("giggy.client.urlopen", side_effect=respond):
163
+ with self.client.transcriptions.live(client_request_id=generation_id) as events:
164
+ event, data = next(events)
165
+ self.assertEqual(event, "microphone_ready")
166
+ self.client.transcriptions.send_live_frame(
167
+ generation_uuid=data["generation_uuid"], input_token=data["input_token"],
168
+ sequence=0, pcm=b"\x01\x00" * 8000, complete=True,
169
+ )
170
+ self.assertEqual(json.loads(calls[0].data)["microphone"], True)
171
+ self.assertEqual(calls[1].full_url, "https://giggy.ai/api/generations/" + generation_id + "/stt-input")
172
+ self.assertIsNone(calls[1].get_header("Xi-api-key"))
173
+ self.assertEqual(calls[1].get_header("User-agent"), "giggy-python")
174
+ self.assertEqual(json.loads(calls[1].data)["sequence"], 0)
175
+ self.assertTrue(live_response.closed)
176
+
177
+
178
+ if __name__ == "__main__":
179
+ unittest.main()