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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4aa33771a20fb9cb3f38ed4af5e46f47f6d884823a83c54e8d96c10fbe92f601
4
- data.tar.gz: 3daec29ae0da53cf1931c3c4866847b717d068f443cc1ac056552b2ef661a616
3
+ metadata.gz: 77ffd1136c8cf4e44cffce0e60f46a55a785d3011f228eb894fa12674a5b0f3a
4
+ data.tar.gz: cbcf965a998720defa2dce9286b132b22cde88bf4e5c243bbae19e1e62579e27
5
5
  SHA512:
6
- metadata.gz: 22522217c54774ae015a720d88a73a8f9fe18d8c465464d3fe08a2f1d65e7f77b8fae598567f6b6bd11efdf88561d2af317cb1b885c526f46c4ae2ea2d3d7284
7
- data.tar.gz: b268e634167156062af46985c4af5df99aa2bf829152c84d2e8d86e41a84547f4a1b4d3df2a8463d637cffb2fff99240649c7ad08536983ff3a830a8f1df9121
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
- [![Gem Version](https://badge.fury.io/rb/flowspeech.svg)](https://rubygems.org/gems/flowspeech)
4
- [![Tests](https://github.com/waeckerlinfederowicz66-sketch/flowspeech-ruby/actions/workflows/test.yml/badge.svg)](https://github.com/waeckerlinfederowicz66-sketch/flowspeech-ruby/actions/workflows/test.yml)
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.1"
8
+ gem "flowspeech", "~> 0.2.0"
22
9
  ```
23
10
 
24
- Create a FlowSpeech API key, then keep it outside source control:
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 speech
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. [cheerfully] Let's make this sound natural.",
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
- The API currently returns base64-encoded linear PCM with format metadata. The
46
- `FlowSpeech::Audio#bytes` method returns the decoded bytes without changing the encoding.
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
- ## Multi-speaker dialogue
36
+ ## Explicit Dialogue
49
37
 
50
38
  ```ruby
51
- audio = client.synthesize(
52
- text: "Host: Welcome to the show.\nGuest: Thanks for having me.",
53
- speakers: [
54
- { speaker: "Host", voice_name: "Charon" },
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
- ## Check quota
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
- ```ruby
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
- begin
71
- client.synthesize(text: "Hello")
72
- rescue FlowSpeech::APIError => error
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
- ## Development
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
- Run the tests without real credentials or network requests:
102
+ ## Development
81
103
 
82
- ```bash
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
- ## Security
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.
@@ -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; end
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
- @code = code
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, :bits_per_sample
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
- @audio_base64 = data.fetch("audioBase64")
30
- @quota = data["quota"]
31
- @sample_rate = data.fetch("sampleRate")
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 => e
39
- raise Error, "FlowSpeech returned invalid base64 audio: #{e.message}"
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: 90, transport: nil)
53
- @api_key = api_key.to_s.strip
54
- @base_url = URI(base_url)
55
- @open_timeout = open_timeout
56
- @read_timeout = read_timeout
57
- @transport = transport
58
-
59
- raise ArgumentError, "api_key is required" if @api_key.empty?
60
- unless @base_url.is_a?(URI::HTTP) && @base_url.host
61
- raise ArgumentError, "base_url must be an HTTP or HTTPS URL"
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
- request(:get, "/api/ai/text-to-speech/quota").fetch("quota")
96
+ quota_details.fetch("quota")
67
97
  end
68
98
 
69
- def synthesize(text:, speakers: [{ voice_name: "Kore" }], original_text: nil)
70
- raise ArgumentError, "text is required" if text.to_s.strip.empty?
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
- normalized_speakers = Array(speakers).map { |speaker| normalize_speaker(speaker) }
73
- raise ArgumentError, "at least one speaker is required" if normalized_speakers.empty?
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
- data = request(
76
- :post,
77
- "/api/ai/text-to-speech",
78
- text: text,
79
- originalText: original_text || text,
80
- speakers: normalized_speakers
81
- )
82
- raise Error, "FlowSpeech response did not include audio" if data["audioBase64"].to_s.empty?
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
- Audio.new(data)
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 normalize_speaker(speaker)
90
- input = speaker.respond_to?(:to_h) ? speaker.to_h : {}
91
- voice_name = input[:voice_name] || input["voice_name"] || input[:voiceName] || input["voiceName"]
92
- raise ArgumentError, "every speaker requires voice_name" if voice_name.to_s.strip.empty?
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
- result = { voiceName: voice_name }
95
- label = input[:speaker] || input["speaker"]
96
- result[:speaker] = label unless label.to_s.strip.empty?
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 request(method, path, payload = nil)
101
- uri = @base_url + path
102
- http_request = build_request(method, uri, payload)
103
- response = @transport ? @transport.call(uri, http_request) : perform(uri, http_request)
104
- parsed = parse_json(response.body)
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
- unless response.code.to_i.between?(200, 299)
107
- error_data = parsed["data"].is_a?(Hash) ? parsed["data"] : {}
108
- message = parsed["message"].to_s.strip
109
- message = "FlowSpeech request failed with HTTP #{response.code}" if message.empty?
110
- raise APIError.new(
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
- unless parsed["code"] == 0 && parsed["data"].is_a?(Hash)
121
- raise Error, parsed["message"].to_s.empty? ? "Unexpected FlowSpeech response" : parsed["message"]
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
- parsed["data"]
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 build_request(method, uri, payload)
128
- request_class = method == :get ? Net::HTTP::Get : Net::HTTP::Post
129
- request = request_class.new(uri)
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
- request
278
+ job["requestId"] = id
279
+ job
137
280
  end
138
281
 
139
- def perform(uri, request)
140
- Net::HTTP.start(
141
- uri.host,
142
- uri.port,
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 parse_json(body)
150
- JSON.parse(body.to_s)
151
- rescue JSON::ParserError => e
152
- raise Error, "FlowSpeech returned invalid JSON: #{e.message}"
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
-
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FlowSpeech
4
- VERSION = "0.1.1"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -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.1.1
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-08-04 00:00:00.000000000 Z
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: Generate context-aware speech, multi-speaker dialogue, and audio metadata
34
- with FlowSpeech.
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: []