flowspeech 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +14 -0
- data/README.md +77 -69
- data/lib/flowspeech/client.rb +353 -95
- data/lib/flowspeech/version.rb +1 -1
- data/lib/flowspeech/wav.rb +38 -0
- metadata +9 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 77ffd1136c8cf4e44cffce0e60f46a55a785d3011f228eb894fa12674a5b0f3a
|
|
4
|
+
data.tar.gz: cbcf965a998720defa2dce9286b132b22cde88bf4e5c243bbae19e1e62579e27
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6af7176948a2a08e459f9ccc6afdb9efe51f6384086b54fa0806c3333cac25217174b69a3c6a30a1f0e4e3421b7b003ddc7f53ac9a280c0a54acb1fd78426ac5
|
|
7
|
+
data.tar.gz: 514fa7b5655ffa6ae3a4f84d486437939988f3bfbac8526c7faa8921b5edee280c67b5d6278c93782c049255b4de262f7a7e29db6b0b5bf4c961bc9c9e9772c9
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
- Validate RIFF/WAVE chunk sizes, 24000 Hz mono 16-bit PCM format and complete aligned nonempty audio data; preserve valid files byte-for-byte and reject malformed downloads without resubmitting.
|
|
6
|
+
|
|
7
|
+
- Replaced retired synchronous endpoints with public API v1 jobs and separate WAV downloads.
|
|
8
|
+
- Preserved single-voice synthesis and locally encoded base64; added explicit dialogue segments, recovery, cancellation, voices and full quota metadata.
|
|
9
|
+
- Added stable UUID retries, total deadlines, Retry-After handling, terminal/deleted-result validation, response limits and credential-free downloads with no redirects.
|
|
10
|
+
- Rejects unsupported legacy options and labelled script mappings. Requires a paid API subscription/key; retains Ruby 2.6 support.
|
|
11
|
+
|
|
12
|
+
## 0.1.1
|
|
13
|
+
|
|
14
|
+
- Previous published synchronous client release, superseded by 0.2.0.
|
data/README.md
CHANGED
|
@@ -1,102 +1,110 @@
|
|
|
1
1
|
# FlowSpeech Ruby
|
|
2
2
|
|
|
3
|
-
[
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
A small Ruby client for the [FlowSpeech text to speech API](https://flowspeech.io/). It supports context-aware synthesis, multi-speaker voice assignments, quota checks, audio metadata, and structured API errors.
|
|
7
|
-
|
|
8
|
-
The gem uses only the Ruby standard library at runtime.
|
|
9
|
-
|
|
10
|
-
## Installation
|
|
11
|
-
|
|
12
|
-
Install the gem:
|
|
13
|
-
|
|
14
|
-
```bash
|
|
15
|
-
gem install flowspeech
|
|
16
|
-
```
|
|
17
|
-
|
|
18
|
-
Or add it to your Gemfile:
|
|
3
|
+
Ruby 2.6+ client for the [FlowSpeech public API v1](https://flowspeech.io/docs/public-api),
|
|
4
|
+
using only the standard library at runtime. Requires an active paid Basic, Pro
|
|
5
|
+
or Scale subscription (not a trial) and a key from [API settings](https://flowspeech.io/settings/apikeys).
|
|
19
6
|
|
|
20
7
|
```ruby
|
|
21
|
-
gem "flowspeech", "~> 0.
|
|
8
|
+
gem "flowspeech", "~> 0.2.0"
|
|
22
9
|
```
|
|
23
10
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
```bash
|
|
27
|
-
export FLOWSPEECH_API_KEY="your_api_key"
|
|
28
|
-
```
|
|
11
|
+
Set `FLOWSPEECH_API_KEY` through your environment or secret manager. Use this gem
|
|
12
|
+
on your server, never in a public client. Generation consumes account credits.
|
|
29
13
|
|
|
30
|
-
## Generate
|
|
14
|
+
## Generate WAV Audio
|
|
31
15
|
|
|
32
16
|
```ruby
|
|
33
17
|
require "flowspeech"
|
|
34
18
|
|
|
35
19
|
client = FlowSpeech::Client.new
|
|
20
|
+
request_id = SecureRandom.uuid
|
|
21
|
+
# Persist this ID before submitting. Reuse it only with unchanged input.
|
|
36
22
|
audio = client.synthesize(
|
|
37
|
-
text: "Welcome
|
|
38
|
-
speakers: [{ voice_name: "Kore" }]
|
|
23
|
+
text: "Welcome to FlowSpeech.",
|
|
24
|
+
speakers: [{ voice_name: "Kore" }],
|
|
25
|
+
request_id: request_id
|
|
39
26
|
)
|
|
40
|
-
|
|
41
|
-
audio.write("speech.pcm")
|
|
27
|
+
audio.write("speech.wav")
|
|
42
28
|
puts "#{audio.sample_rate} Hz, #{audio.num_channels} channel"
|
|
43
29
|
```
|
|
44
30
|
|
|
45
|
-
|
|
46
|
-
|
|
31
|
+
`synthesize` submits a job, polls until `success`, and downloads WAV audio
|
|
32
|
+
(24000 Hz, mono, 16 bit). `audio_base64` is encoded **locally** for compatibility;
|
|
33
|
+
v1 does not return base64 or raw PCM. `Audio` exposes `job_id`, `request_id`,
|
|
34
|
+
`usage`, `bytes`, and `write`. `audio.quota` is no longer populated.
|
|
47
35
|
|
|
48
|
-
##
|
|
36
|
+
## Explicit Dialogue
|
|
49
37
|
|
|
50
38
|
```ruby
|
|
51
|
-
audio = client.synthesize(
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
{ speaker: "Guest", voice_name: "Kore" }
|
|
56
|
-
]
|
|
57
|
-
)
|
|
39
|
+
audio = client.synthesize(segments: [
|
|
40
|
+
{ speaker: "Alice", text: "Welcome!", voice_name: "Kore" },
|
|
41
|
+
{ speaker: "Bob", text: "Thank you.", voice_name: "Puck" }
|
|
42
|
+
])
|
|
58
43
|
```
|
|
59
44
|
|
|
60
|
-
|
|
45
|
+
Use `segments` without `text` or `speakers`. Legacy labelled dialogue, multiple
|
|
46
|
+
speaker assignments, and `original_text` are rejected. Segment hashes accept
|
|
47
|
+
`voice_name` or `voiceName`, not both. Unknown fields/options (including model,
|
|
48
|
+
source, speed and output format) are rejected, not silently ignored.
|
|
61
49
|
|
|
62
|
-
|
|
63
|
-
quota = client.quota
|
|
64
|
-
puts "#{quota.fetch("remaining")} characters remaining"
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
## Error handling
|
|
50
|
+
## Jobs And Recovery
|
|
68
51
|
|
|
69
52
|
```ruby
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
warn "#{error.status}: #{error.message}"
|
|
74
|
-
warn "Retry after #{error.retry_after}s" if error.retryable
|
|
75
|
-
end
|
|
53
|
+
job = client.create_job(text: "Hello", request_id: request_id)
|
|
54
|
+
job = client.wait_job(job)
|
|
55
|
+
File.binwrite("speech.wav", client.download(job))
|
|
76
56
|
```
|
|
77
57
|
|
|
78
|
-
|
|
58
|
+
- `get_job(job_id)` returns a direct v1 job hash.
|
|
59
|
+
- `find_job(request_id)` recovers an interrupted submission by UUID.
|
|
60
|
+
- `cancel_job(job_id)` explicitly cancels server work.
|
|
61
|
+
- `wait_job(job)` polls queued/running/merging/settling; failed/cancelled jobs raise `FlowSpeech::JobError` with `status` and `error_code`.
|
|
62
|
+
- `download(job)` downloads only; retrying it never submits new work.
|
|
63
|
+
- `voices` returns the top-level voice list.
|
|
64
|
+
- `quota` retains the convenience credit hash; `quota_details` preserves the full `quota`, `entitlement`, `subscription`, and `limits` response. Use `quota["available"]` for new work.
|
|
65
|
+
|
|
66
|
+
The server returns `subscription`; `entitlement` is also accepted for
|
|
67
|
+
compatibility. Neither field is discarded. HTTP 200 is not proof that generation succeeded. A deleted
|
|
68
|
+
result has `status: "success"`, `result: nil`, and `resultDeleted: true`;
|
|
69
|
+
`wait_job`/`download` raise `JobError` with `result_deleted == true`.
|
|
70
|
+
|
|
71
|
+
`FlowSpeech::Error` carries `request_id`/`job_id` when known. `APIError` adds
|
|
72
|
+
`status`, `error_code` (also `code`, now a string), `retryable`, `retry_after`, and
|
|
73
|
+
`trace_id`. `DeadlineError` means the configured total deadline was reached or
|
|
74
|
+
the required next wait would exceed it. Local interruption does not cancel work.
|
|
75
|
+
|
|
76
|
+
Recover using the same UUID and input after a lost create response. Do not
|
|
77
|
+
create another recording just because its download failed. Generate a new UUID
|
|
78
|
+
only for intentionally new work.
|
|
79
|
+
|
|
80
|
+
## Configuration And Security
|
|
81
|
+
|
|
82
|
+
Constructor options: `api_key`, `base_url`, `open_timeout` (10 seconds),
|
|
83
|
+
`read_timeout` (30), `timeout` (600 total), `poll_interval` (5 minimum),
|
|
84
|
+
`max_retries` (2, allowed 0..10), and `max_download_bytes` (64 MiB).
|
|
85
|
+
All submit/poll/download work shares one total deadline in `synthesize`.
|
|
86
|
+
Network errors, HTTP 429 and retryable 5xx responses get bounded retries with
|
|
87
|
+
the same UUID and body. Longer numeric and HTTP-date `Retry-After` is honored.
|
|
88
|
+
API responses are bounded to 1 MiB; audio is streamed with the byte limit.
|
|
89
|
+
|
|
90
|
+
Downloads validate the complete RIFF/WAVE container, PCM format (24000 Hz,
|
|
91
|
+
mono, 16 bit), byte rate/block alignment, and nonempty complete sample data.
|
|
92
|
+
Truncated, malformed or mismatched audio is rejected even on HTTP 200. Bounded,
|
|
93
|
+
word-aligned unknown chunks are skipped; valid files are returned unchanged.
|
|
94
|
+
|
|
95
|
+
API and audio transports are separate. No Bearer token, cookies or idempotency
|
|
96
|
+
key is sent to storage. Redirects are refused for both API and audio requests.
|
|
97
|
+
URLs must be HTTPS without credentials/fragments; HTTP loopback is allowed for
|
|
98
|
+
tests. Optional `transport`, `download_transport` and `sleeper` are trusted test
|
|
99
|
+
hooks; injected transports must not add credentials or follow redirects.
|
|
100
|
+
Audio URLs are public link-access URLs, not private authentication tokens.
|
|
79
101
|
|
|
80
|
-
|
|
102
|
+
## Development
|
|
81
103
|
|
|
82
|
-
```
|
|
104
|
+
```sh
|
|
83
105
|
ruby -Ilib test/client_test.rb
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
Build the gem locally:
|
|
87
|
-
|
|
88
|
-
```bash
|
|
89
106
|
gem build flowspeech.gemspec
|
|
90
107
|
```
|
|
91
108
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
- Never commit API keys.
|
|
95
|
-
- Load `FLOWSPEECH_API_KEY` from the environment or a secret manager.
|
|
96
|
-
- The client sends the key only in the `Authorization: Bearer` header.
|
|
97
|
-
- Tests use an injected fake transport and contain no credentials.
|
|
98
|
-
|
|
99
|
-
## License
|
|
100
|
-
|
|
101
|
-
MIT
|
|
102
|
-
|
|
109
|
+
Tests use fake HTTP without keys or generation credits. See [CHANGELOG.md](CHANGELOG.md).
|
|
110
|
+
License: MIT.
|
data/lib/flowspeech/client.rb
CHANGED
|
@@ -3,42 +3,50 @@
|
|
|
3
3
|
require "base64"
|
|
4
4
|
require "json"
|
|
5
5
|
require "net/http"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
require "time"
|
|
8
|
+
require "timeout"
|
|
6
9
|
require "uri"
|
|
10
|
+
require_relative "wav"
|
|
7
11
|
|
|
8
12
|
module FlowSpeech
|
|
9
|
-
class Error < StandardError
|
|
10
|
-
|
|
13
|
+
class Error < StandardError
|
|
14
|
+
attr_accessor :request_id, :job_id
|
|
15
|
+
end
|
|
16
|
+
class DeadlineError < Error; end
|
|
17
|
+
class JobError < Error
|
|
18
|
+
attr_reader :status, :error_code, :result_deleted
|
|
19
|
+
def initialize(job)
|
|
20
|
+
@status = job["status"]
|
|
21
|
+
@error_code = job["error"].is_a?(Hash) ? job["error"]["code"] : nil
|
|
22
|
+
@result_deleted = job["resultDeleted"] == true
|
|
23
|
+
super(@result_deleted ? "Audio result was deleted (resultDeleted=true)" : "Job #{@status}: #{@error_code}")
|
|
24
|
+
@request_id, @job_id = job["requestId"], job["id"]
|
|
25
|
+
end
|
|
26
|
+
end
|
|
11
27
|
class APIError < Error
|
|
12
|
-
attr_reader :status, :code, :error_code, :retryable, :retry_after
|
|
13
|
-
|
|
14
|
-
def initialize(message, status:, code: nil, error_code: nil, retryable: false, retry_after: nil)
|
|
28
|
+
attr_reader :status, :code, :error_code, :retryable, :retry_after, :trace_id
|
|
29
|
+
def initialize(message, status:, code: nil, error_code: nil, retryable: false, retry_after: nil, trace_id: nil)
|
|
15
30
|
super(message)
|
|
16
|
-
@status = status
|
|
17
|
-
@
|
|
18
|
-
@error_code = error_code
|
|
19
|
-
@retryable = retryable
|
|
20
|
-
@retry_after = retry_after
|
|
31
|
+
@status, @code, @error_code = status, code, error_code || code
|
|
32
|
+
@retryable, @retry_after, @trace_id = retryable, retry_after, trace_id
|
|
21
33
|
end
|
|
22
34
|
end
|
|
23
35
|
|
|
24
36
|
class Audio
|
|
25
|
-
attr_reader :mime_type, :audio_base64, :quota, :sample_rate, :num_channels,
|
|
26
|
-
|
|
37
|
+
attr_reader :mime_type, :audio_base64, :quota, :sample_rate, :num_channels,
|
|
38
|
+
:bits_per_sample, :job_id, :request_id, :usage
|
|
27
39
|
def initialize(data)
|
|
28
|
-
@mime_type = data.fetch("mimeType")
|
|
29
|
-
@
|
|
30
|
-
@
|
|
31
|
-
@
|
|
32
|
-
@num_channels = data.fetch("numChannels")
|
|
33
|
-
@bits_per_sample = data.fetch("bitsPerSample")
|
|
40
|
+
@mime_type, @audio_base64 = data.fetch("mimeType"), data.fetch("audioBase64")
|
|
41
|
+
@quota, @sample_rate = data["quota"], data.fetch("sampleRate")
|
|
42
|
+
@num_channels, @bits_per_sample = data.fetch("numChannels"), data.fetch("bitsPerSample")
|
|
43
|
+
@job_id, @request_id, @usage = data["jobId"], data["requestId"], data["usage"]
|
|
34
44
|
end
|
|
35
|
-
|
|
36
45
|
def bytes
|
|
37
46
|
Base64.strict_decode64(audio_base64)
|
|
38
|
-
rescue ArgumentError
|
|
39
|
-
raise Error, "
|
|
47
|
+
rescue ArgumentError
|
|
48
|
+
raise Error, "Invalid local base64 audio"
|
|
40
49
|
end
|
|
41
|
-
|
|
42
50
|
def write(path)
|
|
43
51
|
File.binwrite(path, bytes)
|
|
44
52
|
path
|
|
@@ -47,110 +55,360 @@ module FlowSpeech
|
|
|
47
55
|
|
|
48
56
|
class Client
|
|
49
57
|
DEFAULT_BASE_URL = "https://flowspeech.io"
|
|
58
|
+
JOBS = "/api/v1/tts/jobs"
|
|
59
|
+
UUID = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
|
|
60
|
+
PENDING = %w[queued running merging settling].freeze
|
|
61
|
+
Response = Struct.new(:code, :body, :headers) do
|
|
62
|
+
def [](name)
|
|
63
|
+
headers[name.downcase]
|
|
64
|
+
end
|
|
65
|
+
end
|
|
50
66
|
|
|
67
|
+
# Transports are trusted injection points. A download transport must not
|
|
68
|
+
# add authentication, cookies or redirect handling of its own.
|
|
51
69
|
def initialize(api_key: ENV["FLOWSPEECH_API_KEY"], base_url: DEFAULT_BASE_URL,
|
|
52
|
-
open_timeout: 10, read_timeout:
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
unless @base_url.
|
|
61
|
-
raise ArgumentError, "base_url must be an
|
|
70
|
+
open_timeout: 10, read_timeout: 30, timeout: 600,
|
|
71
|
+
poll_interval: 5, max_retries: 2, max_download_bytes: 64 * 1024 * 1024,
|
|
72
|
+
transport: nil, download_transport: nil, sleeper: nil)
|
|
73
|
+
unless api_key.is_a?(String) && !api_key.empty? && !api_key.match?(/\s/)
|
|
74
|
+
raise ArgumentError, "api_key must be nonempty and contain no whitespace"
|
|
75
|
+
end
|
|
76
|
+
@api_key = api_key
|
|
77
|
+
@base_url = safe_url(base_url)
|
|
78
|
+
unless ["", "/"].include?(@base_url.path) && !@base_url.query
|
|
79
|
+
raise ArgumentError, "base_url must be an origin"
|
|
80
|
+
end
|
|
81
|
+
[open_timeout, read_timeout, timeout, poll_interval].each do |value|
|
|
82
|
+
raise ArgumentError, "timeouts must be finite and positive" unless value.is_a?(Numeric) && value.finite? && value > 0
|
|
83
|
+
end
|
|
84
|
+
raise ArgumentError, "poll_interval must be at least five seconds" if poll_interval < 5
|
|
85
|
+
raise ArgumentError, "max_retries must be 0..10" unless max_retries.is_a?(Integer) && (0..10).cover?(max_retries)
|
|
86
|
+
unless max_download_bytes.is_a?(Integer) && (1..(1 << 30)).cover?(max_download_bytes)
|
|
87
|
+
raise ArgumentError, "max_download_bytes must be 1..1073741824"
|
|
62
88
|
end
|
|
89
|
+
@open_timeout, @read_timeout, @timeout = open_timeout, read_timeout, timeout
|
|
90
|
+
@poll_interval, @max_retries, @max_download_bytes = poll_interval, max_retries, max_download_bytes
|
|
91
|
+
@transport, @download_transport = transport, download_transport
|
|
92
|
+
@sleeper = sleeper || ->(seconds) { sleep(seconds) }
|
|
63
93
|
end
|
|
64
94
|
|
|
65
95
|
def quota
|
|
66
|
-
|
|
96
|
+
quota_details.fetch("quota")
|
|
67
97
|
end
|
|
68
98
|
|
|
69
|
-
|
|
70
|
-
|
|
99
|
+
# Preserves both entitlement (contract) and subscription (server alias).
|
|
100
|
+
def quota_details
|
|
101
|
+
operation do |deadline|
|
|
102
|
+
data = request(:get, "/api/v1/quota", deadline: deadline).first
|
|
103
|
+
raise Error, "Missing quota" unless data["quota"].is_a?(Hash)
|
|
104
|
+
data
|
|
105
|
+
end
|
|
106
|
+
end
|
|
71
107
|
|
|
72
|
-
|
|
73
|
-
|
|
108
|
+
def voices
|
|
109
|
+
operation do |deadline|
|
|
110
|
+
data = request(:get, "/api/v1/voices", deadline: deadline).first
|
|
111
|
+
raise Error, "Missing voices" unless data["voices"].is_a?(Array)
|
|
112
|
+
data["voices"]
|
|
113
|
+
end
|
|
114
|
+
end
|
|
74
115
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
116
|
+
def create_job(text: nil, speakers: nil, voice_name: "Kore", segments: nil, original_text: nil, request_id: nil)
|
|
117
|
+
input = normalize_input(text, speakers, voice_name, segments, original_text)
|
|
118
|
+
id = checked_id(request_id || SecureRandom.uuid)
|
|
119
|
+
operation(request_id: id) { |deadline| submit(input, id, deadline) }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def get_job(id)
|
|
123
|
+
checked_id(id)
|
|
124
|
+
operation(job_id: id) { |deadline| get(id, deadline) }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def find_job(request_id)
|
|
128
|
+
checked_id(request_id)
|
|
129
|
+
operation(request_id: request_id) do |deadline|
|
|
130
|
+
job = job_request(:get, "#{JOBS}?requestId=#{request_id}", deadline: deadline)
|
|
131
|
+
match_request(job, request_id)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def cancel_job(id)
|
|
136
|
+
checked_id(id)
|
|
137
|
+
operation(job_id: id) do |deadline|
|
|
138
|
+
job = job_request(:post, "#{JOBS}/#{id}/cancel", deadline: deadline)
|
|
139
|
+
raise Error, "Job ID mismatch" unless job["id"].casecmp(id).zero?
|
|
140
|
+
job
|
|
141
|
+
end
|
|
142
|
+
end
|
|
83
143
|
|
|
84
|
-
|
|
144
|
+
def wait_job(job)
|
|
145
|
+
validate_job(job)
|
|
146
|
+
operation(request_id: job["requestId"], job_id: job["id"]) { |deadline| await_job(job, deadline) }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Retrying this method never creates a new job.
|
|
150
|
+
def download(job)
|
|
151
|
+
validate_job(job)
|
|
152
|
+
operation(request_id: job["requestId"], job_id: job["id"]) { |deadline| download_bytes(job, deadline) }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def synthesize(text: nil, speakers: nil, voice_name: "Kore", segments: nil, original_text: nil, request_id: nil)
|
|
156
|
+
input = normalize_input(text, speakers, voice_name, segments, original_text)
|
|
157
|
+
id = checked_id(request_id || SecureRandom.uuid)
|
|
158
|
+
job = nil
|
|
159
|
+
operation(request_id: id) do |deadline|
|
|
160
|
+
job = submit(input, id, deadline)
|
|
161
|
+
job = await_job(job, deadline)
|
|
162
|
+
bytes = download_bytes(job, deadline)
|
|
163
|
+
Audio.new(job["result"].merge("audioBase64" => Base64.strict_encode64(bytes),
|
|
164
|
+
"numChannels" => job["result"]["channels"], "jobId" => job["id"], "requestId" => id, "usage" => job["usage"]))
|
|
165
|
+
end
|
|
166
|
+
rescue Error => error
|
|
167
|
+
error.job_id ||= job && job["id"]
|
|
168
|
+
raise
|
|
85
169
|
end
|
|
86
170
|
|
|
87
171
|
private
|
|
88
172
|
|
|
89
|
-
def
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
173
|
+
def normalize_input(text, speakers, voice, segments, original)
|
|
174
|
+
raise ArgumentError, "original_text is unsupported by v1" unless original.nil?
|
|
175
|
+
unless segments.nil?
|
|
176
|
+
unless segments.is_a?(Array) && !segments.empty? && text.nil? && speakers.nil?
|
|
177
|
+
raise ArgumentError, "Use nonempty segments without text or speakers"
|
|
178
|
+
end
|
|
179
|
+
return { "mode" => "multi", "segments" => segments.map { |s| normalize_segment(s) } }
|
|
180
|
+
end
|
|
181
|
+
if speakers
|
|
182
|
+
unless speakers.is_a?(Array) && speakers.length == 1
|
|
183
|
+
raise ArgumentError, "Use explicit segments for multiple speakers"
|
|
184
|
+
end
|
|
185
|
+
s = normalize_keys(speakers.first, %w[voice_name voiceName])
|
|
186
|
+
voice = s["voiceName"] || s["voice_name"]
|
|
187
|
+
end
|
|
188
|
+
required_string(text, "text")
|
|
189
|
+
required_string(voice, "voiceName")
|
|
190
|
+
{ "mode" => "single", "text" => text, "voiceName" => voice }
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def normalize_segment(segment)
|
|
194
|
+
s = normalize_keys(segment, %w[speaker text voiceName voice_name])
|
|
195
|
+
s["voiceName"] ||= s.delete("voice_name")
|
|
196
|
+
%w[speaker text voiceName].each { |key| required_string(s[key], key) }
|
|
197
|
+
s
|
|
198
|
+
end
|
|
93
199
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
result
|
|
200
|
+
def normalize_keys(value, allowed)
|
|
201
|
+
raise ArgumentError, "Expected a hash" unless value.is_a?(Hash)
|
|
202
|
+
result = {}
|
|
203
|
+
value.each do |key, v|
|
|
204
|
+
key = key.to_s
|
|
205
|
+
raise ArgumentError, "Unsupported or duplicate input field: #{key}" unless allowed.include?(key) && !result.key?(key)
|
|
206
|
+
result[key] = v
|
|
207
|
+
end
|
|
208
|
+
if result.key?("voiceName") && result.key?("voice_name")
|
|
209
|
+
raise ArgumentError, "Specify only one voice name field"
|
|
210
|
+
end
|
|
97
211
|
result
|
|
98
212
|
end
|
|
99
213
|
|
|
100
|
-
def
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
214
|
+
def required_string(value, name)
|
|
215
|
+
raise ArgumentError, "#{name} must be a nonempty string" unless value.is_a?(String) && !value.strip.empty?
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def checked_id(id)
|
|
219
|
+
raise ArgumentError, "ID must be a UUID" unless id.is_a?(String) && UUID.match?(id)
|
|
220
|
+
id
|
|
221
|
+
end
|
|
105
222
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
raise
|
|
111
|
-
message,
|
|
112
|
-
status: response.code.to_i,
|
|
113
|
-
code: parsed["code"],
|
|
114
|
-
error_code: error_data["errorCode"],
|
|
115
|
-
retryable: error_data["retryable"] == true,
|
|
116
|
-
retry_after: response["Retry-After"]
|
|
117
|
-
)
|
|
223
|
+
def safe_url(raw)
|
|
224
|
+
uri = URI(raw)
|
|
225
|
+
unless uri.is_a?(URI::HTTP) && uri.host && !uri.userinfo && !uri.fragment &&
|
|
226
|
+
(uri.scheme == "https" || (uri.scheme == "http" && %w[localhost 127.0.0.1 ::1].include?(uri.hostname)))
|
|
227
|
+
raise ArgumentError, "URL must be HTTPS without credentials or fragment; HTTP loopback is allowed for tests"
|
|
118
228
|
end
|
|
229
|
+
uri
|
|
230
|
+
rescue URI::InvalidURIError, TypeError
|
|
231
|
+
raise ArgumentError, "Invalid URL"
|
|
232
|
+
end
|
|
119
233
|
|
|
120
|
-
|
|
121
|
-
|
|
234
|
+
def operation(request_id: nil, job_id: nil)
|
|
235
|
+
deadline = monotonic + @timeout
|
|
236
|
+
Timeout.timeout(@timeout, DeadlineError) { yield deadline }
|
|
237
|
+
rescue Error => error
|
|
238
|
+
error.request_id ||= request_id
|
|
239
|
+
error.job_id ||= job_id
|
|
240
|
+
raise
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def monotonic
|
|
244
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def remaining(deadline)
|
|
248
|
+
value = deadline - monotonic
|
|
249
|
+
raise DeadlineError, "Operation deadline exceeded" if value <= 0
|
|
250
|
+
value
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def pause(raw, deadline)
|
|
254
|
+
delay = @poll_interval
|
|
255
|
+
if raw.to_s.match?(/\A\d+\z/)
|
|
256
|
+
delay = [delay, raw.to_i].max
|
|
257
|
+
elsif raw
|
|
258
|
+
begin
|
|
259
|
+
delay = [delay, Time.httpdate(raw) - Time.now].max
|
|
260
|
+
rescue ArgumentError
|
|
261
|
+
# Invalid Retry-After uses the conservative polling interval.
|
|
262
|
+
end
|
|
122
263
|
end
|
|
264
|
+
raise DeadlineError, "Retry or poll wait would exceed operation deadline" if delay >= remaining(deadline)
|
|
265
|
+
@sleeper.call(delay)
|
|
266
|
+
remaining(deadline)
|
|
267
|
+
end
|
|
123
268
|
|
|
124
|
-
|
|
269
|
+
def submit(input, id, deadline)
|
|
270
|
+
job = job_request(:post, JOBS, body: JSON.generate("input" => input), id: id, deadline: deadline)
|
|
271
|
+
match_request(job, id)
|
|
125
272
|
end
|
|
126
273
|
|
|
127
|
-
def
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
request["Authorization"] = "Bearer #{@api_key}"
|
|
131
|
-
request["Accept"] = "application/json"
|
|
132
|
-
if payload
|
|
133
|
-
request["Content-Type"] = "application/json"
|
|
134
|
-
request.body = JSON.generate(payload)
|
|
274
|
+
def match_request(job, id)
|
|
275
|
+
if job["requestId"] && !job["requestId"].casecmp(id).zero?
|
|
276
|
+
raise Error, "Request ID mismatch"
|
|
135
277
|
end
|
|
136
|
-
|
|
278
|
+
job["requestId"] = id
|
|
279
|
+
job
|
|
137
280
|
end
|
|
138
281
|
|
|
139
|
-
def
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
use_ssl: uri.scheme == "https",
|
|
144
|
-
open_timeout: @open_timeout,
|
|
145
|
-
read_timeout: @read_timeout
|
|
146
|
-
) { |http| http.request(request) }
|
|
282
|
+
def get(id, deadline)
|
|
283
|
+
job = job_request(:get, "#{JOBS}/#{id}", deadline: deadline)
|
|
284
|
+
raise Error, "Job ID mismatch" unless job["id"].casecmp(id).zero?
|
|
285
|
+
job
|
|
147
286
|
end
|
|
148
287
|
|
|
149
|
-
def
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
288
|
+
def job_request(method, path, body: nil, id: nil, deadline:)
|
|
289
|
+
job, retry_after = request(method, path, body: body, id: id, deadline: deadline)
|
|
290
|
+
validate_job(job)
|
|
291
|
+
job["retryAfter"] = retry_after
|
|
292
|
+
job
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def validate_job(job)
|
|
296
|
+
unless job.is_a?(Hash) && job["id"].is_a?(String) && UUID.match?(job["id"])
|
|
297
|
+
raise Error, "Missing or invalid job ID"
|
|
298
|
+
end
|
|
299
|
+
if job.key?("requestId") && (!job["requestId"].is_a?(String) || !UUID.match?(job["requestId"]))
|
|
300
|
+
raise Error, "Invalid request ID"
|
|
301
|
+
end
|
|
302
|
+
if job.key?("resultDeleted") && ![true, false].include?(job["resultDeleted"])
|
|
303
|
+
raise Error, "Invalid resultDeleted flag"
|
|
304
|
+
end
|
|
305
|
+
status = job["status"]
|
|
306
|
+
raise Error, "Unknown job status" unless (PENDING + %w[success failed cancelled]).include?(status)
|
|
307
|
+
return job unless status == "success"
|
|
308
|
+
return job if job["resultDeleted"] == true && job["result"].nil?
|
|
309
|
+
result = job["result"]
|
|
310
|
+
unless job["resultDeleted"] != true && result.is_a?(Hash) && result["mimeType"] == "audio/wav" &&
|
|
311
|
+
result["sampleRate"] == 24000 && result["channels"] == 1 && result["bitsPerSample"] == 16
|
|
312
|
+
raise Error, "Missing or malformed WAV result"
|
|
313
|
+
end
|
|
314
|
+
safe_url(result["url"])
|
|
315
|
+
job
|
|
316
|
+
rescue ArgumentError
|
|
317
|
+
raise Error, "Invalid audio URL"
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def await_job(job, deadline)
|
|
321
|
+
id, request_id = job["id"], job["requestId"]
|
|
322
|
+
loop do
|
|
323
|
+
remaining(deadline)
|
|
324
|
+
if PENDING.include?(job["status"])
|
|
325
|
+
pause(job["retryAfter"], deadline)
|
|
326
|
+
job = get(id, deadline)
|
|
327
|
+
match_request(job, request_id) if request_id
|
|
328
|
+
elsif job["status"] == "success" && job["resultDeleted"] != true
|
|
329
|
+
return job
|
|
330
|
+
else
|
|
331
|
+
raise JobError, job
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def download_bytes(job, deadline)
|
|
337
|
+
raise JobError, job unless job["status"] == "success" && job["resultDeleted"] != true
|
|
338
|
+
uri = safe_url(job.fetch("result").fetch("url"))
|
|
339
|
+
req = Net::HTTP::Get.new(uri)
|
|
340
|
+
response = exchange(uri, req, deadline, @max_download_bytes, @download_transport)
|
|
341
|
+
raise Error, "Download HTTP #{response.code}; retry download with the same job" unless response.code.to_i == 200
|
|
342
|
+
Wav.validate!(response.body.to_s) { remaining(deadline) }
|
|
343
|
+
rescue IOError, SystemCallError, SocketError, Timeout::Error, OpenSSL::SSL::SSLError
|
|
344
|
+
raise Error, "Audio download failed; retry download with the same job"
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def request(method, path, body: nil, id: nil, deadline:)
|
|
348
|
+
uri = @base_url + path
|
|
349
|
+
attempts = 0
|
|
350
|
+
loop do
|
|
351
|
+
retry_after = nil
|
|
352
|
+
begin
|
|
353
|
+
req = (method == :get ? Net::HTTP::Get : Net::HTTP::Post).new(uri)
|
|
354
|
+
req["Authorization"] = "Bearer #{@api_key}"
|
|
355
|
+
req["Accept"] = "application/json"
|
|
356
|
+
req["Idempotency-Key"] = id if id
|
|
357
|
+
if body
|
|
358
|
+
req["Content-Type"] = "application/json"
|
|
359
|
+
req.body = body
|
|
360
|
+
end
|
|
361
|
+
response = exchange(uri, req, deadline, 1024 * 1024, @transport)
|
|
362
|
+
status = response.code.to_i
|
|
363
|
+
retry_after = response["Retry-After"]
|
|
364
|
+
data = begin
|
|
365
|
+
JSON.parse(response.body.to_s)
|
|
366
|
+
rescue JSON::ParserError
|
|
367
|
+
nil
|
|
368
|
+
end
|
|
369
|
+
if (200..299).cover?(status)
|
|
370
|
+
raise Error, "Invalid JSON object response" unless data.is_a?(Hash)
|
|
371
|
+
return [data, retry_after]
|
|
372
|
+
end
|
|
373
|
+
detail = data.is_a?(Hash) && data["error"].is_a?(Hash) ? data["error"] : {}
|
|
374
|
+
error = APIError.new(detail["message"] || "HTTP #{status}", status: status,
|
|
375
|
+
code: detail["code"], retryable: detail["retryable"] == true, retry_after: retry_after,
|
|
376
|
+
trace_id: data.is_a?(Hash) ? data["traceId"] || response["X-Request-Id"] : response["X-Request-Id"])
|
|
377
|
+
raise error unless status == 429 || (status >= 500 && error.retryable)
|
|
378
|
+
rescue IOError, SystemCallError, SocketError, Timeout::Error, OpenSSL::SSL::SSLError
|
|
379
|
+
error = Error.new("API transport failed")
|
|
380
|
+
end
|
|
381
|
+
raise error if attempts >= @max_retries
|
|
382
|
+
attempts += 1
|
|
383
|
+
pause(retry_after, deadline)
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def exchange(uri, request, deadline, limit, transport)
|
|
388
|
+
remaining(deadline)
|
|
389
|
+
response = if transport
|
|
390
|
+
transport.call(uri, request)
|
|
391
|
+
else
|
|
392
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
393
|
+
open_timeout: [@open_timeout, remaining(deadline)].min,
|
|
394
|
+
read_timeout: [@read_timeout, remaining(deadline)].min) do |http|
|
|
395
|
+
http.max_retries = 0 if http.respond_to?(:max_retries=)
|
|
396
|
+
result = nil
|
|
397
|
+
http.request(request) do |res|
|
|
398
|
+
bytes = String.new(encoding: Encoding::BINARY)
|
|
399
|
+
res.read_body do |chunk|
|
|
400
|
+
remaining(deadline)
|
|
401
|
+
raise Error, "Response exceeds byte limit" if bytes.bytesize + chunk.bytesize > limit
|
|
402
|
+
bytes << chunk
|
|
403
|
+
end
|
|
404
|
+
result = Response.new(res.code, bytes, res.to_hash.transform_values(&:first))
|
|
405
|
+
end
|
|
406
|
+
result
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
remaining(deadline)
|
|
410
|
+
raise Error, "Response exceeds byte limit" if response.body.to_s.bytesize > limit
|
|
411
|
+
response
|
|
153
412
|
end
|
|
154
413
|
end
|
|
155
414
|
end
|
|
156
|
-
|
data/lib/flowspeech/version.rb
CHANGED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FlowSpeech
|
|
4
|
+
module Wav
|
|
5
|
+
# Walk bounded chunks, preserving the original file and its sample bytes.
|
|
6
|
+
def self.validate!(bytes)
|
|
7
|
+
invalid = -> { raise Error, "Invalid WAV: expected complete 24000 Hz mono 16-bit PCM with nonempty aligned data" }
|
|
8
|
+
invalid.call unless bytes.bytesize >= 12 && bytes.byteslice(0, 4) == "RIFF" && bytes.byteslice(8, 4) == "WAVE"
|
|
9
|
+
invalid.call unless bytes.byteslice(4, 4).unpack1("V") + 8 == bytes.bytesize
|
|
10
|
+
offset, format_seen, data_seen = 12, false, false
|
|
11
|
+
while offset < bytes.bytesize
|
|
12
|
+
yield if block_given?
|
|
13
|
+
invalid.call if bytes.bytesize - offset < 8
|
|
14
|
+
tag = bytes.byteslice(offset, 4)
|
|
15
|
+
size = bytes.byteslice(offset + 4, 4).unpack1("V")
|
|
16
|
+
start = offset + 8
|
|
17
|
+
finish = start + size
|
|
18
|
+
following = finish + (size & 1)
|
|
19
|
+
invalid.call if following > bytes.bytesize
|
|
20
|
+
case tag
|
|
21
|
+
when "fmt "
|
|
22
|
+
invalid.call if format_seen || data_seen || ![16, 18].include?(size)
|
|
23
|
+
fields = bytes.byteslice(start, 16).unpack("vvVVvv")
|
|
24
|
+
invalid.call unless fields == [1, 1, 24000, 48000, 2, 16]
|
|
25
|
+
invalid.call if size == 18 && bytes.byteslice(start + 16, 2).unpack1("v") != 0
|
|
26
|
+
format_seen = true
|
|
27
|
+
when "data"
|
|
28
|
+
invalid.call unless format_seen && !data_seen && size > 0 && size.even?
|
|
29
|
+
data_seen = true
|
|
30
|
+
end
|
|
31
|
+
offset = following
|
|
32
|
+
end
|
|
33
|
+
invalid.call unless format_seen && data_seen
|
|
34
|
+
yield if block_given?
|
|
35
|
+
bytes
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: flowspeech
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- FlowSpeech
|
|
8
|
-
autorequire:
|
|
8
|
+
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-10-26 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rake
|
|
@@ -30,19 +30,21 @@ dependencies:
|
|
|
30
30
|
- - "<"
|
|
31
31
|
- !ruby/object:Gem::Version
|
|
32
32
|
version: '14'
|
|
33
|
-
description:
|
|
34
|
-
|
|
33
|
+
description: FlowSpeech public API v1 jobs, polling, recovery, multi-speaker segments
|
|
34
|
+
and credential-free WAV downloads.
|
|
35
35
|
email:
|
|
36
36
|
- support@flowspeech.io
|
|
37
37
|
executables: []
|
|
38
38
|
extensions: []
|
|
39
39
|
extra_rdoc_files: []
|
|
40
40
|
files:
|
|
41
|
+
- CHANGELOG.md
|
|
41
42
|
- LICENSE
|
|
42
43
|
- README.md
|
|
43
44
|
- lib/flowspeech.rb
|
|
44
45
|
- lib/flowspeech/client.rb
|
|
45
46
|
- lib/flowspeech/version.rb
|
|
47
|
+
- lib/flowspeech/wav.rb
|
|
46
48
|
homepage: https://flowspeech.io/
|
|
47
49
|
licenses:
|
|
48
50
|
- MIT
|
|
@@ -51,7 +53,7 @@ metadata:
|
|
|
51
53
|
source_code_uri: https://github.com/waeckerlinfederowicz66-sketch/flowspeech-ruby
|
|
52
54
|
changelog_uri: https://github.com/waeckerlinfederowicz66-sketch/flowspeech-ruby/releases
|
|
53
55
|
rubygems_mfa_required: 'true'
|
|
54
|
-
post_install_message:
|
|
56
|
+
post_install_message:
|
|
55
57
|
rdoc_options: []
|
|
56
58
|
require_paths:
|
|
57
59
|
- lib
|
|
@@ -67,7 +69,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
67
69
|
version: '0'
|
|
68
70
|
requirements: []
|
|
69
71
|
rubygems_version: 3.5.22
|
|
70
|
-
signing_key:
|
|
72
|
+
signing_key:
|
|
71
73
|
specification_version: 4
|
|
72
74
|
summary: Ruby client for the FlowSpeech text-to-speech API
|
|
73
75
|
test_files: []
|