@pie-players/tts-server-core 0.3.66 → 0.3.68
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.
- package/dist/http-error-mapping.d.ts +15 -0
- package/dist/http-error-mapping.js +23 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/provider.d.ts +41 -0
- package/dist/provider.js +78 -0
- package/dist/speech-marks.d.ts +10 -0
- package/dist/speech-marks.js +131 -0
- package/dist/speed-rate.d.ts +14 -0
- package/dist/speed-rate.js +22 -0
- package/package.json +1 -1
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP-status error mapping
|
|
3
|
+
* @module @pie-players/tts-server-core
|
|
4
|
+
*/
|
|
5
|
+
import { TTSErrorCode } from "./types.js";
|
|
6
|
+
/**
|
|
7
|
+
* Map a plain HTTP status code to the closest `TTSErrorCode`, for providers
|
|
8
|
+
* whose wire format is a REST API rather than a vendor SDK with its own
|
|
9
|
+
* richer error taxonomy (a vendor SDK should map its own exception types
|
|
10
|
+
* instead — see PollyServerProvider/GoogleCloudTTSProvider). Uses only the
|
|
11
|
+
* handful of status codes with an unambiguous, universal REST meaning;
|
|
12
|
+
* anything else is a provider-level failure a caller can't act on more
|
|
13
|
+
* specifically.
|
|
14
|
+
*/
|
|
15
|
+
export declare function resolveTTSErrorCodeForHttpStatus(status: number): TTSErrorCode;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP-status error mapping
|
|
3
|
+
* @module @pie-players/tts-server-core
|
|
4
|
+
*/
|
|
5
|
+
import { TTSErrorCode } from "./types.js";
|
|
6
|
+
/**
|
|
7
|
+
* Map a plain HTTP status code to the closest `TTSErrorCode`, for providers
|
|
8
|
+
* whose wire format is a REST API rather than a vendor SDK with its own
|
|
9
|
+
* richer error taxonomy (a vendor SDK should map its own exception types
|
|
10
|
+
* instead — see PollyServerProvider/GoogleCloudTTSProvider). Uses only the
|
|
11
|
+
* handful of status codes with an unambiguous, universal REST meaning;
|
|
12
|
+
* anything else is a provider-level failure a caller can't act on more
|
|
13
|
+
* specifically.
|
|
14
|
+
*/
|
|
15
|
+
export function resolveTTSErrorCodeForHttpStatus(status) {
|
|
16
|
+
if (status === 401 || status === 403)
|
|
17
|
+
return TTSErrorCode.AUTHENTICATION_ERROR;
|
|
18
|
+
if (status === 429)
|
|
19
|
+
return TTSErrorCode.RATE_LIMIT_EXCEEDED;
|
|
20
|
+
if (status === 400)
|
|
21
|
+
return TTSErrorCode.INVALID_REQUEST;
|
|
22
|
+
return TTSErrorCode.PROVIDER_ERROR;
|
|
23
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ export type { CacheKeyComponents, CacheStats, ITTSCache, } from "./cache.js";
|
|
|
6
6
|
export { generateCacheKey, generateHashedCacheKey, hashText, MemoryCache, } from "./cache.js";
|
|
7
7
|
export type { ITTSServerProvider, TTSServerConfig, } from "./provider.js";
|
|
8
8
|
export { BaseTTSProvider } from "./provider.js";
|
|
9
|
-
export {
|
|
9
|
+
export { resolveTTSErrorCodeForHttpStatus } from "./http-error-mapping.js";
|
|
10
|
+
export { adjustSpeechMarksForRate, estimateSpeechMarks, filterSpeechMarksByType, getSpeechMarkAtTime, getSpeechMarksStats, mergeSpeechMarks, normalizeSpeechMarks, validateSpeechMarks, } from "./speech-marks.js";
|
|
11
|
+
export { resolveSpeedRateBucket, type SpeedRateBucket, } from "./speed-rate.js";
|
|
10
12
|
export type { GetVoicesOptions, ServerProviderCapabilities, SpeechMark, StandardTTSParameters, SynthesizeMetadata, SynthesizeRequest, SynthesizeResponse, TTSProviderExtensions, Voice, VoiceFeatures, } from "./types.js";
|
|
11
13
|
export { TTSError, TTSErrorCode } from "./types.js";
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export { generateCacheKey, generateHashedCacheKey, hashText, MemoryCache, } from "./cache.js";
|
|
6
6
|
export { BaseTTSProvider } from "./provider.js";
|
|
7
|
+
export { resolveTTSErrorCodeForHttpStatus } from "./http-error-mapping.js";
|
|
7
8
|
// Export speech marks utilities
|
|
8
|
-
export { adjustSpeechMarksForRate, estimateSpeechMarks, filterSpeechMarksByType, getSpeechMarkAtTime, getSpeechMarksStats, mergeSpeechMarks, validateSpeechMarks, } from "./speech-marks.js";
|
|
9
|
+
export { adjustSpeechMarksForRate, estimateSpeechMarks, filterSpeechMarksByType, getSpeechMarkAtTime, getSpeechMarksStats, mergeSpeechMarks, normalizeSpeechMarks, validateSpeechMarks, } from "./speech-marks.js";
|
|
10
|
+
export { resolveSpeedRateBucket, } from "./speed-rate.js";
|
|
9
11
|
export { TTSError, TTSErrorCode } from "./types.js";
|
package/dist/provider.d.ts
CHANGED
|
@@ -120,6 +120,47 @@ export declare abstract class BaseTTSProvider implements ITTSServerProvider {
|
|
|
120
120
|
* @throws {TTSError} If provider not initialized
|
|
121
121
|
*/
|
|
122
122
|
protected ensureInitialized(): void;
|
|
123
|
+
/**
|
|
124
|
+
* The SSML elements every provider recognises.
|
|
125
|
+
*
|
|
126
|
+
* Vendor extensions are not here: `<amazon:*>` and `<aws-*>` mean nothing to
|
|
127
|
+
* Google, and a provider that sniffs for tags it cannot synthesize would send
|
|
128
|
+
* markup to an engine expecting plain text.
|
|
129
|
+
*/
|
|
130
|
+
private static readonly STANDARD_SSML_TAGS;
|
|
131
|
+
/**
|
|
132
|
+
* Whether the text is SSML rather than plain text, which decides how it is
|
|
133
|
+
* handed to the engine.
|
|
134
|
+
*
|
|
135
|
+
* A sniff rather than a parse: callers hand over authored strings that may or
|
|
136
|
+
* may not be marked up, and the engines themselves take the distinction as a
|
|
137
|
+
* flag. `extraTags` carries a provider's own vocabulary — Polly's
|
|
138
|
+
* `<amazon:effect>` among them.
|
|
139
|
+
*/
|
|
140
|
+
protected detectSSML(text: string, extraTags?: string[]): boolean;
|
|
141
|
+
/** Escape special XML characters so plain text is safe to embed in SSML. */
|
|
142
|
+
protected escapeSSML(text: string): string;
|
|
143
|
+
/**
|
|
144
|
+
* Build an SSML `<prosody>` attribute string from a request's `rate` /
|
|
145
|
+
* `pitch`, or `""` if neither differs from its default. `rate` is the
|
|
146
|
+
* standard 0.25–4.0 speed multiplier, mapped straight to `rate` as a
|
|
147
|
+
* percentage. `pitch` follows this repo's existing 0–2 multiplier
|
|
148
|
+
* convention (the TTS settings UI's `normalizePitch`, matching the Web
|
|
149
|
+
* Speech API default of 1.0), converted to SSML's relative percentage
|
|
150
|
+
* form: a 1.2 multiplier is `pitch="+20%"`.
|
|
151
|
+
*/
|
|
152
|
+
protected buildProsodyAttrs(request: SynthesizeRequest): string;
|
|
153
|
+
/**
|
|
154
|
+
* Wrap plain text in an SSML `<prosody>` envelope so a request's `rate`
|
|
155
|
+
* / `pitch` actually reaches the engine instead of being silently
|
|
156
|
+
* dropped. Already-SSML input (per `detectSSML`) is returned unchanged:
|
|
157
|
+
* injecting `<prosody>` into markup the caller authored themselves would
|
|
158
|
+
* require parsing it, which no provider here does.
|
|
159
|
+
*/
|
|
160
|
+
protected applyProsody(text: string, request: SynthesizeRequest, extraSsmlTags?: string[]): {
|
|
161
|
+
text: string;
|
|
162
|
+
isSsml: boolean;
|
|
163
|
+
};
|
|
123
164
|
/**
|
|
124
165
|
* Validate synthesis request
|
|
125
166
|
* @throws {TTSError} If request is invalid
|
package/dist/provider.js
CHANGED
|
@@ -22,6 +22,84 @@ export class BaseTTSProvider {
|
|
|
22
22
|
throw new Error(`Provider ${this.providerId} not initialized`);
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* The SSML elements every provider recognises.
|
|
27
|
+
*
|
|
28
|
+
* Vendor extensions are not here: `<amazon:*>` and `<aws-*>` mean nothing to
|
|
29
|
+
* Google, and a provider that sniffs for tags it cannot synthesize would send
|
|
30
|
+
* markup to an engine expecting plain text.
|
|
31
|
+
*/
|
|
32
|
+
static STANDARD_SSML_TAGS = [
|
|
33
|
+
"<speak",
|
|
34
|
+
"<prosody",
|
|
35
|
+
"<emphasis",
|
|
36
|
+
"<break",
|
|
37
|
+
"<phoneme",
|
|
38
|
+
"<say-as",
|
|
39
|
+
"<mark",
|
|
40
|
+
];
|
|
41
|
+
/**
|
|
42
|
+
* Whether the text is SSML rather than plain text, which decides how it is
|
|
43
|
+
* handed to the engine.
|
|
44
|
+
*
|
|
45
|
+
* A sniff rather than a parse: callers hand over authored strings that may or
|
|
46
|
+
* may not be marked up, and the engines themselves take the distinction as a
|
|
47
|
+
* flag. `extraTags` carries a provider's own vocabulary — Polly's
|
|
48
|
+
* `<amazon:effect>` among them.
|
|
49
|
+
*/
|
|
50
|
+
detectSSML(text, extraTags = []) {
|
|
51
|
+
const tags = [...BaseTTSProvider.STANDARD_SSML_TAGS, ...extraTags];
|
|
52
|
+
return tags.some((tag) => text.includes(tag));
|
|
53
|
+
}
|
|
54
|
+
/** Escape special XML characters so plain text is safe to embed in SSML. */
|
|
55
|
+
escapeSSML(text) {
|
|
56
|
+
return text
|
|
57
|
+
.replace(/&/g, "&")
|
|
58
|
+
.replace(/</g, "<")
|
|
59
|
+
.replace(/>/g, ">")
|
|
60
|
+
.replace(/"/g, """)
|
|
61
|
+
.replace(/'/g, "'");
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Build an SSML `<prosody>` attribute string from a request's `rate` /
|
|
65
|
+
* `pitch`, or `""` if neither differs from its default. `rate` is the
|
|
66
|
+
* standard 0.25–4.0 speed multiplier, mapped straight to `rate` as a
|
|
67
|
+
* percentage. `pitch` follows this repo's existing 0–2 multiplier
|
|
68
|
+
* convention (the TTS settings UI's `normalizePitch`, matching the Web
|
|
69
|
+
* Speech API default of 1.0), converted to SSML's relative percentage
|
|
70
|
+
* form: a 1.2 multiplier is `pitch="+20%"`.
|
|
71
|
+
*/
|
|
72
|
+
buildProsodyAttrs(request) {
|
|
73
|
+
const attrs = [];
|
|
74
|
+
if (typeof request.rate === "number" && request.rate !== 1) {
|
|
75
|
+
attrs.push(`rate="${Math.round(request.rate * 100)}%"`);
|
|
76
|
+
}
|
|
77
|
+
if (typeof request.pitch === "number" && request.pitch !== 1) {
|
|
78
|
+
const percent = Math.round((request.pitch - 1) * 100);
|
|
79
|
+
attrs.push(`pitch="${percent >= 0 ? "+" : ""}${percent}%"`);
|
|
80
|
+
}
|
|
81
|
+
return attrs.join(" ");
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Wrap plain text in an SSML `<prosody>` envelope so a request's `rate`
|
|
85
|
+
* / `pitch` actually reaches the engine instead of being silently
|
|
86
|
+
* dropped. Already-SSML input (per `detectSSML`) is returned unchanged:
|
|
87
|
+
* injecting `<prosody>` into markup the caller authored themselves would
|
|
88
|
+
* require parsing it, which no provider here does.
|
|
89
|
+
*/
|
|
90
|
+
applyProsody(text, request, extraSsmlTags = []) {
|
|
91
|
+
if (this.detectSSML(text, extraSsmlTags)) {
|
|
92
|
+
return { text, isSsml: true };
|
|
93
|
+
}
|
|
94
|
+
const prosodyAttrs = this.buildProsodyAttrs(request);
|
|
95
|
+
if (!prosodyAttrs) {
|
|
96
|
+
return { text, isSsml: false };
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
text: `<speak><prosody ${prosodyAttrs}>${this.escapeSSML(text)}</prosody></speak>`,
|
|
100
|
+
isSsml: true,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
25
103
|
/**
|
|
26
104
|
* Validate synthesis request
|
|
27
105
|
* @throws {TTSError} If request is invalid
|
package/dist/speech-marks.d.ts
CHANGED
|
@@ -3,6 +3,16 @@
|
|
|
3
3
|
* @module @pie-players/tts-server-core
|
|
4
4
|
*/
|
|
5
5
|
import type { SpeechMark } from "./types.js";
|
|
6
|
+
/**
|
|
7
|
+
* Parse and correct a provider's JSONL word-mark response against the text
|
|
8
|
+
* that was actually requested: normalizes second-vs-millisecond time units,
|
|
9
|
+
* rebases offsets that drifted from the request text (a provider quirk seen
|
|
10
|
+
* in production), and clamps ranges to the request text's bounds.
|
|
11
|
+
*
|
|
12
|
+
* Shared by every provider/transport that speaks this wire shape so the
|
|
13
|
+
* correction is applied once rather than reimplemented per caller.
|
|
14
|
+
*/
|
|
15
|
+
export declare function normalizeSpeechMarks(raw: string, requestText: string): SpeechMark[];
|
|
6
16
|
/**
|
|
7
17
|
* Estimate speech marks for text when provider doesn't support them
|
|
8
18
|
*
|
package/dist/speech-marks.js
CHANGED
|
@@ -2,6 +2,137 @@
|
|
|
2
2
|
* Speech marks utilities
|
|
3
3
|
* @module @pie-players/tts-server-core
|
|
4
4
|
*/
|
|
5
|
+
const asFiniteNumber = (value) => {
|
|
6
|
+
const next = Number(value);
|
|
7
|
+
return Number.isFinite(next) ? next : null;
|
|
8
|
+
};
|
|
9
|
+
const sortWordMarks = (marks) => [...marks].sort((left, right) => {
|
|
10
|
+
if (left.time !== right.time)
|
|
11
|
+
return left.time - right.time;
|
|
12
|
+
if (left.start !== right.start)
|
|
13
|
+
return left.start - right.start;
|
|
14
|
+
return left.end - right.end;
|
|
15
|
+
});
|
|
16
|
+
/**
|
|
17
|
+
* Parse a provider's JSONL word-mark wire format: one `{time, type, start,
|
|
18
|
+
* end, value}` object per line. Rows missing a numeric `time` are dropped —
|
|
19
|
+
* that anchor is load-bearing for the timing correction below — but rows
|
|
20
|
+
* missing `start`/`end` still surface (estimated from the previous mark's
|
|
21
|
+
* end) rather than being lost outright.
|
|
22
|
+
*/
|
|
23
|
+
const parseWordMarksJsonl = (raw) => {
|
|
24
|
+
const marks = [];
|
|
25
|
+
let fallbackIndex = 0;
|
|
26
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
27
|
+
const trimmed = line.trim();
|
|
28
|
+
if (!trimmed)
|
|
29
|
+
continue;
|
|
30
|
+
try {
|
|
31
|
+
const parsed = JSON.parse(trimmed);
|
|
32
|
+
if (parsed.type && parsed.type !== "word")
|
|
33
|
+
continue;
|
|
34
|
+
const time = asFiniteNumber(parsed.time);
|
|
35
|
+
if (time === null)
|
|
36
|
+
continue;
|
|
37
|
+
const value = typeof parsed.value === "string" ? parsed.value : "";
|
|
38
|
+
const start = asFiniteNumber(parsed.start) ?? fallbackIndex;
|
|
39
|
+
const end = asFiniteNumber(parsed.end) ?? start + Math.max(1, value.length);
|
|
40
|
+
fallbackIndex = Math.max(end + 1, fallbackIndex);
|
|
41
|
+
marks.push({ time, type: "word", start, end, value });
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// Ignore malformed JSONL rows while preserving valid marks.
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return sortWordMarks(marks);
|
|
48
|
+
};
|
|
49
|
+
const normalizeMarkTimeUnits = (marks) => {
|
|
50
|
+
if (marks.length < 2)
|
|
51
|
+
return marks;
|
|
52
|
+
const times = marks.map((mark) => mark.time).filter((time) => time >= 0);
|
|
53
|
+
const maxTime = times.length ? Math.max(...times) : 0;
|
|
54
|
+
const deltas = [];
|
|
55
|
+
for (let index = 1; index < times.length; index += 1) {
|
|
56
|
+
const delta = times[index] - times[index - 1];
|
|
57
|
+
if (delta > 0 && Number.isFinite(delta))
|
|
58
|
+
deltas.push(delta);
|
|
59
|
+
}
|
|
60
|
+
const medianDelta = deltas.length > 0
|
|
61
|
+
? [...deltas].sort((a, b) => a - b)[Math.floor(deltas.length / 2)]
|
|
62
|
+
: 0;
|
|
63
|
+
const shapeSuggestsSeconds = (maxTime > 0 && maxTime < 100 && marks.length > 3) ||
|
|
64
|
+
(medianDelta > 0 && medianDelta < 10);
|
|
65
|
+
if (!shapeSuggestsSeconds)
|
|
66
|
+
return marks;
|
|
67
|
+
return marks.map((mark) => ({ ...mark, time: mark.time * 1000 }));
|
|
68
|
+
};
|
|
69
|
+
const estimateOffsetShift = (marks, requestText) => {
|
|
70
|
+
if (!marks.length || !requestText.length)
|
|
71
|
+
return 0;
|
|
72
|
+
const textLower = requestText.toLowerCase();
|
|
73
|
+
const candidates = [];
|
|
74
|
+
let cursor = 0;
|
|
75
|
+
for (const mark of marks) {
|
|
76
|
+
const token = mark.value.trim().toLowerCase();
|
|
77
|
+
if (!token)
|
|
78
|
+
continue;
|
|
79
|
+
const found = textLower.indexOf(token, cursor);
|
|
80
|
+
if (found < 0)
|
|
81
|
+
continue;
|
|
82
|
+
const delta = mark.start - found;
|
|
83
|
+
if (delta >= 0)
|
|
84
|
+
candidates.push(delta);
|
|
85
|
+
cursor = found + token.length;
|
|
86
|
+
if (candidates.length >= 8)
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
if (candidates.length > 0) {
|
|
90
|
+
const ordered = [...candidates].sort((a, b) => a - b);
|
|
91
|
+
return ordered[Math.floor(ordered.length / 2)];
|
|
92
|
+
}
|
|
93
|
+
return Math.max(0, Math.floor(marks[0].start));
|
|
94
|
+
};
|
|
95
|
+
const rebaseOffsetsToRequestText = (marks, requestText) => {
|
|
96
|
+
if (!marks.length || !requestText.length)
|
|
97
|
+
return marks;
|
|
98
|
+
const textLength = requestText.length;
|
|
99
|
+
const maxEnd = Math.max(...marks.map((mark) => mark.end));
|
|
100
|
+
if (maxEnd <= textLength + 2)
|
|
101
|
+
return marks;
|
|
102
|
+
const shift = estimateOffsetShift(marks, requestText);
|
|
103
|
+
if (shift <= 0)
|
|
104
|
+
return marks;
|
|
105
|
+
return marks.map((mark) => ({
|
|
106
|
+
...mark,
|
|
107
|
+
start: mark.start - shift,
|
|
108
|
+
end: mark.end - shift,
|
|
109
|
+
}));
|
|
110
|
+
};
|
|
111
|
+
const clampMarkRanges = (marks, requestText) => {
|
|
112
|
+
if (!requestText.length)
|
|
113
|
+
return marks;
|
|
114
|
+
const textLength = requestText.length;
|
|
115
|
+
return sortWordMarks(marks.map((mark) => {
|
|
116
|
+
const start = Math.max(0, Math.min(textLength, Math.floor(mark.start)));
|
|
117
|
+
const end = Math.max(start + 1, Math.min(textLength, Math.floor(mark.end)));
|
|
118
|
+
return { ...mark, start, end };
|
|
119
|
+
}));
|
|
120
|
+
};
|
|
121
|
+
/**
|
|
122
|
+
* Parse and correct a provider's JSONL word-mark response against the text
|
|
123
|
+
* that was actually requested: normalizes second-vs-millisecond time units,
|
|
124
|
+
* rebases offsets that drifted from the request text (a provider quirk seen
|
|
125
|
+
* in production), and clamps ranges to the request text's bounds.
|
|
126
|
+
*
|
|
127
|
+
* Shared by every provider/transport that speaks this wire shape so the
|
|
128
|
+
* correction is applied once rather than reimplemented per caller.
|
|
129
|
+
*/
|
|
130
|
+
export function normalizeSpeechMarks(raw, requestText) {
|
|
131
|
+
const parsed = parseWordMarksJsonl(raw);
|
|
132
|
+
const withTimes = normalizeMarkTimeUnits(parsed);
|
|
133
|
+
const rebased = rebaseOffsetsToRequestText(withTimes, requestText);
|
|
134
|
+
return clampMarkRanges(rebased, requestText);
|
|
135
|
+
}
|
|
5
136
|
/**
|
|
6
137
|
* Estimate speech marks for text when provider doesn't support them
|
|
7
138
|
*
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Speed-rate bucketing utility
|
|
3
|
+
* @module @pie-players/tts-server-core
|
|
4
|
+
*/
|
|
5
|
+
export type SpeedRateBucket = "slow" | "medium" | "fast";
|
|
6
|
+
/**
|
|
7
|
+
* Bucket a numeric rate multiplier into the three-value vocabulary some
|
|
8
|
+
* providers (SchoolCity, and backends modeled on it) accept instead of a
|
|
9
|
+
* continuous rate. `0.95` / `1.5` is a deliberate tolerance band around the
|
|
10
|
+
* `1.0` default rather than a strict `<1` / `>1` split, so small rate
|
|
11
|
+
* perturbations near normal speed still read as `fallback` instead of
|
|
12
|
+
* immediately flipping to `slow`/`fast`.
|
|
13
|
+
*/
|
|
14
|
+
export declare function resolveSpeedRateBucket(rate: number | undefined, fallback?: SpeedRateBucket): SpeedRateBucket;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Speed-rate bucketing utility
|
|
3
|
+
* @module @pie-players/tts-server-core
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Bucket a numeric rate multiplier into the three-value vocabulary some
|
|
7
|
+
* providers (SchoolCity, and backends modeled on it) accept instead of a
|
|
8
|
+
* continuous rate. `0.95` / `1.5` is a deliberate tolerance band around the
|
|
9
|
+
* `1.0` default rather than a strict `<1` / `>1` split, so small rate
|
|
10
|
+
* perturbations near normal speed still read as `fallback` instead of
|
|
11
|
+
* immediately flipping to `slow`/`fast`.
|
|
12
|
+
*/
|
|
13
|
+
export function resolveSpeedRateBucket(rate, fallback = "medium") {
|
|
14
|
+
const value = Number(rate ?? 1);
|
|
15
|
+
if (!Number.isFinite(value))
|
|
16
|
+
return fallback;
|
|
17
|
+
if (value <= 0.95)
|
|
18
|
+
return "slow";
|
|
19
|
+
if (value >= 1.5)
|
|
20
|
+
return "fast";
|
|
21
|
+
return fallback;
|
|
22
|
+
}
|