Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit cafba48

Browse files
committedMar 13, 2025··
feat: audio example
1 parent d1d3724 commit cafba48

File tree

17 files changed

+377
-26
lines changed

17 files changed

+377
-26
lines changed
 

‎assets/app.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import './bootstrap.js';
22
import 'bootstrap/dist/css/bootstrap.min.css';
33
import './styles/app.css';
4+
import './styles/audio.css';
45
import './styles/blog.css';
56
import './styles/youtube.css';
67
import './styles/wikipedia.css';
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { Controller } from '@hotwired/stimulus';
2+
import { getComponent } from '@symfony/ux-live-component';
3+
4+
export default class extends Controller {
5+
async initialize() {
6+
this.component = await getComponent(this.element);
7+
this.scrollToBottom();
8+
9+
const resetButton = document.getElementById('chat-reset');
10+
resetButton.addEventListener('click', (event) => {
11+
this.component.action('reset');
12+
});
13+
14+
const startButton = document.getElementById('micro-start');
15+
const stopButton = document.getElementById('micro-stop');
16+
const botThinkingButton = document.getElementById('bot-thinking');
17+
18+
startButton.addEventListener('click', (event) => {
19+
event.preventDefault();
20+
startButton.classList.add('d-none');
21+
stopButton.classList.remove('d-none');
22+
this.startRecording();
23+
});
24+
stopButton.addEventListener('click', (event) => {
25+
event.preventDefault();
26+
stopButton.classList.add('d-none');
27+
botThinkingButton.classList.remove('d-none');
28+
this.mediaRecorder.stop();
29+
});
30+
31+
this.component.on('loading.state:started', (e,r) => {
32+
if (r.actions.includes('reset')) {
33+
return;
34+
}
35+
document.getElementById('welcome')?.remove();
36+
document.getElementById('loading-message').removeAttribute('class');
37+
this.scrollToBottom();
38+
});
39+
40+
this.component.on('loading.state:finished', () => {
41+
document.getElementById('loading-message').setAttribute('class', 'd-none');
42+
botThinkingButton.classList.add('d-none');
43+
startButton.classList.remove('d-none');
44+
});
45+
46+
this.component.on('render:finished', () => {
47+
this.scrollToBottom();
48+
});
49+
};
50+
51+
async startRecording() {
52+
const stream = await navigator.mediaDevices.getUserMedia({audio: true});
53+
this.mediaRecorder = new MediaRecorder(stream);
54+
let audioChunks = [];
55+
56+
this.mediaRecorder.ondataavailable = (event) => {
57+
audioChunks.push(event.data);
58+
};
59+
60+
this.mediaRecorder.onstop = async () => {
61+
const audioBlob = new Blob(audioChunks, {type: 'audio/wav'});
62+
63+
const base64String = await this.blobToBase64(audioBlob);
64+
this.component.action('submit', { audio: base64String });
65+
};
66+
67+
this.mediaRecorder.start();
68+
}
69+
70+
scrollToBottom() {
71+
const chatBody = document.getElementById('chat-body');
72+
chatBody.scrollTop = chatBody.scrollHeight;
73+
}
74+
75+
blobToBase64(blob) {
76+
return new Promise((resolve) => {
77+
const reader = new FileReader();
78+
reader.readAsDataURL(blob);
79+
reader.onloadend = () => resolve(reader.result.split(',')[1]);
80+
});
81+
}
82+
83+
playBase64Audio(base64String) {
84+
const audioSrc = "data:audio/wav;base64," + base64String;
85+
const audio = new Audio(audioSrc);
86+
87+
audio.play().catch(error => console.error("Playback error:", error));
88+
}
89+
}
Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 1 addition & 0 deletions
Loading

‎assets/icons/iconoir/timer-solid.svg

Lines changed: 1 addition & 0 deletions
Loading

