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