28 changed files with 2301 additions and 978 deletions
@ -0,0 +1,104 @@ |
|||
import 'dart:ui'; |
|||
import 'package:flutter/material.dart'; |
|||
|
|||
/// 使用Shader实现的麦克风流动动画组件 |
|||
class ShaderMicFlow extends StatefulWidget { |
|||
final double size; |
|||
final double amplitude; |
|||
|
|||
const ShaderMicFlow({ |
|||
super.key, |
|||
required this.size, |
|||
this.amplitude = 0.0, |
|||
}); |
|||
|
|||
@override |
|||
State<ShaderMicFlow> createState() => _ShaderMicFlowState(); |
|||
} |
|||
|
|||
class _ShaderMicFlowState extends State<ShaderMicFlow> |
|||
with SingleTickerProviderStateMixin { |
|||
late AnimationController _controller; |
|||
FragmentShader? _shader; |
|||
|
|||
@override |
|||
void initState() { |
|||
super.initState(); |
|||
_controller = AnimationController( |
|||
duration: const Duration(seconds: 60), |
|||
vsync: this, |
|||
)..repeat(); |
|||
|
|||
_loadShader(); |
|||
} |
|||
|
|||
Future<void> _loadShader() async { |
|||
try { |
|||
final program = await FragmentProgram.fromAsset('shaders/mic_flow.frag'); |
|||
setState(() { |
|||
_shader = program.fragmentShader(); |
|||
}); |
|||
} catch (e) { |
|||
debugPrint('Failed to load shader: $e'); |
|||
} |
|||
} |
|||
|
|||
@override |
|||
void dispose() { |
|||
_controller.dispose(); |
|||
_shader?.dispose(); |
|||
super.dispose(); |
|||
} |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
return SizedBox( |
|||
width: widget.size, |
|||
height: widget.size, |
|||
child: AnimatedBuilder( |
|||
animation: _controller, |
|||
builder: (context, child) { |
|||
return CustomPaint( |
|||
painter: _shader != null |
|||
? _ShaderPainter( |
|||
shader: _shader!, |
|||
time: _controller.value * 60.0, |
|||
amplitude: widget.amplitude, |
|||
) |
|||
: null, |
|||
size: Size.square(widget.size), |
|||
); |
|||
}, |
|||
), |
|||
); |
|||
} |
|||
} |
|||
|
|||
class _ShaderPainter extends CustomPainter { |
|||
final FragmentShader shader; |
|||
final double time; |
|||
final double amplitude; |
|||
|
|||
_ShaderPainter({ |
|||
required this.shader, |
|||
required this.time, |
|||
required this.amplitude, |
|||
}); |
|||
|
|||
@override |
|||
void paint(Canvas canvas, Size size) { |
|||
// 设置shader uniforms |
|||
shader.setFloat(0, size.width); // u_resolution.x |
|||
shader.setFloat(1, size.height); // u_resolution.y |
|||
shader.setFloat(2, time); // u_time |
|||
shader.setFloat(3, amplitude); // u_amp |
|||
|
|||
final paint = Paint()..shader = shader; |
|||
canvas.drawRect(Offset.zero & size, paint); |
|||
} |
|||
|
|||
@override |
|||
bool shouldRepaint(covariant _ShaderPainter oldDelegate) { |
|||
return oldDelegate.time != time || oldDelegate.amplitude != amplitude; |
|||
} |
|||
} |
|||
@ -1,89 +0,0 @@ |
|||
import 'dart:ui'; |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:flutter_shaders/flutter_shaders.dart'; |
|||
|
|||
class VoiceMicFlow extends StatefulWidget { |
|||
const VoiceMicFlow({super.key, this.size = 280, this.amp = 0.0}); |
|||
final double size; |
|||
final double amp; // 音量振幅 0.0-1.0 |
|||
|
|||
@override |
|||
State<VoiceMicFlow> createState() => _VoiceMicFlowState(); |
|||
} |
|||
|
|||
class _VoiceMicFlowState extends State<VoiceMicFlow> |
|||
with SingleTickerProviderStateMixin { |
|||
late final AnimationController _ctl; |
|||
|
|||
@override |
|||
void initState() { |
|||
super.initState(); |
|||
_ctl = AnimationController( |
|||
duration: const Duration(seconds: 100), // 100秒的长周期 |
|||
vsync: this, |
|||
)..repeat(); |
|||
} |
|||
|
|||
@override |
|||
void dispose() { |
|||
_ctl.dispose(); |
|||
super.dispose(); |
|||
} |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
return ClipOval( |
|||
child: ShaderBuilder( |
|||
assetKey: 'shaders/mic_flow.frag', |
|||
(context, shader, child) { |
|||
return AnimatedBuilder( |
|||
animation: _ctl, |
|||
builder: (_, __) { |
|||
// 将0.0-1.0的动画值映射到更大的时间范围 |
|||
final time = _ctl.value * 100.0; |
|||
shader |
|||
..setFloat(0, widget.size) // u_resolution.x |
|||
..setFloat(1, widget.size) // u_resolution.y |
|||
..setFloat(2, time) // u_time |
|||
..setFloat(3, widget.amp); // u_amp (音量振幅) |
|||
return CustomPaint( |
|||
size: Size.square(widget.size), |
|||
painter: _ShaderPainter(shader), |
|||
); |
|||
}, |
|||
); |
|||
}, |
|||
// 着色器编译时的占位符 |
|||
child: Container( |
|||
width: widget.size, |
|||
height: widget.size, |
|||
decoration: const BoxDecoration( |
|||
shape: BoxShape.circle, |
|||
gradient: LinearGradient( |
|||
begin: Alignment.topLeft, |
|||
end: Alignment.bottomRight, |
|||
colors: [ |
|||
Color(0xFF1565C0), // 深蓝 |
|||
Color(0xFFE3F2FD), // 浅蓝 |
|||
], |
|||
), |
|||
), |
|||
), |
|||
), |
|||
); |
|||
} |
|||
} |
|||
|
|||
class _ShaderPainter extends CustomPainter { |
|||
_ShaderPainter(this.shader); |
|||
final FragmentShader shader; |
|||
|
|||
@override |
|||
void paint(Canvas canvas, Size size) { |
|||
final paint = Paint()..shader = shader; |
|||
canvas.drawRect(Offset.zero & size, paint); |
|||
} |
|||
|
|||
@override |
|||
bool shouldRepaint(covariant _ShaderPainter oldDelegate) => true; |
|||
} |
|||
@ -0,0 +1,254 @@ |
|||
package com.yunqiinnovation.azure_speech.tools |
|||
|
|||
import java.io.File |
|||
import java.io.FileOutputStream |
|||
import java.io.RandomAccessFile |
|||
|
|||
import android.util.Log |
|||
import java.io.FileInputStream |
|||
import java.util.concurrent.LinkedBlockingQueue |
|||
import java.util.concurrent.atomic.AtomicBoolean |
|||
|
|||
object RecordFile { |
|||
private var currentAudioFile: File? = null |
|||
private var fos: FileOutputStream? = null |
|||
|
|||
// 新增:用于异步写入的队列和线程 |
|||
private val writeQueue = LinkedBlockingQueue<ByteArray>() |
|||
private val isWriting = AtomicBoolean(false) |
|||
private var writeThread: Thread? = null |
|||
|
|||
// 用于存储音频数据的缓冲区 |
|||
private val dataBuffer = mutableListOf<ByteArray>() |
|||
private var totalBytesWritten = 0 |
|||
|
|||
// 音频配置 |
|||
private val sampleRate = 16000 |
|||
|
|||
// 新增:用于存储最后成功保存的文件 |
|||
private var lastSavedFile: File? = null |
|||
private var lastFileTimestamp: String = "" |
|||
var isPause = false |
|||
var fileName = "" |
|||
|
|||
|
|||
// 修改:添加 filePath 参数 |
|||
internal fun creatingFiles(filePath: String) { |
|||
if (fos != null || currentAudioFile != null) { |
|||
return |
|||
} |
|||
|
|||
val file = File(filePath) |
|||
val parentDir = file.parentFile |
|||
if (!parentDir.exists()) { |
|||
parentDir.mkdirs() |
|||
} |
|||
|
|||
currentAudioFile = file |
|||
currentAudioFile?.createNewFile() |
|||
|
|||
fos = FileOutputStream(currentAudioFile).apply { |
|||
write(generateWavHeader(0)) |
|||
close() |
|||
} |
|||
|
|||
fos = FileOutputStream(currentAudioFile, true) |
|||
startWriteThread() |
|||
} |
|||
|
|||
|
|||
// 新增:启动写入线程 |
|||
private fun startWriteThread() { |
|||
if (isWriting.get()) return |
|||
isWriting.set(true) |
|||
writeThread = Thread { |
|||
try { |
|||
while (isWriting.get() || writeQueue.isNotEmpty()) { |
|||
val data = writeQueue.poll() ?: continue |
|||
fos?.write(data) |
|||
} |
|||
} catch (e: Exception) { |
|||
Log.e("tag", "异步写入音频数据失败: ${e.message}") |
|||
} |
|||
} |
|||
writeThread?.start() |
|||
} |
|||
|
|||
/** |
|||
* 保存音频数据到 WAV 文件(异步) |
|||
*/ |
|||
internal fun saveAudioDataToWav(buffer: ByteArray) { |
|||
if (fos == null || currentAudioFile == null) return |
|||
// 放入队列,由写线程写入 |
|||
writeQueue.offer(buffer.copyOf()) |
|||
totalBytesWritten += buffer.size |
|||
} |
|||
|
|||
|
|||
// 更新文件头生成(修正RIFF长度计算) |
|||
private fun generateWavHeader(dataLength: Int): ByteArray { |
|||
val totalLength = 36 + dataLength // RIFF块总长度 = 头部36字节 + 音频数据 |
|||
val byteRate = sampleRate * 2 * 1 // 采样率 * 字节/样本 * 通道数 |
|||
|
|||
return byteArrayOf( |
|||
'R'.code.toByte(), 'I'.code.toByte(), 'F'.code.toByte(), 'F'.code.toByte(), |
|||
(totalLength and 0xFF).toByte(), ((totalLength shr 8) and 0xFF).toByte(), |
|||
((totalLength shr 16) and 0xFF).toByte(), ((totalLength shr 24) and 0xFF).toByte(), |
|||
'W'.code.toByte(), 'A'.code.toByte(), 'V'.code.toByte(), 'E'.code.toByte(), |
|||
'f'.code.toByte(), 'm'.code.toByte(), 't'.code.toByte(), ' '.code.toByte(), |
|||
16, 0, 0, 0, // PCM头长度 |
|||
1, 0, // PCM格式 |
|||
1, 0, // 单声道 |
|||
(sampleRate and 0xFF).toByte(), ((sampleRate shr 8) and 0xFF).toByte(), 0, 0, // 采样率 |
|||
(byteRate and 0xFF).toByte(), ((byteRate shr 8) and 0xFF).toByte(), 0, 0, // 字节率 |
|||
2, 0, // 块对齐 (通道数 * 样本位数/8) |
|||
16, 0, // 样本位数 |
|||
'd'.code.toByte(), 'a'.code.toByte(), 't'.code.toByte(), 'a'.code.toByte(), |
|||
(dataLength and 0xFF).toByte(), ((dataLength shr 8) and 0xFF).toByte(), |
|||
((dataLength shr 16) and 0xFF).toByte(), ((dataLength shr 24) and 0xFF).toByte() |
|||
) |
|||
} |
|||
|
|||
/** |
|||
* 重命名指定路径的音频文件 |
|||
* @param filePath 要重命名的文件完整路径 |
|||
* @param newName 新的文件名(不含扩展名) |
|||
* @return true 重命名成功,false 失败 |
|||
*/ |
|||
internal fun renameFile(filePath: String, newName: String): Boolean { |
|||
val sourceFile = File(filePath) |
|||
|
|||
// 验证源文件存在 |
|||
if (!sourceFile.exists()) { |
|||
Log.w("tag", "重命名失败: 文件不存在 $filePath") |
|||
return false |
|||
} |
|||
|
|||
// 防止重命名正在录制的文件 |
|||
if (sourceFile == currentAudioFile) { |
|||
Log.w("tag", "重命名失败: 不能重命名正在录制的文件") |
|||
return false |
|||
} |
|||
|
|||
val parentDir = sourceFile.parentFile |
|||
val newFile = File(parentDir, "$newName.wav") // 保持.wav扩展名 |
|||
|
|||
// 处理目标文件已存在的情况 |
|||
if (newFile.exists()) { |
|||
Log.w("tag", "重命名失败: 目标文件已存在 ${newFile.absolutePath}") |
|||
return false |
|||
} |
|||
|
|||
// 执行重命名 |
|||
return try { |
|||
val success = sourceFile.renameTo(newFile) |
|||
if (success) { |
|||
Log.d("tag", "文件重命名成功: ${sourceFile.name} -> ${newFile.name}") |
|||
// 更新最后保存的文件引用(如果是同一个文件) |
|||
if (lastSavedFile == sourceFile) { |
|||
lastSavedFile = newFile |
|||
} |
|||
} else { |
|||
Log.w("tag", "文件重命名操作失败") |
|||
} |
|||
success |
|||
} catch (e: Exception) { |
|||
Log.e("tag", "重命名异常: ${e.message}") |
|||
false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 移动文件到新路径 |
|||
* @param sourcePath 原始文件完整路径 |
|||
* @param destPath 目标文件完整路径 |
|||
* @return true 移动成功,false 移动失败 |
|||
*/ |
|||
internal fun moveFile(sourcePath: String, destPath: String): Boolean { |
|||
val sourceFile = File(sourcePath) |
|||
val destFile = File(destPath) |
|||
|
|||
if (!sourceFile.exists()) { |
|||
Log.w("tag", "移动失败: 源文件不存在 ${sourceFile.absolutePath}") |
|||
return false |
|||
} |
|||
|
|||
// 确保目标目录存在 |
|||
val destDir = destFile.parentFile |
|||
if (!destDir.exists() && !destDir.mkdirs()) { |
|||
Log.w("tag", "移动失败: 无法创建目标目录 ${destDir.absolutePath}") |
|||
return false |
|||
} |
|||
|
|||
// 删除已存在的目标文件 |
|||
if (destFile.exists() && !destFile.delete()) { |
|||
Log.w("tag", "移动失败: 无法覆盖已存在文件 ${destFile.absolutePath}") |
|||
return false |
|||
} |
|||
|
|||
return try { |
|||
// 使用renameTo尝试移动(同分区最快) |
|||
if (sourceFile.renameTo(destFile)) { |
|||
true |
|||
} else { |
|||
// 跨分区移动:复制+删除 |
|||
FileInputStream(sourceFile).use { input -> |
|||
FileOutputStream(destFile).use { output -> |
|||
input.copyTo(output) |
|||
} |
|||
} |
|||
if (!sourceFile.delete()) { |
|||
Log.w("tag", "移动警告: 源文件删除失败 ${sourceFile.absolutePath}") |
|||
} |
|||
true |
|||
}.also { |
|||
if (it) { |
|||
Log.d("tag", "文件移动成功: ${sourcePath} -> ${destPath}") |
|||
// 更新最后保存的文件引用 |
|||
if (lastSavedFile?.absolutePath == sourcePath) { |
|||
lastSavedFile = destFile |
|||
} |
|||
} |
|||
} |
|||
} catch (e: Exception) { |
|||
Log.e("tag", "文件移动异常: ${e.message}") |
|||
false |
|||
} |
|||
} |
|||
|
|||
internal fun closeFile(isSave: Boolean): Boolean { |
|||
try { |
|||
if (fos == null || currentAudioFile == null) { |
|||
return false |
|||
} |
|||
isWriting.set(false) |
|||
writeThread?.join(500) |
|||
fos?.close() |
|||
|
|||
currentAudioFile?.let { file -> |
|||
if (file.length() <= 44 || isSave == false) { |
|||
file.delete() |
|||
Log.d("tag", "音频文件过小已删除: ${file.absolutePath}") |
|||
return false |
|||
} else { |
|||
RandomAccessFile(file, "rw").use { raf -> |
|||
raf.seek(0) |
|||
raf.write(generateWavHeader(totalBytesWritten)) |
|||
} |
|||
Log.d("tag", "音频文件保存完成: ${file.absolutePath}") |
|||
|
|||
// 保存成功文件引用 |
|||
lastSavedFile = file |
|||
return true |
|||
} |
|||
} |
|||
} catch (e: Exception) { |
|||
Log.e("tag", "更新WAV文件头失败: ${e.message}") |
|||
return false |
|||
} finally { |
|||
fos = null |
|||
currentAudioFile = null |
|||
} |
|||
return false |
|||
} |
|||
} |
|||
Loading…
Reference in new issue