@lincyaw/addon-speech 0.0.0-stage → 1.0.0-rc.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.
@@ -0,0 +1,106 @@
1
+ import { mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
2
+ import { dirname, join, resolve } from 'node:path'
3
+ import { jsonResponse, problemResponse } from '@lincyaw/kernel'
4
+ import type { VoiceInfo } from '@lincyaw/kernel'
5
+ import { isInside } from '@lincyaw/kernel/node'
6
+ import { consentPath, consentProblem, DEFAULT_CONSENT_DIR, safeName } from '../domain/index.ts'
7
+ import { SpeechError } from './errors.ts'
8
+
9
+ export function consentDir(operatorDir: string, settings: Record<string, unknown>): string {
10
+ const configured = typeof settings.consentDir === 'string' && settings.consentDir.trim() ? settings.consentDir.trim() : DEFAULT_CONSENT_DIR
11
+ const dir = resolve(operatorDir, configured)
12
+ if (!isInside(dir, resolve(operatorDir))) {
13
+ throw new SpeechError(500, `settings.presmium.speech.consentDir (${configured}) leads out of the operator directory`)
14
+ }
15
+ return dir
16
+ }
17
+
18
+ function assertSafeNames(engine: string, voice: string): void {
19
+ if (!safeName(engine) || !safeName(voice)) throw new SpeechError(400, `"${engine}/${voice}" is not an engine id and a voice id (letters, digits, ".", "_", "-")`)
20
+ }
21
+
22
+ export function checkConsent(operatorDir: string | undefined, settings: Record<string, unknown>, engine: string, voice: string): void {
23
+ const what = `voice "${voice}" of engine "${engine}" is a cloned voice`
24
+ if (settings.cloned !== true) {
25
+ throw new SpeechError(403, `${what}, which the operator has not allowed: cloned voices need settings.presmium.speech.cloned: true in presmium.workspace.yaml and a consent record`)
26
+ }
27
+ if (!operatorDir) throw new SpeechError(403, `${what}, and this process has no operator directory to hold its consent record`)
28
+ assertSafeNames(engine, voice)
29
+ const file = join(consentDir(operatorDir, settings), consentPath(engine, voice))
30
+ let text: string
31
+ try {
32
+ text = readFileSync(file, 'utf8')
33
+ } catch {
34
+ throw new SpeechError(403, `${what} without a consent record: ${file} is missing (records in the deck are not read); the owner registers it through the route consent/${engine}/${voice}, or the operator runs presmium-speech consent ${engine} ${voice} <record>`)
35
+ }
36
+ let record: unknown
37
+ try {
38
+ record = JSON.parse(text)
39
+ } catch {
40
+ record = undefined
41
+ }
42
+ const problem = consentProblem(record)
43
+ if (problem) throw new SpeechError(403, `${what} whose consent record ${file} is not valid: ${problem}`)
44
+ }
45
+
46
+ function writeAtomic(file: string, data: string | Uint8Array): void {
47
+ const temporary = `${file}.${process.pid}.tmp`
48
+ writeFileSync(temporary, data, { mode: 0o600 })
49
+ renameSync(temporary, file)
50
+ }
51
+
52
+ /**
53
+ * Writes a consent record; with `statement`, also the speaker's recording of the consent statement beside it
54
+ * (`<voice>.statement.<ext>`), whose sha256 the record holds, so the record can be checked against it later.
55
+ */
56
+ export function writeConsent(operatorDir: string | undefined, settings: Record<string, unknown>, engine: string, voice: string, record: unknown, statement?: { bytes: Uint8Array, ext: string }): string {
57
+ if (!operatorDir) throw new SpeechError(409, 'this process has no operator directory to hold consent records')
58
+ assertSafeNames(engine, voice)
59
+ const problem = consentProblem(record)
60
+ if (problem) throw new SpeechError(400, problem)
61
+ const file = join(consentDir(operatorDir, settings), consentPath(engine, voice))
62
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 })
63
+ if (statement) writeAtomic(file.replace(/\.json$/, `.statement.${statement.ext}`), statement.bytes)
64
+ writeAtomic(file, `${JSON.stringify(record, null, 2)}\n`)
65
+ return file
66
+ }
67
+
68
+ /** The voices of an engine that have a valid consent record, named by their speaker: cloned voices the operator can use. */
69
+ export function consentedVoices(operatorDir: string | undefined, settings: Record<string, unknown>, engine: string): VoiceInfo[] {
70
+ if (!operatorDir || settings.cloned !== true || !safeName(engine)) return []
71
+ let names: string[]
72
+ try {
73
+ names = readdirSync(join(consentDir(operatorDir, settings), engine))
74
+ } catch {
75
+ return []
76
+ }
77
+ return names.flatMap((name) => {
78
+ const voice = name.endsWith('.json') && !name.includes('.statement.') ? name.slice(0, -'.json'.length) : ''
79
+ if (!voice || !safeName(voice)) return []
80
+ try {
81
+ const record: unknown = JSON.parse(readFileSync(join(consentDir(operatorDir, settings), engine, name), 'utf8'))
82
+ if (consentProblem(record)) return []
83
+ return [{ id: voice, label: (record as { speaker: string }).speaker, lang: [], cloned: true }]
84
+ } catch {
85
+ return []
86
+ }
87
+ })
88
+ }
89
+
90
+ export async function consentRoute(request: Request, rest: string, register: (engine: string, voice: string, record: unknown) => string): Promise<Response> {
91
+ if (request.method !== 'POST' && request.method !== 'PUT') return problemResponse(405, 'POST the consent record (JSON) to consent/<engine>/<voice>')
92
+ const [engine, voice, ...more] = rest.split('/').filter(Boolean)
93
+ if (!engine || !voice || more.length) return problemResponse(404, 'the route is consent/<engine>/<voice>')
94
+ let record: unknown
95
+ try {
96
+ record = JSON.parse(await request.text())
97
+ } catch {
98
+ return problemResponse(400, 'the body is the consent record as JSON { speaker, scope, date, statementSha256 }')
99
+ }
100
+ try {
101
+ register(engine, voice, record)
102
+ return jsonResponse({ engine, voice, registered: consentPath(engine, voice) })
103
+ } catch (error) {
104
+ return problemResponse(error instanceof SpeechError ? error.status : 500, error instanceof Error ? error.message : String(error))
105
+ }
106
+ }
@@ -0,0 +1,263 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { aigcLabel, remapWords, speechEngineId } from '@lincyaw/kernel'
3
+ import type { Logger, SpeechWord, SynthesisRequest, TranscriptionRequest, VoiceCatalog, VoiceCloneStatus, VoiceInfo } from '@lincyaw/kernel'
4
+ import type { EngineDefinition, EngineUse, LexiconText } from '../domain/index.ts'
5
+ import { applyLexiconWithSpans, whyEngineCannot, engineDoes, engineInfo, howToDefine, operatorEngines, providerName } from '../domain/index.ts'
6
+ import { audioMime, labelAudio } from './audio/label.ts'
7
+ import type { CloneInput, SpeechBackend, SynthesisResult } from './backend.ts'
8
+ import { cacheKey, profileCacheKey } from './cache.ts'
9
+ import { checkConsent, consentedVoices, writeConsent } from './consent.ts'
10
+ import { engineAdapter } from './engines/index.ts'
11
+ import type { EngineAdapter } from './engines/index.ts'
12
+ import { speaks } from './engines/types.ts'
13
+ import { SpeechError } from './errors.ts'
14
+
15
+ function writtenWords(words: SpeechWord[], written: string, spoken: LexiconText): SpeechWord[] {
16
+ if (spoken.spans.length === 0) return words
17
+ return remapWords(words, written, spoken.text, spoken.spans).map(({ text, start, end }) => ({ text: text.trim(), start, end }))
18
+ }
19
+
20
+ export interface CoreOptions {
21
+ names(): Iterable<string>
22
+ secret(name: string): string | undefined
23
+ settings: Record<string, unknown>
24
+ operatorDir?: string
25
+ log: Logger
26
+ }
27
+
28
+ export interface SpeechCore extends SpeechBackend {
29
+ registerConsent(engine: string, voice: string, record: unknown): string
30
+ cloneVoice(input: CloneInput, signal?: AbortSignal): Promise<VoiceCloneStatus>
31
+ cloneStatus(engine: string | undefined, voice: string, signal?: AbortSignal): Promise<VoiceCloneStatus>
32
+ }
33
+
34
+ interface CatalogLoad {
35
+ promise: Promise<VoiceCatalog>
36
+ controller: AbortController
37
+ borrowers: number
38
+ settled: boolean
39
+ }
40
+
41
+ export function speechCore(options: CoreOptions): SpeechCore {
42
+ let defined: ReturnType<typeof operatorEngines> | undefined
43
+ let definitionKey: string | undefined
44
+ const engines = () => {
45
+ const next = operatorEngines(options.names(), options.secret)
46
+ const key = createHash('sha256').update(JSON.stringify(next)).digest('hex')
47
+ if (key !== definitionKey) {
48
+ definitionKey = key; defined = next
49
+ for (const list of lists.values()) list.controller.abort()
50
+ lists.clear(); known.clear(); unlisted.clear(); adapters = new WeakMap()
51
+ for (const warning of defined.warnings) options.log.warn(warning)
52
+ }
53
+ return defined!
54
+ }
55
+ const profileOf = (engine: EngineDefinition, use: EngineUse) => createHash('sha256').update(JSON.stringify([
56
+ 'presmium.speech-profile/1', engine.id, engine.kind, engine.url, engine.provider, use,
57
+ use === 'synthesize' ? engine.model : engine.transcribeModel,
58
+ ])).digest('hex')
59
+ let adapters = new WeakMap<EngineDefinition, EngineAdapter>()
60
+ const adapter = (engine: EngineDefinition) => {
61
+ let found = adapters.get(engine)
62
+ if (!found) adapters.set(engine, found = engineAdapter(engine, options.log))
63
+ return found
64
+ }
65
+ const lists = new Map<string, CatalogLoad>()
66
+ const known = new Map<string, VoiceInfo[]>()
67
+ const unlisted = new Set<string>()
68
+ const allCatalog = (engine: EngineDefinition, signal?: AbortSignal): Promise<VoiceCatalog> => {
69
+ signal?.throwIfAborted()
70
+ let list = lists.get(engine.id)
71
+ if (!list) {
72
+ const own = adapter(engine), controller = new AbortController(), requestSignal = AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)])
73
+ const created: CatalogLoad = { controller, borrowers: 0, settled: false, promise: Promise.resolve().then(async () => {
74
+ try {
75
+ const catalog = await own.voiceCatalog(undefined, requestSignal)
76
+ requestSignal.throwIfAborted()
77
+ if (lists.get(engine.id) === created) known.set(engine.id, catalog.voices)
78
+ return catalog
79
+ } catch (error) {
80
+ if (lists.get(engine.id) === created) lists.delete(engine.id)
81
+ if (controller.signal.aborted) throw controller.signal.reason
82
+ throw new SpeechError(502, `the voices of speech engine "${engine.id}" are not known, so a cloned voice cannot be told: ${error instanceof Error ? error.message : String(error)}`)
83
+ } finally { created.settled = true }
84
+ }) }
85
+ lists.set(engine.id, list = created)
86
+ }
87
+ const borrowed = list
88
+ borrowed.borrowers++
89
+ return new Promise((resolve, reject) => {
90
+ let finished = false
91
+ const release = () => {
92
+ if (finished) return false
93
+ finished = true
94
+ signal?.removeEventListener('abort', abort)
95
+ borrowed.borrowers--
96
+ if (!borrowed.borrowers && !borrowed.settled) {
97
+ if (lists.get(engine.id) === borrowed) lists.delete(engine.id)
98
+ borrowed.controller.abort()
99
+ }
100
+ return true
101
+ }
102
+ const abort = () => { if (release()) reject(signal!.reason) }
103
+ signal?.addEventListener('abort', abort, { once: true })
104
+ borrowed.promise.then(catalog => { if (release()) resolve(catalog) }, error => { if (release()) reject(error) })
105
+ if (signal?.aborted) abort()
106
+ })
107
+ }
108
+ // Cloned voices with a consent record are the operator's to use even when the engine does not list them (no
109
+ // console credentials, or a voice trained moments ago): they join the engine's list, read fresh each time.
110
+ const withConsented = (engine: EngineDefinition, catalog: VoiceCatalog): VoiceCatalog => {
111
+ const extra = consentedVoices(options.operatorDir, options.settings, engine.id).filter(voice => !catalog.voices.some(own => own.id === voice.id))
112
+ return extra.length && catalog.status === 'available' ? { ...catalog, voices: [...catalog.voices, ...extra] } : catalog
113
+ }
114
+ const allVoices = async (engine: EngineDefinition, signal?: AbortSignal) => {
115
+ const catalog = withConsented(engine, await allCatalog(engine, signal))
116
+ signal?.throwIfAborted()
117
+ return catalog.voices
118
+ }
119
+ const cloning = (id: string | undefined) => {
120
+ const engine = pick(id, 'synthesize'), own = adapter(engine)
121
+ if (!own.clone || !own.cloneStatus) throw new SpeechError(400, `speech engine "${engine.id}" cannot train cloned voices`)
122
+ if (options.settings.cloned !== true) throw new SpeechError(403, 'cloned voices are off: the operator allows them with settings.presmium.speech.cloned: true in presmium.workspace.yaml')
123
+ return { engine, own: own as Required<Pick<EngineAdapter, 'clone' | 'cloneStatus'>> }
124
+ }
125
+
126
+ // Without a name, the first engine that does the use: an engine only for saying text is not asked to transcribe.
127
+ const pick = (id: string | undefined, use: EngineUse): EngineDefinition => {
128
+ const { engines: list, unavailable } = engines()
129
+ if (!id) {
130
+ const first = list.find(engine => engineDoes(engine, use))
131
+ if (first) return first
132
+ if (list.length > 0) throw new SpeechError(400, `no speech engine ${use === 'synthesize' ? 'synthesizes' : 'transcribes'}: ${list.map(engine => whyEngineCannot(engine, use)).join('; ')}`)
133
+ throw new SpeechError(400, 'no speech engine is defined, so none was asked: the operator defines one in the environment (PRESMIUM_SPEECH_<ID>_URL and PRESMIUM_SPEECH_<ID>_PROVIDER, or PRESMIUM_SPEECH_VOLCENGINE_KEY)')
134
+ }
135
+ const found = list.find(engine => engine.id === speechEngineId(id))
136
+ if (found && !engineDoes(found, use)) throw new SpeechError(400, whyEngineCannot(found, use))
137
+ if (found) return found
138
+ const reason = unavailable.find(engine => engine.id === speechEngineId(id))?.reason
139
+ throw new SpeechError(400, reason ? `speech engine "${id}" is unavailable: ${reason}` : `speech engine "${id}" is not defined; ${howToDefine(id)}`)
140
+ }
141
+
142
+ return {
143
+ async listEngines(lang) {
144
+ const { engines: list, unavailable } = engines()
145
+ return { engines: list.map(engine => ({ ...engineInfo(engine, lang), model: engine.model, transcribeModel: engine.transcribeModel })), unavailable }
146
+ },
147
+
148
+ async defaultSynthesisEngine() {
149
+ return engines().engines.find(engine => engine.synthesizes)?.id
150
+ },
151
+
152
+ async cacheProfile(id, use, lang) {
153
+ const { engines: list, unavailable } = engines()
154
+ const engine = id ? list.find(own => own.id === speechEngineId(id)) : list.find(own => engineDoes(own, use))
155
+ if (engine) return engineDoes(engine, use) ? { status: 'configured', profile: profileOf(engine, use), model: use === 'synthesize' ? engine.model : engine.transcribeModel, provider: providerName(engine, lang) } : { status: 'unavailable' }
156
+ return { status: (id ? unavailable.some(own => own.id === speechEngineId(id)) : unavailable.length > 0) ? 'unavailable' : 'absent' }
157
+ },
158
+
159
+ async voices(id, lang, signal) {
160
+ return (await allVoices(pick(id, 'synthesize'), signal)).filter(voice => speaks(voice, lang))
161
+ },
162
+
163
+ async voiceCatalog(id, lang, signal) {
164
+ signal?.throwIfAborted()
165
+ const engine = id ? pick(id, 'synthesize') : engines().engines.find(engine => engine.synthesizes)
166
+ if (!engine) return { status: 'unavailable', reason: 'not-configured', voices: [] }
167
+ const catalog = withConsented(engine, await allCatalog(engine, signal))
168
+ signal?.throwIfAborted()
169
+ return { ...catalog, voices: catalog.voices.filter(voice => speaks(voice, lang)) }
170
+ },
171
+
172
+ async synthesize(request: SynthesisRequest, signal): Promise<SynthesisResult> {
173
+ const engine = pick(request.engine, 'synthesize')
174
+ const listed = (await allVoices(engine, signal)).find(voice => voice.id === request.voice)
175
+ const cloned = listed?.cloned === true || adapter(engine).cloned?.(request.voice) === true
176
+ if (cloned) checkConsent(options.operatorDir, options.settings, engine.id, request.voice)
177
+ const spoken = applyLexiconWithSpans(request.text, request.lexicon)
178
+ const requestKey = cacheKey({ ...request, engine: engine.id }, spoken.text)
179
+ const profile = profileOf(engine, 'synthesize')
180
+ const key = profileCacheKey(requestKey, profile)
181
+ const produced = await adapter(engine).synthesize({ voice: request.voice, text: spoken.text, lang: request.lang, rate: request.rate, style: request.style }, signal)
182
+ const provider = providerName(engine, request.lang)
183
+ const label = aigcLabel(provider, key)
184
+ const labeled = labelAudio(produced.audio, label)
185
+ const words = produced.words?.length ? writtenWords(produced.words, request.text, spoken) : undefined
186
+ return {
187
+ audio: labeled.audio,
188
+ mime: labeled.mime,
189
+ ext: labeled.ext,
190
+ duration: labeled.duration || produced.duration || words?.at(-1)?.end || 0,
191
+ ...words ? { words } : {},
192
+ engine: engine.id,
193
+ model: engine.model,
194
+ voice: request.voice,
195
+ provider,
196
+ cloned,
197
+ key,
198
+ requestKey,
199
+ profile,
200
+ AIGC: label,
201
+ }
202
+ },
203
+
204
+ async transcribe(request: TranscriptionRequest, signal) {
205
+ const engine = pick(request.engine, 'transcribe')
206
+ const audio = new Uint8Array(await request.audio.arrayBuffer())
207
+ return adapter(engine).transcribe({ audio, mime: audioMime(audio, request.audio.type), lang: request.lang, hints: request.hints }, signal)
208
+ },
209
+
210
+ async consent(engine, voice, recorded, signal) {
211
+ signal?.throwIfAborted()
212
+ if (!recorded) {
213
+ const defined = engines().engines.find(own => own.id === speechEngineId(engine))
214
+ if (!defined) return
215
+ if (adapter(defined).cloned?.(voice) !== true) {
216
+ const voices = known.get(defined.id) ?? (unlisted.has(defined.id)
217
+ ? []
218
+ : await allVoices(defined, signal).catch(() => {
219
+ signal?.throwIfAborted()
220
+ if (engines().engines.includes(defined)) unlisted.add(defined.id)
221
+ return []
222
+ }))
223
+ if (!voices.some(own => own.id === voice && own.cloned === true)) return
224
+ }
225
+ }
226
+ checkConsent(options.operatorDir, options.settings, speechEngineId(engine), voice)
227
+ },
228
+
229
+ async cloneVoice(input: CloneInput, signal): Promise<VoiceCloneStatus> {
230
+ const { engine, own } = cloning(input.engine)
231
+ // Consent before anything leaves the machine: the record names the speaker and the scope, and holds the sha256
232
+ // of their recorded statement, which is kept beside it.
233
+ const statementMime = audioMime(input.consent.statement, input.consent.statementMime)
234
+ const record = { speaker: input.consent.speaker.trim(), scope: input.consent.scope.trim(), date: new Date().toISOString(), statementSha256: createHash('sha256').update(input.consent.statement).digest('hex') }
235
+ const file = writeConsent(options.operatorDir, options.settings, engine.id, input.voice, record, { bytes: input.consent.statement, ext: statementMime.split('/')[1]?.replace('mpeg', 'mp3').replace(/[^a-z0-9]/g, '') || 'bin' })
236
+ options.log.info(`consent record of voice ${input.voice} of engine ${engine.id} registered: ${file}`)
237
+ const status = await own.clone({ voice: input.voice, audio: input.sample, mime: audioMime(input.sample, input.sampleMime), ...input.lang ? { lang: input.lang } : {}, ...input.text ? { text: input.text } : {} }, signal)
238
+ lists.delete(engine.id)
239
+ return { engine: engine.id, voice: input.voice, ...status }
240
+ },
241
+
242
+ async cloneStatus(id, voice, signal): Promise<VoiceCloneStatus> {
243
+ const { engine, own } = cloning(id)
244
+ // Only voices trained through Presmium, with their consent, are asked about.
245
+ checkConsent(options.operatorDir, options.settings, engine.id, voice)
246
+ const status = await own.cloneStatus(voice, signal)
247
+ if (status.state === 'ready') lists.delete(engine.id)
248
+ return { engine: engine.id, voice, ...status }
249
+ },
250
+
251
+ registerConsent(engine, voice, record) {
252
+ const file = writeConsent(options.operatorDir, options.settings, engine, voice, record)
253
+ options.log.info(`consent record of voice ${voice} of engine ${engine} registered: ${file}`)
254
+ return file
255
+ },
256
+ }
257
+ }
258
+
259
+ export async function reportEngines(core: SpeechBackend, log: Logger): Promise<void> {
260
+ const { engines, unavailable } = await core.listEngines('en')
261
+ if (engines.length) log.info(`speech engines: ${engines.map(engine => `${engine.id} (${engine.provider})`).join(', ')}`)
262
+ for (const { id, reason } of unavailable) log.warn(`speech engine ${id} is unavailable: ${reason}`)
263
+ }
@@ -0,0 +1,12 @@
1
+ import type { Logger } from '@lincyaw/kernel'
2
+ import type { EngineDefinition } from '../../domain/index.ts'
3
+ import { openaiEngine } from './openai.ts'
4
+ import type { EngineAdapter } from './types.ts'
5
+ import { volcengineEngine } from './volcengine.ts'
6
+
7
+ export type { EngineAdapter, EngineCloneRequest, EngineCloneStatus, EngineSynthesisRequest, EngineSynthesisResult, EngineTranscriptionRequest } from './types.ts'
8
+ export { blobOf } from './openai.ts'
9
+
10
+ export function engineAdapter(engine: EngineDefinition, log?: Logger): EngineAdapter {
11
+ return engine.kind === 'volcengine' ? volcengineEngine(engine, log) : openaiEngine(engine)
12
+ }
@@ -0,0 +1,132 @@
1
+ import type { SpeechWord, Transcript, VoiceCatalog, VoiceInfo } from '@lincyaw/kernel'
2
+ import { isRecord } from '@lincyaw/kernel'
3
+ import type { EngineDefinition } from '../../domain/index.ts'
4
+ import { oneSegment, wordsIntoSegments } from '../../domain/index.ts'
5
+ import { SpeechError, upstreamError } from '../errors.ts'
6
+ import type { EngineAdapter, EngineCloneStatus, EngineSynthesisRequest, EngineSynthesisResult, EngineTranscriptionRequest } from './types.ts'
7
+ import { speaks } from './types.ts'
8
+
9
+ export function blobOf(bytes: Uint8Array, type: string): Blob {
10
+ return new Blob([new Uint8Array(bytes)], { type })
11
+ }
12
+
13
+ function timedWords(value: unknown): SpeechWord[] {
14
+ if (!Array.isArray(value)) return []
15
+ return value.flatMap((word) => {
16
+ if (!isRecord(word) || typeof word.start !== 'number' || typeof word.end !== 'number') return []
17
+ const text = typeof word.text === 'string' ? word.text : typeof word.word === 'string' ? word.word : ''
18
+ return text.trim() ? [{ text: text.trim(), start: word.start, end: word.end }] : []
19
+ })
20
+ }
21
+
22
+ function cloneStatusOf(value: unknown): EngineCloneStatus {
23
+ if (!isRecord(value)) return { state: 'unknown' }
24
+ const state = value.state === 'training' || value.state === 'ready' || value.state === 'failed' ? value.state : 'unknown'
25
+ return { state, ...typeof value.trainingsLeft === 'number' ? { trainingsLeft: value.trainingsLeft } : {} }
26
+ }
27
+
28
+ function voiceOf(value: unknown): VoiceInfo | undefined {
29
+ if (typeof value === 'string') return { id: value, label: value, lang: [] }
30
+ if (!isRecord(value)) return undefined
31
+ const id = [value.id, value.voice_id, value.name].find((v): v is string => typeof v === 'string' && v.length > 0)
32
+ if (!id) return undefined
33
+ const langs = [value.lang, value.language, value.languages].flat().filter((v): v is string => typeof v === 'string')
34
+ return { id, label: typeof value.label === 'string' ? value.label : typeof value.name === 'string' ? value.name : id, lang: langs, ...value.cloned === true ? { cloned: true } : {} }
35
+ }
36
+
37
+ export function openaiEngine(engine: EngineDefinition): EngineAdapter {
38
+ let timestamps = true
39
+ const call = (path: string, init: RequestInit, signal?: AbortSignal) => fetch(`${engine.url}/${path}`, {
40
+ ...init,
41
+ headers: { ...engine.key ? { Authorization: `Bearer ${engine.key}` } : {}, ...init.headers as Record<string, string> },
42
+ redirect: 'error',
43
+ signal,
44
+ })
45
+ const what = `speech engine "${engine.id}" (${engine.provider})`
46
+
47
+ const voiceCatalog = async (lang?: string, signal?: AbortSignal): Promise<VoiceCatalog> => {
48
+ const response = await call('audio/voices', { method: 'GET' }, signal)
49
+ if (response.status === 404 || response.status === 405) return { status: 'unavailable', reason: 'unsupported', voices: [] }
50
+ if (!response.ok) throw await upstreamError(response, what)
51
+ const body: unknown = await response.json().catch(() => undefined)
52
+ const list = isRecord(body) ? body.voices ?? body.data : body
53
+ if (!Array.isArray(list)) throw new SpeechError(502, `${what} answered audio/voices without a list of voices`)
54
+ return { status: 'available', source: 'provider', voices: list.flatMap(item => voiceOf(item) ?? []).filter(voice => speaks(voice, lang)) }
55
+ }
56
+
57
+ // OpenAI's audio API has no cloning; Presmium's fake engine extends it with audio/voices/clone so the whole
58
+ // clone flow can be tried without a provider account.
59
+ const cloning: Pick<EngineAdapter, 'clone' | 'cloneStatus'> = engine.kind !== 'fake' ? {} : {
60
+ async clone(request, signal) {
61
+ const form = new FormData()
62
+ form.set('file', blobOf(request.audio, request.mime), 'sample')
63
+ form.set('voice', request.voice)
64
+ if (request.lang) form.set('language', request.lang)
65
+ if (request.text) form.set('text', request.text)
66
+ const response = await call('audio/voices/clone', { method: 'POST', body: form }, signal)
67
+ if (!response.ok) throw await upstreamError(response, what)
68
+ return cloneStatusOf(await response.json())
69
+ },
70
+ async cloneStatus(voice, signal) {
71
+ const response = await call(`audio/voices/clone?voice=${encodeURIComponent(voice)}`, { method: 'GET' }, signal)
72
+ if (!response.ok) throw await upstreamError(response, what)
73
+ return cloneStatusOf(await response.json())
74
+ },
75
+ }
76
+
77
+ return {
78
+ voiceCatalog,
79
+ async voices(lang, signal) { return (await voiceCatalog(lang, signal)).voices },
80
+ ...cloning,
81
+
82
+ async synthesize(request: EngineSynthesisRequest, signal): Promise<EngineSynthesisResult> {
83
+ const body = JSON.stringify({
84
+ model: engine.model,
85
+ voice: request.voice,
86
+ input: request.text,
87
+ response_format: 'opus',
88
+ language: request.lang,
89
+ ...request.rate ? { speed: request.rate } : {},
90
+ ...request.style ? { instructions: request.style } : {},
91
+ })
92
+ const headers = { 'Content-Type': 'application/json' }
93
+ if (timestamps) {
94
+ const response = await call('audio/speech/timestamps', { method: 'POST', headers, body }, signal)
95
+ if (response.ok) {
96
+ const answer = await response.json() as { audio?: unknown, duration?: unknown, words?: unknown }
97
+ if (typeof answer.audio !== 'string') throw new SpeechError(502, `${what} gave no audio`)
98
+ return { audio: new Uint8Array(Buffer.from(answer.audio, 'base64')), words: timedWords(answer.words), ...typeof answer.duration === 'number' ? { duration: answer.duration } : {} }
99
+ }
100
+ if (response.status !== 404 && response.status !== 405) throw await upstreamError(response, what)
101
+ timestamps = false
102
+ }
103
+ const response = await call('audio/speech', { method: 'POST', headers, body }, signal)
104
+ if (!response.ok) throw await upstreamError(response, what)
105
+ return { audio: new Uint8Array(await response.arrayBuffer()), contentType: response.headers.get('content-type') ?? undefined }
106
+ },
107
+
108
+ async transcribe(input: EngineTranscriptionRequest, signal): Promise<Transcript> {
109
+ const form = new FormData()
110
+ form.set('file', blobOf(input.audio, input.mime), `audio.${input.mime.includes('ogg') ? 'ogg' : input.mime.includes('wav') ? 'wav' : 'bin'}`)
111
+ form.set('model', engine.transcribeModel)
112
+ form.set('response_format', 'verbose_json')
113
+ form.append('timestamp_granularities[]', 'word')
114
+ form.append('timestamp_granularities[]', 'segment')
115
+ if (input.lang) form.set('language', input.lang.split('-')[0]!.toLowerCase())
116
+ if (input.hints?.length) form.set('prompt', input.hints.join(', '))
117
+ const response = await call('audio/transcriptions', { method: 'POST', body: form }, signal)
118
+ if (!response.ok) throw await upstreamError(response, what)
119
+ const answer = await response.json() as Record<string, unknown>
120
+ const text = typeof answer.text === 'string' ? answer.text.trim() : ''
121
+ const words = timedWords(answer.words)
122
+ const duration = typeof answer.duration === 'number' ? answer.duration : words.at(-1)?.end ?? 0
123
+ const lang = input.lang ?? (typeof answer.language === 'string' ? answer.language : 'und')
124
+ const segments = Array.isArray(answer.segments)
125
+ ? answer.segments.flatMap(s => (isRecord(s) && typeof s.start === 'number' && typeof s.end === 'number' ? [{ start: s.start, end: s.end, text: typeof s.text === 'string' ? s.text : '', words: timedWords(s.words) }] : []))
126
+ : []
127
+ if (segments.length === 0) return oneSegment(text, words, lang, duration)
128
+ const placed = segments.some(s => s.words.length) ? segments : wordsIntoSegments(segments, words)
129
+ return { lang, duration, text, segments: placed }
130
+ },
131
+ }
132
+ }
@@ -0,0 +1,54 @@
1
+ import type { SpeechWord, Transcript, VoiceCatalog, VoiceCloneState, VoiceInfo } from '@lincyaw/kernel'
2
+
3
+ export interface EngineSynthesisRequest {
4
+ voice: string
5
+ text: string
6
+ lang: string
7
+ rate?: number
8
+ style?: string
9
+ }
10
+
11
+ export interface EngineSynthesisResult {
12
+ audio: Uint8Array
13
+ contentType?: string
14
+ words?: SpeechWord[]
15
+ duration?: number
16
+ }
17
+
18
+ export interface EngineTranscriptionRequest {
19
+ audio: Uint8Array
20
+ mime: string
21
+ lang?: string
22
+ hints?: string[]
23
+ }
24
+
25
+ export interface EngineCloneRequest {
26
+ voice: string
27
+ audio: Uint8Array
28
+ mime: string
29
+ lang?: string
30
+ text?: string
31
+ }
32
+
33
+ export interface EngineCloneStatus {
34
+ state: VoiceCloneState
35
+ trainingsLeft?: number
36
+ message?: string
37
+ }
38
+
39
+ export interface EngineAdapter {
40
+ voices(lang?: string, signal?: AbortSignal): Promise<VoiceInfo[]>
41
+ voiceCatalog(lang?: string, signal?: AbortSignal): Promise<VoiceCatalog>
42
+ cloned?(voice: string): boolean
43
+ synthesize(request: EngineSynthesisRequest, signal?: AbortSignal): Promise<EngineSynthesisResult>
44
+ transcribe(input: EngineTranscriptionRequest, signal?: AbortSignal): Promise<Transcript>
45
+ /** Trains `voice` from a sample; absent when the engine cannot clone. */
46
+ clone?(request: EngineCloneRequest, signal?: AbortSignal): Promise<EngineCloneStatus>
47
+ cloneStatus?(voice: string, signal?: AbortSignal): Promise<EngineCloneStatus>
48
+ }
49
+
50
+ export function speaks(voice: VoiceInfo, lang: string | undefined): boolean {
51
+ if (!lang || voice.lang.length === 0) return true
52
+ const primary = (tag: string) => tag.toLowerCase().split('-')[0]
53
+ return voice.lang.some(tag => primary(tag) === primary(lang))
54
+ }
@@ -0,0 +1,66 @@
1
+ import type { VoiceInfo } from '@lincyaw/kernel'
2
+ import { ListSpeakersCommand, SPEECHSAASPRODClient } from '@volcengine/speechsaasprod'
3
+ import { BatchListMegaTTSTrainStatusCommand, SPEECHSAASPROD20250521Client } from '@volcengine/speechsaasprod20250521'
4
+
5
+ // The account side of Volcengine speech, through its official SDK (speech_saas_prod on open.volcengineapi.com):
6
+ // the voices the model offers and the account's own cloned voices. It signs with the operator's IAM access key
7
+ // pair, which is separate from the speech API key, and only with that pair: the clients get the keys explicitly,
8
+ // so the SDK never falls back to its default credential chain (VOLCENGINE_* variables, CLI profiles, instance
9
+ // metadata), and a credential reaches no address but Volcengine's own.
10
+
11
+ export interface ConsoleCredentials { accessKeyId: string, secretAccessKey: string, project: string }
12
+
13
+ const HOST = 'open.volcengineapi.com'
14
+ const REGION = 'cn-beijing'
15
+ const PAGE = 100
16
+
17
+ /** Volcengine's language codes (zh-cn, en, ja …) as BCP 47 tags. */
18
+ export function languageTag(code: string): string {
19
+ const [primary = '', region] = code.trim().split(/[-_]/)
20
+ return region ? `${primary.toLowerCase()}-${region.toUpperCase()}` : primary.toLowerCase()
21
+ }
22
+
23
+ /** The states of a trained voice that can be synthesized. */
24
+ const USABLE = ['Success', 'Active']
25
+
26
+ /** The SDK's errors carry Volcengine's own code and message in the response metadata: name them. */
27
+ function explained(error: unknown): Error {
28
+ const meta = (error as { data?: { ResponseMetadata?: { Error?: { Code?: string, Message?: string } } } } | undefined)?.data?.ResponseMetadata?.Error
29
+ if (meta?.Code) return new Error(`${meta.Code}: ${meta.Message ?? ''}`.trim())
30
+ return error instanceof Error ? error : new Error(String(error))
31
+ }
32
+
33
+ export function volcengineConsole(credentials: ConsoleCredentials) {
34
+ const config = { host: HOST, region: REGION, protocol: 'https' as const, accessKeyId: credentials.accessKeyId, secretAccessKey: credentials.secretAccessKey, autoRetry: false }
35
+ const voices = new SPEECHSAASPRODClient(config)
36
+ const clones = new SPEECHSAASPROD20250521Client(config)
37
+ return {
38
+ /** Every voice of the model (`resource`), page by page. */
39
+ async speakers(resource: string, signal?: AbortSignal): Promise<VoiceInfo[]> {
40
+ const found: VoiceInfo[] = []
41
+ for (let page = 1; ; page++) {
42
+ const answer = await voices.send(new ListSpeakersCommand({ ResourceIDs: [resource], Page: page, Limit: String(PAGE) }), { abortSignal: signal, timeout: 20_000 }).catch((error: unknown) => { throw explained(error) })
43
+ const speakers = answer.Result?.Speakers ?? []
44
+ for (const speaker of speakers) {
45
+ if (!speaker.VoiceType) continue
46
+ const lang = [...new Set((speaker.Languages ?? []).flatMap(language => (language.Language ? [languageTag(language.Language)] : [])))]
47
+ found.push({ id: speaker.VoiceType, label: speaker.Name || speaker.VoiceType, lang })
48
+ }
49
+ if (speakers.length < PAGE || found.length >= (answer.Result?.Total ?? 0)) return found
50
+ }
51
+ },
52
+ /** The account's cloned voices that can be used now, in the operator's project. */
53
+ async cloned(signal?: AbortSignal): Promise<VoiceInfo[]> {
54
+ const found: VoiceInfo[] = []
55
+ for (const state of USABLE) {
56
+ for (let page = 1; ; page++) {
57
+ const answer = await clones.send(new BatchListMegaTTSTrainStatusCommand({ ProjectName: credentials.project, State: state, PageNumber: page, PageSize: PAGE }), { abortSignal: signal, timeout: 20_000 }).catch((error: unknown) => { throw explained(error) })
58
+ const statuses = answer.Result?.Statuses ?? []
59
+ for (const status of statuses) if (status.SpeakerID) found.push({ id: status.SpeakerID, label: status.Alias || status.SpeakerID, lang: [], cloned: true })
60
+ if (statuses.length < PAGE) break
61
+ }
62
+ }
63
+ return found
64
+ },
65
+ }
66
+ }