Skip to content

Commit 064dd4a

Browse files
committed
feat!: complete first version 0.1.0
1 parent 16a36d4 commit 064dd4a

File tree

24 files changed

+2577
-1
lines changed

24 files changed

+2577
-1
lines changed

.gitattributes

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Exclude files from the generated tarball
2+
3+
# .gitignore and similar
4+
.git* export-ignore
5+
# CI configurations
6+
.github export-ignore
7+
8+
# vscode settings
9+
.vscode export-ignore

.github/workflows/publish.yml

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
name: Publish Python 🐍 distributions 📦
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
publish_testpypi:
7+
type: boolean
8+
default: false
9+
description: Publish to TestPyPI
10+
publish_pypi:
11+
type: boolean
12+
default: false
13+
description: Publish to PyPI
14+
publish_gh_release:
15+
type: boolean
16+
default: true
17+
description: Publish to GitHub Release
18+
use_changelog:
19+
type: boolean
20+
default: true
21+
description: Extract release notes from CHANGELOG.md
22+
changelog_file:
23+
type: string
24+
default: CHANGELOG.md
25+
description: Path to changelog file
26+
required: false
27+
release_tag:
28+
type: string
29+
description: Tag to package (empty for latest tag)
30+
required: false
31+
32+
jobs:
33+
get-tag:
34+
runs-on: ubuntu-latest
35+
outputs:
36+
release_tag: ${{ steps.set_release_tag.outputs.tag }}
37+
steps:
38+
- name: Checkout code
39+
uses: actions/checkout@v4
40+
- name: Fetch all tags
41+
run: |
42+
git fetch --prune --unshallow --tags
43+
- name: Verify and set release tag
44+
id: set_release_tag
45+
run: |
46+
release_tag=${{ inputs.release_tag }}
47+
if [ -z "$release_tag" ]; then
48+
echo "Input tag is empty. Fetching latest tag."
49+
release_tag=$(git describe --tags $(git rev-list --tags --max-count=1))
50+
if [ -z "$release_tag" ]; then
51+
echo "No latest tag available. Exiting workflow."
52+
exit 1
53+
fi
54+
else
55+
if ! git rev-parse -q --verify "refs/tags/$release_tag" >/dev/null; then
56+
echo "Invalid tag '$release_tag'. Exiting workflow."
57+
exit 1
58+
fi
59+
fi
60+
echo "tag=$release_tag" >> $GITHUB_OUTPUT
61+
build-n-publish:
62+
needs: get-tag
63+
permissions:
64+
id-token: write # IMPORTANT: mandatory for trusted publishing and sigstore
65+
contents: write # IMPORTANT: mandatory for making GitHub Releases
66+
uses: atomiechen/reusable-workflows/.github/workflows/publish-python-distributions.yml@main
67+
with:
68+
publish_testpypi: ${{ inputs.publish_testpypi }}
69+
publish_pypi: ${{ inputs.publish_pypi }}
70+
publish_gh_release: ${{ inputs.publish_gh_release }}
71+
use_changelog: ${{ inputs.use_changelog }}
72+
changelog_file: ${{ inputs.changelog_file }}
73+
release_tag: ${{ needs.get-tag.outputs.release_tag }}
74+
secrets:
75+
TEST_PYPI_API_TOKEN: ${{ secrets.TEST_PYPI_API_TOKEN }}
76+
PYPI_API_TOKEN: ${{ secrets.PYPI_API_TOKEN }}

.gitignore

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
# media files
2+
*.pcm
3+
*.wav
4+
*.mp3
5+
*.mp4
6+
7+
8+
# == Below are commonly ignored files for Python projects == #
9+
110
# Byte-compiled / optimized / DLL files
211
__pycache__/
312
*.py[codz]

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Change Log
2+
3+
All notable changes will be documented in this file.
4+
5+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6+
7+
8+
9+
## [0.1.0] - 2025-08-04
10+
11+
### Added
12+
13+
Initial features:
14+
15+
- Both synchronous and asynchronous (`async`) support everywhere
16+
- Command Line Interface (CLI) and Python API
17+
- Auto decoding of messages with real timestamps (`FunASRMessageDecoded`)
18+
- Real-time audio recognition from a microphone (`mic_asr`)
19+
- File-based audio recognition (`file_asr`)

README.md

