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.
- giggy-0.1.2/.github/workflows/ci.yml +36 -0
- giggy-0.1.2/.github/workflows/publish.yml +48 -0
- giggy-0.1.2/.gitignore +13 -0
- giggy-0.1.2/LICENSE +7 -0
- giggy-0.1.2/PKG-INFO +216 -0
- giggy-0.1.2/README.md +196 -0
- giggy-0.1.2/pyproject.toml +30 -0
- giggy-0.1.2/src/giggy/__init__.py +4 -0
- giggy-0.1.2/src/giggy/client.py +366 -0
- giggy-0.1.2/src/giggy/errors.py +7 -0
- giggy-0.1.2/src/giggy/py.typed +0 -0
- giggy-0.1.2/tests/test_client.py +179 -0
|
@@ -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
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)
|
|
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()
|