‎assets/styles/audio.css

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
.audio {
2+
body&, .card-img-top {
3+
background: #df662f;
4+
background: linear-gradient(0deg, #df662f 0%, #a80a1d 100%);
5+
}
6+
7+
.card-img-top {
8+
color: #ffffff;
9+
}
10+
11+
&.chat {
12+
.user-message {
13+
background: #df662f;
14+
color: #ffffff;
15+
}
16+
17+
.bot-message {
18+
color: #ffffff;
19+
background: #215d9a;
20+
21+
&.loading {
22+
color: rgba(255, 255, 255, 0.5);
23+
}
24+
25+
a {
26+
color: #c8d8ef;
27+
28+
&:hover {
29+
color: #ffffff;
30+
}
31+
}
32+
33+
code {
34+
color: #ffb1ca;
35+
}
36+
}
37+
38+
.avatar {
39+
&.bot {
40+
outline: 1px solid #c0dbf4;
41+
background: #c0dbf4;
42+
}
43+
44+
&.user {
45+
outline: 1px solid #f3b396;
46+
background: #f3b396;
47+
}
48+
}
49+
50+
#welcome h4 {
51+
color: #2c5282;
52+
}
53+
54+
#chat-reset, #chat-submit {
55+
&:hover {
56+
background: #a80a1d;
57+
border-color: #a80a1d;
58+
}
59+
}
60+
}
61+
}

‎composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"ext-iconv": "*",
1010
"codewithkyrian/chromadb-php": "^0.3.0",
1111
"league/commonmark": "^2.6",
12+
"php-llm/llm-chain": "dev-feat-whisper-support as 0.17.0",
1213
"php-llm/llm-chain-bundle": "^0.17",
1314
"runtime/frankenphp-symfony": "^0.2.0",
1415
"symfony/asset": "7.2.*",

‎composer.lock

Lines changed: 29 additions & 20 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎config/packages/llm_chain.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ llm_chain:
3737
system_prompt: 'Please answer the users question based on Wikipedia and provide a link to the article.'
3838
tools:
3939
- 'PhpLlm\LlmChain\Chain\ToolBox\Tool\Wikipedia'
40+
audio:
41+
model:
42+
name: 'GPT'
43+
version: 'gpt-4o-mini'
44+
system_prompt: 'You are a friendly chatbot that likes to have a conversation with users and asks them some questions.'
45+
tools: false
4046
store:
4147
chroma_db:
4248
symfonycon:

‎config/routes.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@ blog:
1111
template: 'chat.html.twig'
1212
context: { chat: 'blog' }
1313

14+
audio:
15+
path: '/audio'
16+
controller: 'Symfony\Bundle\FrameworkBundle\Controller\TemplateController'
17+
defaults:
18+
template: 'chat.html.twig'
19+
context: { chat: 'audio' }
20+
1421
youtube:
1522
path: '/youtube'
1623
controller: 'Symfony\Bundle\FrameworkBundle\Controller\TemplateController'

‎demo.png

60.3 KB
Loading

‎src/Audio/Chat.php

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Audio;
6+
7+
use PhpLlm\LlmChain\Bridge\OpenAI\Whisper;
8+
use PhpLlm\LlmChain\Bridge\OpenAI\Whisper\File;
9+
use PhpLlm\LlmChain\ChainInterface;
10+
use PhpLlm\LlmChain\Model\Message\Message;
11+
use PhpLlm\LlmChain\Model\Message\MessageBag;
12+
use PhpLlm\LlmChain\Model\Response\AsyncResponse;
13+
use PhpLlm\LlmChain\Model\Response\TextResponse;
14+
use PhpLlm\LlmChain\PlatformInterface;
15+
use Symfony\Component\DependencyInjection\Attribute\Autowire;
16+
use Symfony\Component\HttpFoundation\RequestStack;
17+
18+
final class Chat
19+
{
20+
private const SESSION_KEY = 'audio-chat';
21+
22+
public function __construct(
23+
private readonly PlatformInterface $platform,
24+
private readonly RequestStack $requestStack,
25+
#[Autowire(service: 'llm_chain.chain.audio')]
26+
private readonly ChainInterface $chain,
27+
) {
28+
}
29+
30+
public function say(string $base64audio): void
31+
{
32+
// Convert base64 to temporary binary file
33+
$path = tempnam(sys_get_temp_dir(), 'audio').'.wav';
34+
file_put_contents($path, base64_decode($base64audio));
35+
36+
$response = $this->platform->request(new Whisper(), new File($path));
37+
assert($response instanceof AsyncResponse);
38+
$response = $response->unwrap();
39+
assert($response instanceof TextResponse);
40+
41+
$this->submitMessage($response->getContent());
42+
}
43+
44+
public function loadMessages(): MessageBag
45+
{
46+
return $this->requestStack->getSession()->get(self::SESSION_KEY, new MessageBag());
47+
}
48+
49+
public function submitMessage(string $message): void
50+
{
51+
$messages = $this->loadMessages();
52+
53+
$messages->add(Message::ofUser($message));
54+
$response = $this->chain->call($messages);
55+
56+
assert($response instanceof TextResponse);
57+
58+
$messages->add(Message::ofAssistant($response->getContent()));
59+
60+
$this->saveMessages($messages);
61+
}
62+
63+
public function reset(): void
64+
{
65+
$this->requestStack->getSession()->remove(self::SESSION_KEY);
66+
}
67+
68+
private function saveMessages(MessageBag $messages): void
69+
{
70+
$this->requestStack->getSession()->set(self::SESSION_KEY, $messages);
71+
}
72+
}

