@huynle/hub 0.0.0-stage → 0.6.1
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/CHANGELOG.md +269 -0
- package/Dockerfile +51 -0
- package/LICENSE +21 -0
- package/README.md +1029 -2
- package/bin/hub.ts +20 -0
- package/docker/hub-deps.ts +42 -0
- package/package.json +85 -4
- package/rpc.ts +2 -0
- package/scripts/hub-local.sh +28 -0
- package/scripts/io.github.huynle.hub.plist +42 -0
- package/server.ts +3 -0
- package/src/central/assets.ts +74 -0
- package/src/central/auth.ts +271 -0
- package/src/central/config.ts +310 -0
- package/src/central/http.ts +1247 -0
- package/src/central/main.ts +136 -0
- package/src/central/oidc.ts +295 -0
- package/src/central/push-target.ts +97 -0
- package/src/central/push.ts +285 -0
- package/src/central/registry.ts +1153 -0
- package/src/central/speech/models.ts +89 -0
- package/src/central/speech/openai.ts +66 -0
- package/src/central/speech/openrouter.ts +71 -0
- package/src/central/speech/provider.ts +74 -0
- package/src/central/speech/service.ts +168 -0
- package/src/central/speech/settings.ts +375 -0
- package/src/central/spoke-ws.ts +300 -0
- package/src/central/store.ts +653 -0
- package/src/config.ts +170 -0
- package/src/env.ts +63 -0
- package/src/index.ts +236 -0
- package/src/key.ts +17 -0
- package/src/loopback.ts +41 -0
- package/src/migrate.ts +105 -0
- package/src/openrouter.ts +3 -0
- package/src/options.ts +33 -0
- package/src/protocol.ts +650 -0
- package/src/reply.ts +27 -0
- package/src/rpc.ts +98 -0
- package/src/setup.ts +124 -0
- package/src/speakable.ts +35 -0
- package/src/spoke/client.ts +272 -0
- package/src/spoke/conversation.ts +93 -0
- package/src/spoke/enroll.ts +79 -0
- package/src/spoke/herdr.ts +346 -0
- package/src/spoke/listing.ts +108 -0
- package/src/spoke/localhub.ts +287 -0
- package/src/spoke/locations.ts +34 -0
- package/src/spoke/own-server.ts +32 -0
- package/src/spoke/presence.ts +45 -0
- package/src/spoke/singleton.ts +103 -0
- package/src/spoke/spoke.ts +1212 -0
- package/src/spoke/summary.ts +117 -0
- package/src/spoke/timeline.ts +93 -0
- package/src/spoke/tracker.ts +345 -0
- package/src/spoke/watch.ts +78 -0
- package/src/status.ts +232 -0
- package/src/tui-commands.ts +53 -0
- package/src/tui-forms.ts +77 -0
- package/src/tui-presence.ts +75 -0
- package/src/tui.ts +479 -0
- package/src/turns.ts +94 -0
- package/src/voice-command.ts +65 -0
- package/tui.ts +2 -0
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/index-DClBvfLL.css +1 -0
- package/web/dist/assets/index-Djo_yF0a.js +12 -0
- package/web/dist/favicon.ico +0 -0
- package/web/dist/icon-192.png +0 -0
- package/web/dist/icon-512.png +0 -0
- package/web/dist/icon-maskable-512.png +0 -0
- package/web/dist/icon.svg +18 -0
- package/web/dist/index.html +25 -0
- package/web/dist/manifest.webmanifest +18 -0
- package/web/dist/sw.js +151 -0
- package/web/dist/vad-worklet.js +27 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Speech model lists of OpenAI-compatible providers. Their GET /models usually lists every model of the
|
|
2
|
+
// gateway (chat, image, embedding, music…) without a speech filter, so the models are classified here: speech-
|
|
3
|
+
// to-text by name ("transcribe", "whisper", "stt", "asr") or as audio-in/text-out audio models; text-to-speech by
|
|
4
|
+
// name ("tts", "text-to-speech", "speech", "voice") or by speech/audio output, never music or audio chat models.
|
|
5
|
+
// The configured model is always offered first (gateways may serve models they do not list), and when nothing is
|
|
6
|
+
// labeled for a kind the full list is shown with a note.
|
|
7
|
+
|
|
8
|
+
export type SpeechModelKind = "stt" | "tts"
|
|
9
|
+
|
|
10
|
+
export interface SpeechModelInfo {
|
|
11
|
+
readonly id: string
|
|
12
|
+
readonly name: string
|
|
13
|
+
/** The model configured for this kind right now. */
|
|
14
|
+
readonly current?: true
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SpeechModelList {
|
|
18
|
+
readonly models: SpeechModelInfo[]
|
|
19
|
+
/** Set when the list could not be narrowed to speech models. */
|
|
20
|
+
readonly note?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const SPEECH_UNLABELED_NOTE = "This provider doesn't label speech models; showing all"
|
|
24
|
+
|
|
25
|
+
const STT_NAME = /transcri|whisper|(^|[^a-z])stt|speech-to-text|(^|[^a-z])asr/i
|
|
26
|
+
const TTS_NAME = /(^|[^a-z])tts|text-to-speech|speech(?!-to-text)|voice/i
|
|
27
|
+
const EXPLICIT_TTS = /(^|[^a-z])tts|text-to-speech/i
|
|
28
|
+
const MUSIC = /lyria|music|gpt-audio\b/i
|
|
29
|
+
const NOT_SPEECH_TYPES = new Set(["chat", "embedding", "image", "rerank"])
|
|
30
|
+
|
|
31
|
+
interface RawModel {
|
|
32
|
+
readonly id: string
|
|
33
|
+
readonly name: string
|
|
34
|
+
readonly type?: string
|
|
35
|
+
readonly output: readonly string[] | undefined
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const strings = (value: unknown): string[] | undefined => (Array.isArray(value) ? value.filter((x): x is string => typeof x === "string").map((x) => x.toLowerCase()) : undefined)
|
|
39
|
+
|
|
40
|
+
/** `{data: [{id, name?, type?, output_modalities?}]}` entries with an id. */
|
|
41
|
+
function rawModels(body: unknown): RawModel[] {
|
|
42
|
+
const data = (body as { data?: unknown } | null | undefined)?.data
|
|
43
|
+
if (!Array.isArray(data)) return []
|
|
44
|
+
const out: RawModel[] = []
|
|
45
|
+
const seen = new Set<string>()
|
|
46
|
+
for (const entry of data) {
|
|
47
|
+
const e = entry as Record<string, unknown> | null
|
|
48
|
+
const id = typeof e?.id === "string" ? e.id : ""
|
|
49
|
+
if (!id || seen.has(id)) continue
|
|
50
|
+
seen.add(id)
|
|
51
|
+
const name = typeof e!.name === "string" && e!.name ? e!.name : id
|
|
52
|
+
out.push({ id, name, ...(typeof e!.type === "string" ? { type: e!.type.toLowerCase() } : {}), output: strings(e!.output_modalities) })
|
|
53
|
+
}
|
|
54
|
+
return out
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isStt(model: RawModel): boolean {
|
|
58
|
+
if (model.type && NOT_SPEECH_TYPES.has(model.type)) return false
|
|
59
|
+
const label = `${model.id} ${model.name}`
|
|
60
|
+
if (STT_NAME.test(label) || model.output?.includes("transcription")) return true
|
|
61
|
+
// Unlabeled audio models that only answer with text (nova, parakeet, chirp…).
|
|
62
|
+
return model.type === "audio" && !TTS_NAME.test(label) && !MUSIC.test(label) && (!model.output || model.output.every((x) => x === "text"))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isTts(model: RawModel): boolean {
|
|
66
|
+
if (model.type && NOT_SPEECH_TYPES.has(model.type)) return false
|
|
67
|
+
const label = `${model.id} ${model.name}`
|
|
68
|
+
if (STT_NAME.test(label)) return false
|
|
69
|
+
if (MUSIC.test(label) && !EXPLICIT_TTS.test(label)) return false
|
|
70
|
+
if (TTS_NAME.test(label) || model.output?.includes("speech")) return true
|
|
71
|
+
return model.type === "audio" && !!model.output?.includes("audio")
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The models of `kind` in a provider's /models answer, with the configured model `current` first. */
|
|
75
|
+
export function speechModelList(kind: SpeechModelKind, body: unknown, current: string): SpeechModelList {
|
|
76
|
+
const all = rawModels(body)
|
|
77
|
+
const matching = all.filter(kind === "stt" ? isStt : isTts)
|
|
78
|
+
const unlabeled = all.length > 0 && matching.length === 0
|
|
79
|
+
return withCurrent((unlabeled ? all : matching).map(({ id, name }) => ({ id, name })), current, unlabeled ? SPEECH_UNLABELED_NOTE : undefined)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Puts the configured model first, marked current (added when the list does not have it). */
|
|
83
|
+
export function withCurrent(models: readonly SpeechModelInfo[], current: string, note?: string): SpeechModelList {
|
|
84
|
+
const id = current.trim()
|
|
85
|
+
const rest = models.filter((m) => m.id !== id).map(({ id, name }) => ({ id, name }))
|
|
86
|
+
const found = models.find((m) => m.id === id)
|
|
87
|
+
const list = id ? [{ id, name: found?.name ?? id, current: true as const }, ...rest] : rest
|
|
88
|
+
return { models: list, ...(note ? { note } : {}) }
|
|
89
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// OpenAI-compatible speech endpoints (OpenAI, Groq, local whisper/kokoro servers, ...):
|
|
2
|
+
// STT as multipart POST {baseURL}/audio/transcriptions (file, model, language?), TTS as
|
|
3
|
+
// POST {baseURL}/audio/speech {model, input, voice, response_format: "mp3", speed?}.
|
|
4
|
+
import { audioMime, SpeechError, upstreamDetail, withSpeedRetry, type SpeechProvider } from "./provider"
|
|
5
|
+
|
|
6
|
+
export interface OpenAICompatibleOptions {
|
|
7
|
+
/** Base URL including the version path, e.g. https://api.openai.com/v1 (no trailing slash). */
|
|
8
|
+
readonly baseURL: string
|
|
9
|
+
/** Optional: local servers often need no key. */
|
|
10
|
+
readonly apiKey: () => Promise<string | undefined>
|
|
11
|
+
readonly sttModel: string
|
|
12
|
+
readonly ttsModel: string
|
|
13
|
+
readonly voice: string
|
|
14
|
+
readonly language?: string
|
|
15
|
+
readonly fetch?: typeof globalThis.fetch
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class OpenAICompatible implements SpeechProvider {
|
|
19
|
+
private readonly fetch: typeof globalThis.fetch
|
|
20
|
+
|
|
21
|
+
constructor(private readonly options: OpenAICompatibleOptions) {
|
|
22
|
+
this.fetch = options.fetch ?? globalThis.fetch
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async transcribe(audio: Uint8Array, format: string, signal?: AbortSignal): Promise<string> {
|
|
26
|
+
const form = new FormData()
|
|
27
|
+
form.append("file", new File([new Uint8Array(audio)], `audio.${format}`, { type: audioMime(format) }))
|
|
28
|
+
form.append("model", this.options.sttModel)
|
|
29
|
+
if (this.options.language) form.append("language", this.options.language)
|
|
30
|
+
const response = await this.fetch(`${this.options.baseURL}/audio/transcriptions`, {
|
|
31
|
+
method: "POST",
|
|
32
|
+
headers: await this.headers(),
|
|
33
|
+
body: form,
|
|
34
|
+
signal,
|
|
35
|
+
})
|
|
36
|
+
if (!response.ok) throw await failure("transcription", response)
|
|
37
|
+
const body = (await response.json()) as { text?: unknown }
|
|
38
|
+
return String(body.text ?? "").trim()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async speak(text: string, speed?: number, signal?: AbortSignal): Promise<Uint8Array<ArrayBuffer>> {
|
|
42
|
+
const { ttsModel: model, voice } = this.options
|
|
43
|
+
const headers = { ...(await this.headers()), "content-type": "application/json" }
|
|
44
|
+
const response = await withSpeedRetry(
|
|
45
|
+
(s) =>
|
|
46
|
+
this.fetch(`${this.options.baseURL}/audio/speech`, {
|
|
47
|
+
method: "POST",
|
|
48
|
+
headers,
|
|
49
|
+
body: JSON.stringify({ model, input: text, voice, response_format: "mp3", ...(s !== undefined ? { speed: s } : {}) }),
|
|
50
|
+
signal,
|
|
51
|
+
}),
|
|
52
|
+
speed,
|
|
53
|
+
)
|
|
54
|
+
if (!response.ok) throw await failure("speech", response)
|
|
55
|
+
return new Uint8Array(await response.arrayBuffer())
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private async headers(): Promise<Record<string, string>> {
|
|
59
|
+
const key = await this.options.apiKey()
|
|
60
|
+
return key ? { authorization: `Bearer ${key}` } : {}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function failure(kind: string, response: Response): Promise<SpeechError> {
|
|
65
|
+
return new SpeechError(`Speech provider ${kind} failed (${response.status}): ${await upstreamDetail(response)}`, response.status)
|
|
66
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// OpenRouter speech-to-text (/audio/transcriptions, JSON with base64 input_audio) and text-to-speech
|
|
2
|
+
// (/audio/speech).
|
|
3
|
+
import { SpeechError, upstreamDetail, withSpeedRetry, type SpeechProvider } from "./provider"
|
|
4
|
+
|
|
5
|
+
export const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
|
6
|
+
|
|
7
|
+
export interface OpenRouterOptions {
|
|
8
|
+
readonly apiKey: () => Promise<string | undefined>
|
|
9
|
+
readonly sttModel: string
|
|
10
|
+
readonly ttsModel: string
|
|
11
|
+
readonly voice: string
|
|
12
|
+
readonly language?: string
|
|
13
|
+
readonly baseURL?: string
|
|
14
|
+
readonly fetch?: typeof globalThis.fetch
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class OpenRouterError extends SpeechError {
|
|
18
|
+
constructor(message: string, status?: number) {
|
|
19
|
+
super(message, status)
|
|
20
|
+
this.name = "OpenRouterError"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class OpenRouter implements SpeechProvider {
|
|
25
|
+
private readonly baseURL: string
|
|
26
|
+
private readonly fetch: typeof globalThis.fetch
|
|
27
|
+
|
|
28
|
+
constructor(private readonly options: OpenRouterOptions) {
|
|
29
|
+
this.baseURL = options.baseURL ?? OPENROUTER_BASE_URL
|
|
30
|
+
this.fetch = options.fetch ?? globalThis.fetch
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async transcribe(audio: Uint8Array, format: string, signal?: AbortSignal): Promise<string> {
|
|
34
|
+
const { sttModel: model, language } = this.options
|
|
35
|
+
const input_audio = { data: Buffer.from(audio).toString("base64"), format }
|
|
36
|
+
const response = await this.post("/audio/transcriptions", { model, input_audio, ...(language ? { language } : {}) }, signal)
|
|
37
|
+
if (!response.ok) throw await failure("transcription", response)
|
|
38
|
+
const body = (await response.json()) as { text?: unknown }
|
|
39
|
+
return String(body.text ?? "").trim()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async speak(text: string, speed?: number, signal?: AbortSignal): Promise<Uint8Array<ArrayBuffer>> {
|
|
43
|
+
const { ttsModel: model, voice } = this.options
|
|
44
|
+
const response = await withSpeedRetry(
|
|
45
|
+
(s) =>
|
|
46
|
+
this.post("/audio/speech", { model, input: text, voice, response_format: "mp3", ...(s !== undefined ? { speed: s } : {}) }, signal),
|
|
47
|
+
speed,
|
|
48
|
+
)
|
|
49
|
+
if (!response.ok) throw await failure("speech", response)
|
|
50
|
+
return new Uint8Array(await response.arrayBuffer())
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private async post(path: string, body: unknown, signal?: AbortSignal): Promise<Response> {
|
|
54
|
+
const key = await this.options.apiKey()
|
|
55
|
+
if (!key) {
|
|
56
|
+
throw new OpenRouterError(
|
|
57
|
+
"No OpenRouter API key: set one in the speech settings, set OPENROUTER_API_KEY, or connect OpenRouter in OpenCode",
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
return this.fetch(`${this.baseURL}${path}`, {
|
|
61
|
+
method: "POST",
|
|
62
|
+
headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
|
|
63
|
+
body: JSON.stringify(body),
|
|
64
|
+
signal,
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function failure(kind: string, response: Response): Promise<OpenRouterError> {
|
|
70
|
+
return new OpenRouterError(`OpenRouter ${kind} failed (${response.status}): ${await upstreamDetail(response)}`, response.status)
|
|
71
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Speech provider interface shared by OpenRouter and OpenAI-compatible endpoints, the typed upstream
|
|
2
|
+
// error (mapped to 502 by the hub), audio format detection, and the speed-retry rule.
|
|
3
|
+
|
|
4
|
+
/** Speech-to-text and text-to-speech against one configured provider. */
|
|
5
|
+
export interface SpeechProvider {
|
|
6
|
+
transcribe(audio: Uint8Array, format: string, signal?: AbortSignal): Promise<string>
|
|
7
|
+
/** MP3 audio. `speed` is optional; providers that reject it get one retry without it. */
|
|
8
|
+
speak(text: string, speed?: number, signal?: AbortSignal): Promise<Uint8Array<ArrayBuffer>>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** The speech provider refused or failed a request (or is not configured). Never carries the API key. */
|
|
12
|
+
export class SpeechError extends Error {
|
|
13
|
+
constructor(
|
|
14
|
+
message: string,
|
|
15
|
+
readonly status?: number,
|
|
16
|
+
) {
|
|
17
|
+
super(message)
|
|
18
|
+
this.name = "SpeechError"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const FORMATS: Record<string, string> = {
|
|
23
|
+
"audio/webm": "webm",
|
|
24
|
+
"audio/mp4": "m4a",
|
|
25
|
+
"audio/x-m4a": "m4a",
|
|
26
|
+
"audio/m4a": "m4a",
|
|
27
|
+
"audio/aac": "aac",
|
|
28
|
+
"audio/ogg": "ogg",
|
|
29
|
+
"audio/wav": "wav",
|
|
30
|
+
"audio/x-wav": "wav",
|
|
31
|
+
"audio/wave": "wav",
|
|
32
|
+
"audio/mpeg": "mp3",
|
|
33
|
+
"audio/mp3": "mp3",
|
|
34
|
+
"audio/flac": "flac",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const MIME: Record<string, string> = {
|
|
38
|
+
webm: "audio/webm",
|
|
39
|
+
m4a: "audio/mp4",
|
|
40
|
+
aac: "audio/aac",
|
|
41
|
+
ogg: "audio/ogg",
|
|
42
|
+
wav: "audio/wav",
|
|
43
|
+
mp3: "audio/mpeg",
|
|
44
|
+
flac: "audio/flac",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Maps a browser recording's Content-Type to an audio format name (webm, m4a, wav, ...). */
|
|
48
|
+
export function audioFormat(contentType: string | null | undefined): string | undefined {
|
|
49
|
+
const mime = contentType?.split(";")[0]?.trim().toLowerCase()
|
|
50
|
+
return mime ? FORMATS[mime] : undefined
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Content type for an audio format name. */
|
|
54
|
+
export const audioMime = (format: string): string => MIME[format] ?? "application/octet-stream"
|
|
55
|
+
|
|
56
|
+
/** Upstream error message: the JSON `error.message` when present, else the body, capped. */
|
|
57
|
+
export async function upstreamDetail(response: Response): Promise<string> {
|
|
58
|
+
const raw = await response.text().catch(() => "")
|
|
59
|
+
try {
|
|
60
|
+
const parsed = JSON.parse(raw) as { error?: { message?: unknown } | string; message?: unknown }
|
|
61
|
+
if (typeof parsed.error === "string") return parsed.error.slice(0, 300)
|
|
62
|
+
if (parsed.error?.message) return String(parsed.error.message).slice(0, 300)
|
|
63
|
+
if (typeof parsed.message === "string") return parsed.message.slice(0, 300)
|
|
64
|
+
} catch {}
|
|
65
|
+
return raw.slice(0, 300)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Sends with `speed`; when that is refused with 400, sends once more without it. */
|
|
69
|
+
export async function withSpeedRetry(send: (speed?: number) => Promise<Response>, speed?: number): Promise<Response> {
|
|
70
|
+
const response = await send(speed)
|
|
71
|
+
if (response.status !== 400 || speed === undefined) return response
|
|
72
|
+
await response.body?.cancel().catch(() => {})
|
|
73
|
+
return send(undefined)
|
|
74
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// Speech for the hub: builds the configured provider for each request from the stored settings (so
|
|
2
|
+
// changes apply at once), proxies model lists for the settings page, and caches them for 10 minutes.
|
|
3
|
+
import { OpenAICompatible } from "./openai"
|
|
4
|
+
import { OpenRouter } from "./openrouter"
|
|
5
|
+
import { speechModelList, withCurrent, type SpeechModelInfo, type SpeechModelList } from "./models"
|
|
6
|
+
import { SpeechError, upstreamDetail, type SpeechProvider } from "./provider"
|
|
7
|
+
import {
|
|
8
|
+
lowerLayers,
|
|
9
|
+
resolveStt,
|
|
10
|
+
resolveTts,
|
|
11
|
+
speechView,
|
|
12
|
+
updateSpeech,
|
|
13
|
+
userSpeechView,
|
|
14
|
+
type Resolved,
|
|
15
|
+
type SpeechDefaults,
|
|
16
|
+
type SpeechView,
|
|
17
|
+
type StoredSpeech,
|
|
18
|
+
type UserSpeechView,
|
|
19
|
+
} from "./settings"
|
|
20
|
+
|
|
21
|
+
export const MODELS_CACHE_MS = 10 * 60_000
|
|
22
|
+
const MODELS_TIMEOUT_MS = 10_000
|
|
23
|
+
|
|
24
|
+
export interface SpeechSettingsStore {
|
|
25
|
+
/** The server default. */
|
|
26
|
+
speechSettings(): StoredSpeech
|
|
27
|
+
setSpeechSettings(settings: StoredSpeech): void
|
|
28
|
+
/** A user's own settings (multi-user hubs). */
|
|
29
|
+
userSpeech?(userID: string): StoredSpeech
|
|
30
|
+
setUserSpeech?(userID: string, settings: StoredSpeech): void
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Speech for one scope: a user (their settings over the default) or, without a user, the server default. */
|
|
34
|
+
export interface ScopedSpeech {
|
|
35
|
+
transcribe(audio: Uint8Array, format: string): Promise<string>
|
|
36
|
+
speak(text: string): Promise<Uint8Array<ArrayBuffer>>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SpeechServiceOptions {
|
|
40
|
+
readonly store: SpeechSettingsStore
|
|
41
|
+
readonly defaults: SpeechDefaults
|
|
42
|
+
readonly fetch?: typeof globalThis.fetch
|
|
43
|
+
readonly now?: () => number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type SpeechKind = "stt" | "tts"
|
|
47
|
+
|
|
48
|
+
export type ModelInfo = SpeechModelInfo
|
|
49
|
+
export type ModelList = SpeechModelList
|
|
50
|
+
|
|
51
|
+
export class SpeechService {
|
|
52
|
+
private readonly fetch: typeof globalThis.fetch
|
|
53
|
+
private readonly now: () => number
|
|
54
|
+
private readonly cache = new Map<string, { at: number; body: unknown }>()
|
|
55
|
+
|
|
56
|
+
constructor(private readonly options: SpeechServiceOptions) {
|
|
57
|
+
this.fetch = options.fetch ?? globalThis.fetch
|
|
58
|
+
this.now = options.now ?? Date.now
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The server default's view, or a user's view (own settings over the default). */
|
|
62
|
+
view(): SpeechView
|
|
63
|
+
view(userID: string | undefined): SpeechView | UserSpeechView
|
|
64
|
+
view(userID?: string): SpeechView | UserSpeechView {
|
|
65
|
+
const stored = this.options.store.speechSettings()
|
|
66
|
+
if (userID === undefined) return speechView(stored, this.options.defaults)
|
|
67
|
+
return userSpeechView(stored, this.options.defaults, this.userSettings(userID) ?? {})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Partial update of the server default, or of a user's own settings; throws ValidationError without saving anything. */
|
|
71
|
+
update(input: unknown, userID?: string): SpeechView | UserSpeechView {
|
|
72
|
+
const { store, defaults } = this.options
|
|
73
|
+
if (userID === undefined) store.setSpeechSettings(updateSpeech(store.speechSettings(), input, defaults.file))
|
|
74
|
+
else {
|
|
75
|
+
if (!store.setUserSpeech) throw new Error("per-user speech settings are not supported by this store")
|
|
76
|
+
// Validated against (and keys dropped relative to) the layers below: server default and hub.json.
|
|
77
|
+
store.setUserSpeech(userID, updateSpeech(this.userSettings(userID) ?? {}, input, lowerLayers(store.speechSettings(), defaults)))
|
|
78
|
+
}
|
|
79
|
+
this.cache.clear()
|
|
80
|
+
return this.view(userID)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Speech with a user's effective settings (undefined: the server default). */
|
|
84
|
+
forUser(userID: string | undefined): ScopedSpeech {
|
|
85
|
+
return {
|
|
86
|
+
transcribe: (audio, format) => this.transcribe(audio, format, undefined, userID),
|
|
87
|
+
speak: (text) => this.speak(text, undefined, userID),
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
transcribe(audio: Uint8Array, format: string, signal?: AbortSignal, userID?: string): Promise<string> {
|
|
92
|
+
return this.provider("stt", userID).transcribe(audio, format, signal)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
speak(text: string, signal?: AbortSignal, userID?: string): Promise<Uint8Array<ArrayBuffer>> {
|
|
96
|
+
const tts = resolveTts(this.options.store.speechSettings(), this.options.defaults, this.userSettings(userID))
|
|
97
|
+
return this.provider("tts", userID).speak(text, tts.speed, signal)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Models offered by the configured provider for `kind` (a user's effective provider when given), the
|
|
102
|
+
* configured model first and marked `current`. OpenRouter filters by output modality; other providers list
|
|
103
|
+
* everything, so their models are classified here (the full list with a `note` when nothing is labeled).
|
|
104
|
+
*/
|
|
105
|
+
async models(kind: SpeechKind, userID?: string): Promise<ModelList> {
|
|
106
|
+
const settings = this.options.store.speechSettings()
|
|
107
|
+
const user = this.userSettings(userID)
|
|
108
|
+
const resolved: Resolved = kind === "stt" ? resolveStt(settings, this.options.defaults, user) : resolveTts(settings, this.options.defaults, user)
|
|
109
|
+
const openrouter = resolved.provider === "openrouter"
|
|
110
|
+
const url = openrouter
|
|
111
|
+
? `${resolved.baseURL}/models?output_modalities=${kind === "stt" ? "transcription" : "speech"}`
|
|
112
|
+
: `${resolved.baseURL}/models`
|
|
113
|
+
// Per scope: a list fetched with one user's key is never served to another.
|
|
114
|
+
const cacheKey = `${userID ?? ""} ${url}`
|
|
115
|
+
const cached = this.cache.get(cacheKey)
|
|
116
|
+
let body: unknown
|
|
117
|
+
if (cached && this.now() - cached.at < MODELS_CACHE_MS) body = cached.body
|
|
118
|
+
else {
|
|
119
|
+
try {
|
|
120
|
+
// OpenRouter's list is public: no key is sent. Other providers usually need theirs.
|
|
121
|
+
const headers: Record<string, string> = !openrouter && resolved.apiKey ? { authorization: `Bearer ${resolved.apiKey}` } : {}
|
|
122
|
+
const response = await this.fetch(url, { headers, signal: AbortSignal.timeout(MODELS_TIMEOUT_MS) })
|
|
123
|
+
if (!response.ok) throw new SpeechError(`Model list failed (${response.status}): ${await upstreamDetail(response)}`, response.status)
|
|
124
|
+
body = await response.json()
|
|
125
|
+
} catch (cause) {
|
|
126
|
+
if (!openrouter) return withCurrent([], resolved.model)
|
|
127
|
+
throw cause instanceof SpeechError ? cause : new SpeechError(`Model list failed: ${cause instanceof Error ? cause.message : cause}`)
|
|
128
|
+
}
|
|
129
|
+
this.cache.set(cacheKey, { at: this.now(), body })
|
|
130
|
+
}
|
|
131
|
+
return openrouter ? withCurrent(parseModels(body), resolved.model) : speechModelList(kind, body, resolved.model)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private userSettings(userID: string | undefined): StoredSpeech | undefined {
|
|
135
|
+
return userID === undefined ? undefined : (this.options.store.userSpeech?.(userID) ?? {})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private provider(kind: SpeechKind, userID?: string): SpeechProvider {
|
|
139
|
+
const settings = this.options.store.speechSettings()
|
|
140
|
+
const user = this.userSettings(userID)
|
|
141
|
+
const stt = resolveStt(settings, this.options.defaults, user)
|
|
142
|
+
const tts = resolveTts(settings, this.options.defaults, user)
|
|
143
|
+
const active = kind === "stt" ? stt : tts
|
|
144
|
+
const options = {
|
|
145
|
+
apiKey: async () => active.apiKey,
|
|
146
|
+
sttModel: stt.model,
|
|
147
|
+
ttsModel: tts.model,
|
|
148
|
+
voice: tts.voice,
|
|
149
|
+
...(stt.language ? { language: stt.language } : {}),
|
|
150
|
+
baseURL: active.baseURL,
|
|
151
|
+
fetch: this.fetch,
|
|
152
|
+
}
|
|
153
|
+
return active.provider === "openrouter" ? new OpenRouter(options) : new OpenAICompatible(options)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function parseModels(body: unknown): ModelInfo[] {
|
|
158
|
+
const data = (body as { data?: unknown } | null)?.data
|
|
159
|
+
if (!Array.isArray(data)) return []
|
|
160
|
+
const models: ModelInfo[] = []
|
|
161
|
+
for (const entry of data) {
|
|
162
|
+
const id = (entry as { id?: unknown } | null)?.id
|
|
163
|
+
if (typeof id !== "string" || !id) continue
|
|
164
|
+
const name = (entry as { name?: unknown }).name
|
|
165
|
+
models.push({ id, name: typeof name === "string" && name ? name : id })
|
|
166
|
+
}
|
|
167
|
+
return models
|
|
168
|
+
}
|