@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,280 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
2
|
+
import { closeSync, constants, fstatSync, mkdirSync, openSync, readFileSync, readSync, realpathSync, renameSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { join, relative, sep } from 'node:path'
|
|
4
|
+
import type { AigcLabel, DocumentFiles, SpeechWord, SynthesisRequest } from '@lincyaw/kernel'
|
|
5
|
+
import { isRecord, speechEngineId } from '@lincyaw/kernel'
|
|
6
|
+
import { fileVersion } from '@lincyaw/kernel/node'
|
|
7
|
+
import { applyLexicon } from '../domain/index.ts'
|
|
8
|
+
import type { CacheProfile } from './backend.ts'
|
|
9
|
+
import type { SynthesisResult } from './backend.ts'
|
|
10
|
+
|
|
11
|
+
export function cacheKey(request: SynthesisRequest & { engine: string }, spoken: string): string {
|
|
12
|
+
const parts = ['presmium.speech/1', speechEngineId(request.engine), request.voice, request.lang, request.rate ?? 1, request.style ?? '', request.text, spoken]
|
|
13
|
+
return createHash('sha256').update(JSON.stringify(parts)).digest('hex').slice(0, 32)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function profileCacheKey(requestKey: string, profile: string): string {
|
|
17
|
+
return createHash('sha256').update(JSON.stringify(['presmium.speech/2', requestKey, profile])).digest('hex').slice(0, 32)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const KEY = /^[0-9a-f]{32}$/
|
|
21
|
+
const SHA = /^[0-9a-f]{64}$/
|
|
22
|
+
const MAX_LOCK_ENTRIES = 16_384
|
|
23
|
+
const MAX_LOCK_BYTES = 4_194_304
|
|
24
|
+
const LOCK_WRITES = new Map<string, Promise<unknown>>()
|
|
25
|
+
|
|
26
|
+
export interface CacheRecord {
|
|
27
|
+
key: string
|
|
28
|
+
audio: string
|
|
29
|
+
mime: string
|
|
30
|
+
duration: number
|
|
31
|
+
words?: SpeechWord[]
|
|
32
|
+
engine: string
|
|
33
|
+
model: string
|
|
34
|
+
voice: string
|
|
35
|
+
provider: string
|
|
36
|
+
cloned: boolean
|
|
37
|
+
lang: string
|
|
38
|
+
text: string
|
|
39
|
+
synthetic: true
|
|
40
|
+
AIGC: AigcLabel
|
|
41
|
+
sha256: string
|
|
42
|
+
/** Opaque, credential-free configuration identity. Absent on legacy locked records. */
|
|
43
|
+
profile?: string
|
|
44
|
+
requestKey?: string
|
|
45
|
+
request?: { spoken: string, rate: number, style: string }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface LockFile {
|
|
49
|
+
version: 1
|
|
50
|
+
synthesis: Record<string, { audio: string, sha256: string, requestKey?: string, profile?: string }>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface LockSnapshot {
|
|
54
|
+
version: string
|
|
55
|
+
lock: Readonly<{ version: 1, synthesis: Readonly<LockFile['synthesis']> }>
|
|
56
|
+
requests: ReadonlyMap<string, string | undefined>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const OPEN_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0)
|
|
60
|
+
|
|
61
|
+
function readRegular(file: string, maxBytes = Infinity): Buffer | undefined {
|
|
62
|
+
let fd: number
|
|
63
|
+
try {
|
|
64
|
+
fd = openSync(file, OPEN_FLAGS)
|
|
65
|
+
} catch {
|
|
66
|
+
return undefined
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
const stat = fstatSync(fd)
|
|
70
|
+
if (!stat.isFile() || stat.size > maxBytes) return undefined
|
|
71
|
+
const bytes = readFileSync(fd)
|
|
72
|
+
return bytes.length <= maxBytes ? bytes : undefined
|
|
73
|
+
} catch {
|
|
74
|
+
return undefined
|
|
75
|
+
} finally {
|
|
76
|
+
closeSync(fd)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function wordsOf(value: unknown): value is SpeechWord[] {
|
|
81
|
+
return Array.isArray(value) && value.every(word => isRecord(word) && typeof word.text === 'string' && Number.isFinite(word.start) && Number.isFinite(word.end) && (word.start as number) >= 0 && (word.end as number) >= (word.start as number))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function writeAtomic(file: string, data: string | Uint8Array): void {
|
|
85
|
+
const temporary = `${file}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`
|
|
86
|
+
writeFileSync(temporary, data, { flag: 'wx' })
|
|
87
|
+
renameSync(temporary, file)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export class SynthesisCache {
|
|
91
|
+
readonly dir: string
|
|
92
|
+
readonly lockFile: string
|
|
93
|
+
|
|
94
|
+
readonly #deckDir: string
|
|
95
|
+
readonly #workspaceRoot: string
|
|
96
|
+
readonly #files: () => DocumentFiles | undefined
|
|
97
|
+
#lockSnapshot?: LockSnapshot
|
|
98
|
+
|
|
99
|
+
constructor(deckDir: string, workspaceRoot = deckDir, files: () => DocumentFiles | undefined = () => undefined) {
|
|
100
|
+
this.#deckDir = deckDir
|
|
101
|
+
this.#workspaceRoot = workspaceRoot
|
|
102
|
+
this.#files = files
|
|
103
|
+
this.dir = join(deckDir, '.presmium', 'speech')
|
|
104
|
+
this.lockFile = join(deckDir, '.presmium', 'speech.lock.json')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async #put(file: string, data: string | Uint8Array): Promise<void> {
|
|
108
|
+
const files = this.#files()
|
|
109
|
+
if (!files) return writeAtomic(file, data)
|
|
110
|
+
await files.write([{ path: relative(this.#workspaceRoot, file).split(sep).join('/'), content: typeof data === 'string' ? Buffer.from(data) : data }])
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
#contained(): boolean {
|
|
114
|
+
const deck = realpathSync(this.#deckDir)
|
|
115
|
+
const dir = realpathSync(this.dir)
|
|
116
|
+
return dir === join(deck, '.presmium', 'speech')
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async find(key: string): Promise<{ record: CacheRecord, file: string, audio: Buffer } | undefined> {
|
|
120
|
+
if (!KEY.test(key)) return undefined
|
|
121
|
+
let found: { record: CacheRecord, file: string, audio: Buffer }
|
|
122
|
+
try {
|
|
123
|
+
if (!this.#contained()) return undefined
|
|
124
|
+
const json = readRegular(join(this.dir, `${key}.json`), 1_048_576)
|
|
125
|
+
const record = json && JSON.parse(json.toString('utf8')) as CacheRecord
|
|
126
|
+
if (!isRecord(record) || record.key !== key || typeof record.audio !== 'string') return undefined
|
|
127
|
+
if (record.audio !== `${key}.wav` && record.audio !== `${key}.ogg`) return undefined
|
|
128
|
+
if (!['engine', 'model', 'voice', 'provider', 'lang', 'text', 'mime'].every(field => typeof record[field] === 'string') || !Number.isFinite(record.duration) || record.duration <= 0 || !SHA.test(record.sha256) || record.synthetic !== true || !isRecord(record.AIGC) || record.AIGC.ProduceID !== key || record.AIGC.ContentProducer !== record.provider) return undefined
|
|
129
|
+
if (record.words !== undefined && !wordsOf(record.words)) return undefined
|
|
130
|
+
if (record.profile !== undefined || record.requestKey !== undefined || record.request !== undefined) {
|
|
131
|
+
if (!SHA.test(record.profile ?? '') || !KEY.test(record.requestKey ?? '') || !isRecord(record.request) || typeof record.request.spoken !== 'string' || typeof record.request.style !== 'string' || !Number.isFinite(record.request.rate)) return undefined
|
|
132
|
+
if (cacheKey({ engine: record.engine, voice: record.voice, lang: record.lang, text: record.text, rate: record.request.rate, style: record.request.style }, record.request.spoken) !== record.requestKey || profileCacheKey(record.requestKey!, record.profile!) !== key) return undefined
|
|
133
|
+
}
|
|
134
|
+
const file = join(this.dir, record.audio)
|
|
135
|
+
const audio = readRegular(file)
|
|
136
|
+
if (!audio || createHash('sha256').update(audio).digest('hex') !== record.sha256) return undefined
|
|
137
|
+
found = { record, file, audio }
|
|
138
|
+
} catch {
|
|
139
|
+
return undefined
|
|
140
|
+
}
|
|
141
|
+
return found
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Offline reuse requires an explicit lock; a read never creates or repairs that lock. */
|
|
145
|
+
locked(record: CacheRecord): boolean {
|
|
146
|
+
const entry = this.#readLock()?.lock.synthesis[record.key]
|
|
147
|
+
return !!entry && entry.audio === record.audio && entry.sha256 === record.sha256 && entry.profile === record.profile && entry.requestKey === record.requestKey
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
resolveRequest(requestKey: string): string | undefined {
|
|
151
|
+
if (!KEY.test(requestKey)) return undefined
|
|
152
|
+
return this.#readLock()?.requests.get(requestKey)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async write(result: SynthesisResult, request: SynthesisRequest): Promise<{ record: CacheRecord, file: string }> {
|
|
156
|
+
const spoken = applyLexicon(request.text, request.lexicon)
|
|
157
|
+
if (!SHA.test(result.profile ?? '') || cacheKey({ ...request, engine: result.engine }, spoken) !== result.requestKey || profileCacheKey(result.requestKey!, result.profile!) !== result.key) throw new Error('synthesis cache identity does not match the generated request')
|
|
158
|
+
if (!this.#files()) {
|
|
159
|
+
mkdirSync(this.dir, { recursive: true })
|
|
160
|
+
if (!this.#contained()) throw new Error(`the synthesis cache ${this.dir} resolves outside the deck`)
|
|
161
|
+
}
|
|
162
|
+
const audio = `${result.key}.${result.ext}`
|
|
163
|
+
const file = join(this.dir, audio)
|
|
164
|
+
const record: CacheRecord = {
|
|
165
|
+
key: result.key,
|
|
166
|
+
audio,
|
|
167
|
+
mime: result.mime,
|
|
168
|
+
duration: result.duration,
|
|
169
|
+
...result.words ? { words: result.words } : {},
|
|
170
|
+
engine: result.engine,
|
|
171
|
+
model: result.model,
|
|
172
|
+
voice: result.voice,
|
|
173
|
+
provider: result.provider,
|
|
174
|
+
cloned: result.cloned,
|
|
175
|
+
lang: request.lang,
|
|
176
|
+
text: request.text,
|
|
177
|
+
synthetic: true,
|
|
178
|
+
AIGC: result.AIGC,
|
|
179
|
+
sha256: createHash('sha256').update(result.audio).digest('hex'),
|
|
180
|
+
profile: result.profile,
|
|
181
|
+
requestKey: result.requestKey,
|
|
182
|
+
request: { spoken, rate: request.rate ?? 1, style: request.style ?? '' },
|
|
183
|
+
}
|
|
184
|
+
await this.#put(file, result.audio)
|
|
185
|
+
await this.#put(join(this.dir, `${result.key}.json`), `${JSON.stringify(record, null, 2)}\n`)
|
|
186
|
+
await this.#lock(result.key, record)
|
|
187
|
+
return { record, file }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
readAlignment(record: CacheRecord, lang: string, transcriber: CacheProfile): SpeechWord[] | undefined {
|
|
191
|
+
const key = record.key
|
|
192
|
+
if (!KEY.test(key)) return undefined
|
|
193
|
+
try {
|
|
194
|
+
if (!this.#contained()) return undefined
|
|
195
|
+
const value: unknown = JSON.parse(readRegular(join(this.dir, `${key}.align.json`), 1_048_576)?.toString('utf8') ?? '')
|
|
196
|
+
if (!isRecord(value) || value.key !== key || value.sha256 !== record.sha256 || value.profile !== (record.profile ?? null) || value.lang !== lang || typeof value.transcriber !== 'string' || !SHA.test(value.transcriber) || !wordsOf(value.words)) return undefined
|
|
197
|
+
if (transcriber.status === 'unavailable' || transcriber.status === 'configured' && value.transcriber !== transcriber.profile) return undefined
|
|
198
|
+
return value.words
|
|
199
|
+
} catch {
|
|
200
|
+
return undefined
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async writeAlignment(record: CacheRecord, lang: string, transcriber: string, words: SpeechWord[]): Promise<void> {
|
|
205
|
+
await this.#put(join(this.dir, `${record.key}.align.json`), `${JSON.stringify({ key: record.key, sha256: record.sha256, profile: record.profile ?? null, lang, transcriber, words }, null, 2)}\n`)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async pruneLock(keep: ReadonlySet<string>): Promise<number> {
|
|
209
|
+
return this.#updateLock(async lock => {
|
|
210
|
+
const kept = Object.keys(lock.synthesis).filter(key => keep.has(key))
|
|
211
|
+
const dropped = Object.keys(lock.synthesis).length - kept.length
|
|
212
|
+
if (dropped > 0) await this.#writeLock(Object.fromEntries(kept.map(key => [key, lock.synthesis[key]!])))
|
|
213
|
+
return dropped
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async #lock(key: string, record: CacheRecord): Promise<void> {
|
|
218
|
+
await this.#updateLock(async lock => {
|
|
219
|
+
const entry = { audio: record.audio, sha256: record.sha256, requestKey: record.requestKey, profile: record.profile }
|
|
220
|
+
if (JSON.stringify(lock.synthesis[key]) === JSON.stringify(entry)) return
|
|
221
|
+
lock.synthesis[key] = entry
|
|
222
|
+
await this.#writeLock(lock.synthesis)
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async #updateLock<T>(update: (lock: LockFile) => Promise<T>): Promise<T> {
|
|
227
|
+
const before = LOCK_WRITES.get(this.lockFile) ?? Promise.resolve()
|
|
228
|
+
const pending = before.catch(() => undefined).then(() => update({ version: 1, synthesis: { ...this.#readLock()?.lock.synthesis } }))
|
|
229
|
+
LOCK_WRITES.set(this.lockFile, pending)
|
|
230
|
+
try { return await pending }
|
|
231
|
+
finally { if (LOCK_WRITES.get(this.lockFile) === pending) LOCK_WRITES.delete(this.lockFile) }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
#readLock(): LockSnapshot | undefined {
|
|
235
|
+
let fd: number | undefined
|
|
236
|
+
try {
|
|
237
|
+
fd = openSync(this.lockFile, OPEN_FLAGS)
|
|
238
|
+
const before = fstatSync(fd, { bigint: true })
|
|
239
|
+
if (!before.isFile() || before.size > BigInt(MAX_LOCK_BYTES)) throw new Error('invalid speech lock size')
|
|
240
|
+
const version = fileVersion(before)
|
|
241
|
+
if (this.#lockSnapshot?.version === version) return this.#lockSnapshot
|
|
242
|
+
// Read at most the observed extent plus one byte, and reject an in-place change while reading.
|
|
243
|
+
const bytes = Buffer.alloc(Number(before.size) + 1)
|
|
244
|
+
let length = 0
|
|
245
|
+
while (length < bytes.length) {
|
|
246
|
+
const count = readSync(fd, bytes, length, bytes.length - length, length)
|
|
247
|
+
if (!count) break
|
|
248
|
+
length += count
|
|
249
|
+
}
|
|
250
|
+
if (length !== Number(before.size) || fileVersion(fstatSync(fd, { bigint: true })) !== version) throw new Error('speech lock changed while reading')
|
|
251
|
+
const read: unknown = JSON.parse(bytes.subarray(0, length).toString('utf8'))
|
|
252
|
+
if (!isRecord(read) || read.version !== 1 || !isRecord(read.synthesis)) throw new Error('invalid speech lock')
|
|
253
|
+
const entries = Object.entries(read.synthesis)
|
|
254
|
+
if (entries.length > MAX_LOCK_ENTRIES || !entries.every(([key, entry]) => KEY.test(key) && isRecord(entry) && (entry.audio === `${key}.ogg` || entry.audio === `${key}.wav`) && typeof entry.sha256 === 'string' && SHA.test(entry.sha256) && (entry.profile === undefined && entry.requestKey === undefined || typeof entry.profile === 'string' && SHA.test(entry.profile) && typeof entry.requestKey === 'string' && KEY.test(entry.requestKey)))) throw new Error('invalid speech lock entries')
|
|
255
|
+
const lock = read as unknown as LockFile, requests = new Map<string, string | undefined>()
|
|
256
|
+
for (const [key, entry] of Object.entries(lock.synthesis)) {
|
|
257
|
+
const requestKey = entry.requestKey ?? key
|
|
258
|
+
requests.set(requestKey, requests.has(requestKey) ? undefined : key)
|
|
259
|
+
Object.freeze(entry)
|
|
260
|
+
}
|
|
261
|
+
Object.freeze(lock.synthesis)
|
|
262
|
+
Object.freeze(lock)
|
|
263
|
+
return this.#lockSnapshot = { version, lock, requests }
|
|
264
|
+
} catch {
|
|
265
|
+
this.#lockSnapshot = undefined
|
|
266
|
+
return undefined
|
|
267
|
+
} finally {
|
|
268
|
+
if (fd !== undefined) closeSync(fd)
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async #writeLock(synthesis: LockFile['synthesis']): Promise<void> {
|
|
273
|
+
if (Object.keys(synthesis).length > MAX_LOCK_ENTRIES) throw new Error('synthesis lock exceeds its entry limit')
|
|
274
|
+
const entries = Object.keys(synthesis).sort().map(k => ` ${JSON.stringify(k)}: ${JSON.stringify(synthesis[k])}`)
|
|
275
|
+
const text = `{\n "version": 1,\n "synthesis": {${entries.length ? `\n${entries.join(',\n')}\n ` : ''}}\n}\n`
|
|
276
|
+
if (Buffer.byteLength(text) > MAX_LOCK_BYTES) throw new Error('synthesis lock exceeds its byte limit')
|
|
277
|
+
await this.#put(this.lockFile, text)
|
|
278
|
+
this.#lockSnapshot = undefined
|
|
279
|
+
}
|
|
280
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { DOCUMENT_FILES, speechEngineId } from '@lincyaw/kernel'
|
|
2
|
+
import type { NodeServiceContext, SpeechWord, SynthesisRequest, TranscriptionRequest, VoiceCatalog } from '@lincyaw/kernel'
|
|
3
|
+
import type { DeckSpeechConfig } from '../domain/index.ts'
|
|
4
|
+
import { applyLexicon, deckSpeechConfig } from '../domain/index.ts'
|
|
5
|
+
import type { CloneInput, SpeechBackend, SynthesisResult } from './backend.ts'
|
|
6
|
+
import { cacheKey, profileCacheKey, SynthesisCache } from './cache.ts'
|
|
7
|
+
import type { SpeechCore } from './core.ts'
|
|
8
|
+
import { speechCore } from './core.ts'
|
|
9
|
+
import { SpeechError } from './errors.ts'
|
|
10
|
+
|
|
11
|
+
export class CachedSpeech implements SpeechBackend {
|
|
12
|
+
readonly #inner: SpeechBackend
|
|
13
|
+
readonly #defaults: DeckSpeechConfig
|
|
14
|
+
readonly cache: SynthesisCache
|
|
15
|
+
|
|
16
|
+
constructor(inner: SpeechBackend, cache: SynthesisCache, defaults: DeckSpeechConfig) {
|
|
17
|
+
this.#inner = inner
|
|
18
|
+
this.cache = cache
|
|
19
|
+
this.#defaults = defaults
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
listEngines(lang?: string) {
|
|
23
|
+
return this.#inner.listEngines(lang)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async defaultSynthesisEngine() {
|
|
27
|
+
return this.#defaults.engine ?? this.#inner.defaultSynthesisEngine()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
cacheProfile(engine: string | undefined, use: 'synthesize' | 'transcribe', lang?: string) {
|
|
31
|
+
return this.#inner.cacheProfile(engine, use, lang)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Resolves the cache identity locally, including document defaults, without generating audio. */
|
|
35
|
+
async synthesisKey(request: SynthesisRequest): Promise<string | undefined> {
|
|
36
|
+
const engine = request.engine ?? await this.defaultSynthesisEngine()
|
|
37
|
+
if (!engine) return undefined
|
|
38
|
+
const requestKey = cacheKey({ ...request, engine: speechEngineId(engine) }, applyLexicon(request.text, request.lexicon))
|
|
39
|
+
const profile = await this.cacheProfile(engine, 'synthesize')
|
|
40
|
+
return profile.status === 'configured' ? profileCacheKey(requestKey, profile.profile) : profile.status === 'absent' ? this.cache.resolveRequest(requestKey) : undefined
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Every consumer, including direct clip playback and alignment, uses this identity/SHA gate. */
|
|
44
|
+
async cached(key: string) {
|
|
45
|
+
const hit = await this.cache.find(key)
|
|
46
|
+
if (!hit) return undefined
|
|
47
|
+
const current = await this.cacheProfile(hit.record.engine, 'synthesize', hit.record.lang)
|
|
48
|
+
if (current.status === 'configured') return hit.record.profile === current.profile && hit.record.model === current.model && hit.record.provider === current.provider ? hit : undefined
|
|
49
|
+
return current.status === 'absent' && this.cache.locked(hit.record) ? hit : undefined
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async lookup(request: SynthesisRequest) {
|
|
53
|
+
const engine = request.engine ?? await this.defaultSynthesisEngine()
|
|
54
|
+
const key = await this.synthesisKey({ ...request, engine })
|
|
55
|
+
const hit = key ? await this.cached(key) : undefined
|
|
56
|
+
return hit && hit.record.engine === speechEngineId(engine!) && hit.record.text === request.text && hit.record.lang === request.lang && hit.record.voice === request.voice ? hit : undefined
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The engine that transcribes without one named: the deck's transcription engine, else its default engine when that one transcribes. */
|
|
60
|
+
async #transcriptionEngine(): Promise<string | undefined> {
|
|
61
|
+
if (this.#defaults.transcribe) return this.#defaults.transcribe
|
|
62
|
+
const named = this.#defaults.engine
|
|
63
|
+
if (!named) return undefined
|
|
64
|
+
const { engines } = await this.#inner.listEngines()
|
|
65
|
+
return engines.find(engine => engine.id === speechEngineId(named))?.stt ? named : undefined
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
voices(engine: string | undefined, lang?: string, signal?: AbortSignal) {
|
|
69
|
+
return this.#inner.voices(engine ?? this.#defaults.engine, lang, signal)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async voiceCatalog(engine: string | undefined, lang?: string, signal?: AbortSignal): Promise<VoiceCatalog> {
|
|
73
|
+
return this.#inner.voiceCatalog?.(engine ?? this.#defaults.engine, lang, signal) ?? { status: 'unavailable', reason: 'unknown', voices: [] }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async synthesizeThroughCache(request: SynthesisRequest, signal?: AbortSignal): Promise<{ result: SynthesisResult, file: string }> {
|
|
77
|
+
const engine = request.engine ?? await this.defaultSynthesisEngine()
|
|
78
|
+
if (!engine) return this.#write(await this.#inner.synthesize(request, signal), request)
|
|
79
|
+
const hit = await this.lookup({ ...request, engine })
|
|
80
|
+
if (hit) {
|
|
81
|
+
const { record, file, audio } = hit
|
|
82
|
+
await this.#inner.consent(record.engine, record.voice, record.cloned === true, signal)
|
|
83
|
+
const result: SynthesisResult = {
|
|
84
|
+
audio: new Uint8Array(audio),
|
|
85
|
+
mime: record.mime,
|
|
86
|
+
ext: record.audio.split('.').at(-1)!,
|
|
87
|
+
duration: record.duration,
|
|
88
|
+
...record.words ? { words: record.words } : {},
|
|
89
|
+
engine: record.engine,
|
|
90
|
+
model: record.model,
|
|
91
|
+
voice: record.voice,
|
|
92
|
+
provider: record.provider,
|
|
93
|
+
cloned: record.cloned,
|
|
94
|
+
key: record.key,
|
|
95
|
+
...record.profile ? { profile: record.profile, requestKey: record.requestKey } : {},
|
|
96
|
+
AIGC: record.AIGC,
|
|
97
|
+
}
|
|
98
|
+
return { result, file }
|
|
99
|
+
}
|
|
100
|
+
return this.#write(await this.#inner.synthesize({ ...request, engine }, signal), request)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async #write(result: SynthesisResult, request: SynthesisRequest): Promise<{ result: SynthesisResult, file: string }> {
|
|
104
|
+
return { result, file: (await this.cache.write(result, request)).file }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async synthesize(request: SynthesisRequest, signal?: AbortSignal): Promise<SynthesisResult> {
|
|
108
|
+
return (await this.synthesizeThroughCache(request, signal)).result
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async alignWords(key: string, options: { lang: string, transcribe?: boolean }, signal?: AbortSignal): Promise<SpeechWord[] | undefined> {
|
|
112
|
+
const hit = await this.cached(key)
|
|
113
|
+
if (!hit) return undefined
|
|
114
|
+
const engine = await this.#transcriptionEngine()
|
|
115
|
+
const transcriber = await this.cacheProfile(engine, 'transcribe')
|
|
116
|
+
const kept = this.cache.readAlignment(hit.record, options.lang, transcriber)
|
|
117
|
+
if (kept) return kept
|
|
118
|
+
if (options.transcribe === false || transcriber.status !== 'configured') return undefined
|
|
119
|
+
try {
|
|
120
|
+
const transcript = await this.#inner.transcribe({ audio: new Blob([new Uint8Array(hit.audio)], { type: hit.record.mime }), lang: options.lang, engine }, signal)
|
|
121
|
+
const words = transcript.segments.flatMap(segment => segment.words)
|
|
122
|
+
if (words.length === 0) return undefined
|
|
123
|
+
await this.cache.writeAlignment(hit.record, options.lang, transcriber.profile, words)
|
|
124
|
+
return words
|
|
125
|
+
} catch {
|
|
126
|
+
return undefined
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
consent(engine: string, voice: string, recorded: boolean, signal?: AbortSignal): Promise<void> {
|
|
131
|
+
return this.#inner.consent(engine, voice, recorded, signal)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async cloneVoice(input: CloneInput, signal?: AbortSignal) {
|
|
135
|
+
if (!this.#inner.cloneVoice) throw new SpeechError(400, 'this speech service cannot clone voices')
|
|
136
|
+
return this.#inner.cloneVoice({ ...input, engine: input.engine ?? this.#defaults.engine }, signal)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async cloneStatus(engine: string | undefined, voice: string, signal?: AbortSignal) {
|
|
140
|
+
if (!this.#inner.cloneStatus) throw new SpeechError(400, 'this speech service cannot clone voices')
|
|
141
|
+
return this.#inner.cloneStatus(engine ?? this.#defaults.engine, voice, signal)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async transcribe(request: TranscriptionRequest, signal?: AbortSignal) {
|
|
145
|
+
const engine = request.engine ?? await this.#transcriptionEngine()
|
|
146
|
+
return this.#inner.transcribe({ ...request, ...engine ? { engine } : {} }, signal)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export type DeckSpeechContext = Pick<NodeServiceContext, 'deckDir' | 'workspaceRoot' | 'config' | 'settings' | 'operatorDir' | 'log' | 'secret' | 'service'>
|
|
151
|
+
|
|
152
|
+
export function createDeckSpeech(ctx: DeckSpeechContext): { core: SpeechCore, speech: CachedSpeech } {
|
|
153
|
+
const core = speechCore({ names: () => [...new Set([...Object.keys(process.env), ...ctx.secret('PRESMIUM_SPEECH_VOLCENGINE_KEY') ? ['PRESMIUM_SPEECH_VOLCENGINE_KEY'] : []])], secret: name => ctx.secret(name), settings: ctx.settings, operatorDir: ctx.operatorDir, log: ctx.log })
|
|
154
|
+
const cache = new SynthesisCache(ctx.deckDir, ctx.workspaceRoot, () => ctx.service?.(DOCUMENT_FILES))
|
|
155
|
+
return { core, speech: new CachedSpeech(core, cache, deckSpeechConfig(ctx.config).config) }
|
|
156
|
+
}
|
package/src/node/cli.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { resolve } from 'node:path'
|
|
4
|
+
import { parseArgs } from 'node:util'
|
|
5
|
+
import { operatorDir, workspaceSettings } from '@lincyaw/compiler'
|
|
6
|
+
import { errorMessage } from '@lincyaw/kernel'
|
|
7
|
+
import { SPEECH_ID } from '../domain/index.ts'
|
|
8
|
+
import { writeConsent } from './consent.ts'
|
|
9
|
+
import { speechCore } from './core.ts'
|
|
10
|
+
import { startFakeEngine } from './fake-engine.ts'
|
|
11
|
+
|
|
12
|
+
const USAGE = `Usage: presmium-speech <command> [options]
|
|
13
|
+
|
|
14
|
+
Commands:
|
|
15
|
+
fake the deterministic fake engine, an HTTP service of the OpenAI audio API shape;
|
|
16
|
+
define it with PRESMIUM_SPEECH_FAKE_URL=<the address it prints> and
|
|
17
|
+
PRESMIUM_SPEECH_FAKE_PROVIDER=<a name>
|
|
18
|
+
--port <n> port (default: a free port)
|
|
19
|
+
--host <addr> address (default: 127.0.0.1)
|
|
20
|
+
consent <engine> <voice> <record>
|
|
21
|
+
registers the consent record (JSON: speaker, scope, date, statementSha256) of a
|
|
22
|
+
cloned voice as <consentDir>/<engine>/<voice>.json of the operator directory
|
|
23
|
+
(consentDir from settings.presmium.speech of presmium.workspace.yaml, default consent)
|
|
24
|
+
--operator-dir <dir> the operator directory (default: the workspace's in the
|
|
25
|
+
user's state directory, or PRESMIUM_OPERATOR_DIR)
|
|
26
|
+
clone <engine> <voice> <sample>
|
|
27
|
+
trains the cloned voice <voice> (Volcengine: a slot of the account, S_…, or a new
|
|
28
|
+
name for a pay-as-you-go voice) from a recording of its speaker (10–30 s, one voice,
|
|
29
|
+
WAV, MP3, Ogg or M4A), after registering their consent; training is paid for at the
|
|
30
|
+
provider. Needs settings.presmium.speech.cloned: true. Waits until it is trained
|
|
31
|
+
--speaker <name> who speaks
|
|
32
|
+
--scope <text> what the voice may be used for
|
|
33
|
+
--statement <file> the speaker's recording of the consent statement
|
|
34
|
+
--lang <tag> the sample's language (default zh-CN)
|
|
35
|
+
--text <text> what the sample says, when known
|
|
36
|
+
--status only show the state of an earlier training
|
|
37
|
+
--operator-dir <dir> as for consent
|
|
38
|
+
|
|
39
|
+
-h, --help show this help`
|
|
40
|
+
|
|
41
|
+
function workspaceRoot(dir: string): string {
|
|
42
|
+
try {
|
|
43
|
+
return resolve(execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim())
|
|
44
|
+
} catch {
|
|
45
|
+
return resolve(dir)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function fake(args: string[]): Promise<number> {
|
|
50
|
+
const { values } = parseArgs({ args, options: { port: { type: 'string' }, host: { type: 'string' } } })
|
|
51
|
+
const port = values.port === undefined ? 0 : Number(values.port)
|
|
52
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error(`--port takes a port number, not "${values.port}"`)
|
|
53
|
+
const engine = await startFakeEngine({ port, listenAddress: values.host })
|
|
54
|
+
process.stdout.write(`fake ${engine.url}\n`)
|
|
55
|
+
await new Promise<void>((done) => {
|
|
56
|
+
process.once('SIGINT', done)
|
|
57
|
+
process.once('SIGTERM', done)
|
|
58
|
+
})
|
|
59
|
+
await engine.close()
|
|
60
|
+
return 0
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function consent(args: string[]): number {
|
|
64
|
+
const { values, positionals } = parseArgs({ args, allowPositionals: true, options: { 'operator-dir': { type: 'string' } } })
|
|
65
|
+
const [engine, voice, file, ...more] = positionals
|
|
66
|
+
if (!engine || !voice || !file || more.length) throw new Error('consent takes <engine> <voice> <record>')
|
|
67
|
+
const record: unknown = JSON.parse(readFileSync(resolve(file), 'utf8'))
|
|
68
|
+
const root = workspaceRoot(process.cwd())
|
|
69
|
+
const { settings, problem } = workspaceSettings(root)
|
|
70
|
+
if (problem) throw new Error(problem)
|
|
71
|
+
const dir = operatorDir(root, values['operator-dir'] ?? process.env.PRESMIUM_OPERATOR_DIR)
|
|
72
|
+
const written = writeConsent(dir, settings[SPEECH_ID] ?? {}, engine, voice, record)
|
|
73
|
+
process.stdout.write(`consent ${written}\n`)
|
|
74
|
+
return 0
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const MIME: Record<string, string> = { wav: 'audio/wav', mp3: 'audio/mpeg', ogg: 'audio/ogg', m4a: 'audio/mp4', aac: 'audio/aac' }
|
|
78
|
+
const mimeOf = (file: string) => MIME[file.split('.').at(-1)?.toLowerCase() ?? ''] ?? 'application/octet-stream'
|
|
79
|
+
|
|
80
|
+
async function clone(args: string[]): Promise<number> {
|
|
81
|
+
const { values, positionals } = parseArgs({ args, allowPositionals: true, options: {
|
|
82
|
+
speaker: { type: 'string' }, scope: { type: 'string' }, statement: { type: 'string' }, lang: { type: 'string' }, text: { type: 'string' },
|
|
83
|
+
status: { type: 'boolean' }, 'operator-dir': { type: 'string' },
|
|
84
|
+
} })
|
|
85
|
+
const [engine, voice, sample, ...more] = positionals
|
|
86
|
+
if (!engine || !voice || (!values.status && !sample) || more.length) throw new Error('clone takes <engine> <voice> <sample> (or <engine> <voice> --status)')
|
|
87
|
+
const root = workspaceRoot(process.cwd())
|
|
88
|
+
const { settings, problem } = workspaceSettings(root)
|
|
89
|
+
if (problem) throw new Error(problem)
|
|
90
|
+
const say = (...parts: unknown[]) => process.stderr.write(`${parts.map(String).join(' ')}\n`)
|
|
91
|
+
const core = speechCore({
|
|
92
|
+
names: () => Object.keys(process.env),
|
|
93
|
+
secret: name => process.env[name],
|
|
94
|
+
settings: settings[SPEECH_ID] ?? {},
|
|
95
|
+
operatorDir: operatorDir(root, values['operator-dir'] ?? process.env.PRESMIUM_OPERATOR_DIR),
|
|
96
|
+
log: { debug: () => {}, info: say, warn: say, error: say },
|
|
97
|
+
})
|
|
98
|
+
let status = values.status
|
|
99
|
+
? await core.cloneStatus(engine, voice)
|
|
100
|
+
: await (async () => {
|
|
101
|
+
if (!values.speaker || !values.scope || !values.statement) throw new Error('clone needs --speaker, --scope and --statement: the speaker\'s consent comes first')
|
|
102
|
+
return core.cloneVoice({
|
|
103
|
+
engine, voice, sample: readFileSync(resolve(sample!)), sampleMime: mimeOf(sample!), lang: values.lang ?? 'zh-CN', ...values.text ? { text: values.text } : {},
|
|
104
|
+
consent: { speaker: values.speaker, scope: values.scope, statement: readFileSync(resolve(values.statement)), statementMime: mimeOf(values.statement) },
|
|
105
|
+
})
|
|
106
|
+
})()
|
|
107
|
+
for (let polls = 0; !values.status && status.state === 'training' && polls < 200; polls++) {
|
|
108
|
+
process.stdout.write(`training ${voice}\n`)
|
|
109
|
+
await new Promise(done => setTimeout(done, 3000))
|
|
110
|
+
status = await core.cloneStatus(engine, voice)
|
|
111
|
+
}
|
|
112
|
+
process.stdout.write(`${status.state} ${voice}${status.trainingsLeft === undefined ? '' : ` (${status.trainingsLeft} trainings left)`}\n`)
|
|
113
|
+
return status.state === 'failed' ? 1 : 0
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function main(argv: string[]): Promise<number> {
|
|
117
|
+
const [command, ...args] = argv
|
|
118
|
+
try {
|
|
119
|
+
if (command === 'fake') return await fake(args)
|
|
120
|
+
if (command === 'consent') return consent(args)
|
|
121
|
+
if (command === 'clone') return await clone(args)
|
|
122
|
+
if (command === '-h' || command === '--help' || command === undefined) {
|
|
123
|
+
process.stdout.write(`${USAGE}\n`)
|
|
124
|
+
return command === undefined ? 2 : 0
|
|
125
|
+
}
|
|
126
|
+
throw new Error(`unknown command "${command}"`)
|
|
127
|
+
} catch (error) {
|
|
128
|
+
process.stderr.write(`presmium-speech: ${errorMessage(error)}\n\n${USAGE}\n`)
|
|
129
|
+
return 2
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { jsonResponse, problemResponse } from '@lincyaw/kernel'
|
|
2
|
+
import type { CloneInput } from './backend.ts'
|
|
3
|
+
import { errorResponse } from './errors.ts'
|
|
4
|
+
|
|
5
|
+
/** The clone route's body: a sample and a recorded consent statement, each at most this size. */
|
|
6
|
+
export const MAX_CLONE_AUDIO = 10 * 1024 * 1024
|
|
7
|
+
|
|
8
|
+
const field = (form: FormData, name: string) => {
|
|
9
|
+
const value = form.get(name)
|
|
10
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `clone` (POST, multipart): `voice`, `speaker`, `scope`, `confirmed=1` and the files `sample` and `statement`;
|
|
15
|
+
* optionally `engine`, `lang` and `text`. The consent is registered first, then the provider trains, which costs
|
|
16
|
+
* money, hence the explicit confirmation. `clone/status` (GET `?engine=&voice=`): the training's state.
|
|
17
|
+
*/
|
|
18
|
+
export async function cloneRoute(request: Request, rest: string, speech: { cloneVoice(input: CloneInput, signal?: AbortSignal): Promise<unknown>, cloneStatus(engine: string | undefined, voice: string, signal?: AbortSignal): Promise<unknown> }): Promise<Response> {
|
|
19
|
+
try {
|
|
20
|
+
if (rest === 'status') {
|
|
21
|
+
if (request.method !== 'GET') return problemResponse(405, 'GET clone/status?engine=&voice=')
|
|
22
|
+
const query = new URL(request.url).searchParams
|
|
23
|
+
const voice = query.get('voice')?.trim()
|
|
24
|
+
if (!voice) return problemResponse(400, 'clone/status needs ?voice=')
|
|
25
|
+
return jsonResponse(await speech.cloneStatus(query.get('engine')?.trim() || undefined, voice, request.signal))
|
|
26
|
+
}
|
|
27
|
+
if (rest) return problemResponse(404, 'the routes are clone and clone/status')
|
|
28
|
+
if (request.method !== 'POST') return problemResponse(405, 'POST multipart form data to clone')
|
|
29
|
+
const form = await request.formData().catch(() => undefined)
|
|
30
|
+
if (!form) return problemResponse(400, 'clone takes multipart form data')
|
|
31
|
+
if (form.get('confirmed') !== '1') return problemResponse(409, 'training a cloned voice is paid for at the provider: send confirmed=1 once the owner has confirmed it')
|
|
32
|
+
const voice = field(form, 'voice'), speaker = field(form, 'speaker'), scope = field(form, 'scope')
|
|
33
|
+
const sample = form.get('sample'), statement = form.get('statement')
|
|
34
|
+
if (!voice || !speaker || !scope) return problemResponse(400, 'clone needs voice, speaker and scope')
|
|
35
|
+
if (!(sample instanceof Blob) || !(statement instanceof Blob)) return problemResponse(400, 'clone needs the files sample (the voice to learn) and statement (the speaker reading the consent statement)')
|
|
36
|
+
if (sample.size > MAX_CLONE_AUDIO || statement.size > MAX_CLONE_AUDIO) return problemResponse(413, 'each recording is at most 10 MB')
|
|
37
|
+
const bytes = async (blob: Blob) => new Uint8Array(await blob.arrayBuffer())
|
|
38
|
+
const engine = field(form, 'engine'), lang = field(form, 'lang'), text = field(form, 'text')
|
|
39
|
+
return jsonResponse(await speech.cloneVoice({
|
|
40
|
+
...engine ? { engine } : {},
|
|
41
|
+
voice,
|
|
42
|
+
sample: await bytes(sample),
|
|
43
|
+
sampleMime: sample.type,
|
|
44
|
+
...lang ? { lang } : {},
|
|
45
|
+
...text ? { text } : {},
|
|
46
|
+
consent: { speaker, scope, statement: await bytes(statement), statementMime: statement.type },
|
|
47
|
+
}, request.signal))
|
|
48
|
+
} catch (error) {
|
|
49
|
+
return errorResponse(error)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import type { IRPass } from '@lincyaw/kernel'
|
|
3
|
+
import { deckSpeechConfig, SPEECH_ID } from '../domain/index.ts'
|
|
4
|
+
|
|
5
|
+
function configLine(file: string): number {
|
|
6
|
+
try {
|
|
7
|
+
const index = readFileSync(file, 'utf8').split('\n').findIndex(line => /presmium\.speech\b/.test(line))
|
|
8
|
+
return index >= 0 ? index + 1 : 1
|
|
9
|
+
} catch {
|
|
10
|
+
return 1
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const speechConfigPass: IRPass = {
|
|
15
|
+
name: 'presmium.speech.config',
|
|
16
|
+
run(doc, ctx) {
|
|
17
|
+
const { problems } = deckSpeechConfig(ctx.config)
|
|
18
|
+
if (problems.length === 0) return
|
|
19
|
+
const source = { file: doc.meta.entry, line: configLine(doc.meta.entry) }
|
|
20
|
+
for (const { severity, message } of problems) ctx.report({ checkId: SPEECH_ID, severity, message, source })
|
|
21
|
+
},
|
|
22
|
+
}
|