‎src/Audio/TwigComponent.php

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Audio;
6+
7+
use PhpLlm\LlmChain\Model\Message\MessageInterface;
8+
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
9+
use Symfony\UX\LiveComponent\Attribute\LiveAction;
10+
use Symfony\UX\LiveComponent\Attribute\LiveArg;
11+
use Symfony\UX\LiveComponent\DefaultActionTrait;
12+
13+
#[AsLiveComponent('audio')]
14+
final class TwigComponent
15+
{
16+
use DefaultActionTrait;
17+
18+
public function __construct(
19+
private readonly Chat $chat,
20+
) {
21+
}
22+
23+
/**
24+
* @return MessageInterface[]
25+
*/
26+
public function getMessages(): array
27+
{
28+
return $this->chat->loadMessages()->withoutSystemMessage()->getMessages();
29+
}
30+
31+
#[LiveAction]
32+
public function submit(#[LiveArg] string $audio): void
33+
{
34+
$this->chat->say($audio);
35+
}
36+
37+
#[LiveAction]
38+
public function reset(): void
39+
{
40+
$this->chat->reset();
41+
}
42+
}

‎templates/base.html.twig

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@
3131
<li class="nav-item">
3232
<a class="nav-link" href="{{ path('wikipedia') }}">{{ ux_icon('mdi:wikipedia', { height: '20px', width: '20px' }) }} Wikipedia Research Bot</a>
3333
</li>
34+
<li class="nav-item">
35+
<a class="nav-link" href="{{ path('audio') }}">{{ ux_icon('iconoir:microphone-solid', { height: '20px', width: '20px' }) }} Audio Bot</a>
36+
</li>
3437
<li class="nav-item"><span class="nav-link">|</span></li>
3538
<li class="nav-item">
3639
<a class="nav-link" href="https://github.com/php-llm/symfony-demo" target="_blank">{{ ux_icon('mdi:github', { height: '20px', width: '20px' }) }} GitHub</a>

‎templates/components/audio.html.twig

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
{% import "_message.html.twig" as message %}
2+
3+
<div class="card mx-auto shadow-lg" {{ attributes.defaults(stimulus_controller('audio')) }}>
4+
<div class="card-header p-2">
5+
{{ ux_icon('iconoir:microphone-solid', { height: '32px', width: '32px' }) }}
6+
<strong class="ms-1 pt-1 d-inline-block">Conversational Bot</strong>
7+
<button id="chat-reset" class="btn btn-sm btn-outline-secondary float-end">{{ ux_icon('material-symbols:cancel') }} Reset Chat</button>
8+
</div>
9+
<div id="chat-body" class="card-body p-4 overflow-auto">
10+
{% for message in this.messages %}
11+
{% include '_message.html.twig' with { message, latest: loop.last } %}
12+
{% else %}
13+
<div id="welcome" class="text-center mt-5 py-5 bg-white rounded-5 shadow-sm w-75 mx-auto">
14+
{{ ux_icon('iconoir:microphone-solid', { height: '200px', width: '200px' }) }}
15+
<h4 class="mt-5">Audio Bot</h4>
16+
<span class="text-muted">Please hit the button below to start talking and again to stop</span>
17+
</div>
18+
{% endfor %}
19+
<div id="loading-message" class="d-none">
20+
{{ message.user([{text:''}]) }}
21+
{{ message.bot('The Bot is looking for an answer ...', true) }}
22+
</div>
23+
</div>
24+
<div class="card-footer p-2 text-center">
25+
<button id="micro-start" class="btn btn-primary" type="button">
26+
{{ ux_icon('iconoir:microphone-solid', { height: '24px', width: '24px' }) }}
27+
<strong>Say something</strong>
28+
</button>
29+
<button id="micro-stop" class="btn btn-danger d-none" type="button">
30+
{{ ux_icon('iconoir:microphone-mute-solid', { height: '24px', width: '24px' }) }}
31+
<strong>Stop</strong>
32+
</button>
33+
<button id="bot-thinking" class="btn btn-secondary disabled d-none" type="button">
34+
{{ ux_icon('iconoir:timer-solid', { height: '24px', width: '24px' }) }}
35+
<strong>Bot is thinking</strong>
36+
</button>
37+
</div>
38+
</div>

‎templates/index.html.twig

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
</p>
1414
<h3 class="text-dark">Examples</h3>
1515
<div class="row">
16-
<div class="col-md-4">
16+
<div class="col-md-3">
1717
<div class="card blog bg-body shadow-sm">
1818
<div class="card-img-top py-2">
1919
{{ ux_icon('mdi:symfony', { height: '150px', width: '150px' }) }}
@@ -32,14 +32,14 @@
3232
{% endif %}
3333
</div>
3434
</div>
35-
<div class="col-md-4">
35+
<div class="col-md-3">
3636
<div class="card youtube bg-body shadow-sm">
3737
<div class="card-img-top py-2">
3838
{{ ux_icon('bi:youtube', { height: '150px', width: '150px' }) }}
3939
</div>
4040
<div class="card-body">
4141
<h5 class="card-title">YouTube Transcript Bot</h5>
42-
<p class="card-text">Question answering initialized with transcript of YouTube video.</p>
42+
<p class="card-text">Question answering started with a YouTube video ID which gets converted into a transcript.</p>
4343
<a href="{{ path('youtube') }}" class="btn btn-outline-dark d-block">Try YouTube Transcript Bot</a>
4444
</div>
4545
{# Profiler route only available in dev #}
@@ -51,14 +51,14 @@
5151
{% endif %}
5252
</div>
5353
</div>
54-
<div class="col-md-4">
54+
<div class="col-md-3">
5555
<div class="card wikipedia bg-body shadow-sm">
5656
<div class="card-img-top py-2">
5757
{{ ux_icon('mdi:wikipedia', { height: '150px', width: '150px' }) }}
5858
</div>
5959
<div class="card-body">
6060
<h5 class="card-title">Wikipedia Research Bot</h5>
61-
<p class="card-text">Some quick example text to build on the card title and make up the bulk of the card's content.</p>
61+
<p class="card-text">A chatbot equipped with tools to search and read on Wikipedia about topics the user asks for.</p>
6262
<a href="{{ path('wikipedia') }}" class="btn btn-outline-dark d-block">Try Wikipedia Research Bot</a>
6363
</div>
6464
{# Profiler route only available in dev #}
@@ -70,6 +70,25 @@
7070
{% endif %}
7171
</div>
7272
</div>
73+
<div class="col-md-3">
74+
<div class="card audio bg-body shadow-sm">
75+
<div class="card-img-top py-2">
76+
{{ ux_icon('iconoir:microphone-solid', { height: '150px', width: '150px' }) }}
77+
</div>
78+
<div class="card-body">
79+
<h5 class="card-title">Audio Bot</h5>
80+
<p class="card-text">Simple demonstration of text-to-speech with Whisper in combination with GPT.</p>
81+
<a href="{{ path('audio') }}" class="btn btn-outline-dark d-block">Try Audio Bot</a>
82+
</div>
83+
{# Profiler route only available in dev #}
84+
{% if 'dev' == app.environment %}
85+
<div class="card-footer">
86+
{{ ux_icon('solar:code-linear', { height: '20px', width: '20px' }) }}
87+
<a href="{{ path('_profiler_open_file', { file: 'src/Audio/Chat.php', line: 14 }) }}">See Implementation</a>
88+
</div>
89+
{% endif %}
90+
</div>
91+
</div>
7392
</div>
7493
</div>
7594
{% endblock %}

‎tests/SmokeTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ public function testIndex(): void
2121

2222
self::assertResponseIsSuccessful();
2323
self::assertSelectorTextSame('h1', 'Welcome to the LLM Chain Demo');
24-
self::assertSelectorCount(3, '.card');
24+
self::assertSelectorCount(4, '.card');
2525
}
2626

2727
#[DataProvider('provideChats')]

0 commit comments

Comments
 (0)
Please sign in to comment.