Overview
Overview
A speech provider synthesizes speech to audio and pipes the audio data back to
the caller through a file descriptor handed to it in the
org.freedesktop.Speech.Provider.Synthesize() call. Each voice advertises the
format it produces so that the receiver can play or transcode it correctly. For
example, audio/x-raw,format=S16LE,channels=1,rate=22050 means uncompressed PCM
in signed 16 bit little-endian byte order, one channel, at 22.05 kHz.
Plain audio, however, carries no information about what is being spoken at any given moment. Screen readers and read-aloud interfaces need to highlight the word or sentence that is currently audible, which means the stream has to say where each word begins relative to the audio around it.
This library defines a binary stream format that interleaves such metadata with
the audio, so that a client can map a portion of the audio back onto a range of
the input text. SpeechProviderStreamWriter produces the format and
SpeechProviderStreamReader consumes it; a provider that only ever emits
plain audio does not need either, and can write raw bytes to the pipe.
Advertising the Format
A voice that emits this format advertises its output format with the
audio/x-spiel media type instead of audio/x-raw, keeping the same
parameters. For 16 bit PCM at 22.05 kHz the full format specifier is:
audio/x-spiel,format=S16LE,channels=1,rate=22050
The parameters describe the audio payload carried inside the stream’s audio
chunks, not the stream as a whole. A client that decodes the chunk framing is
left with audio matching the same specifier with audio/x-raw substituted back in.
A client that encounters audio/x-spiel knows the stream is not raw audio and
that it must be de-framed before playback. A client that does not understand the
media type should refrain from playing the stream at all rather than treat it as
raw audio, since the header and chunk framing would be audible as noise.
Advertising audio/x-spiel says the stream can carry
events; which events a voice actually emits is advertised separately through the
EVENTS_* bits of the voice’s SpeechProviderVoiceFeature field. A
voice should not claim an EVENTS_* feature unless it advertises
audio/x-spiel, since there would be no way to deliver those events.
Stream Structure
┌──────────────┐
│ 4-byte header│ "0.01"
├──────────────┤
│ chunk │ audio or event
├──────────────┤
│ chunk │
│ ... │
└──────────────┘
EOF = end of utterance
The stream opens with a fixed 4 byte header that carries the format version. Everything after it is a sequence of self-delimiting chunks, each tagged with a one byte type, sent until the writer closes the pipe.
Chunk order is unconstrained: an audio chunk may follow an audio chunk, an event chunk may follow an event chunk, and a stream may legitimately contain no chunks at all (for empty input text). Readers must therefore be prepared for any ordering and must not assume that events and audio alternate.
All multi-byte integers are written in host byte order with no padding between fields. The stream is only ever passed between processes on the same machine over a file descriptor, so both ends necessarily agree; implementations that hand-roll the format must use native, unaligned packing rather than network byte order.
Stream Header
| Offset | Size | Field | Value |
|---|---|---|---|
| 0 | 4 | version |
ASCII version string, e.g. 0.01 |
The header is the four ASCII characters of the format version, not
NUL-terminated. The current version is 0.01.
The reader compares the four bytes against the version it was built for and
reports the mismatch to its caller, which keeps a reader from misinterpreting a
stream written by an incompatible writer as chunk data. There is no negotiation:
speech_provider_stream_reader_get_stream_header() returns FALSE on
mismatch, and the only reasonable response is to abandon the stream. Note that
the four bytes have been consumed either way.
Audio Chunk
An audio chunk carries a run of encoded audio in the format the voice advertised.
| Offset | Size | Field | Value |
|---|---|---|---|
| 0 | 1 | chunk_type |
SPEECH_PROVIDER_CHUNK_TYPE_AUDIO (1) |
| 1 | 4 | length |
Number of audio bytes that follow |
| 5 | n | data |
n = length bytes of audio |
Chunk boundaries carry no meaning beyond framing. A provider may split its output into as many or as few chunks as is convenient, and a reader must not infer anything from where the splits fall — in particular, a chunk is not guaranteed to hold a whole number of audio frames, so a consumer that needs frame alignment must buffer across chunks. In practice providers split audio precisely where an event needs to be inserted (see below).
Zero-length audio chunks are legal but carry no information.
Event Chunk
An event chunk marks a landmark in the text that is about to be spoken — a word or sentence boundary, or an SSML mark.
| Offset | Size | Field | Value |
|---|---|---|---|
| 0 | 1 | chunk_type |
SPEECH_PROVIDER_CHUNK_TYPE_EVENT (2) |
| 1 | 1 | event_type |
A SpeechProviderEventType value |
| 2 | 4 | range_start |
Start offset in the input text |
| 6 | 4 | range_end |
End offset in the input text |
| 10 | 4 | mark_name_length |
Byte length of the mark name that follows |
| 14 | n | mark_name |
n = mark_name_length bytes of UTF-8 |
The fixed part of an event chunk is therefore always 14 bytes.
range_start and range_end are character offsets into the text that was
passed to Synthesize(), forming the half-open range [range_start,
range_end).
mark_name is a UTF-8 string that is not NUL-terminated, and its length
field may be zero. It is only meaningful for MARK events, which correspond to
an SSML <mark name="foo"/> element in the input; for every other event type
providers send an empty name.
Event Types
| Value | Name | Meaning |
|---|---|---|
| 0 | NONE |
Not a valid event on the wire; reported by the reader when no event was read |
| 1 | WORD |
A word is about to be spoken |
| 2 | SENTENCE |
A sentence is about to be spoken |
| 3 | RANGE |
An unspecified range is about to be spoken |
| 4 | MARK |
An SSML mark has been reached |
Positioning Events
An event applies to the audio that follows it: the writer emits the event immediately before the first audio chunk containing the range it describes. This is what makes the format worth having — the position of the event in the byte stream, rather than any timestamp, is what ties text to audio.
A provider does this by splitting its audio at the sample where a landmark begins:
- Write the audio up to the landmark as one or more audio chunks.
- Write the event chunk.
- Continue writing audio.
Synthesizers usually report the audio position of an event in milliseconds, so
the provider converts that to a byte offset in its own output —
ms * rate / 1000 * bytes_per_frame for PCM — and tracks how many bytes it has
already written in order to find the split point within the current buffer.
Because a client typically observes the stream as it plays, this ordering means an event is seen slightly before the corresponding audio is heard, by however much audio the client has buffered.
Reading a Stream
The reader is a pull API with one byte of lookahead. Each getter peeks at the
next chunk’s type byte, and if the chunk is not of the requested kind it returns
FALSE without consuming anything, leaving the chunk available to the other
getter. Calling the “wrong” getter is therefore harmless, and the idiomatic loop
simply tries both:
SpeechProviderStreamReader *reader = speech_provider_stream_reader_new (fd);
if (!speech_provider_stream_reader_get_stream_header (reader))
return; /* version mismatch: not a stream we can read */
for (;;)
{
SpeechProviderEventType event_type = SPEECH_PROVIDER_EVENT_TYPE_NONE;
guint32 range_start = 0, range_end = 0;
g_autofree char *mark_name = NULL;
guint8 *chunk = NULL;
guint32 chunk_size = 0;
gboolean got_event, got_audio;
got_event = speech_provider_stream_reader_get_event (
reader, &event_type, &range_start, &range_end, &mark_name);
if (got_event)
handle_event (event_type, range_start, range_end, mark_name);
got_audio =
speech_provider_stream_reader_get_audio (reader, &chunk, &chunk_size);
if (got_audio)
handle_audio (chunk, chunk_size); /* takes ownership of chunk */
if (!got_event && !got_audio)
break; /* end of stream */
}
speech_provider_stream_reader_close (reader);
speech_provider_stream_reader_get_stream_header() must be called exactly
once, before any chunk is read.
Note that both getters returning FALSE is the only end-of-stream signal, since
the format has no terminator chunk: when the writer closes the pipe, reads
return no data and no chunk type can be determined. On a non-blocking file
descriptor this is indistinguishable from “no data has arrived yet”, so a reader
using non-blocking I/O should poll for readability before entering the loop, as
GStreamer’s spielprovidersrc element in libspiel does.
On a successful read, speech_provider_stream_reader_get_audio() hands over
a newly allocated buffer that the caller owns; on failure it reports a size of
zero. speech_provider_stream_reader_get_event() reports an event type of
NONE on failure, and yields a NULL mark name whenever the mark name length
was zero.
The reader does not close its file descriptor when it is finalized; call
speech_provider_stream_reader_close() when you are done with the stream.
Writing a Stream
A provider creates a writer around the file descriptor it received from
Synthesize(), sends the header once, and then interleaves audio and events as
synthesis proceeds:
SpeechProviderStreamWriter *writer = speech_provider_stream_writer_new (fd);
speech_provider_stream_writer_send_stream_header (writer);
speech_provider_stream_writer_send_audio (writer, leading_audio, leading_size);
speech_provider_stream_writer_send_event (writer, SPEECH_PROVIDER_EVENT_TYPE_WORD,
0, 5, "");
speech_provider_stream_writer_send_audio (writer, word_audio, word_size);
speech_provider_stream_writer_close (writer);
speech_provider_stream_writer_send_stream_header() must be called exactly
once, before any chunk is written.
speech_provider_stream_writer_send_event() requires a non-NULL mark name,
so pass an empty string for events that do not carry one. Closing the pipe is
what tells the client the utterance is complete, and the writer closes its file
descriptor when it is finalized as well as on
speech_provider_stream_writer_close().
Providers are expected to serve concurrent Synthesize() calls, which means one
writer per call. Interleaving writes from two writers on the same file
descriptor would corrupt the framing, so each call must get its own pipe.
Worked Example
A minimal stream containing four bytes of audio, a word event covering the first five characters of the input text, and four more bytes of audio:
30 2E 30 31 "0.01" stream header
01 chunk type audio
04 00 00 00 length 4
11 22 33 44 audio data
02 chunk type event
01 event type WORD
00 00 00 00 range start 0
05 00 00 00 range end 5
00 00 00 00 mark name len 0
01 chunk type audio
04 00 00 00 length 4
55 66 77 88 audio data
Integer fields are shown little-endian, as they would appear on a little-endian host.
Notes for Implementers
The framing is deliberately simple enough to write by hand — a provider in a
language without GObject bindings can emit it with a handful of struct.pack
calls — but a few details are easy to get wrong:
- Pack, do not align. The header, chunk type, and event structures are
packed; in particular an event chunk is 14 bytes, not 16. A naive struct
definition in a language that aligns
guint32fields will insert padding afterevent_typeand desynchronize the stream. - Use host byte order, as described under “Stream Structure” above.
- Do short reads and writes properly. A single
write()of a large audio chunk to a pipe can transfer fewer bytes than requested, and aread()can likewise return a partial chunk; loop until the whole field or payload has been transferred. This applies to the C implementation in this library as well, which is why callers should keep individual audio chunks modest in size. mark_name_lengthis a byte count, not a character count, since that is what the reader consumes.
Versioning
The version in the header exists so that a reader can refuse a stream it cannot parse rather than misinterpret it. Any change to the framing — a new field, a different integer width, a new chunk type that older readers could not skip — requires a version bump.
New SpeechProviderEventType values do not, because an unknown event type
still occupies a well-formed 14 byte chunk: readers can skip past what they do
not recognize, so readers should ignore unfamiliar event types rather than treat
them as errors. New SpeechProviderChunkType values, by contrast, are not
skippable, since a reader that does not know a chunk’s type cannot know its length.