Lines changed: 207 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,207 @@
1-
# FunASR-Client
1+
# Python FunASR-Client
2+
3+
[![GitHub](https://img.shields.io/badge/github-FunASR--Client-blue?logo=github)](https://github.com/atomiechen/FunASR-Client)
4+
[![PyPI](https://img.shields.io/pypi/v/funasr-client?logo=pypi&logoColor=white)](https://pypi.org/project/funasr-client/)
5+
6+
7+
Really easy-to-use Python client for [FunASR][1] runtime server.
8+
9+
To deploy your own FunASR server, follow the [FunASR runtime guide][2], or use the improved startup scripts [here][3].
10+
11+
## Features
12+
13+
- ☯️ Both synchronous and asynchronous (`async`) support everywhere
14+
- 💻 Both Command Line Interface (CLI) and Python API
15+
- 🔤 Auto decoding of messages with real timestamps (`FunASRMessageDecoded`)
16+
- 🎙️ Real-time audio recognition from a microphone (`mic_asr`)
17+
- 🎵 File-based audio recognition (`file_asr`)
18+
19+
20+
## Installation
21+
22+
Install directly from PyPI:
23+
24+
```bash
25+
pip install funasr-client
26+
```
27+
28+
If you want to use the microphone (`pyaudio`) for real-time recognition, install with:
29+
30+
```bash
31+
pip install "funasr-client[mic]"
32+
```
33+
34+
Install from github for the latest updates:
35+
36+
```bash
37+
pip install "git+https://github.com/atomiechen/FunASR-Client.git"
38+
```
39+
40+
## CLI
41+
42+
The CLI supports either real-time microphone input or file input for ASR, and outputs the recognized results in JSON (file) or JSON Lines (mic) format.
43+
44+
45+
<details>
46+
<summary>View all CLI options by running <code>funasr-client -h</code>.</summary>
47+
48+
```
49+
usage: funasr-client [-h] [-v] [--mode MODE] [--chunk_size P C F] [--chunk_interval CHUNK_INTERVAL] [--audio_fs AUDIO_FS]
50+
[--hotwords WORD:WEIGHT [WORD:WEIGHT ...]] [--no-itn] [--svs_lang SVS_LANG] [--no-svs_itn] [--async]
51+
URI [FILE_PATH]
52+
53+
FunASR Client CLI v0.1.0. Use microphone for real-time recognition (needs pyaudio), or specify input audio file.
54+
55+
positional arguments:
56+
URI WebSocket URI to connect to the FunASR server.
57+
FILE_PATH Optional input audio file path (suppress microphone). (default: None)
58+
59+
optional arguments:
60+
-h, --help show this help message and exit
61+
-v, --version show program's version number and exit
62+
--mode MODE offline, online, 2pass (default: 2pass)
63+
--chunk_size P C F Chunk size: past, current, future. (default: [5, 10, 5])
64+
--chunk_interval CHUNK_INTERVAL
65+
Chunk interval. (default: 10)
66+
--audio_fs AUDIO_FS Audio sampling frequency. (default: 16000)
67+
--hotwords WORD:WEIGHT [WORD:WEIGHT ...]
68+
Hotwords with weights, e.g., 'hello:10 world:5'. (default: [])
69+
--no-itn Disable ITN (default: True)
70+
--svs_lang SVS_LANG SVS language. (default: auto)
71+
--no-svs_itn Disable SVS ITN (default: True)
72+
--async Use asynchronous client. (default: False)
73+
```
74+
75+
</details>
76+
77+
### Microphone Real-time ASR
78+
79+
Requires `pyaudio` for microphone support (install it using `pip install "funasr-client[mic]"`).
80+
81+
```sh
82+
funasr-client ws://localhost:10096
83+
```
84+
85+
### File ASR
86+
87+
```sh
88+
funasr-client ws://localhost:10096 path/to/audio.wav
89+
```
90+
91+
92+
## Python API
93+
94+
Sync API (`funasr_client`):
95+
```python
96+
from funasr_client import funasr_client
97+
98+
with funasr_client("ws://localhost:10096") as client:
99+
@client.on_message
100+
def callback(msg):
101+
print("Received:", msg)
102+
```
103+
104+
Async API (`async_funasr_client`):
105+
```python
106+
from funasr_client import async_funasr_client
107+
108+
async def main():
109+
async with async_funasr_client("ws://localhost:10096") as client:
110+
# NOTE: sync callback is also supported
111+
@client.on_message
112+
async def callback(msg):
113+
print("Received:", msg)
114+
```
115+
116+
See scripts in the [examples directory](examples/) for real-world usage.
117+
118+
### Registering Callbacks in non-blocking mode
119+
120+
By default, the client runs in non-blocking mode, which allows you to continue using your program while waiting for ASR results.
121+
It starts a background loop in a thread (sync) or an async task (async) to handle incoming messages.
122+
123+
Two ways to register message callbacks (**both** sync and async are supported):
124+
1. Using `@client.on_message` decorator (like shown above).
125+
2. Passing `callback` handler to the constructor.
126+
```python
127+
funasr_client(
128+
...
129+
callback=lambda msg: print(msg)
130+
)
131+
```
132+
133+
> [!NOTE]
134+
> Sync callback in async client will be run in a thread pool executor.
135+
136+
137+
### Blocking Mode
138+
139+
To run in blocking mode (disable background loop), pass `blocking=True` to the client constructor.
140+
It is your responsibility to call `client.stream()` or `client.recv()` to receive messages.
141+
142+
Use `client.stream()` (async) generator to receive messages in a loop:
143+
144+
```python
145+
from funasr_client import funasr_client
146+
with funasr_client("ws://localhost:10096", blocking=True) as client:
147+
for msg in client.stream():
148+
print("Received:", msg)
149+
```
150+
151+
Or, use the low-level `client.recv()` method to receive messages one by one:
152+
153+
```python
154+
from funasr_client import funasr_client
155+
with funasr_client("ws://localhost:10096", blocking=True) as client:
156+
while True:
157+
msg = client.recv()
158+
if msg is None:
159+
break
160+
print("Received:", msg)
161+
```
162+
163+
164+
### Decoding Messages
165+
166+
By default, the client decodes response messages into `FunASRMessageDecoded` dicts, which parses `timestamps` JSON string into a list.
167+
If `start_time` (int in ms) is provided to the client, `real_timestamp` and `real_stamp_sents` will be calculated and added to the decoded message.
168+
169+
To disable decoding, pass `decode=False` to the constructor to get original dict object.
170+
171+
172+
### Microphone Real-time ASR
173+
174+
Open a microphone stream and get the stream of **decoded** messages (`mic_asr` / `async_mic_asr`):
175+
176+
```python
177+
from funasr_client import mic_asr
178+
with mic_asr("ws://localhost:10096") as msg_gen:
179+
for msg in msg_gen:
180+
print("Received:", msg)
181+
```
182+
183+
### File ASR
184+
185+
Get the final result as a **merged decoded** message (`file_asr` / `async_file_asr`):
186+
187+
```python
188+
from funasr_client import file_asr
189+
190+
result = file_asr("path/to/audio.wav", "ws://localhost:10096")
191+
print(result)
192+
```
193+
194+
Or, get the stream of **decoded or original** (depends on `decode` option) messages (`file_asr_stream` / `async_file_asr_stream`):
195+
196+
```python
197+
from funasr_client import file_asr_stream
198+
199+
with file_asr_stream("path/to/audio.wav", "ws://localhost:10096") as msg_gen:
200+
for msg in msg_gen:
201+
print("Received:", msg)
202+
```
203+
204+
205+
[1]: https://github.com/modelscope/FunASR
206+
[2]: https://github.com/modelscope/FunASR/blob/main/runtime/readme.md
207+
[3]: https://gist.github.com/atomiechen/2deaf80dba21b4434ab21d6bf656fbca

examples/decode.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import asyncio
2+
import os
3+
4+
from dotenv import load_dotenv
5+
from funasr_client import async_funasr_client
6+
7+
8+
load_dotenv()
9+
FUNASR_URI = os.getenv("FUNASR_URI", "wss://www.funasr.com:10096/")
10+
11+
12+
async def with_decode():
13+
async with async_funasr_client(
14+
uri=FUNASR_URI,
15+
blocking=True,
16+
decode=True,
17+
) as client:
18+
print("Connected to FunASR WebSocket server.")
19+
# send some audio data to the server
20+
with open("test.pcm", "rb") as f:
21+
await client.send(f.read())
22+
async for response in client.stream():
23+
# check your IDE to see the type of response: FunASRMessageDecoded
24+
print("Received decoded response:", response)
25+
26+
27+
async def no_decode():
28+
async with async_funasr_client(
29+
uri=FUNASR_URI,
30+
blocking=True,
31+
decode=False,
32+
) as client:
33+
print("Connected to FunASR WebSocket server.")
34+
# send some audio data to the server
35+
with open("test.pcm", "rb") as f:
36+
await client.send(f.read())
37+
async for response in client.stream():
38+
# check your IDE to see the type of response: FunASRMessage
39+
print("Received original response:", response)
40+
41+
42+
if __name__ == "__main__":
43+
decode = True
44+
if decode:
45+
asyncio.run(with_decode())
46+
else:
47+
asyncio.run(no_decode())

0 commit comments

Comments
 (0)