利用语音Modem实现电话点播和留言功能
有一段时间没有更新网站了,最近挺忙的,所以写书的进度慢了一些,两周只写了10多页设计模式相关的内容。希望在接下来的几周能加快进度,赶紧弄完。另外前两天,我被评为了Borland Delphi产品专家,加上这两天北京的非典形势也缓和多了,很高兴。为此公开很久以前写的一篇文章,与大家分享一下我的快乐。
偶然的起因
记得还是在去年情人节的时候,当时一直在为给女朋友送什么礼物而发愁,觉得送花实在没有什么创意,可又不知道什么样的礼物即能给她一个惊喜同事又不昂贵。这时,我的一个好朋友出了一个主意,说不如电话点歌吧,还比较特别。可是如果是通过电台点歌后,再告诉她收听的话就起不到意外的效果了。
就在没有什么好办法的时候,我在Delphi论坛上瞎逛的时候,一个人提出的问题突然启发了我,问题是关于如果编程实现语音留言和电话按键的记录功能的。我突然想为什么我不能写一个程序来控制电话,然后再给女友打一个传呼,让她回电话,当电话接通后,我的程序先播放一段事先录制好的话,提示她通过电话按键来选歌,并能提供留言的功能呢。主意一定,我就赶忙查阅这方面的资料了,一开始朋友们告诉可以通过语音卡来实现这些功能,可是语音卡比较贵,而且我买了后,除了用一次以外以后也不会经常用到,实在是有点浪费,后来网友cced提到他听人说TurboPower公司出的Async Professional控件提供了一组基于Telephone Api的控件可以通过语音Modem来实现类似的功能。这个看来成本就低多了,我的Modem正好是语音Modem,于是我就下载了Async Professional(官方网www.turbopower.com)试验了一下,果然不同反响,便宜且简单。
开始设计
下面我们就来看看如何利用这组控件实现语音功能,对于我们程序的应用来说,只需要使用两个TAPI控件TApdComPort和TApdTapiDevice即可,其中TApdComPort控件是一个串口通讯控件,因为Modem是同串口相连接的,因此需要串口通讯控件来进行控制。而TapdTapiDevice则是提供语音功能的核心控件。
首先,新建一个程序项目,在窗体上放置一个TApdComport控件,设置其属性为AutoOpen:=False;TapiMode=tmOn;这里TapiMode 设定为tmOn 表明TApdComPort 将由同其关联的TApdTapiDevice.控件来控制,而将AutoOpen设定为False 是因为串口的打开和关闭现在可以完全由TAPI来控制了。
然后,在窗体上放置一个TApdTapiDevice控件,设定其Comport属性为前面的TApdComPort控件。设定AnswerOnRing属性为1,表明第一次振铃后就开始由程序控制电话的应答。设定ShowTapiDevices为True表明当调用控件的SelectDevice方法时,会显示一个选择TAPI设备的对话框。ShowPorts属性为false,表明调用SelectDevice方法不会显示串行口列表。
接下来,本程序主要是采用有限状态机来控制流程的,下面我们来定义枚举状态
Type
TCurrentState = (csIdle, csWaiting, csConnected, csPlaying, csRecording, sDisconnected);
其中csIdle状态表示电话处于空闲状态,正等待接入。csWaiting则表示电话处于程序控制下,等待接入,如果有电话打入,程序会自动应答。csConnected则表示有电话打入,处于连接状态,csRecording则用来表示当前处于记录电话留言状态。csDisconnected则表示当前连接挂断了。
程序初始化
下面就是程序的OnCreate的事件处理函数,非常简单,就是先设置当前状态为csIdle,并设置ApdTapiDevice控件的TrimSeconds属性为5,表示当录音时如果有5秒的沉默时间就挂断。
procedure TFrmMain.FormCreate(Sender: TObject);
var
TeleIni: TIniFile;
begin
CurrentState := csIdle;
ApdTapiDevice.TrimSeconds := 5; //录音时有5秒静音就挂断
CommandList := TStringList.Create;
TeleIni := TIniFile.Create(ExtractFilePath(ParamStr(0)) + 'Tele.ini');
TeleIni.ReadSectionvalues('Commands', CommandList);
TeleIni.Free;
WindowState := wsMaximized;
end;
然后是将定义在Tele.Ini文件中的将要播放的声音列表文件目录加载到CommandList中。Tele.Ini的示例如下:
[Commands]
1#=1.wav
2#=2.wav
3#=3.wav
123#=E:\Program Files\APRO\Examples\Beep.wav
其中1#,表示当用户按下1和#号按键后,程序会播放其对应的1.wav文件。接下来就是我们要提供两个命令,一个是监控电话,一个是挂断电话,先在窗体上添加一个TlistBox,起名为LBSysInfo,然后添加两个菜单项,并同两个Action连接,编写Action的OnExecute事件处理函数:
//监控电话
procedure TFrmMain.ActionAnswerExecute(Sender: TObject);
begin
try
ApdTapiDevice.EnableVoice := True;
except
Application.MessageBox('当前设备不支持语音扩展', '错误', MB_OK);
end;
if ApdTapiDevice.EnableVoice then
begin
ApdTapiDevice.AutoAnswer;
LBSysInfo.Items.Add('answer:接听对方电话');
CurrentState := csWaiting;
end
end;
因为不是所有的Modem都支持语音功能,因此在监控电话接入前应该先判断设置ApdTapiDevice.EnableVoice := True;,如果出现异常,表明Modem不支持语音功能。如果支持的话,就调用AutoAnswer方法等待接入同时设置状态为csWaiting,并在列表框中写入日志。
//挂断电话
procedure TFrmMain.ActionCancelExecute(Sender: TObject);
begin
ApdTapiDevice.CancelCall;
LBSysInfo.Items.Add('cancel:挂断对方电话');
CurrentState := csIdle;
end;
挂断电话就简单多了,只要简单的调用TApdTapiDevice控件的CancelCall方法就可以了,还需要设置当前状态为csIdle。
如果系统中存在多个TAPI设备的时候,我们还可以选择使用哪一个来接听电话,下面是选择设备的方法:
//选择设备
procedure TFrmMain.ActionSelDevExecute(Sender: TObject);
begin
ApdTapiDevice.SelectDevice;
ApdTapiDevice.EnableVoice := True;
end;
事件驱动
Telephone API是基于事件驱动的,因此核心功能需要在事件处理函数中实现,先来看程序的TApdTapiDevice的OnConnect事件处理函数代码:
procedure TFrmMain.ApdTapiDeviceTapiConnect(Sender: TObject);
begin
CurrentState := csConnected;
LBSysInfo.Items.Add('Connect:连接成功');
ApdTapiDevice.PlayWaveFile('Greeting.wav');//播放功能提示语音
LBSysInfo.Items.Add('connect:播放greeting.wav');
end;
当用户打入被监控的电话后,会激发这个事件,程序应该在用户接入后播放提示语音,提示用户按不同功能键来点歌或留言。程序设置当前状态为csConnected,然后调用ApdTapiDevice的PlayWaveFile方法播放提示语音波文件。
要注意的是:不同Modem支持播放的波文件的格式是不同的,但它们都支持PCM 8位单声道的波文件,但这种类型波文件的音质非常差,用来播放歌曲效果实在糟糕,不过大多数语音Modem都支持音质更好的波文件格式,但通常都是 PCM格式的,比如我的Lucent Voice Modem就支持PCM 16位 单声道的波文件的播放。歌曲转化为波文件非常简单,我用Winamp将mp3文件通过Winamp本身的Disk Writer Plug-in插件直接将mp3转化成44位的波文件(通常为40-70M大小),然后在用一个叫goldwave的软件(我忘了从什么地方下载的了)将其转化为16位的单声道波文件(通常4-7M大小)。至于提示语音,我则是使用windows自带的录音机程序通过麦克风录制的。
当用户听完提示语音后,他们会按键来点歌或留言,而用户的按键会激发TApdTapiDevice的OnDTMF事件,我们就可以在这个事件中对按键进行处理,下面就是处理过程代码:
procedure TFrmMain.ApdTapiDeviceTapiDTMF(CP: TObject; Digit: Char;
ErrorCode: Integer);
begin
if (Digit = '') or (Digit = ' ') then
Exit;
LBSysInfo.Items.Add('dtmf:按键=' + Digit);
CurrentCommand := CurrentCommand + Digit;
{简单状态机}
if Digit = '#' then
begin
if CurrentCommand = '*#' then
begin
CurrentCommand := '';
ApdTapiDevice.MaxMessageLength := 30; //最长记录时间30秒
ApdTapiDevice.InterruptWave := False; //按键不能中断提示语音的播放
ApdTapiDevice.PlayWaveFile('recordhint.wav');//播放录音提示语音
CurrentState := csRecording;
Exit;
end;
if CommandList.values[CurrentCommand] <> '' then
begin
ApdTapiDevice.PlayWaveFile(CommandList.values[CurrentCommand]);
LBSysInfo.Items.Add(Format('%s %s 正在播放 %s',
[ApdTapiDevice.calleridname, apdtapidevice.callerid,
CommandList.values[CurrentCommand]]));
end
else
begin
//播放错误提示语音,并要求用户重新输入命令
ApdTapiDevice.PlayWaveFile('errorno.wav');
LBSysInfo.Items.Add(Format('%s %s 输入了错误的号码',
[ApdTapiDevice.calleridname, apdtapidevice.callerid]));
end;
//重置命令为空
CurrentCommand := '';
end;
end;
程序对按键进行判断(按键对应于digit参数),如果输入的为'*#'键,就进入录音功能,在录音前先播放提示语音,可以告诉用户留言长度为30秒,然后设置当前状态为csRecording,有人可能要问,没看到用来录音的代码呀,这部分其实是实现在另外的事件中的,我们稍后就会讲到。再来看点歌部分,同样的根据按键的组合在先前加载进CommandList的字符串列表中查找相匹配的歌曲,如果有相应的歌曲就播放,否则播放错误提示语音,提示用户重新输入命令,然后将按键清空等待重新输入。另外注意在事件的日志记录中我记录了ApdTapiDevice.calleridname和CallerID的属性,它们对应的是打入电话的号码,不过这项功能只对开通了来电显示功能的电话号码才有效,通过对打入电话号码信息的处理,我们可以提供一些额外的功能,不过这是题外话了。
前面提到了在按键处理事件中我们并没有进行留言的录制功能,这主要是因为我们要保证留言提示语音不被按键中断(设定Interruptwave:=false),因此把留言录制功能放到了TApdTapiDevice的OnWaveNotify事件中了,这个事件可以提示波文件播放的状态,比如播放结束和录音所需声音数据准备状态等,在本程序中我们需要在提示语音播放结束后,开始记录留言,并在留言声音数据准备好后,将其保存到磁盘文件中。下面是处理过程的流程:
procedure TFrmMain.ApdTapiDeviceTapiWaveNotify(CP: TObject;
Msg: TWaveMessage);
var
TimeStr: string;
FileName: string;
begin
//决不能在case外做耗时的操作
case Msg of
waPlayOpen: LBSysInfo.Items.Add('wavnotify:播放开始');
waPlayDone:
begin
LBSysInfo.Items.Add('wavnotify:播放结束');
if CurrentState = csRecording then
begin
try
//等待波设备状态为wsIdle再开始录音
while ApdTapiDevice.WaveState <> wsIdle do
Application.ProcessMessages;
ApdTapiDevice.InterruptWave := True;
ApdTapiDevice.StartWaveRecord;
LBSysInfo.Items.Add('dtmf:录音成功');
except
LBSysInfo.Items.Add('dtmf:录音失败');
end;
end;
end;
waPlayClose: LBSysInfo.Items.Add('wavnotify:播放关闭');
waRecordOpen: LBSysInfo.Items.Add('wavnotify:录音开始');
waDataReady:
begin
LBSysInfo.Items.Add('wavnotify:数据准备');
TimeSeparator := '-';
FileName := DateTimeToStr(Now) + '.wav';
try
ApdTapiDevice.SaveWaveFile(ExtractFilePath(ParamStr(0)) + 'record\' +
FileName, True);
LBSysInfo.Items.Add('wavNotify:保存声音文件 ' + FileName);
except
LBSysInfo.Items.Add('wavnotify:保存声音文件失败');
end;
end;
waRecordClose:
begin
LBSysInfo.Items.Add('wavnotify:记录声音结束');
CurrentState := csWaiting;
ActionCancelExecute(nil);
Timer1.Enabled := True;
end;
end;
end;
整个流程就是通过一个Case语句来判断当前声音状态,如果为waPlayDone(播放完毕),同事CurrentStatus为csRecording的话,就调用StartWaveRecord方法来记录声音。而当Msg为waDataReady状态时,表明录音数据已经可以存盘了,这时根据当前时间生成一个文件名,并将数据保存为波文件。而当录音结束后,我们就需要调用ActionCancelExecute(nil)来挂断电话,并将状态设置为csWaiting来等待下次接入,注意的在代码最后,我们将一个TTimer控件激活了。这个TTimer控件的时间间隔Interval设置为8秒,同时其OnTimer事件代码如下:
procedure TFrmMain.Timer1Timer(Sender: TObject);
begin
try
//应答电话
ActionAnswerExecute(nil);
CurrentState := csWaiting;
Timer1.Enabled := False;
except
end;
end;
这样设置的原因在于,当调用CancelCall方法来挂断电话后,TAPI设备需要8秒来恢复正常状态,如果立刻执行AutoAnswer的话,这个方法就会失效,无法正确监控电话接入,因此要用TTimer来控制恢复电话应答的时间。
异常处理
要想程序非常健壮的反复应答电话接入,我们必须对用户突然挂断电话进行处理,用户断开的事件会激发控件的OnTapiStatus事件,当用户挂断电话时,我们要做的是如果当前还在录音,就停止录音,如果是在播放歌曲,就挂断电话,然后设置TTimer生效,重新进入电话应答状态。下面就是整个处理过程的代码:
procedure TFrmMain.ApdTapiDeviceTapiStatus(CP: TObject; First,
Last: Boolean; Device, Message, Param1, Param2, Param3: Cardinal);
begin
if (Message = Line_CallState) then
begin
case Param1 of
LineCallState_Disconnected:
begin
LBSysInfo.Items.Add('status:disconnected from remote modem');
if CurrentState = csRecording then
begin
ApdTapiDevice.StopWaveRecord;
Exit;
end;
CurrentState := csDisconnected;
ActionCancelExecute(nil);
Timer1.Enabled := True;
end;
end;
end;
end;
进一步完善
当录音完毕后,我们想听一下电话留言的话,可以在窗体上放置一个打开文件对话框,用下面代码实现:
procedure TFrmMain.ActionPlayRecExecute(Sender: TObject);
var
FrmPlay: TFrmPlayRec;
begin
DlgOpenRec.InitialDir := ExtractFilePath(ParamStr(0)) + 'Record\';
if DlgOpenRec.Execute then
//播放声音记录文件
ShellExecute(Application.Handle, PChar('open'), PChar(DlgOpenRec.FileName),
nil, nil, SW_SHOW);
end;
另外,如果大家自信自己的歌喉不比那些歌星差的话,完全可以录制自己的歌声,然后播放给你的女朋友或朋友听,也许效果更棒:)。
最后,我要说的就是Telephone API所能提供的功能远远不止本文中所提到的,感兴趣的朋友可以进一步查阅相关资料来研究。
最后,要说的是Turbo Power已经不再开发Async Pro了,它把所有的源码都放到了Sourceforge上共享,大家可以到SourceForge上下载。