@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,255 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import type { Logger, SpeechWord, Transcript, TranscriptSegment, VoiceCloneState, VoiceInfo } from '@lincyaw/kernel'
3
+ import { isRecord } from '@lincyaw/kernel'
4
+ import type { EngineDefinition } from '../../domain/index.ts'
5
+ import { audioMime } from '../audio/label.ts'
6
+ import { SpeechError } from '../errors.ts'
7
+ import type { EngineAdapter, EngineCloneRequest, EngineCloneStatus, EngineSynthesisRequest, EngineSynthesisResult, EngineTranscriptionRequest } from './types.ts'
8
+ import { speaks } from './types.ts'
9
+ import { volcengineConsole } from './volcengine-console.ts'
10
+ import { VOLCENGINE_VOICES } from './volcengine-voices.ts'
11
+
12
+ // Volcengine speech (豆包语音) over its HTTP API with the operator's API key: synthesis with the 2.0 model
13
+ // (seed-tts-2.0; cloned voices on seed-icl-2.0), the flash recognizer, and voice cloning 2.0 (voice_clone,
14
+ // get_voice). No SDK covers these; the account side (the voice list, the account's cloned voices) goes through the
15
+ // official SDK in volcengine-console.ts when the operator also gives an IAM access key pair.
16
+
17
+ const TTS = '/api/v3/tts/unidirectional'
18
+ const ASR = '/api/v3/auc/bigmodel/recognize/flash'
19
+ const CLONE = '/api/v3/tts/voice_clone'
20
+ const VOICE = '/api/v3/tts/get_voice'
21
+ const OK = 20000000
22
+ const SILENT = '20000003'
23
+ const TTS_MODEL = 'seed-tts-2.0'
24
+ const CLONE_RESOURCE = 'seed-icl-2.0'
25
+
26
+ /**
27
+ * Official voice ids follow the provider's naming (zh_…_uranus_bigtts, ICL_uranus_…_tob, BV…); a prepaid cloned
28
+ * voice is an S_… slot, and a postpaid one has a name of the operator's that Volcengine forbids from looking like
29
+ * either (no xx_, ICL_, MIX_, DiT_ or BV prefix, no _bigtts, _tob or _streaming suffix).
30
+ */
31
+ const OFFICIAL = /^(ICL_|MIX_|DiT_|BV)|^[a-z]{2}_|_(bigtts|tob|streaming)$/
32
+ export function volcengineCloned(voice: string): boolean {
33
+ return voice.startsWith('S_') || !OFFICIAL.test(voice)
34
+ }
35
+
36
+ /** A postpaid voice's own name, as Volcengine accepts it. */
37
+ function customName(voice: string): boolean {
38
+ return /^[A-Za-z][A-Za-z0-9_-]{7,127}$/.test(voice) && !OFFICIAL.test(voice)
39
+ }
40
+
41
+ /**
42
+ * The language to fix, for text that is not Chinese. Chinese narration is left to the model, which reads the
43
+ * English words in it; fixing zh-cn risks dropping them, since text in another language than the fixed one is
44
+ * skipped.
45
+ */
46
+ function explicitLanguage(lang: string): string | undefined {
47
+ const [primary = '', region = ''] = lang.toLowerCase().split('-')
48
+ if (!primary || primary === 'zh') return undefined
49
+ if ((primary === 'pt' && region === 'br') || (primary === 'es' && region === 'mx')) return `${primary}-${region}`
50
+ return primary
51
+ }
52
+
53
+ /** voice_clone's language numbers. */
54
+ const CLONE_LANGUAGES: Record<string, number> = { zh: 0, en: 1, ja: 2, es: 3, id: 4, pt: 5, de: 6, fr: 7, ko: 8, it: 9, th: 10, vi: 11, ru: 12, fil: 13, ms: 14, ar: 15, pl: 19, tr: 20, sv: 21 }
55
+ function cloneLanguage(lang: string | undefined): number {
56
+ const [primary = 'zh', region = ''] = (lang ?? 'zh').toLowerCase().split('-')
57
+ if (primary === 'es' && region === 'mx') return 16
58
+ if (primary === 'pt' && region === 'br') return 17
59
+ return CLONE_LANGUAGES[primary] ?? 0
60
+ }
61
+
62
+ /** get_voice's states: 0 not found, 1 training, 2 trained, 3 failed, 4 activated (trained and in use). */
63
+ const STATES: VoiceCloneState[] = ['unknown', 'training', 'ready', 'failed', 'ready']
64
+
65
+ const FORMATS: Record<string, string> = { 'audio/wav': 'wav', 'audio/x-wav': 'wav', 'audio/mpeg': 'mp3', 'audio/mp3': 'mp3', 'audio/ogg': 'ogg', 'audio/mp4': 'm4a', 'audio/x-m4a': 'm4a', 'audio/aac': 'aac' }
66
+ const MAX_SAMPLE = 10 * 1024 * 1024
67
+
68
+ function secondsOf(value: unknown): number {
69
+ return typeof value === 'number' ? value / 1000 : 0
70
+ }
71
+
72
+ function wordsOf(value: unknown, scale: (v: unknown) => number, key: [string, string]): SpeechWord[] {
73
+ if (!Array.isArray(value)) return []
74
+ return value.flatMap((word) => {
75
+ if (!isRecord(word)) return []
76
+ const text = typeof word.word === 'string' ? word.word : typeof word.text === 'string' ? word.text : ''
77
+ return text ? [{ text, start: scale(word[key[0]]), end: scale(word[key[1]]) }] : []
78
+ })
79
+ }
80
+
81
+ /** Volcengine answers too many concurrent requests (the default is 10 per model) with 45000000 and a quota message. */
82
+ function failure(what: string, status: number, body: string): SpeechError {
83
+ if (/quota exceeded/i.test(body) && /concurrency/i.test(body)) return new SpeechError(429, `${what} is busy: more requests at once than the account's concurrency quota; try again shortly`)
84
+ return new SpeechError(502, `${what} answered ${status}: ${body.slice(0, 300)}`)
85
+ }
86
+
87
+ export function volcengineEngine(engine: EngineDefinition, log?: Logger): EngineAdapter {
88
+ const auth = (appHeader: string): Record<string, string> => (engine.appid
89
+ ? { [appHeader]: engine.appid, 'X-Api-Access-Key': engine.key ?? '' }
90
+ : { 'X-Api-Key': engine.key ?? '' })
91
+ const what = 'Volcengine (火山引擎)'
92
+ const account = engine.console ? volcengineConsole(engine.console) : undefined
93
+
94
+ const catalog = async (signal?: AbortSignal): Promise<{ source: 'provider' | 'bundled', voices: VoiceInfo[] }> => {
95
+ if (!account) return { source: 'bundled', voices: VOLCENGINE_VOICES }
96
+ try {
97
+ const [speakers, cloned] = await Promise.all([account.speakers(TTS_MODEL, signal), account.cloned(signal)])
98
+ const official = new Set(speakers.map(voice => voice.id))
99
+ return { source: 'provider', voices: [...speakers, ...cloned.filter(voice => !official.has(voice.id))] }
100
+ } catch (error) {
101
+ signal?.throwIfAborted()
102
+ // The account's list is a convenience: synthesis does not depend on it, so the bundled list stands in.
103
+ log?.warn(`${what}: the account's voice list is unavailable, so the bundled one is shown: ${error instanceof Error ? error.message : String(error)}`)
104
+ return { source: 'bundled', voices: VOLCENGINE_VOICES }
105
+ }
106
+ }
107
+
108
+ const cloneStatusOf = (answer: Record<string, unknown>): EngineCloneStatus => {
109
+ const state = typeof answer.status === 'number' ? STATES[answer.status] ?? 'unknown' : 'unknown'
110
+ return { state, ...typeof answer.available_training_times === 'number' ? { trainingsLeft: answer.available_training_times } : {} }
111
+ }
112
+ const cloneCall = async (path: string, body: Record<string, unknown>, signal?: AbortSignal): Promise<Record<string, unknown>> => {
113
+ const response = await fetch(`${engine.url}${path}`, {
114
+ method: 'POST',
115
+ headers: { ...auth('X-Api-App-Key'), 'X-Api-Request-Id': randomUUID(), 'Content-Type': 'application/json' },
116
+ body: JSON.stringify(body),
117
+ redirect: 'error',
118
+ signal,
119
+ })
120
+ const text = await response.text()
121
+ if (!response.ok) throw failure(what, response.status, text)
122
+ let answer: unknown
123
+ try { answer = JSON.parse(text) } catch { throw new SpeechError(502, `${what} answered with no JSON: ${text.slice(0, 200)}`) }
124
+ if (!isRecord(answer)) throw new SpeechError(502, `${what} answered with no object`)
125
+ const code = typeof answer.code === 'number' ? answer.code : 0
126
+ if (code !== 0 && code !== OK) throw new SpeechError(502, `${what}: ${code} ${String(answer.message ?? '')}`)
127
+ return answer
128
+ }
129
+ const speaker = (voice: string) => (voice.startsWith('S_') ? { speaker_id: voice } : { speaker_id: 'custom_speaker_id', custom_speaker_id: voice })
130
+
131
+ return {
132
+ async voices(lang, signal) {
133
+ return (await catalog(signal)).voices.filter(voice => speaks(voice, lang))
134
+ },
135
+ async voiceCatalog(lang, signal) {
136
+ const { source, voices } = await catalog(signal)
137
+ return { status: 'available', source, voices: voices.filter(voice => speaks(voice, lang)) }
138
+ },
139
+
140
+ cloned: volcengineCloned,
141
+
142
+ async synthesize(request: EngineSynthesisRequest, signal): Promise<EngineSynthesisResult> {
143
+ const cloned = volcengineCloned(request.voice)
144
+ const additions: Record<string, unknown> = {}
145
+ const language = explicitLanguage(request.lang)
146
+ if (language) additions.explicit_language = language
147
+ // A style is an instruction to the model (context_texts); cloned voices take the expressive model instead.
148
+ if (request.style && !cloned) additions.context_texts = [request.style]
149
+ const rate = request.rate ? Math.max(-50, Math.min(100, Math.round((request.rate - 1) * 100))) : 0
150
+ const response = await fetch(`${engine.url}${TTS}`, {
151
+ method: 'POST',
152
+ headers: {
153
+ ...auth('X-Api-App-Id'),
154
+ 'X-Api-Resource-Id': cloned ? CLONE_RESOURCE : engine.model,
155
+ 'X-Api-Request-Id': randomUUID(),
156
+ 'Content-Type': 'application/json',
157
+ },
158
+ body: JSON.stringify({
159
+ user: { uid: 'presmium' },
160
+ req_params: {
161
+ text: request.text,
162
+ speaker: request.voice,
163
+ ...cloned ? { model: request.style ? 'seed-tts-2.0-expressive' : 'seed-tts-2.0-standard' } : {},
164
+ // Ogg/Opus only at 48 kHz in the 2.0 API.
165
+ audio_params: { format: 'ogg_opus', sample_rate: 48000, enable_subtitle: true, speech_rate: rate },
166
+ additions: JSON.stringify(additions),
167
+ },
168
+ }),
169
+ redirect: 'error',
170
+ signal,
171
+ })
172
+ const text = await response.text()
173
+ if (!response.ok) throw failure(what, response.status, text)
174
+ const chunks: Buffer[] = []
175
+ const words: SpeechWord[] = []
176
+ // Word times arrive in their own lines, possibly after later audio: read every line before finishing.
177
+ for (const line of text.split(/\r?\n/)) {
178
+ if (!line.trim()) continue
179
+ let event: Record<string, unknown>
180
+ try {
181
+ event = JSON.parse(line) as Record<string, unknown>
182
+ } catch {
183
+ continue
184
+ }
185
+ if (event.code !== 0 && event.code !== OK) throw failure(what, response.status, `${String(event.code)} ${String(event.message ?? '')}`)
186
+ if (typeof event.data === 'string' && event.data) chunks.push(Buffer.from(event.data, 'base64'))
187
+ if (isRecord(event.sentence)) words.push(...wordsOf(event.sentence.words, v => (typeof v === 'number' ? v : 0), ['startTime', 'endTime']))
188
+ }
189
+ if (chunks.length === 0) throw new SpeechError(502, `${what} gave no audio`)
190
+ return { audio: new Uint8Array(Buffer.concat(chunks)), contentType: 'audio/ogg', words, ...words.length ? { duration: words.at(-1)!.end } : {} }
191
+ },
192
+
193
+ async transcribe(input: EngineTranscriptionRequest, signal): Promise<Transcript> {
194
+ const format = input.mime.includes('wav') ? { format: 'wav' } : input.mime.includes('ogg') ? { format: 'ogg', codec: 'opus' } : input.mime.includes('mpeg') || input.mime.includes('mp3') ? { format: 'mp3' } : undefined
195
+ if (!format) throw new SpeechError(400, `Volcengine's recognition takes WAV, MP3 or Ogg/Opus, not ${input.mime}`)
196
+ const language = input.lang && !input.lang.toLowerCase().startsWith('zh') ? { language: input.lang } : {}
197
+ const response = await fetch(`${engine.url}${ASR}`, {
198
+ method: 'POST',
199
+ headers: {
200
+ ...auth('X-Api-App-Key'),
201
+ 'X-Api-Resource-Id': engine.transcribeModel,
202
+ 'X-Api-Request-Id': randomUUID(),
203
+ 'X-Api-Sequence': '-1',
204
+ 'Content-Type': 'application/json',
205
+ },
206
+ body: JSON.stringify({
207
+ user: { uid: 'presmium' },
208
+ audio: { data: Buffer.from(input.audio).toString('base64'), ...format, ...language },
209
+ request: {
210
+ model_name: 'bigmodel',
211
+ enable_itn: true,
212
+ enable_punc: true,
213
+ show_utterances: true,
214
+ ...input.hints?.length ? { corpus: { context: JSON.stringify({ hotwords: input.hints.map(word => ({ word })) }) } } : {},
215
+ },
216
+ }),
217
+ redirect: 'error',
218
+ signal,
219
+ })
220
+ const status = response.headers.get('x-api-status-code')
221
+ const body = await response.text()
222
+ const lang = input.lang ?? 'zh-CN'
223
+ if (status === SILENT) return { lang, duration: 0, text: '', segments: [] }
224
+ if (!response.ok || (status && Number(status) !== OK)) throw new SpeechError(502, `${what} answered ${response.status} ${status ?? ''} ${response.headers.get('x-api-message') ?? ''}: ${body.slice(0, 300)}`)
225
+ const answer = JSON.parse(body) as { audio_info?: { duration?: number }, result?: { text?: string, utterances?: unknown[] } }
226
+ const segments: TranscriptSegment[] = (answer.result?.utterances ?? []).flatMap(u => (isRecord(u)
227
+ ? [{ start: secondsOf(u.start_time), end: secondsOf(u.end_time), text: typeof u.text === 'string' ? u.text : '', words: wordsOf(u.words, secondsOf, ['start_time', 'end_time']) }]
228
+ : []))
229
+ return { lang, duration: secondsOf(answer.audio_info?.duration) || (segments.at(-1)?.end ?? 0), text: answer.result?.text ?? segments.map(s => s.text).join(''), segments }
230
+ },
231
+
232
+ async clone(request: EngineCloneRequest, signal): Promise<EngineCloneStatus> {
233
+ if (!request.voice.startsWith('S_') && !customName(request.voice)) {
234
+ throw new SpeechError(400, `"${request.voice}" is neither a voice slot of the account (S_…) nor a name Volcengine accepts for a pay-as-you-go cloned voice: 8–128 letters, digits, "_" or "-", starting with a letter, not shaped like an official voice id`)
235
+ }
236
+ if (request.audio.byteLength > MAX_SAMPLE) throw new SpeechError(413, `the sample is ${Math.round(request.audio.byteLength / 1024 / 1024)} MB; Volcengine takes at most 10 MB`)
237
+ const format = FORMATS[audioMime(request.audio, request.mime).split(';')[0]!.trim()]
238
+ if (!format) throw new SpeechError(415, `Volcengine trains from WAV, MP3, Ogg, M4A or AAC, not ${request.mime || 'this file'}`)
239
+ const answer = await cloneCall(CLONE, {
240
+ ...speaker(request.voice),
241
+ audio: { data: Buffer.from(request.audio).toString('base64'), format },
242
+ ...request.text ? { text: request.text } : {},
243
+ language: cloneLanguage(request.lang),
244
+ extra_params: { enable_audio_denoise: true },
245
+ }, signal)
246
+ // The training answer may not carry a status yet: it has started.
247
+ const status = cloneStatusOf(answer)
248
+ return status.state === 'unknown' ? { ...status, state: 'training' } : status
249
+ },
250
+
251
+ async cloneStatus(voice, signal): Promise<EngineCloneStatus> {
252
+ return cloneStatusOf(await cloneCall(VOICE, speaker(voice), signal))
253
+ },
254
+ }
255
+ }
@@ -0,0 +1,29 @@
1
+ import { problemResponse } from '@lincyaw/kernel'
2
+
3
+ export class SpeechError extends Error {
4
+ readonly status: number
5
+
6
+ constructor(status: number, message: string) {
7
+ super(message)
8
+ this.name = 'SpeechError'
9
+ this.status = status
10
+ }
11
+ }
12
+
13
+ export function errorResponse(error: unknown): Response {
14
+ if (error instanceof SpeechError) return problemResponse(error.status, error.message)
15
+ if (error instanceof Error && error.name === 'AbortError') return problemResponse(499, 'aborted')
16
+ return problemResponse(502, error instanceof Error ? error.message : String(error))
17
+ }
18
+
19
+ export async function upstreamError(response: Response, what: string): Promise<SpeechError> {
20
+ const text = await response.text().catch(() => '')
21
+ let message = text.slice(0, 500)
22
+ try {
23
+ const body = JSON.parse(text) as { error?: unknown, message?: unknown }
24
+ const error = body.error
25
+ message = typeof error === 'string' ? error : typeof (error as { message?: unknown })?.message === 'string' ? (error as { message: string }).message : typeof body.message === 'string' ? body.message : message
26
+ } catch {
27
+ }
28
+ return new SpeechError(502, `${what} answered ${response.status}${message ? `: ${message}` : ''}`)
29
+ }
@@ -0,0 +1,151 @@
1
+ import { createServer } from 'node:http'
2
+ import type { AddressInfo } from 'node:net'
3
+ import type { SpeechWord, VoiceInfo } from '@lincyaw/kernel'
4
+ import { isCjk, isRecord, splitWords } from '@lincyaw/kernel'
5
+ import { bracketIPv6 } from '@lincyaw/kernel/node'
6
+ import { oneSegment } from '../domain/index.ts'
7
+ import { isWav, makeWav, wavChunks, wavDuration } from './audio/riff.ts'
8
+
9
+ const RATE = 8000
10
+ const CHUNK = 'pmfk'
11
+
12
+ function fakeWords(text: string): { words: SpeechWord[], duration: number } {
13
+ const words: SpeechWord[] = []
14
+ let ms = 0
15
+ for (const { text: word } of splitWords(text)) {
16
+ const length = isCjk(word) ? 220 : 60 * [...word].length
17
+ if (words.length) ms += 80
18
+ words.push({ text: word, start: ms / 1000, end: (ms + length) / 1000 })
19
+ ms += length
20
+ }
21
+ return { words, duration: ms / 1000 }
22
+ }
23
+
24
+ export const FAKE_VOICES: VoiceInfo[] = [
25
+ { id: 'alpha', label: '假音色甲 Alpha', lang: ['zh-CN', 'en-US'] },
26
+ { id: 'beta', label: '假音色乙 Beta', lang: ['zh-CN', 'en-US'] },
27
+ { id: 'cloned-lin', label: '林老师(克隆音色)', lang: ['zh-CN'], cloned: true },
28
+ ]
29
+
30
+ export interface FakeContent {
31
+ text: string
32
+ voice: string
33
+ lang: string
34
+ words: SpeechWord[]
35
+ }
36
+
37
+ export function fakeAudio(text: string, voice: string, lang: string): { audio: Uint8Array, words: SpeechWord[], duration: number } {
38
+ const { words, duration } = fakeWords(text)
39
+ const samples = new Int16Array(Math.round(duration * RATE))
40
+ for (const word of words) {
41
+ const frequency = 220 + (word.text.codePointAt(0)! % 64) * 8
42
+ for (let i = Math.round(word.start * RATE); i < Math.round(word.end * RATE); i++) samples[i] = Math.round(6000 * Math.sin((2 * Math.PI * frequency * i) / RATE))
43
+ }
44
+ const content: FakeContent = { text, voice, lang, words }
45
+ return { audio: makeWav(samples, RATE, [{ id: CHUNK, data: new TextEncoder().encode(JSON.stringify(content)) }]), words, duration }
46
+ }
47
+
48
+ export function fakeContent(bytes: Uint8Array): FakeContent | undefined {
49
+ if (!isWav(bytes)) return undefined
50
+ const chunk = wavChunks(bytes).find(c => c.id === CHUNK)
51
+ if (!chunk) return undefined
52
+ try {
53
+ return JSON.parse(new TextDecoder().decode(chunk.data)) as FakeContent
54
+ } catch {
55
+ return undefined
56
+ }
57
+ }
58
+
59
+ export interface FakeEngine {
60
+ url: string
61
+ close(): Promise<void>
62
+ }
63
+
64
+ interface Logged {
65
+ method: string
66
+ path: string
67
+ authorization: string | null
68
+ }
69
+
70
+ export async function startFakeEngine(options: { port?: number, listenAddress?: string } = {}): Promise<FakeEngine> {
71
+ const requests: Logged[] = []
72
+ // Voices trained through audio/voices/clone: training on the first status check, ready on the next.
73
+ const trained = new Map<string, { label: string, checks: number }>()
74
+ const voices = () => [...FAKE_VOICES, ...[...trained].filter(([, voice]) => voice.checks > 1).map(([id, voice]): VoiceInfo => ({ id, label: voice.label, lang: ['zh-CN'], cloned: true }))]
75
+ const answer = (status: number, body: unknown, type = 'application/json; charset=utf-8') => new Response(body instanceof Uint8Array ? new Blob([new Uint8Array(body)]) : JSON.stringify(body), { status, headers: { 'Content-Type': type } })
76
+
77
+ const route = async (request: Request, path: string): Promise<Response> => {
78
+ if (path === '/v1/audio/voices' && request.method === 'GET') return answer(200, { voices: voices() })
79
+ if (path === '/v1/audio/voices/clone' && request.method === 'POST') {
80
+ const form = await request.formData().catch(() => undefined)
81
+ const file = form?.get('file'), voice = form?.get('voice')
82
+ if (!(file instanceof Blob) || typeof voice !== 'string' || !voice) return answer(400, { error: { message: 'multipart form data with `file` and `voice` expected' } })
83
+ if (file.size < 1000) return answer(400, { error: { message: 'the sample is too short to train from' } })
84
+ trained.set(voice, { label: `${voice}(克隆音色)`, checks: 0 })
85
+ return answer(200, { voice, state: 'training', trainingsLeft: 9 })
86
+ }
87
+ if (path === '/v1/audio/voices/clone' && request.method === 'GET') {
88
+ const voice = new URL(request.url).searchParams.get('voice') ?? ''
89
+ const found = trained.get(voice)
90
+ if (!found) return answer(200, { voice, state: 'unknown' })
91
+ found.checks++
92
+ return answer(200, { voice, state: found.checks > 1 ? 'ready' : 'training', trainingsLeft: 9 })
93
+ }
94
+ if ((path === '/v1/audio/speech' || path === '/v1/audio/speech/timestamps') && request.method === 'POST') {
95
+ const body: unknown = await request.json().catch(() => undefined)
96
+ if (!isRecord(body) || typeof body.input !== 'string' || typeof body.voice !== 'string') return answer(400, { error: { message: '{ model, voice, input } expected' } })
97
+ if (!voices().some(voice => voice.id === body.voice)) return answer(400, { error: { message: `no voice "${body.voice}"; the fake engine has ${voices().map(v => v.id).join(', ')}` } })
98
+ const { audio, words, duration } = fakeAudio(body.input, body.voice, typeof body.language === 'string' ? body.language : 'zh-CN')
99
+ if (path === '/v1/audio/speech') return answer(200, audio, 'audio/wav')
100
+ return answer(200, { audio: Buffer.from(audio).toString('base64'), format: 'wav', duration, words })
101
+ }
102
+ if (path === '/v1/audio/transcriptions' && request.method === 'POST') {
103
+ const form = await request.formData().catch(() => undefined)
104
+ const file = form?.get('file')
105
+ if (!(file instanceof Blob)) return answer(400, { error: { message: 'multipart form data with `file` expected' } })
106
+ const bytes = new Uint8Array(await file.arrayBuffer())
107
+ const content = fakeContent(bytes)
108
+ const language = typeof form?.get('language') === 'string' ? form.get('language') as string : undefined
109
+ const duration = isWav(bytes) ? wavDuration(bytes) : 0
110
+ const transcript = oneSegment(content?.text ?? '', content?.words ?? [], language ?? content?.lang ?? 'zh-CN', duration)
111
+ return answer(200, { ...transcript, language: transcript.lang, words: content?.words ?? [] })
112
+ }
113
+ if (path === '/v1/models' && request.method === 'GET') return answer(200, { object: 'list', data: [{ id: 'fake-tts', object: 'model' }, { id: 'fake-stt', object: 'model' }] })
114
+ return answer(404, { error: { message: `no route ${request.method} ${path}` } })
115
+ }
116
+
117
+ const server = createServer((req, res) => {
118
+ const chunks: Buffer[] = []
119
+ req.on('data', (chunk: Buffer) => chunks.push(chunk))
120
+ req.on('end', () => {
121
+ const path = (req.url ?? '/').split('?')[0]!
122
+ const send = (response: Response) => response.arrayBuffer().then((body) => {
123
+ res.writeHead(response.status, Object.fromEntries(response.headers))
124
+ res.end(Buffer.from(body))
125
+ })
126
+ if (path === '/requests' && req.method === 'GET') {
127
+ void send(answer(200, { requests }))
128
+ return
129
+ }
130
+ requests.push({ method: req.method ?? 'GET', path, authorization: req.headers.authorization ?? null })
131
+ const headers = new Headers()
132
+ for (const [name, value] of Object.entries(req.headers)) if (typeof value === 'string') headers.set(name, value)
133
+ const method = req.method ?? 'GET'
134
+ const request = new Request(`http://fake${req.url ?? '/'}`, { method, headers, ...method === 'GET' || method === 'HEAD' ? {} : { body: Buffer.concat(chunks) } })
135
+ void route(request, path).catch((error: unknown) => answer(500, { error: { message: String(error) } })).then(send)
136
+ })
137
+ })
138
+ await new Promise<void>((resolve, reject) => {
139
+ server.once('error', reject)
140
+ server.listen(options.port ?? 0, options.listenAddress ?? '127.0.0.1', () => resolve())
141
+ })
142
+ const { port } = server.address() as AddressInfo
143
+ const host = options.listenAddress && options.listenAddress !== '0.0.0.0' ? options.listenAddress : '127.0.0.1'
144
+ return {
145
+ url: `http://${bracketIPv6(host)}:${port}/v1`,
146
+ close: () => new Promise((resolve) => {
147
+ server.close(() => resolve())
148
+ server.closeAllConnections()
149
+ }),
150
+ }
151
+ }
@@ -0,0 +1,63 @@
1
+ import { speechEngineId } from '@lincyaw/kernel'
2
+ import type { NodeServiceContext, SpeechService, Synthesis } from '@lincyaw/kernel'
3
+ import { createDeckSpeech } from './cached-speech.ts'
4
+
5
+ export function nodeSpeech(ctx: NodeServiceContext): SpeechService {
6
+ const { speech } = createDeckSpeech(ctx)
7
+ const synthesisOf = async (hit: Awaited<ReturnType<typeof speech.cached>>, signal?: AbortSignal): Promise<Synthesis | undefined> => {
8
+ if (!hit) return undefined
9
+ const { record, file } = hit
10
+ await speech.consent(record.engine, record.voice, record.cloned === true, signal)
11
+ return {
12
+ audio: file, mime: record.mime, duration: record.duration,
13
+ ...record.words ? { words: record.words } : {},
14
+ key: record.key, engine: record.engine, model: record.model, voice: record.voice, provider: record.provider, synthetic: true,
15
+ }
16
+ }
17
+ return {
18
+ async engines() {
19
+ return (await speech.listEngines(ctx.lang)).engines.map(({ model: _model, transcribeModel: _transcribe, ...info }) => info)
20
+ },
21
+ async defaultSynthesisEngine() {
22
+ const selected = await speech.defaultSynthesisEngine()
23
+ return selected ? (await speech.listEngines()).engines.find(engine => engine.tts && engine.id === speechEngineId(selected))?.id : undefined
24
+ },
25
+ voices: (engine, lang) => speech.voices(engine, lang),
26
+ voiceCatalog: (engine, lang, signal) => speech.voiceCatalog(engine, lang, signal),
27
+ async synthesize(request, signal) {
28
+ const { result, file } = await speech.synthesizeThroughCache(request, signal)
29
+ return {
30
+ audio: file,
31
+ mime: result.mime,
32
+ duration: result.duration,
33
+ ...result.words ? { words: result.words } : {},
34
+ key: result.key,
35
+ engine: result.engine,
36
+ model: result.model,
37
+ voice: result.voice,
38
+ provider: result.provider,
39
+ synthetic: true,
40
+ }
41
+ },
42
+ cached: async (key, signal) => synthesisOf(await speech.cached(key), signal),
43
+ async lookup(request, signal) {
44
+ return synthesisOf(await speech.lookup(request), signal)
45
+ },
46
+ alignWords: (key, options, signal) => speech.alignWords(key, options, signal),
47
+ pruneLock: keep => speech.cache.pruneLock(keep),
48
+ transcribe: (request, signal) => speech.transcribe(request, signal),
49
+ async cloneVoice(request, signal) {
50
+ const bytes = async (blob: Blob) => new Uint8Array(await blob.arrayBuffer())
51
+ return speech.cloneVoice({
52
+ ...request.engine ? { engine: request.engine } : {},
53
+ voice: request.voice,
54
+ sample: await bytes(request.sample),
55
+ sampleMime: request.sample.type,
56
+ ...request.lang ? { lang: request.lang } : {},
57
+ ...request.text ? { text: request.text } : {},
58
+ consent: { speaker: request.consent.speaker, scope: request.consent.scope, statement: await bytes(request.consent.statement), statementMime: request.consent.statement.type },
59
+ }, signal)
60
+ },
61
+ cloneStatus: (engine, voice, signal) => speech.cloneStatus(engine, voice, signal),
62
+ }
63
+ }
@@ -0,0 +1,21 @@
1
+ import { createRequire } from 'node:module'
2
+ import { SPEECH } from '@lincyaw/kernel'
3
+ import type { PresmiumNodePlugin } from '@lincyaw/kernel'
4
+ import { SPEECH_ID } from '../domain/index.ts'
5
+ import { speechConfigPass } from './config-pass.ts'
6
+ import { nodeSpeech } from './node-service.ts'
7
+ import { speechServer } from './server.ts'
8
+
9
+ const { version } = createRequire(import.meta.url)('../../package.json') as { version: string }
10
+
11
+ const plugin: PresmiumNodePlugin = {
12
+ id: SPEECH_ID,
13
+ version,
14
+ kernel: '^1.0.0',
15
+ operator: true,
16
+ passes: [speechConfigPass],
17
+ services: ctx => [[SPEECH, nodeSpeech(ctx)]],
18
+ server: speechServer,
19
+ }
20
+
21
+ export default plugin
@@ -0,0 +1,67 @@
1
+ import { jsonResponse, problemResponse } from '@lincyaw/kernel'
2
+ import type { SynthesisResponseBody, TranscriptionResponseBody } from '../domain/index.ts'
3
+ import { toSynthesisRequest } from '../domain/index.ts'
4
+ import type { SpeechBackend, SynthesisResult } from './backend.ts'
5
+ import { blobOf } from './engines/index.ts'
6
+ import { errorResponse } from './errors.ts'
7
+
8
+ function synthesisResponseBody(result: SynthesisResult): SynthesisResponseBody {
9
+ return {
10
+ audio: Buffer.from(result.audio).toString('base64'),
11
+ format: result.ext,
12
+ mime: result.mime,
13
+ duration: result.duration,
14
+ ...result.words ? { words: result.words } : {},
15
+ engine: result.engine,
16
+ model: result.model,
17
+ voice: result.voice,
18
+ provider: result.provider,
19
+ cloned: result.cloned,
20
+ key: result.key,
21
+ synthetic: true,
22
+ AIGC: result.AIGC,
23
+ }
24
+ }
25
+
26
+ const text = (value: FormDataEntryValue | null | undefined) => (typeof value === 'string' && value.trim() ? value.trim() : undefined)
27
+
28
+ export function protocolHandler(backend: SpeechBackend): (request: Request, path: string) => Promise<Response> {
29
+ return async (request, path) => {
30
+ const method = request.method.toUpperCase()
31
+ try {
32
+ if (path === 'engines' && method === 'GET') return jsonResponse(await backend.listEngines(request.headers.get('accept-language') ?? undefined))
33
+ if (path === 'audio/consent' && method === 'GET') {
34
+ const query = new URL(request.url).searchParams
35
+ await backend.consent(query.get('engine') ?? '', query.get('voice') ?? '', query.get('recorded') !== '0', request.signal)
36
+ return jsonResponse({ ok: true })
37
+ }
38
+ if (path === 'audio/voice-catalog' && method === 'GET') {
39
+ const query = new URL(request.url).searchParams
40
+ return jsonResponse(await backend.voiceCatalog?.(query.get('engine') || undefined, query.get('lang') || undefined, request.signal) ?? { status: 'unavailable', reason: 'unknown', voices: [] })
41
+ }
42
+ if (path === 'audio/voices' && method === 'GET') {
43
+ const query = new URL(request.url).searchParams
44
+ return jsonResponse({ voices: await backend.voices(query.get('engine') || undefined, query.get('lang') || undefined) })
45
+ }
46
+ if ((path === 'audio/speech' || path === 'audio/speech/timestamps') && method === 'POST') {
47
+ const parsed = toSynthesisRequest(await request.json().catch(() => undefined))
48
+ if (typeof parsed === 'string') return problemResponse(400, parsed)
49
+ const result = await backend.synthesize(parsed, request.signal)
50
+ if (path === 'audio/speech/timestamps') return jsonResponse(synthesisResponseBody(result))
51
+ return new Response(blobOf(result.audio, result.mime), { headers: { 'Content-Type': result.mime, 'Cache-Control': 'no-store', 'X-Presmium-Key': result.key } })
52
+ }
53
+ if (path === 'audio/transcriptions' && method === 'POST') {
54
+ const form = await request.formData().catch(() => undefined)
55
+ const file = form?.get('file')
56
+ if (!form || !(file instanceof Blob)) return problemResponse(400, 'multipart form data with the audio as `file`')
57
+ const hints = form.getAll('hints[]').flatMap(hint => text(hint) ?? [])
58
+ const transcript = await backend.transcribe({ audio: file, engine: text(form.get('engine')), lang: text(form.get('language')), ...hints.length ? { hints } : {} }, request.signal)
59
+ const body: TranscriptionResponseBody = { ...transcript, language: transcript.lang, words: transcript.segments.flatMap(segment => segment.words) }
60
+ return jsonResponse(body)
61
+ }
62
+ return problemResponse(404, `no speech route ${method} v1/${path}`)
63
+ } catch (error) {
64
+ return errorResponse(error)
65
+ }
66
+ }
67
+ }
@@ -0,0 +1,24 @@
1
+ import type { ServerContext, ServerContribution } from '@lincyaw/kernel'
2
+ import { CLONE_PATH, CONSENT_PATH, PROTOCOL_PATH } from '../domain/index.ts'
3
+ import { createDeckSpeech } from './cached-speech.ts'
4
+ import { cloneRoute, MAX_CLONE_AUDIO } from './clone-route.ts'
5
+ import { consentRoute } from './consent.ts'
6
+ import { reportEngines } from './core.ts'
7
+ import { protocolHandler } from './protocol-routes.ts'
8
+
9
+ const MAX_AUDIO = 32 * 1024 * 1024
10
+
11
+ export function speechServer(ctx: ServerContext): ServerContribution {
12
+ const { core, speech } = createDeckSpeech(ctx)
13
+ void reportEngines(core, ctx.log)
14
+ const handle = protocolHandler(speech)
15
+ return {
16
+ routes: {
17
+ [`${PROTOCOL_PATH}/*`]: { role: 'editor', maxBody: MAX_AUDIO, handle: (request, route) => handle(request, route.rest) },
18
+ [`${CONSENT_PATH}/*`]: { role: 'owner', handle: (request, route) => consentRoute(request, route.rest, (engine, voice, record) => core.registerConsent(engine, voice, record)) },
19
+ // Training registers the speaker's consent and spends money: the owner's alone, like consent.
20
+ [CLONE_PATH]: { role: 'owner', maxBody: 2 * MAX_CLONE_AUDIO + 65536, handle: request => cloneRoute(request, '', speech) },
21
+ [`${CLONE_PATH}/*`]: { role: 'owner', handle: (request, route) => cloneRoute(request, route.rest, speech) },
22
+ },
23
+ }
24
+ }