feat: 添加批量转换OGG音频的相关脚本,移动声波配网HTML文件到scripts文件夹下 (#1107)

* feat: 添加批量转换OGG音频的相关脚本,移动声波配网HTML文件到scripts文件夹下

* Rename

* moved README.md
This commit is contained in:
香草味的纳西妲喵
2025-08-22 00:53:18 +08:00
committed by GitHub
parent 2d772dad68
commit 7cd37427b2
3 changed files with 259 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
# ogg_covertor 小智AI OGG 批量转换器
本脚本为OGG批量转换工具支持将输入的音频文件转换为小智可使用的OGG格式
基于Python第三方库`ffmpeg-python`实现
支持OGG和音频之间的互转响度调节等功能
# 创建并激活虚拟环境
```bash
# 创建虚拟环境
python -m venv venv
# 激活虚拟环境
source venv/bin/activate # Mac/Linux
venv\Scripts\activate # Windows
```
# 安装依赖
请在虚拟环境中执行
```bash
pip install ffmpeg-python
```
# 运行脚本
```bash
python ogg_covertor.py
```

View File

@@ -0,0 +1,230 @@
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
import os
import threading
import sys
import ffmpeg
class AudioConverterApp:
def __init__(self, master):
self.master = master
master.title("小智AI OGG音频批量转换工具")
master.geometry("680x600") # 调整窗口高度
# 初始化变量
self.mode = tk.StringVar(value="audio_to_ogg")
self.output_dir = tk.StringVar()
self.output_dir.set(os.path.abspath("output"))
self.enable_loudnorm = tk.BooleanVar(value=True)
self.target_lufs = tk.DoubleVar(value=-16.0)
# 创建UI组件
self.create_widgets()
self.redirect_output()
def create_widgets(self):
# 模式选择
mode_frame = ttk.LabelFrame(self.master, text="转换模式")
mode_frame.grid(row=0, column=0, padx=10, pady=5, sticky="ew")
ttk.Radiobutton(mode_frame, text="音频转到OGG", variable=self.mode,
value="audio_to_ogg", command=self.toggle_settings,
width=12).grid(row=0, column=0, padx=5)
ttk.Radiobutton(mode_frame, text="OGG转回音频", variable=self.mode,
value="ogg_to_audio", command=self.toggle_settings,
width=12).grid(row=0, column=1, padx=5)
# 响度设置
self.loudnorm_frame = ttk.Frame(self.master)
self.loudnorm_frame.grid(row=1, column=0, padx=10, pady=5, sticky="ew")
ttk.Checkbutton(self.loudnorm_frame, text="启用响度调整",
variable=self.enable_loudnorm, width=15
).grid(row=0, column=0, padx=2)
ttk.Entry(self.loudnorm_frame, textvariable=self.target_lufs,
width=6).grid(row=0, column=1, padx=2)
ttk.Label(self.loudnorm_frame, text="LUFS").grid(row=0, column=2, padx=2)
# 文件选择
file_frame = ttk.LabelFrame(self.master, text="输入文件")
file_frame.grid(row=2, column=0, padx=10, pady=5, sticky="nsew")
# 文件操作按钮
ttk.Button(file_frame, text="选择文件", command=self.select_files,
width=12).grid(row=0, column=0, padx=5, pady=2)
ttk.Button(file_frame, text="移除选中", command=self.remove_selected,
width=12).grid(row=0, column=1, padx=5, pady=2)
ttk.Button(file_frame, text="清空列表", command=self.clear_files,
width=12).grid(row=0, column=2, padx=5, pady=2)
# 文件列表使用Treeview
self.tree = ttk.Treeview(file_frame, columns=("selected", "filename"),
show="headings", height=8)
self.tree.heading("selected", text="选中", anchor=tk.W)
self.tree.heading("filename", text="文件名", anchor=tk.W)
self.tree.column("selected", width=60, anchor=tk.W)
self.tree.column("filename", width=600, anchor=tk.W)
self.tree.grid(row=1, column=0, columnspan=3, sticky="nsew", padx=5, pady=2)
self.tree.bind("<ButtonRelease-1>", self.on_tree_click)
# 输出目录
output_frame = ttk.LabelFrame(self.master, text="输出目录")
output_frame.grid(row=3, column=0, padx=10, pady=5, sticky="ew")
ttk.Entry(output_frame, textvariable=self.output_dir, width=60
).grid(row=0, column=0, padx=5, sticky="ew")
ttk.Button(output_frame, text="浏览", command=self.select_output_dir,
width=8).grid(row=0, column=1, padx=5)
# 转换按钮区域
button_frame = ttk.Frame(self.master)
button_frame.grid(row=4, column=0, padx=10, pady=10, sticky="ew")
ttk.Button(button_frame, text="转换全部文件", command=lambda: self.start_conversion(True),
width=15).pack(side=tk.LEFT, padx=5)
ttk.Button(button_frame, text="转换选中文件", command=lambda: self.start_conversion(False),
width=15).pack(side=tk.LEFT, padx=5)
# 日志区域
log_frame = ttk.LabelFrame(self.master, text="日志")
log_frame.grid(row=5, column=0, padx=10, pady=5, sticky="nsew")
self.log_text = tk.Text(log_frame, height=14, width=80)
self.log_text.pack(fill=tk.BOTH, expand=True)
# 配置布局权重
self.master.columnconfigure(0, weight=1)
self.master.rowconfigure(2, weight=1)
self.master.rowconfigure(5, weight=3)
file_frame.columnconfigure(0, weight=1)
file_frame.rowconfigure(1, weight=1)
def toggle_settings(self):
if self.mode.get() == "audio_to_ogg":
self.loudnorm_frame.grid()
else:
self.loudnorm_frame.grid_remove()
def select_files(self):
file_types = [
("音频文件", "*.wav *.mogg *.ogg *.flac") if self.mode.get() == "audio_to_ogg"
else ("ogg文件", "*.ogg")
]
files = filedialog.askopenfilenames(filetypes=file_types)
for f in files:
self.tree.insert("", tk.END, values=("[ ]", os.path.basename(f)), tags=(f,))
def on_tree_click(self, event):
"""处理复选框点击事件"""
region = self.tree.identify("region", event.x, event.y)
if region == "cell":
col = self.tree.identify_column(event.x)
item = self.tree.identify_row(event.y)
if col == "#1": # 点击的是选中列
current_val = self.tree.item(item, "values")[0]
new_val = "[√]" if current_val == "[ ]" else "[ ]"
self.tree.item(item, values=(new_val, self.tree.item(item, "values")[1]))
def remove_selected(self):
"""移除选中的文件"""
to_remove = []
for item in self.tree.get_children():
if self.tree.item(item, "values")[0] == "[√]":
to_remove.append(item)
for item in reversed(to_remove):
self.tree.delete(item)
def clear_files(self):
"""清空所有文件"""
for item in self.tree.get_children():
self.tree.delete(item)
def select_output_dir(self):
path = filedialog.askdirectory()
if path:
self.output_dir.set(path)
def redirect_output(self):
class StdoutRedirector:
def __init__(self, text_widget):
self.text_widget = text_widget
self.original_stdout = sys.stdout
def write(self, message):
self.text_widget.insert(tk.END, message)
self.text_widget.see(tk.END)
self.original_stdout.write(message)
def flush(self):
self.original_stdout.flush()
sys.stdout = StdoutRedirector(self.log_text)
def start_conversion(self, convert_all):
"""开始转换"""
input_files = []
for item in self.tree.get_children():
if convert_all or self.tree.item(item, "values")[0] == "[√]":
input_files.append(self.tree.item(item, "tags")[0])
if not input_files:
msg = "没有找到可转换的文件" if convert_all else "没有选中任何文件"
messagebox.showwarning("警告", msg)
return
os.makedirs(self.output_dir.get(), exist_ok=True)
try:
if self.mode.get() == "audio_to_ogg":
target_lufs = self.target_lufs.get() if self.enable_loudnorm.get() else None
thread = threading.Thread(target=self.convert_audio_to_ogg, args=(target_lufs, input_files))
else:
thread = threading.Thread(target=self.convert_ogg_to_audio, args=(input_files,))
thread.start()
except Exception as e:
print(f"转换初始化失败: {str(e)}")
def convert_audio_to_ogg(self, target_lufs, input_files):
"""音频转到ogg转换逻辑"""
for input_path in input_files:
try:
filename = os.path.basename(input_path)
base_name = os.path.splitext(filename)[0]
output_path = os.path.join(self.output_dir.get(), f"{base_name}.ogg")
print(f"正在转换: {filename}")
(
ffmpeg
.input(input_path)
.output(output_path, acodec='libopus', audio_bitrate='16k', ac=1, ar=16000, frame_duration=60)
.run(overwrite_output=True)
)
print(f"转换成功: {filename}\n")
except Exception as e:
print(f"转换失败: {str(e)}\n")
def convert_ogg_to_audio(self, input_files):
"""ogg转回音频转换逻辑"""
for input_path in input_files:
try:
filename = os.path.basename(input_path)
base_name = os.path.splitext(filename)[0]
output_path = os.path.join(self.output_dir.get(), f"{base_name}.ogg")
print(f"正在转换: {filename}")
(
ffmpeg
.input(input_path)
.output(output_path, acodec='libopus', audio_bitrate='16k', ac=1, ar=16000, frame_duration=60)
.run(overwrite_output=True)
)
print(f"转换成功: {filename}\n")
except Exception as e:
print(f"转换失败: {str(e)}\n")
if __name__ == "__main__":
root = tk.Tk()
app = AudioConverterApp(root)
root.mainloop()

View File

@@ -0,0 +1,208 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<title>小智声波配网</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style>
body {
font-family: "Segoe UI", "PingFang SC", sans-serif;
background: #f0f2f5;
margin: 0;
padding: 2rem 1rem;
display: flex;
justify-content: center;
}
.card {
background: #fff;
padding: 2rem 1.5rem;
border-radius: 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
max-width: 400px;
width: 100%;
}
h2 {
text-align: center;
margin-bottom: 2rem;
}
label {
font-weight: bold;
display: block;
margin: 1rem 0 0.3rem;
}
input[type="text"],
input[type="password"] {
width: 100%;
padding: 0.75rem;
font-size: 1rem;
border-radius: 8px;
border: 1px solid #ccc;
box-sizing: border-box;
}
input[type="checkbox"] {
margin-right: 0.5rem;
}
.checkbox-container {
margin-top: 1rem;
font-size: 0.95rem;
}
button {
width: 100%;
margin-top: 1rem;
padding: 0.8rem;
font-size: 1rem;
border: none;
border-radius: 8px;
background-color: #4a90e2;
color: #fff;
cursor: pointer;
transition: background-color 0.2s;
}
button:hover {
background-color: #357ab8;
}
button:active {
background-color: #2f6ea2;
}
audio {
margin-top: 1.5rem;
width: 100%;
outline: none;
}
</style>
</head>
<body>
<div class="card">
<h2>📶 小智声波配网</h2>
<label for="ssid">WiFi 名称</label>
<input id="ssid" type="text" value="" placeholder="请输入 WiFi 名称" />
<label for="pwd">WiFi 密码</label>
<input id="pwd" type="password" value="" placeholder="请输入 WiFi 密码" />
<div class="checkbox-container">
<label><input type="checkbox" id="loopCheck" checked /> 自动循环播放声波</label>
</div>
<button onclick="generate()">🎵 生成并播放声波</button>
<button onclick="stopPlay()">⏹️ 停止播放</button>
<audio id="player" controls></audio>
</div>
<script>
const MARK = 1800;
const SPACE = 1500;
const SAMPLE_RATE = 44100;
const BIT_RATE = 100;
const START_BYTES = [0x01, 0x02];
const END_BYTES = [0x03, 0x04];
let loopTimer = null;
function checksum(data) {
return data.reduce((sum, b) => (sum + b) & 0xff, 0);
}
function toBits(byte) {
const bits = [];
for (let i = 7; i >= 0; i--) bits.push((byte >> i) & 1);
return bits;
}
function afskModulate(bits) {
const samplesPerBit = SAMPLE_RATE / BIT_RATE;
const totalSamples = Math.floor(bits.length * samplesPerBit);
const buffer = new Float32Array(totalSamples);
for (let i = 0; i < bits.length; i++) {
const freq = bits[i] ? MARK : SPACE;
for (let j = 0; j < samplesPerBit; j++) {
const t = (i * samplesPerBit + j) / SAMPLE_RATE;
buffer[i * samplesPerBit + j] = Math.sin(2 * Math.PI * freq * t);
}
}
return buffer;
}
function floatTo16BitPCM(floatSamples) {
const buffer = new Uint8Array(floatSamples.length * 2);
for (let i = 0; i < floatSamples.length; i++) {
const s = Math.max(-1, Math.min(1, floatSamples[i]));
const val = s < 0 ? s * 0x8000 : s * 0x7fff;
buffer[i * 2] = val & 0xff;
buffer[i * 2 + 1] = (val >> 8) & 0xff;
}
return buffer;
}
function buildWav(pcm) {
const wavHeader = new Uint8Array(44);
const dataLen = pcm.length;
const fileLen = 36 + dataLen;
const writeStr = (offset, str) => {
for (let i = 0; i < str.length; i++) wavHeader[offset + i] = str.charCodeAt(i);
};
const write32 = (offset, value) => {
wavHeader[offset] = value & 0xff;
wavHeader[offset + 1] = (value >> 8) & 0xff;
wavHeader[offset + 2] = (value >> 16) & 0xff;
wavHeader[offset + 3] = (value >> 24) & 0xff;
};
const write16 = (offset, value) => {
wavHeader[offset] = value & 0xff;
wavHeader[offset + 1] = (value >> 8) & 0xff;
};
writeStr(0, 'RIFF');
write32(4, fileLen);
writeStr(8, 'WAVE');
writeStr(12, 'fmt ');
write32(16, 16);
write16(20, 1);
write16(22, 1);
write32(24, SAMPLE_RATE);
write32(28, SAMPLE_RATE * 2);
write16(32, 2);
write16(34, 16);
writeStr(36, 'data');
write32(40, dataLen);
return new Blob([wavHeader, pcm], { type: 'audio/wav' });
}
function generate() {
stopPlay();
const ssid = document.getElementById('ssid').value.trim();
const pwd = document.getElementById('pwd').value.trim();
const dataStr = ssid + '\n' + pwd;
const textBytes = Array.from(new TextEncoder().encode(dataStr));
const fullBytes = [...START_BYTES, ...textBytes, checksum(textBytes), ...END_BYTES];
let bits = [];
fullBytes.forEach((b) => (bits = bits.concat(toBits(b))));
const floatBuf = afskModulate(bits);
const pcmBuf = floatTo16BitPCM(floatBuf);
const wavBlob = buildWav(pcmBuf);
const audio = document.getElementById('player');
audio.src = URL.createObjectURL(wavBlob);
audio.load();
audio.play();
// 修改了这里:使用 'ended' 事件来实现循环播放
if (document.getElementById('loopCheck').checked) {
audio.onended = function() {
audio.currentTime = 0; // 从头开始
audio.play(); // 重新播放
};
}
}
function stopPlay() {
const audio = document.getElementById('player');
audio.pause();
audio.onended = null; // 清除事件监听
}
</script>
</body>
</html>