@mingxy/ocosay 1.0.0

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.
Files changed (126) hide show
  1. package/README.md +556 -0
  2. package/TECH_PLAN.md +352 -0
  3. package/__mocks__/@opencode-ai/plugin.ts +32 -0
  4. package/dist/config.d.ts +26 -0
  5. package/dist/config.d.ts.map +1 -0
  6. package/dist/config.js +95 -0
  7. package/dist/config.js.map +1 -0
  8. package/dist/core/backends/afplay-backend.d.ts +33 -0
  9. package/dist/core/backends/afplay-backend.d.ts.map +1 -0
  10. package/dist/core/backends/afplay-backend.js +144 -0
  11. package/dist/core/backends/afplay-backend.js.map +1 -0
  12. package/dist/core/backends/aplay-backend.d.ts +33 -0
  13. package/dist/core/backends/aplay-backend.d.ts.map +1 -0
  14. package/dist/core/backends/aplay-backend.js +142 -0
  15. package/dist/core/backends/aplay-backend.js.map +1 -0
  16. package/dist/core/backends/base.d.ts +94 -0
  17. package/dist/core/backends/base.d.ts.map +1 -0
  18. package/dist/core/backends/base.js +6 -0
  19. package/dist/core/backends/base.js.map +1 -0
  20. package/dist/core/backends/index.d.ts +29 -0
  21. package/dist/core/backends/index.d.ts.map +1 -0
  22. package/dist/core/backends/index.js +114 -0
  23. package/dist/core/backends/index.js.map +1 -0
  24. package/dist/core/backends/naudiodon-backend.d.ts +52 -0
  25. package/dist/core/backends/naudiodon-backend.d.ts.map +1 -0
  26. package/dist/core/backends/naudiodon-backend.js +123 -0
  27. package/dist/core/backends/naudiodon-backend.js.map +1 -0
  28. package/dist/core/backends/powershell-backend.d.ts +34 -0
  29. package/dist/core/backends/powershell-backend.d.ts.map +1 -0
  30. package/dist/core/backends/powershell-backend.js +154 -0
  31. package/dist/core/backends/powershell-backend.js.map +1 -0
  32. package/dist/core/player.d.ts +97 -0
  33. package/dist/core/player.d.ts.map +1 -0
  34. package/dist/core/player.js +268 -0
  35. package/dist/core/player.js.map +1 -0
  36. package/dist/core/speaker.d.ts +97 -0
  37. package/dist/core/speaker.d.ts.map +1 -0
  38. package/dist/core/speaker.js +218 -0
  39. package/dist/core/speaker.js.map +1 -0
  40. package/dist/core/stream-player.d.ts +107 -0
  41. package/dist/core/stream-player.d.ts.map +1 -0
  42. package/dist/core/stream-player.js +272 -0
  43. package/dist/core/stream-player.js.map +1 -0
  44. package/dist/core/stream-reader.d.ts +86 -0
  45. package/dist/core/stream-reader.d.ts.map +1 -0
  46. package/dist/core/stream-reader.js +172 -0
  47. package/dist/core/stream-reader.js.map +1 -0
  48. package/dist/core/streaming-synthesizer.d.ts +51 -0
  49. package/dist/core/streaming-synthesizer.d.ts.map +1 -0
  50. package/dist/core/streaming-synthesizer.js +103 -0
  51. package/dist/core/streaming-synthesizer.js.map +1 -0
  52. package/dist/core/types.d.ts +141 -0
  53. package/dist/core/types.d.ts.map +1 -0
  54. package/dist/core/types.js +37 -0
  55. package/dist/core/types.js.map +1 -0
  56. package/dist/index.d.ts +40 -0
  57. package/dist/index.d.ts.map +1 -0
  58. package/dist/index.js +179 -0
  59. package/dist/index.js.map +1 -0
  60. package/dist/plugin.d.ts +4 -0
  61. package/dist/plugin.d.ts.map +1 -0
  62. package/dist/plugin.js +151 -0
  63. package/dist/plugin.js.map +1 -0
  64. package/dist/providers/base.d.ts +55 -0
  65. package/dist/providers/base.d.ts.map +1 -0
  66. package/dist/providers/base.js +95 -0
  67. package/dist/providers/base.js.map +1 -0
  68. package/dist/providers/minimax.d.ts +84 -0
  69. package/dist/providers/minimax.d.ts.map +1 -0
  70. package/dist/providers/minimax.js +387 -0
  71. package/dist/providers/minimax.js.map +1 -0
  72. package/dist/tools/tts.d.ts +147 -0
  73. package/dist/tools/tts.d.ts.map +1 -0
  74. package/dist/tools/tts.js +232 -0
  75. package/dist/tools/tts.js.map +1 -0
  76. package/jest.config.js +15 -0
  77. package/package.json +49 -0
  78. package/src/config.ts +121 -0
  79. package/src/core/backends/afplay-backend.ts +162 -0
  80. package/src/core/backends/aplay-backend.ts +160 -0
  81. package/src/core/backends/base.ts +117 -0
  82. package/src/core/backends/index.ts +128 -0
  83. package/src/core/backends/naudiodon-backend.ts +164 -0
  84. package/src/core/backends/powershell-backend.ts +173 -0
  85. package/src/core/player.ts +322 -0
  86. package/src/core/speaker.ts +283 -0
  87. package/src/core/stream-player.ts +326 -0
  88. package/src/core/stream-reader.ts +190 -0
  89. package/src/core/streaming-synthesizer.ts +123 -0
  90. package/src/core/types.ts +185 -0
  91. package/src/index.ts +233 -0
  92. package/src/plugin.ts +166 -0
  93. package/src/providers/base.ts +150 -0
  94. package/src/providers/minimax.ts +515 -0
  95. package/src/tools/tts.ts +277 -0
  96. package/src/types/naudiodon.d.ts +19 -0
  97. package/tests/__mocks__/@opencode-ai/plugin.ts +32 -0
  98. package/tests/backends.test.ts +831 -0
  99. package/tests/index.test.ts +201 -0
  100. package/tests/integration-test.d.ts +6 -0
  101. package/tests/integration-test.d.ts.map +1 -0
  102. package/tests/integration-test.js +84 -0
  103. package/tests/integration-test.js.map +1 -0
  104. package/tests/integration-test.ts +93 -0
  105. package/tests/p1-fixes.test.ts +160 -0
  106. package/tests/plugin.test.ts +311 -0
  107. package/tests/provider.test.d.ts +2 -0
  108. package/tests/provider.test.d.ts.map +1 -0
  109. package/tests/provider.test.js +69 -0
  110. package/tests/provider.test.js.map +1 -0
  111. package/tests/provider.test.ts +87 -0
  112. package/tests/speaker.test.d.ts +2 -0
  113. package/tests/speaker.test.d.ts.map +1 -0
  114. package/tests/speaker.test.js +63 -0
  115. package/tests/speaker.test.js.map +1 -0
  116. package/tests/speaker.test.ts +232 -0
  117. package/tests/stream-player.test.ts +303 -0
  118. package/tests/stream-reader.test.ts +269 -0
  119. package/tests/streaming-synthesizer.test.ts +225 -0
  120. package/tests/tts-tools.test.ts +270 -0
  121. package/tests/types.test.d.ts +2 -0
  122. package/tests/types.test.d.ts.map +1 -0
  123. package/tests/types.test.js +61 -0
  124. package/tests/types.test.js.map +1 -0
  125. package/tests/types.test.ts +63 -0
  126. package/tsconfig.json +22 -0
