@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,29 @@
|
|
|
1
|
+
import type { WordSpan } from '@lincyaw/kernel'
|
|
2
|
+
|
|
3
|
+
export interface LexiconText {
|
|
4
|
+
text: string
|
|
5
|
+
spans: WordSpan[]
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function applyLexiconWithSpans(text: string, lexicon: Record<string, string> | undefined): LexiconText {
|
|
9
|
+
const entries = Object.entries(lexicon ?? {}).filter(([written]) => written.length > 0).sort((a, b) => b[0].length - a[0].length)
|
|
10
|
+
if (entries.length === 0) return { text, spans: [] }
|
|
11
|
+
const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
12
|
+
const pattern = new RegExp(entries.map(([written]) => escape(written)).join('|'), 'g')
|
|
13
|
+
const spokenForm = new Map(entries)
|
|
14
|
+
const spans: LexiconText['spans'] = []
|
|
15
|
+
let spoken = ''
|
|
16
|
+
let last = 0
|
|
17
|
+
for (const match of text.matchAll(pattern)) {
|
|
18
|
+
const replacement = spokenForm.get(match[0]) ?? match[0]
|
|
19
|
+
spoken += text.slice(last, match.index)
|
|
20
|
+
spans.push({ from: spoken.length, to: spoken.length + replacement.length, start: match.index, end: match.index + match[0].length })
|
|
21
|
+
spoken += replacement
|
|
22
|
+
last = match.index + match[0].length
|
|
23
|
+
}
|
|
24
|
+
return { text: spoken + text.slice(last), spans }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function applyLexicon(text: string, lexicon: Record<string, string> | undefined): string {
|
|
28
|
+
return applyLexiconWithSpans(text, lexicon).text
|
|
29
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { isRecord } from '@lincyaw/kernel'
|
|
2
|
+
import type { AigcLabel, SpeechEngineInfo, SpeechWord, SynthesisRequest, Transcript, VoiceInfo } from '@lincyaw/kernel'
|
|
3
|
+
import type { UnavailableEngine } from './engines.ts'
|
|
4
|
+
|
|
5
|
+
export interface ListedEngine extends SpeechEngineInfo {
|
|
6
|
+
model: string
|
|
7
|
+
transcribeModel: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface EnginesResponseBody {
|
|
11
|
+
engines: ListedEngine[]
|
|
12
|
+
unavailable: UnavailableEngine[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface VoicesResponseBody {
|
|
16
|
+
voices: VoiceInfo[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SynthesisRequestBody {
|
|
20
|
+
engine?: string
|
|
21
|
+
voice: string
|
|
22
|
+
input: string
|
|
23
|
+
lang?: string
|
|
24
|
+
speed?: number
|
|
25
|
+
style?: string
|
|
26
|
+
lexicon?: Record<string, string>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SynthesisResponseBody {
|
|
30
|
+
audio: string
|
|
31
|
+
format: string
|
|
32
|
+
mime: string
|
|
33
|
+
duration: number
|
|
34
|
+
words?: SpeechWord[]
|
|
35
|
+
engine: string
|
|
36
|
+
model: string
|
|
37
|
+
voice: string
|
|
38
|
+
provider: string
|
|
39
|
+
cloned: boolean
|
|
40
|
+
key: string
|
|
41
|
+
synthetic: true
|
|
42
|
+
AIGC: AigcLabel
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface TranscriptionResponseBody extends Transcript {
|
|
46
|
+
language: string
|
|
47
|
+
words: SpeechWord[]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function synthesisRequestBody(request: SynthesisRequest): SynthesisRequestBody {
|
|
51
|
+
return {
|
|
52
|
+
...request.engine ? { engine: request.engine } : {},
|
|
53
|
+
voice: request.voice,
|
|
54
|
+
input: request.text,
|
|
55
|
+
lang: request.lang,
|
|
56
|
+
...request.rate !== undefined ? { speed: request.rate } : {},
|
|
57
|
+
...request.style ? { style: request.style } : {},
|
|
58
|
+
...request.lexicon ? { lexicon: request.lexicon } : {},
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function toSynthesisRequest(body: unknown): SynthesisRequest | string {
|
|
63
|
+
if (!isRecord(body)) return 'a JSON object { voice, input, lang?, engine?, speed?, style?, lexicon? }'
|
|
64
|
+
const text = typeof body.input === 'string' ? body.input : typeof body.text === 'string' ? body.text : undefined
|
|
65
|
+
if (typeof body.voice !== 'string' || !body.voice) return 'voice is needed'
|
|
66
|
+
if (!text?.trim()) return 'input (the text to speak) is needed'
|
|
67
|
+
const lexicon = isRecord(body.lexicon) ? Object.fromEntries(Object.entries(body.lexicon).filter((entry): entry is [string, string] => typeof entry[1] === 'string')) : undefined
|
|
68
|
+
return {
|
|
69
|
+
...typeof body.engine === 'string' && body.engine ? { engine: body.engine } : {},
|
|
70
|
+
voice: body.voice,
|
|
71
|
+
text,
|
|
72
|
+
lang: typeof body.lang === 'string' && body.lang ? body.lang : 'zh-CN',
|
|
73
|
+
...typeof body.speed === 'number' && body.speed > 0 ? { rate: body.speed } : {},
|
|
74
|
+
...typeof body.style === 'string' && body.style ? { style: body.style } : {},
|
|
75
|
+
...lexicon && Object.keys(lexicon).length ? { lexicon } : {},
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function transcriptOf(body: TranscriptionResponseBody): Transcript {
|
|
80
|
+
return { lang: body.lang ?? body.language, duration: body.duration, text: body.text, segments: body.segments }
|
|
81
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { SpeechWord, Transcript, TranscriptSegment } from '@lincyaw/kernel'
|
|
2
|
+
|
|
3
|
+
export function oneSegment(text: string, words: SpeechWord[], lang: string, duration: number): Transcript {
|
|
4
|
+
const segment: TranscriptSegment = { start: words[0]?.start ?? 0, end: words.at(-1)?.end ?? 0, text, words }
|
|
5
|
+
return { lang, duration, text, segments: text || words.length ? [segment] : [] }
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function wordsIntoSegments(segments: { start: number, end: number, text: string }[], words: SpeechWord[]): TranscriptSegment[] {
|
|
9
|
+
return segments.map((segment, index) => {
|
|
10
|
+
const last = index === segments.length - 1
|
|
11
|
+
const inside = words.filter((word) => {
|
|
12
|
+
const middle = (word.start + word.end) / 2
|
|
13
|
+
return middle >= segment.start && (middle < segment.end || (last && middle <= segment.end))
|
|
14
|
+
})
|
|
15
|
+
return { start: segment.start, end: segment.end, text: segment.text.trim(), words: inside }
|
|
16
|
+
})
|
|
17
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { aigcString } from '@lincyaw/kernel'
|
|
2
|
+
import type { AigcLabel } from '@lincyaw/kernel'
|
|
3
|
+
import { SpeechError } from '../errors.ts'
|
|
4
|
+
import { isOgg, oggOpusDuration, withOpusComment } from './ogg.ts'
|
|
5
|
+
import { isWav, wavDuration, withInfo } from './riff.ts'
|
|
6
|
+
|
|
7
|
+
export interface LabeledAudio {
|
|
8
|
+
audio: Uint8Array
|
|
9
|
+
mime: string
|
|
10
|
+
ext: string
|
|
11
|
+
duration: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function labelAudio(bytes: Uint8Array, label: AigcLabel): LabeledAudio {
|
|
15
|
+
if (isWav(bytes)) {
|
|
16
|
+
return { audio: withInfo(bytes, [['AIGC', JSON.stringify(label)], ['ICMT', aigcString(label)]]), mime: 'audio/wav', ext: 'wav', duration: wavDuration(bytes) }
|
|
17
|
+
}
|
|
18
|
+
if (isOgg(bytes)) {
|
|
19
|
+
try {
|
|
20
|
+
return { audio: withOpusComment(bytes, aigcString(label)), mime: 'audio/ogg', ext: 'ogg', duration: oggOpusDuration(bytes) }
|
|
21
|
+
} catch (error) {
|
|
22
|
+
throw new SpeechError(502, `the engine's Ogg audio cannot carry the AI-generated label: ${error instanceof Error ? error.message : String(error)}`)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
throw new SpeechError(502, 'the engine returned audio that is neither WAV nor Ogg/Opus, so the AI-generated label cannot be written into it')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function audioMime(bytes: Uint8Array, given?: string): string {
|
|
29
|
+
if (isWav(bytes)) return 'audio/wav'
|
|
30
|
+
if (isOgg(bytes)) return 'audio/ogg'
|
|
31
|
+
return given?.split(';')[0]?.trim() || 'application/octet-stream'
|
|
32
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
interface Page {
|
|
2
|
+
flags: number
|
|
3
|
+
granule: bigint
|
|
4
|
+
serial: number
|
|
5
|
+
sequence: number
|
|
6
|
+
segments: number[]
|
|
7
|
+
body: Uint8Array
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const encoder = new TextEncoder()
|
|
11
|
+
const decoder = new TextDecoder()
|
|
12
|
+
|
|
13
|
+
const CRC = (() => {
|
|
14
|
+
const table = new Uint32Array(256)
|
|
15
|
+
for (let i = 0; i < 256; i++) {
|
|
16
|
+
let r = i << 24
|
|
17
|
+
for (let j = 0; j < 8; j++) r = r & 0x80000000 ? (r << 1) ^ 0x04c11db7 : r << 1
|
|
18
|
+
table[i] = r >>> 0
|
|
19
|
+
}
|
|
20
|
+
return table
|
|
21
|
+
})()
|
|
22
|
+
|
|
23
|
+
function crc(bytes: Uint8Array): number {
|
|
24
|
+
let value = 0
|
|
25
|
+
for (const byte of bytes) value = ((value << 8) ^ CRC[((value >>> 24) ^ byte) & 0xff]!) >>> 0
|
|
26
|
+
return value
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function isOgg(bytes: Uint8Array): boolean {
|
|
30
|
+
return bytes.length >= 4 && String.fromCharCode(...bytes.subarray(0, 4)) === 'OggS'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function pages(bytes: Uint8Array): Page[] {
|
|
34
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
35
|
+
const out: Page[] = []
|
|
36
|
+
let at = 0
|
|
37
|
+
while (at + 27 <= bytes.length) {
|
|
38
|
+
if (String.fromCharCode(...bytes.subarray(at, at + 4)) !== 'OggS') throw new Error(`not an Ogg page at byte ${at}`)
|
|
39
|
+
const count = bytes[at + 26]!
|
|
40
|
+
const segments = [...bytes.subarray(at + 27, at + 27 + count)]
|
|
41
|
+
const length = segments.reduce((n, s) => n + s, 0)
|
|
42
|
+
const start = at + 27 + count
|
|
43
|
+
if (start + length > bytes.length) throw new Error('the last Ogg page is cut short')
|
|
44
|
+
out.push({ flags: bytes[at + 5]!, granule: view.getBigInt64(at + 6, true), serial: view.getUint32(at + 14, true), sequence: view.getUint32(at + 18, true), segments, body: bytes.subarray(start, start + length) })
|
|
45
|
+
at = start + length
|
|
46
|
+
}
|
|
47
|
+
return out
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function pageBytes(page: Page): Uint8Array {
|
|
51
|
+
const out = new Uint8Array(27 + page.segments.length + page.body.length)
|
|
52
|
+
const view = new DataView(out.buffer)
|
|
53
|
+
out.set(encoder.encode('OggS'), 0)
|
|
54
|
+
out[5] = page.flags
|
|
55
|
+
view.setBigInt64(6, page.granule, true)
|
|
56
|
+
view.setUint32(14, page.serial, true)
|
|
57
|
+
view.setUint32(18, page.sequence, true)
|
|
58
|
+
out[26] = page.segments.length
|
|
59
|
+
out.set(page.segments, 27)
|
|
60
|
+
out.set(page.body, 27 + page.segments.length)
|
|
61
|
+
view.setUint32(22, crc(out), true)
|
|
62
|
+
return out
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function commentHeader(all: Page[]): { end: number, packet: Uint8Array } {
|
|
66
|
+
const parts: Uint8Array[] = []
|
|
67
|
+
for (let i = 1; i < all.length; i++) {
|
|
68
|
+
const page = all[i]!
|
|
69
|
+
if (page.serial !== all[0]!.serial) throw new Error('an Ogg file of several streams')
|
|
70
|
+
parts.push(page.body)
|
|
71
|
+
if (page.segments.at(-1)! < 255) {
|
|
72
|
+
const packet = new Uint8Array(parts.reduce((n, p) => n + p.length, 0))
|
|
73
|
+
let at = 0
|
|
74
|
+
for (const part of parts) {
|
|
75
|
+
packet.set(part, at)
|
|
76
|
+
at += part.length
|
|
77
|
+
}
|
|
78
|
+
if (String.fromCharCode(...packet.subarray(0, 8)) !== 'OpusTags') throw new Error('the second Ogg packet is not an Opus comment header')
|
|
79
|
+
return { end: i, packet }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
throw new Error('the Ogg file ends inside its comment header')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function parseTags(packet: Uint8Array): { vendor: Uint8Array, comments: Uint8Array[], rest: Uint8Array } {
|
|
86
|
+
const view = new DataView(packet.buffer, packet.byteOffset, packet.byteLength)
|
|
87
|
+
let at = 8
|
|
88
|
+
const vendorLength = view.getUint32(at, true)
|
|
89
|
+
const vendor = packet.subarray(at + 4, at + 4 + vendorLength)
|
|
90
|
+
at += 4 + vendorLength
|
|
91
|
+
const count = view.getUint32(at, true)
|
|
92
|
+
at += 4
|
|
93
|
+
const comments: Uint8Array[] = []
|
|
94
|
+
for (let i = 0; i < count; i++) {
|
|
95
|
+
const length = view.getUint32(at, true)
|
|
96
|
+
comments.push(packet.subarray(at + 4, at + 4 + length))
|
|
97
|
+
at += 4 + length
|
|
98
|
+
}
|
|
99
|
+
return { vendor, comments, rest: packet.subarray(at) }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function u32(value: number): Uint8Array {
|
|
103
|
+
const out = new Uint8Array(4)
|
|
104
|
+
new DataView(out.buffer).setUint32(0, value, true)
|
|
105
|
+
return out
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function opusComments(bytes: Uint8Array): string[] {
|
|
109
|
+
return parseTags(commentHeader(pages(bytes)).packet).comments.map(comment => decoder.decode(comment))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function withOpusComment(bytes: Uint8Array, comment: string): Uint8Array {
|
|
113
|
+
const all = pages(bytes)
|
|
114
|
+
const { end, packet } = commentHeader(all)
|
|
115
|
+
const tags = parseTags(packet)
|
|
116
|
+
const comments = [...tags.comments, encoder.encode(comment)]
|
|
117
|
+
const parts = [encoder.encode('OpusTags'), u32(tags.vendor.length), tags.vendor, u32(comments.length), ...comments.flatMap(c => [u32(c.length), c]), tags.rest]
|
|
118
|
+
const header = new Uint8Array(parts.reduce((n, p) => n + p.length, 0))
|
|
119
|
+
let at = 0
|
|
120
|
+
for (const part of parts) {
|
|
121
|
+
header.set(part, at)
|
|
122
|
+
at += part.length
|
|
123
|
+
}
|
|
124
|
+
const lacing = [...Array<number>(Math.floor(header.length / 255)).fill(255), header.length % 255]
|
|
125
|
+
const rebuilt: Page[] = []
|
|
126
|
+
let offset = 0
|
|
127
|
+
for (let i = 0; i < lacing.length; i += 255) {
|
|
128
|
+
const segments = lacing.slice(i, i + 255)
|
|
129
|
+
const length = segments.reduce((n, s) => n + s, 0)
|
|
130
|
+
const last = i + 255 >= lacing.length
|
|
131
|
+
rebuilt.push({ flags: i === 0 ? 0 : 1, granule: last ? 0n : -1n, serial: all[0]!.serial, sequence: 1 + rebuilt.length, segments, body: header.subarray(offset, offset + length) })
|
|
132
|
+
offset += length
|
|
133
|
+
}
|
|
134
|
+
const shift = rebuilt.length - end
|
|
135
|
+
const later = all.slice(end + 1).map(page => ({ ...page, sequence: page.sequence + shift }))
|
|
136
|
+
const out = [all[0]!, ...rebuilt, ...later].map(pageBytes)
|
|
137
|
+
const result = new Uint8Array(out.reduce((n, p) => n + p.length, 0))
|
|
138
|
+
at = 0
|
|
139
|
+
for (const page of out) {
|
|
140
|
+
result.set(page, at)
|
|
141
|
+
at += page.length
|
|
142
|
+
}
|
|
143
|
+
return result
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function oggOpusDuration(bytes: Uint8Array): number {
|
|
147
|
+
const all = pages(bytes)
|
|
148
|
+
const head = all[0]?.body
|
|
149
|
+
if (!head || String.fromCharCode(...head.subarray(0, 8)) !== 'OpusHead') return 0
|
|
150
|
+
const preSkip = new DataView(head.buffer, head.byteOffset, head.byteLength).getUint16(10, true)
|
|
151
|
+
const granule = all.filter(page => page.granule >= 0n).at(-1)?.granule ?? 0n
|
|
152
|
+
return Math.max(0, Number(granule) - preSkip) / 48000
|
|
153
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
export interface RiffChunk {
|
|
2
|
+
id: string
|
|
3
|
+
data: Uint8Array
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const encoder = new TextEncoder()
|
|
7
|
+
const decoder = new TextDecoder()
|
|
8
|
+
|
|
9
|
+
function ascii(bytes: Uint8Array, at: number, length: number): string {
|
|
10
|
+
return String.fromCharCode(...bytes.subarray(at, at + length))
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function u32(value: number): Uint8Array {
|
|
14
|
+
const out = new Uint8Array(4)
|
|
15
|
+
new DataView(out.buffer).setUint32(0, value, true)
|
|
16
|
+
return out
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function concat(parts: Uint8Array[]): Uint8Array {
|
|
20
|
+
const out = new Uint8Array(parts.reduce((n, part) => n + part.length, 0))
|
|
21
|
+
let at = 0
|
|
22
|
+
for (const part of parts) {
|
|
23
|
+
out.set(part, at)
|
|
24
|
+
at += part.length
|
|
25
|
+
}
|
|
26
|
+
return out
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function chunkBytes(id: string, data: Uint8Array): Uint8Array {
|
|
30
|
+
return concat([encoder.encode(id.padEnd(4, ' ').slice(0, 4)), u32(data.length), data, data.length % 2 ? new Uint8Array(1) : new Uint8Array(0)])
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isWav(bytes: Uint8Array): boolean {
|
|
34
|
+
return bytes.length >= 12 && ascii(bytes, 0, 4) === 'RIFF' && ascii(bytes, 8, 4) === 'WAVE'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function wavChunks(bytes: Uint8Array): RiffChunk[] {
|
|
38
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
39
|
+
const chunks: RiffChunk[] = []
|
|
40
|
+
let at = 12
|
|
41
|
+
while (at + 8 <= bytes.length) {
|
|
42
|
+
const size = Math.min(view.getUint32(at + 4, true), bytes.length - at - 8)
|
|
43
|
+
chunks.push({ id: ascii(bytes, at, 4), data: bytes.subarray(at + 8, at + 8 + size) })
|
|
44
|
+
at += 8 + size + (size % 2)
|
|
45
|
+
}
|
|
46
|
+
return chunks
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function riff(chunks: RiffChunk[]): Uint8Array {
|
|
50
|
+
const body = concat([encoder.encode('WAVE'), ...chunks.map(chunk => chunkBytes(chunk.id, chunk.data))])
|
|
51
|
+
return concat([encoder.encode('RIFF'), u32(body.length), body])
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function withInfo(bytes: Uint8Array, entries: [string, string][]): Uint8Array {
|
|
55
|
+
const info = concat([encoder.encode('INFO'), ...entries.map(([id, value]) => chunkBytes(id, encoder.encode(`${value}\0`)))])
|
|
56
|
+
return riff([...wavChunks(bytes), { id: 'LIST', data: info }])
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function infoEntries(bytes: Uint8Array): Record<string, string> {
|
|
60
|
+
const entries: Record<string, string> = {}
|
|
61
|
+
for (const chunk of wavChunks(bytes)) {
|
|
62
|
+
if (chunk.id !== 'LIST' || ascii(chunk.data, 0, 4) !== 'INFO') continue
|
|
63
|
+
const view = new DataView(chunk.data.buffer, chunk.data.byteOffset, chunk.data.byteLength)
|
|
64
|
+
let at = 4
|
|
65
|
+
while (at + 8 <= chunk.data.length) {
|
|
66
|
+
const size = Math.min(view.getUint32(at + 4, true), chunk.data.length - at - 8)
|
|
67
|
+
entries[ascii(chunk.data, at, 4)] = decoder.decode(chunk.data.subarray(at + 8, at + 8 + size)).replace(/\0+$/, '')
|
|
68
|
+
at += 8 + size + (size % 2)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return entries
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function wavDuration(bytes: Uint8Array): number {
|
|
75
|
+
const chunks = wavChunks(bytes)
|
|
76
|
+
const fmt = chunks.find(chunk => chunk.id === 'fmt ')
|
|
77
|
+
const data = chunks.find(chunk => chunk.id === 'data')
|
|
78
|
+
if (!fmt || !data || fmt.data.length < 12) return 0
|
|
79
|
+
const byteRate = new DataView(fmt.data.buffer, fmt.data.byteOffset, fmt.data.byteLength).getUint32(8, true)
|
|
80
|
+
return byteRate ? data.data.length / byteRate : 0
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function makeWav(samples: Int16Array, rate: number, extra: RiffChunk[] = []): Uint8Array {
|
|
84
|
+
const fmt = new Uint8Array(16)
|
|
85
|
+
const view = new DataView(fmt.buffer)
|
|
86
|
+
view.setUint16(0, 1, true)
|
|
87
|
+
view.setUint16(2, 1, true)
|
|
88
|
+
view.setUint32(4, rate, true)
|
|
89
|
+
view.setUint32(8, rate * 2, true)
|
|
90
|
+
view.setUint16(12, 2, true)
|
|
91
|
+
view.setUint16(14, 16, true)
|
|
92
|
+
const data = new Uint8Array(samples.length * 2)
|
|
93
|
+
const pcm = new DataView(data.buffer)
|
|
94
|
+
samples.forEach((sample, i) => pcm.setInt16(i * 2, sample, true))
|
|
95
|
+
return riff([{ id: 'fmt ', data: fmt }, ...extra, { id: 'data', data }])
|
|
96
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { AigcLabel, SpeechWord, SynthesisRequest, Transcript, TranscriptionRequest, VoiceCatalog, VoiceCloneStatus, VoiceInfo } from '@lincyaw/kernel'
|
|
2
|
+
import type { EnginesResponseBody } from '../domain/index.ts'
|
|
3
|
+
|
|
4
|
+
export interface SynthesisResult {
|
|
5
|
+
audio: Uint8Array
|
|
6
|
+
mime: string
|
|
7
|
+
ext: string
|
|
8
|
+
duration: number
|
|
9
|
+
words?: SpeechWord[]
|
|
10
|
+
engine: string
|
|
11
|
+
model: string
|
|
12
|
+
voice: string
|
|
13
|
+
provider: string
|
|
14
|
+
cloned: boolean
|
|
15
|
+
key: string
|
|
16
|
+
requestKey?: string
|
|
17
|
+
profile?: string
|
|
18
|
+
AIGC: AigcLabel
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Training a cloned voice: the sample, and the speaker's consent with their recorded statement. */
|
|
22
|
+
export interface CloneInput {
|
|
23
|
+
engine?: string
|
|
24
|
+
voice: string
|
|
25
|
+
sample: Uint8Array
|
|
26
|
+
sampleMime: string
|
|
27
|
+
lang?: string
|
|
28
|
+
text?: string
|
|
29
|
+
consent: { speaker: string, scope: string, statement: Uint8Array, statementMime: string }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type CacheProfile = { status: 'configured', profile: string, model: string, provider: string } | { status: 'absent' | 'unavailable' }
|
|
33
|
+
|
|
34
|
+
export interface SpeechBackend {
|
|
35
|
+
listEngines(lang?: string): Promise<EnginesResponseBody>
|
|
36
|
+
defaultSynthesisEngine(): Promise<string | undefined>
|
|
37
|
+
/** Configuration identity only: never contacts a provider and never includes credentials. */
|
|
38
|
+
cacheProfile(engine: string | undefined, use: 'synthesize' | 'transcribe', lang?: string): Promise<CacheProfile>
|
|
39
|
+
voices(engine: string | undefined, lang?: string, signal?: AbortSignal): Promise<VoiceInfo[]>
|
|
40
|
+
voiceCatalog?(engine: string | undefined, lang?: string, signal?: AbortSignal): Promise<VoiceCatalog>
|
|
41
|
+
synthesize(request: SynthesisRequest, signal?: AbortSignal): Promise<SynthesisResult>
|
|
42
|
+
transcribe(request: TranscriptionRequest, signal?: AbortSignal): Promise<Transcript>
|
|
43
|
+
consent(engine: string, voice: string, recorded: boolean, signal?: AbortSignal): Promise<void>
|
|
44
|
+
/** Registers the consent, then starts training; absent when cloning is not wired. */
|
|
45
|
+
cloneVoice?(input: CloneInput, signal?: AbortSignal): Promise<VoiceCloneStatus>
|
|
46
|
+
cloneStatus?(engine: string | undefined, voice: string, signal?: AbortSignal): Promise<VoiceCloneStatus>
|
|
47
|
+
}
|