@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.
@@ -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
+ }
@@ -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
+ }