@@ -0,0 +1,190 @@
1
+ /**
2
+ * StreamReader - 流式文本缓冲与句子边界检测
3
+ *
4
+ * 功能:
5
+ * - 订阅 TuiEventBus 的 message.part.delta 事件
6
+ * - 缓冲区满或遇到句子结束符时触发 textReady 事件
7
+ * - 支持超时强制发送
8
+ */
9
+
10
+ import { EventEmitter } from 'events'
11
+ import { StreamState, TTSError, TTSErrorCode } from './types'
12
+
13
+ export class StreamReader extends EventEmitter {
14
+ private state: StreamState = StreamState.IDLE
15
+ private buffer: string = ''
16
+ private sessionID?: string
17
+ private messageID?: string
18
+ private partID?: string
19
+ private timeoutHandle?: NodeJS.Timeout
20
+
21
+ constructor(
22
+ private bufferSize: number = 30,
23
+ private bufferTimeout: number = 2000
24
+ ) {
25
+ super()
26
+ }
27
+
28
+ /**
29
+ * 启动流式监听
30
+ * 将状态从 IDLE 切换到 BUFFERING,开始监听事件
31
+ */
32
+ start(): void {
33
+ if (this.state === StreamState.IDLE) {
34
+ this.state = StreamState.BUFFERING
35
+ this.emit('streamStart')
36
+ }
37
+ }
38
+
39
+ /**
40
+ * 处理 message.part.delta 事件
41
+ */
42
+ handleDelta(sessionID: string, messageID: string, partID: string, delta: string): void {
43
+ if (this.state === StreamState.IDLE) {
44
+ this.state = StreamState.BUFFERING
45
+ this.sessionID = sessionID
46
+ this.messageID = messageID
47
+ this.partID = partID
48
+ this.emit('streamStart')
49
+ }
50
+
51
+ this.buffer += delta
52
+ this.resetTimeout()
53
+
54
+ if (this.shouldFlush()) {
55
+ this.flushBuffer()
56
+ }
57
+ }
58
+
59
+ /**
60
+ * 处理流结束
61
+ */
62
+ handleEnd(): void {
63
+ if (this.state === StreamState.ENDED) {
64
+ return
65
+ }
66
+ if (this.buffer.length > 0) {
67
+ this.flushBuffer()
68
+ }
69
+ this.state = StreamState.ENDED
70
+ this.clearTimeout()
71
+ this.emit('streamEnd')
72
+ }
73
+
74
+ /**
75
+ * 处理错误
76
+ */
77
+ handleError(error: TTSError): void {
78
+ this.clearTimeout()
79
+ this.state = StreamState.IDLE
80
+ this.buffer = ''
81
+ this.emit('streamError', error)
82
+ }
83
+
84
+ /**
85
+ * 重置缓冲器
86
+ */
87
+ reset(): void {
88
+ this.state = StreamState.IDLE
89
+ this.buffer = ''
90
+ this.sessionID = undefined
91
+ this.messageID = undefined
92
+ this.partID = undefined
93
+ this.clearTimeout()
94
+ }
95
+
96
+ /**
97
+ * 判断是否应该刷新缓冲区
98
+ * 条件:
99
+ * 1. 包含句子结束符(任何长度)
100
+ * 2. 缓冲区长度 >= bufferSize
101
+ */
102
+ private shouldFlush(): boolean {
103
+ // 句子结束标记:。!?.!?……(中文句号、感叹号、问号、省略号)
104
+ const sentenceEnd = /[。!?.!?]|……/
105
+ if (sentenceEnd.test(this.buffer)) {
106
+ return true
107
+ }
108
+ // 缓冲区达到阈值
109
+ if (this.buffer.length >= this.bufferSize) {
110
+ return true
111
+ }
112
+ return false
113
+ }
114
+
115
+ /**
116
+ * 刷新缓冲区,发送textReady事件
117
+ */
118
+ private flushBuffer(): void {
119
+ const text = this.buffer.trim()
120
+ if (text.length > 0) {
121
+ this.emit('textReady', text)
122
+ }
123
+ this.buffer = ''
124
+ this.resetTimeout()
125
+ }
126
+
127
+ /**
128
+ * 重置超时计时器
129
+ */
130
+ private resetTimeout(): void {
131
+ this.clearTimeout()
132
+ this.timeoutHandle = setTimeout(() => {
133
+ if (this.buffer.length > 0) {
134
+ this.flushBuffer()
135
+ }
136
+ }, this.bufferTimeout)
137
+ }
138
+
139
+ /**
140
+ * 清除超时计时器
141
+ */
142
+ private clearTimeout(): void {
143
+ if (this.timeoutHandle) {
144
+ clearTimeout(this.timeoutHandle)
145
+ this.timeoutHandle = undefined
146
+ }
147
+ }
148
+
149
+ /**
150
+ * 获取当前状态
151
+ */
152
+ getState(): StreamState {
153
+ return this.state
154
+ }
155
+
156
+ /**
157
+ * 检查流是否处于活跃状态
158
+ */
159
+ isActive(): boolean {
160
+ return this.state === StreamState.BUFFERING
161
+ }
162
+
163
+ /**
164
+ * 获取当前缓冲区内容
165
+ */
166
+ getBuffer(): string {
167
+ return this.buffer
168
+ }
169
+
170
+ /**
171
+ * 获取当前会话ID
172
+ */
173
+ getSessionID(): string | undefined {
174
+ return this.sessionID
175
+ }
176
+
177
+ /**
178
+ * 获取当前消息ID
179
+ */
180
+ getMessageID(): string | undefined {
181
+ return this.messageID
182
+ }
183
+
184
+ /**
185
+ * 获取当前分块ID
186
+ */
187
+ getPartID(): string | undefined {
188
+ return this.partID
189
+ }
190
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * StreamingSynthesizer - 流式合成器
3
+ *
4
+ * 功能:
5
+ * - 接收 StreamReader 发来的文本(通过 synthesize 方法)
6
+ * - 调用 TTSProvider 的流式合成接口
7
+ * - 将返回的音频 chunk 传递给下游(StreamPlayer)
8
+ *
9
+ * 数据流:
10
+ * StreamReader.textReady → StreamingSynthesizer.synthesize() → StreamPlayer (边收边播)
11
+ */
12
+
13
+ import { EventEmitter } from 'events'
14
+ import { TTSProvider, TTSError, TTSErrorCode, StreamingSynthesizerOptions, AudioResult } from './types'
15
+
16
+ export interface StreamingSynthesizerEvents {
17
+ on(event: 'chunk', handler: (chunk: Buffer) => void): void
18
+ on(event: 'error', handler: (error: TTSError) => void): void
19
+ on(event: 'done', handler: () => void): void
20
+ }
21
+
22
+ export class StreamingSynthesizer extends EventEmitter {
23
+ private audioChunks: Buffer[] = []
24
+
25
+ constructor(private options: StreamingSynthesizerOptions) {
26
+ super()
27
+ }
28
+
29
+ /**
30
+ * 发送文本片段进行合成
31
+ * 调用 provider.speak() 并处理返回的音频流
32
+ */
33
+ async synthesize(text: string): Promise<void> {
34
+ if (!text || text.trim().length === 0) {
35
+ return
36
+ }
37
+
38
+ try {
39
+ const result = await this.options.provider.speak(text, {
40
+ model: 'stream',
41
+ voice: this.options.voice,
42
+ speed: this.options.speed,
43
+ volume: this.options.volume,
44
+ pitch: this.options.pitch
45
+ })
46
+
47
+ await this.processAudioResult(result)
48
+
49
+ this.emit('done')
50
+ } catch (error) {
51
+ const ttsError = error instanceof TTSError
52
+ ? error
53
+ : new TTSError(
54
+ error instanceof Error ? error.message : 'Synthesis failed',
55
+ 'UNKNOWN' as TTSErrorCode,
56
+ this.options.provider.name,
57
+ error
58
+ )
59
+ this.emit('error', ttsError)
60
+ }
61
+ }
62
+
63
+ /**
64
+ * 处理 AudioResult,根据 audioData 类型进行相应处理
65
+ */
66
+ private async processAudioResult(result: AudioResult): Promise<void> {
67
+ if (result.isStream && result.audioData instanceof ReadableStream) {
68
+ // 流式数据:ReadableStream
69
+ await this.processReadableStream(result.audioData)
70
+ } else if (Buffer.isBuffer(result.audioData)) {
71
+ // 非流式数据:Buffer
72
+ this.emitChunk(result.audioData)
73
+ }
74
+ }
75
+
76
+ /**
77
+ * 处理 ReadableStream,逐chunk emit
78
+ */
79
+ private async processReadableStream(stream: ReadableStream): Promise<void> {
80
+ const reader = stream.getReader()
81
+
82
+ try {
83
+ while (true) {
84
+ const { done, value } = await reader.read()
85
+
86
+ if (done) {
87
+ break
88
+ }
89
+
90
+ if (value) {
91
+ const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value)
92
+ this.emitChunk(chunk)
93
+ }
94
+ }
95
+ } finally {
96
+ reader.releaseLock()
97
+ }
98
+ }
99
+
100
+ /**
101
+ * emit chunk 并累积
102
+ */
103
+ private emitChunk(chunk: Buffer): void {
104
+ this.audioChunks.push(chunk)
105
+ this.emit('chunk', chunk)
106
+ }
107
+
108
+ /**
109
+ * 重置状态
110
+ * 清空累积的音频数据
111
+ */
112
+ reset(): void {
113
+ this.audioChunks = []
114
+ }
115
+
116
+ /**
117
+ * 获取累积的音频数据
118
+ * 返回所有已接收的 chunk
119
+ */
120
+ getAudioChunks(): Buffer[] {
121
+ return [...this.audioChunks]
122
+ }
123
+ }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * TTS Core Types
3
+ * 核心类型定义
4
+ */
5
+
6
+ export enum TTSErrorCode {
7
+ NETWORK = 'NETWORK',
8
+ AUTH = 'AUTH',
9
+ QUOTA = 'QUOTA',
10
+ INVALID_VOICE = 'INVALID_VOICE',
11
+ INVALID_PARAMS = 'INVALID_PARAMS',
12
+ PLAYER_ERROR = 'PLAYER_ERROR',
13
+ UNKNOWN = 'UNKNOWN'
14
+ }
15
+
16
+ export class TTSError extends Error {
17
+ constructor(
18
+ message: string,
19
+ code: TTSErrorCode,
20
+ provider: string,
21
+ details?: unknown
22
+ ) {
23
+ super(message)
24
+ this.name = 'TTSError'
25
+ this.code = code
26
+ this.provider = provider
27
+ this.details = details
28
+ }
29
+
30
+ code: TTSErrorCode
31
+ provider: string
32
+ details?: unknown
33
+ }
34
+
35
+ export interface Voice {
36
+ id: string
37
+ name: string
38
+ language?: string
39
+ gender?: 'male' | 'female' | 'neutral'
40
+ previewUrl?: string
41
+ }
42
+
43
+ export interface TTSCapabilities {
44
+ speak: true
45
+ voiceClone?: boolean
46
+ stream?: boolean
47
+ voiceList?: boolean
48
+ sync?: boolean
49
+ async?: boolean
50
+ }
51
+
52
+ export type SynthesisModel = 'sync' | 'async' | 'stream'
53
+
54
+ export interface SpeakOptions {
55
+ voice?: string
56
+ model?: SynthesisModel
57
+ speed?: number
58
+ volume?: number
59
+ pitch?: number
60
+ sourceVoice?: string
61
+ }
62
+
63
+ export interface AudioResult {
64
+ audioData: Buffer | ReadableStream
65
+ sampleRate?: number
66
+ channels?: number
67
+ duration?: number
68
+ format: string
69
+ isStream: boolean
70
+ }
71
+
72
+ export type TTSEvent =
73
+ | 'start'
74
+ | 'end'
75
+ | 'error'
76
+ | 'progress'
77
+ | 'pause'
78
+ | 'resume'
79
+ | 'stop'
80
+
81
+ export interface SpeakerEvents {
82
+ on(event: 'start', handler: (text: string) => void): void
83
+ on(event: 'end', handler: (text: string) => void): void
84
+ on(event: 'error', handler: (error: TTSError) => void): void
85
+ on(event: 'progress', handler: (progress: { current: number; total: number }) => void): void
86
+ on(event: 'pause', handler: () => void): void
87
+ on(event: 'resume', handler: () => void): void
88
+ on(event: 'stop', handler: () => void): void
89
+ off(event: TTSEvent, handler: Function): void
90
+ }
91
+
92
+ export interface TTSProvider {
93
+ name: string
94
+ capabilities: TTSCapabilities
95
+
96
+ initialize(): Promise<void>
97
+ destroy(): Promise<void>
98
+
99
+ speak(text: string, options?: SpeakOptions): Promise<AudioResult>
100
+ pause(): Promise<void>
101
+ resume(): Promise<void>
102
+ stop(): Promise<void>
103
+ listVoices(): Promise<Voice[]>
104
+
105
+ getCapabilities(): TTSCapabilities
106
+ }
107
+
108
+ // ============================================================================
109
+ // 配置相关类型
110
+ // ============================================================================
111
+
112
+ export interface GlobalConfig {
113
+ defaultProvider: string
114
+ defaultModel?: SynthesisModel
115
+ defaultVoice?: string
116
+ }
117
+
118
+ export interface ProviderConfig {
119
+ enabled?: boolean
120
+ apiKey?: string
121
+ [key: string]: unknown
122
+ }
123
+
124
+ export interface OcosayConfig {
125
+ enabled?: boolean
126
+ autoPlay?: boolean
127
+ autoRead?: boolean
128
+ streamMode?: boolean
129
+ streamBufferSize?: number
130
+ streamBufferTimeout?: number
131
+ provider?: string
132
+ ttsModel?: string
133
+ baseURL?: string
134
+ speed?: number
135
+ volume?: number
136
+ pitch?: number
137
+ }
138
+
139
+ // ============================================================================
140
+ // 流式朗读相关类型
141
+ // ============================================================================
142
+
143
+ export enum StreamState {
144
+ IDLE = 'idle',
145
+ BUFFERING = 'buffering',
146
+ STREAMING = 'streaming',
147
+ ENDED = 'ended'
148
+ }
149
+
150
+ export interface OcosayStreamConfig {
151
+ enabled: boolean
152
+ autoPlay: boolean
153
+ autoRead: boolean
154
+ streamMode: boolean
155
+ streamBufferSize: number
156
+ streamBufferTimeout: number
157
+ provider: string
158
+ voiceId?: string
159
+ ttsModel?: string
160
+ baseURL?: string
161
+ speed?: number
162
+ volume?: number
163
+ pitch?: number
164
+ apiKey?: string
165
+ }
166
+
167
+ export interface StreamReaderEvents {
168
+ onTextReady: (text: string) => void
169
+ onStreamStart: () => void
170
+ onStreamEnd: () => void
171
+ onStreamError: (error: TTSError) => void
172
+ }
173
+
174
+ export interface StreamingSynthesizerOptions {
175
+ provider: TTSProvider
176
+ voice?: string
177
+ speed?: number
178
+ volume?: number
179
+ pitch?: number
180
+ }
181
+
182
+ export interface StreamPlayerOptions {
183
+ format?: 'mp3' | 'wav' | 'flac'
184
+ onProgress?: (bytesReceived: number) => void
185
+ }