@lincyaw/addon-speech 0.0.0-stage → 1.0.0-rc.2
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/LICENSE +202 -0
- package/README.md +42 -2
- package/bin/presmium-speech.mjs +13 -0
- package/package.json +54 -3
- package/src/client/index.ts +2 -0
- package/src/client/plugin.ts +23 -0
- package/src/client/speech.ts +63 -0
- package/src/domain/config.ts +50 -0
- package/src/domain/consent.ts +25 -0
- package/src/domain/engines.ts +152 -0
- package/src/domain/index.ts +11 -0
- package/src/domain/lexicon.ts +29 -0
- package/src/domain/protocol.ts +81 -0
- package/src/domain/transcript.ts +17 -0
- package/src/node/audio/label.ts +32 -0
- package/src/node/audio/ogg.ts +153 -0
- package/src/node/audio/riff.ts +96 -0
- package/src/node/backend.ts +47 -0
- package/src/node/cache.ts +280 -0
- package/src/node/cached-speech.ts +156 -0
- package/src/node/cli.ts +131 -0
- package/src/node/clone-route.ts +51 -0
- package/src/node/config-pass.ts +22 -0
- package/src/node/consent.ts +106 -0
- package/src/node/core.ts +263 -0
- package/src/node/engines/index.ts +12 -0
- package/src/node/engines/openai.ts +132 -0
- package/src/node/engines/types.ts +54 -0
- package/src/node/engines/volcengine-console.ts +66 -0
- package/src/node/engines/volcengine-voices.ts +472 -0
- package/src/node/engines/volcengine.ts +255 -0
- package/src/node/errors.ts +29 -0
- package/src/node/fake-engine.ts +151 -0
- package/src/node/node-service.ts +63 -0
- package/src/node/presmium.ts +21 -0
- package/src/node/protocol-routes.ts +67 -0
- package/src/node/server.ts +24 -0
|
@@ -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
|
+
}
|
package/src/node/core.ts
ADDED
|
@@ -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
|
+
}
|