feat: OPD speech-to-text mic, medicine label enhancements & template search

- Add complaints Speech-to-Text (browser + server STT fallback) with interim preview
- Add Medical Mic (server STT) for examination, diagnosis, provisional diagnosis fields
- Add speech-test route + Home::speechTest() view for STT testing
- OPD prescription: resolve dosage/when/frequency labels from lookup tables
- OPD prescription: show generic/salt name below medicine name in print
- OPD prescription: translate complaints/diagnosis/advice/investigation to Hindi for patient
- Add translateToLocalPatientText() helper with English->Hindi medical term map
- Add 'Remove All' medicines button (btn_clear_medicine)
- Add template search box in Load Template modal
- Fix investigation batch add: dedup + guard against concurrent add
- Fix single investigation add: check duplicate before adding
- Fix rx status text overflow: ellipsis + title tooltip
- Remove unused FHIR History and Back to Invoice buttons from Rx header
- .gitignore: exclude public/uploads/* and writable/ayushman/*
This commit is contained in:
DevSoftTech Server 2026-03-30 18:46:33 +05:30
parent 8e981cdf15
commit 17e2d8e16b
7 changed files with 1459 additions and 36 deletions

5
.gitignore vendored
View File

@ -63,6 +63,8 @@ writable/uploads/*
writable/debugbar/*
!writable/debugbar/index.html
writable/ayushman/*
php_errors.log
#-------------------------
@ -124,3 +126,6 @@ _modules/*
/results/
/phpunit*.xml
public/uploads/*
!public/uploads/.gitkeep

View File

@ -9,6 +9,7 @@ $routes->get('/', 'Home::index');
$routes->get('dashboard', 'Home::dashboard');
$routes->get('my-profile', 'Home::myProfile');
$routes->post('my-profile/save', 'Home::myProfileSave');
$routes->get('speech-test', 'Home::speechTest');
// Pharmacy (Medical) - phase 1
$routes->get('Medical', 'Medical::index');

View File

@ -236,6 +236,16 @@ class Home extends BaseController
]);
}
public function speechTest()
{
$authRedirect = $this->ensureAuthenticated();
if ($authRedirect !== null) {
return $authRedirect;
}
return view('tools/speech_test');
}
public function myProfileSave()
{
$authRedirect = $this->ensureAuthenticated();

View File

@ -1745,14 +1745,21 @@ class Opd extends BaseController
$i = 0;
foreach ($rxMeds as $med) {
$i++;
$name = trim((string) ($med['drug_name'] ?? $med['medicine_name'] ?? $med['item_name'] ?? ''));
$dose = trim((string) ($med['drug_dose'] ?? $med['dose'] ?? ''));
$freq = trim((string) ($med['drug_freq'] ?? $med['frequency'] ?? ''));
$days = trim((string) ($med['drug_day'] ?? $med['days'] ?? ''));
$inst = trim((string) ($med['drug_instruction'] ?? $med['instruction'] ?? ''));
$name = trim((string) ($med['med_name'] ?? $med['drug_name'] ?? $med['medicine_name'] ?? $med['item_name'] ?? ''));
$generic = trim((string) ($med['genericname'] ?? ($med['generic_name'] ?? ($med['salt_name'] ?? ''))));
$dose = trim((string) ($med['dosage_label'] ?? $med['dosage'] ?? $med['drug_dose'] ?? $med['dose'] ?? ''));
$freq = trim((string) ($med['dosage_freq_label'] ?? $med['dosage_freq'] ?? $med['drug_freq'] ?? $med['frequency'] ?? ''));
$days = trim((string) ($med['no_of_days'] ?? $med['drug_day'] ?? $med['days'] ?? ''));
$when = trim((string) ($med['dosage_when_label'] ?? $med['dosage_when'] ?? ''));
$remark = trim((string) ($med['remark'] ?? ''));
$inst = trim(($when !== '' ? $when : '') . ($remark !== '' ? ($when !== '' ? ' | ' : '') . $remark : ''));
$nameHtml = esc($name);
if ($generic !== '') {
$nameHtml .= '<div style="font-size:10px;color:#444;">Salt/Generic: ' . esc($generic) . '</div>';
}
$medicalHtml .= '<tr>'
. '<td>' . $i . '</td>'
. '<td>' . esc($name) . '</td>'
. '<td>' . $nameHtml . '</td>'
. '<td>' . esc(trim($dose . ' ' . $freq)) . '</td>'
. '<td>' . esc($days) . '</td>'
. '<td>' . esc($inst) . '</td>'
@ -1785,6 +1792,26 @@ class Opd extends BaseController
$rxAdvice = implode(', ', $parts);
}
$complaintText = (string) ($rx['complaints'] ?? '');
$diagnosisText = (string) (($rx['Provisional_diagnosis'] ?? '') !== '' ? ($rx['Provisional_diagnosis'] ?? '') : ($rx['diagnosis'] ?? ''));
$complaintLocal = $this->translateToLocalPatientText($complaintText);
$diagnosisLocal = $this->translateToLocalPatientText($diagnosisText);
$investigationLocal = $this->translateToLocalPatientText($rxInvestigation);
$rxAdviceLocal = $this->translateToLocalPatientText($rxAdvice);
if (strtolower(trim($complaintLocal)) === strtolower(trim($complaintText))) {
$complaintLocal = '';
}
if (strtolower(trim($diagnosisLocal)) === strtolower(trim($diagnosisText))) {
$diagnosisLocal = '';
}
if (strtolower(trim($investigationLocal)) === strtolower(trim($rxInvestigation))) {
$investigationLocal = '';
}
if (strtolower(trim($rxAdviceLocal)) === strtolower(trim($rxAdvice))) {
$rxAdviceLocal = '';
}
$bp = trim((string) ($rx['bp'] ?? ''));
$diastolic = trim((string) ($rx['diastolic'] ?? ''));
$pulse = trim((string) ($rx['pulse'] ?? ''));
@ -1856,10 +1883,13 @@ class Opd extends BaseController
'total_no_visit' => $totalNoVisit,
'last_opdvisit_date' => $lastVisitText,
'str_opd_book_date' => $bookTime,
'Complaint' => (string) ($rx['complaints'] ?? ''),
'diagnosis' => (string) (($rx['Provisional_diagnosis'] ?? '') !== '' ? ($rx['Provisional_diagnosis'] ?? '') : ($rx['diagnosis'] ?? '')),
'Complaint' => $complaintText,
'Complaint_local' => $complaintLocal,
'diagnosis' => $diagnosisText,
'diagnosis_local' => $diagnosisLocal,
'Provisional_diagnosis' => (string) ($rx['Provisional_diagnosis'] ?? ''),
'investigation' => $rxInvestigation,
'investigation_local' => $investigationLocal,
'medical' => $medicalHtml,
'doctor' => '<p style="text-align:right;">Dr. ' . esc((string) ($opd->doc_name ?? '')) . '</p>',
'top_content' => '',
@ -1867,6 +1897,7 @@ class Opd extends BaseController
'Finding_Examinations' => (string) ($rx['Finding_Examinations'] ?? ''),
'Prescriber_Remarks' => (string) ($rx['Prescriber_Remarks'] ?? ''),
'advice' => $rxAdvice,
'advice_local' => $rxAdviceLocal,
'next_visit' => (string) ($rx['next_visit'] ?? ''),
'refer_to' => (string) ($rx['refer_to'] ?? ''),
'painscale' => '',
@ -1877,6 +1908,69 @@ class Opd extends BaseController
];
}
private function translateToLocalPatientText(string $input): string
{
$text = trim($input);
if ($text === '') {
return '';
}
$map = [
'before food' => 'भोजन से पहले',
'after food' => 'भोजन के बाद',
'after breakfast' => 'नाश्ते के बाद',
'before breakfast' => 'नाश्ते से पहले',
'after lunch' => 'दोपहर के भोजन के बाद',
'before lunch' => 'दोपहर के भोजन से पहले',
'after dinner' => 'रात के भोजन के बाद',
'before dinner' => 'रात के भोजन से पहले',
'morning' => 'सुबह',
'afternoon' => 'दोपहर',
'evening' => 'शाम',
'night' => 'रात',
'once daily' => 'दिन में एक बार',
'twice daily' => 'दिन में दो बार',
'thrice daily' => 'दिन में तीन बार',
'daily' => 'रोज',
'weekly' => 'साप्ताहिक',
'for' => 'के लिए',
'days' => 'दिन',
'day' => 'दिन',
'tab' => 'टैबलेट',
'cap' => 'कैप्सूल',
'syp' => 'सिरप',
'inj' => 'इंजेक्शन',
'apply' => 'लगाएं',
'continue' => 'जारी रखें',
'rest' => 'आराम करें',
'hydrate' => 'पर्याप्त पानी लें',
'avoid oily food' => 'तैलीय भोजन से बचें',
'avoid spicy food' => 'मसालेदार भोजन से बचें',
'follow up' => 'फॉलो-अप करें',
'review' => 'पुनः जांच',
'advice' => 'सलाह',
'investigation' => 'जांच',
'diagnosis' => 'निदान',
'fever' => 'बुखार',
'cough' => 'खांसी',
'cold' => 'जुकाम',
'headache' => 'सिर दर्द',
'abdominal pain' => 'पेट दर्द',
];
uksort($map, static function ($a, $b) {
return strlen((string) $b) <=> strlen((string) $a);
});
$translated = $text;
foreach ($map as $en => $local) {
$translated = preg_replace('/\b' . preg_quote($en, '/') . '\b/i', $local, $translated) ?? $translated;
}
$translated = preg_replace('/\s+/', ' ', trim($translated)) ?? trim($translated);
return $translated;
}
/**
* @param array<string, mixed> $vars
*/
@ -2221,6 +2315,11 @@ class Opd extends BaseController
$tokens['doctor_sign_html'] = nl2br((string) ($data['opd_master'][0]->doc_sign ?? ''));
$tokens['content'] = (string) ($tokens['content'] ?? '');
$adviceLocal = trim((string) ($tokens['advice_local'] ?? ''));
$tokens['advice_local_line'] = $adviceLocal !== ''
? ('<br><strong>सलाह:</strong> ' . $adviceLocal)
: '';
return $tokens;
}
@ -2499,6 +2598,115 @@ class Opd extends BaseController
$medBuilder->orderBy('id', 'ASC');
}
$data['rx_medicines'] = $medBuilder->get()->getResultArray();
if (!empty($data['rx_medicines'])) {
$mapDose = [];
$mapWhen = [];
$mapFreq = [];
$medGenericMap = [];
$missingGenericMedIds = [];
if ($this->db->tableExists('opd_dose_shed')) {
$f = $this->db->getFieldNames('opd_dose_shed');
$idF = $this->resolveFirstField($f, ['dose_shed_id', 'id']);
$labelF = $this->resolveFirstField($f, ['dose_show_sign', 'dose_sign', 'dose_sign_desc', 'name']);
if ($idF !== null && $labelF !== null) {
$rows = $this->db->table('opd_dose_shed')->select($idF . ' as id,' . $labelF . ' as label')->where($labelF . ' !=', '')->get()->getResultArray();
foreach ($rows as $r) {
$id = trim((string) ($r['id'] ?? ''));
$label = trim((string) ($r['label'] ?? ''));
if ($id !== '' && $label !== '') {
$mapDose[$id] = $label;
}
}
}
}
if ($this->db->tableExists('opd_dose_when')) {
$f = $this->db->getFieldNames('opd_dose_when');
$idF = $this->resolveFirstField($f, ['dose_when_id', 'id']);
$labelF = $this->resolveFirstField($f, ['dose_sign', 'dose_sign_desc', 'name']);
if ($idF !== null && $labelF !== null) {
$rows = $this->db->table('opd_dose_when')->select($idF . ' as id,' . $labelF . ' as label')->where($labelF . ' !=', '')->get()->getResultArray();
foreach ($rows as $r) {
$id = trim((string) ($r['id'] ?? ''));
$label = trim((string) ($r['label'] ?? ''));
if ($id !== '' && $label !== '') {
$mapWhen[$id] = $label;
}
}
}
}
if ($this->db->tableExists('opd_dose_frequency')) {
$f = $this->db->getFieldNames('opd_dose_frequency');
$idF = $this->resolveFirstField($f, ['dose_freq_id', 'id']);
$labelF = $this->resolveFirstField($f, ['dose_sign', 'dose_sign_desc', 'name']);
if ($idF !== null && $labelF !== null) {
$rows = $this->db->table('opd_dose_frequency')->select($idF . ' as id,' . $labelF . ' as label')->where($labelF . ' !=', '')->get()->getResultArray();
foreach ($rows as $r) {
$id = trim((string) ($r['id'] ?? ''));
$label = trim((string) ($r['label'] ?? ''));
if ($id !== '' && $label !== '') {
$mapFreq[$id] = $label;
}
}
}
}
foreach ($data['rx_medicines'] as &$medRow) {
$doseRaw = trim((string) ($medRow['dosage'] ?? ''));
$whenRaw = trim((string) ($medRow['dosage_when'] ?? ''));
$freqRaw = trim((string) ($medRow['dosage_freq'] ?? ''));
$medRow['dosage_label'] = ($doseRaw !== '' && isset($mapDose[$doseRaw])) ? $mapDose[$doseRaw] : $doseRaw;
$medRow['dosage_when_label'] = ($whenRaw !== '' && isset($mapWhen[$whenRaw])) ? $mapWhen[$whenRaw] : $whenRaw;
$medRow['dosage_freq_label'] = ($freqRaw !== '' && isset($mapFreq[$freqRaw])) ? $mapFreq[$freqRaw] : $freqRaw;
$genericInline = trim((string) ($medRow['genericname'] ?? ($medRow['salt_name'] ?? '')));
if ($genericInline === '') {
$medId = (int) ($medRow['med_id'] ?? 0);
if ($medId > 0) {
$missingGenericMedIds[$medId] = true;
}
}
}
unset($medRow);
if (!empty($missingGenericMedIds) && $this->db->tableExists('opd_med_master')) {
$masterFields = $this->db->getFieldNames('opd_med_master');
$masterIdField = $this->resolveFirstField($masterFields, ['id']);
$masterGenericField = $this->resolveFirstField($masterFields, ['genericname', 'generic_name']);
$masterSaltField = $this->resolveFirstField($masterFields, ['salt_name', 'sal_name', 'salt', 'saltname']);
$labelField = $masterSaltField ?? $masterGenericField;
if ($masterIdField !== null && $labelField !== null) {
$rows = $this->db->table('opd_med_master')
->select($masterIdField . ' as med_id,' . $labelField . ' as generic_fallback')
->whereIn($masterIdField, array_map('intval', array_keys($missingGenericMedIds)))
->get()
->getResultArray();
foreach ($rows as $r) {
$id = (int) ($r['med_id'] ?? 0);
$label = trim((string) ($r['generic_fallback'] ?? ''));
if ($id > 0 && $label !== '') {
$medGenericMap[$id] = $label;
}
}
}
}
foreach ($data['rx_medicines'] as &$medRow) {
$genericInline = trim((string) ($medRow['genericname'] ?? ($medRow['salt_name'] ?? '')));
if ($genericInline === '') {
$fallback = $medGenericMap[(int) ($medRow['med_id'] ?? 0)] ?? '';
if ($fallback !== '') {
$medRow['genericname'] = $fallback;
}
}
}
unset($medRow);
}
} else {
$data['rx_medicines'] = [];
}

View File

@ -58,6 +58,20 @@
}
.rx-save-status {
font-size: .875rem;
display: flex;
align-items: center;
gap: .4rem;
min-width: 0;
max-width: 320px;
white-space: nowrap;
}
#rx_status_text {
display: inline-block;
min-width: 0;
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rx-tab-btn.active {
background: #0d6efd;
@ -123,6 +137,56 @@
float: right;
margin-top: -2px;
}
.rx-label-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: .5rem;
flex-wrap: wrap;
margin-bottom: .25rem;
}
.rx-label-actions .rx-label-title {
font-weight: 600;
color: #344054;
}
.rx-complaint-actions {
display: inline-flex;
align-items: center;
gap: .35rem;
}
.rx-complaint-actions .rx-ai-btn {
float: none;
margin-top: 0;
}
.btn-complaints-rewrite {
border-color: #2b6cb0;
color: #2b6cb0;
background: #eef6ff;
}
.btn-complaints-rewrite:hover {
background: #2b6cb0;
color: #fff;
}
.btn-complaints-mic {
border-color: #13795b;
color: #13795b;
background: #ecfdf3;
min-width: 120px;
}
.btn-medical-stt {
border-color: #0f766e;
color: #0f766e;
background: #ecfeff;
min-width: 120px;
}
.btn-medical-stt:hover {
background: #0f766e;
color: #fff;
}
.btn-complaints-mic:hover {
background: #13795b;
color: #fff;
}
.rx-scan-banner {
display: none;
border: 1px solid #b6d4fe;
@ -222,8 +286,6 @@
<li><button type="button" class="dropdown-item text-warning" id="btn_print2">Print Rx With OPD Head LetterHead</button></li>
</ul>
</div>
<button type="button" class="btn btn-outline-secondary btn-sm" id="btn_toggle_fhir_history" title="Show/Hide FHIR Export History">📄 FHIR History</button>
<a href="javascript:load_form('<?= base_url('Opd/invoice') ?>/<?= esc($opd_id) ?>','OPD Invoice');" class="btn btn-secondary btn-sm">Back to Invoice</a>
</div>
</div>
<div id="rx_scan_banner" class="rx-scan-banner"></div>
@ -499,18 +561,19 @@
</div>
<div class="mb-3">
<label class="form-label">Complaints
<button type="button" class="btn btn-outline-primary btn-sm rx-ai-btn btn-ai-rewrite" data-target="complaints" data-mode="hinglish_to_english">Hinglish -> English</button>
<button type="button" class="btn btn-outline-primary btn-sm rx-ai-btn btn-ai-rewrite btn-complaints-rewrite" data-target="complaints" data-mode="hinglish_to_english" title="Convert Hinglish text to English">↔ Hinglish → English</button>
<button type="button" class="btn btn-outline-success btn-sm rx-ai-btn" id="btn_complaints_mic" title="Speech to text for complaints">Mic</button>
</label>
<textarea class="form-control rx-field" id="complaints" rows="4" maxlength="4000"><?= esc($opd_prescription[0]->complaints ?? '') ?></textarea>
<div id="complaints_interim_preview" style="display:none;font-size:.8rem;color:#6c757d;padding:2px 4px;font-style:italic;"></div>
<div class="rx-counter" id="counter_complaints">0/4000</div>
</div>
<div class="mb-3">
<label class="form-label">Examination
</label>
<div class="small text-muted mb-1">AI Assist is optional. Use it only if you want cleaner clinical wording; manual typing works as usual.</div>
<div class="d-flex flex-wrap gap-1 mb-1">
<button type="button" class="btn btn-outline-primary btn-sm btn-ai-rewrite" data-target="finding_examinations" data-mode="finding">AI Assist</button>
<button type="button" class="btn btn-sm btn-medical-stt" data-target="finding_examinations" data-label="🎙 Med Mic">🎙 Med Mic</button>
<button type="button" class="btn btn-outline-secondary btn-sm btn-field-past" data-section="finding_examinations" data-target="finding_examinations">Past Data</button>
<button type="button" class="btn btn-outline-secondary btn-sm btn-template-load" data-section="finding_examinations" data-target="finding_examinations">Load Template</button>
<button type="button" class="btn btn-outline-secondary btn-sm btn-template-save" data-section="finding_examinations" data-target="finding_examinations">Save as Template</button>
@ -524,7 +587,7 @@
<label class="form-label">Diagnosis
</label>
<div class="d-flex flex-wrap gap-1 mb-1">
<button type="button" class="btn btn-outline-primary btn-sm btn-ai-rewrite" data-target="diagnosis" data-mode="diagnosis">AI Assist</button>
<button type="button" class="btn btn-sm btn-medical-stt" data-target="diagnosis" data-label="🎙 Med Mic">🎙 Med Mic</button>
<button type="button" class="btn btn-outline-secondary btn-sm btn-field-past" data-section="diagnosis" data-target="diagnosis">Past Data</button>
<button type="button" class="btn btn-outline-secondary btn-sm btn-template-load" data-section="diagnosis" data-target="diagnosis">Load Template</button>
<button type="button" class="btn btn-outline-secondary btn-sm btn-template-save" data-section="diagnosis" data-target="diagnosis">Save as Template</button>
@ -538,7 +601,7 @@
<label class="form-label">Provisional Diagnosis
</label>
<div class="d-flex flex-wrap gap-1 mb-1">
<button type="button" class="btn btn-outline-primary btn-sm btn-ai-rewrite" data-target="provisional_diagnosis" data-mode="diagnosis">AI Assist</button>
<button type="button" class="btn btn-sm btn-medical-stt" data-target="provisional_diagnosis" data-label="🎙 Med Mic">🎙 Med Mic</button>
<button type="button" class="btn btn-outline-secondary btn-sm btn-field-past" data-section="provisional_diagnosis" data-target="provisional_diagnosis">Past Data</button>
<button type="button" class="btn btn-outline-secondary btn-sm btn-template-load" data-section="provisional_diagnosis" data-target="provisional_diagnosis">Load Template</button>
<button type="button" class="btn btn-outline-secondary btn-sm btn-template-save" data-section="provisional_diagnosis" data-target="provisional_diagnosis">Save as Template</button>
@ -552,7 +615,6 @@
<label class="form-label">Prescription
</label>
<div class="d-flex flex-wrap gap-1 mb-1">
<button type="button" class="btn btn-outline-primary btn-sm btn-ai-rewrite" data-target="prescriber_remarks" data-mode="autotype">AI Assist</button>
<button type="button" class="btn btn-outline-secondary btn-sm btn-field-past" data-section="prescriber_remarks" data-target="prescriber_remarks">Past Data</button>
<button type="button" class="btn btn-outline-secondary btn-sm btn-template-load" data-section="prescriber_remarks" data-target="prescriber_remarks">Load Template</button>
<button type="button" class="btn btn-outline-secondary btn-sm btn-template-save" data-section="prescriber_remarks" data-target="prescriber_remarks">Save as Template</button>
@ -572,7 +634,6 @@
<div class="mb-3">
<label class="form-label"><strong>Advice Add</strong></label>
<div class="d-flex flex-wrap gap-1 mb-1">
<button type="button" class="btn btn-outline-primary btn-sm btn-ai-rewrite" data-target="advice" data-mode="autotype">AI Assist</button>
<button type="button" class="btn btn-outline-secondary btn-sm btn-field-past" data-section="advice" data-target="advice">Past Data</button>
<button type="button" class="btn btn-outline-secondary btn-sm btn-template-load" data-section="advice" data-target="advice">Load Template</button>
<button type="button" class="btn btn-outline-secondary btn-sm btn-template-save" data-section="advice" data-target="advice">Save as Template</button>
@ -717,6 +778,7 @@
</div>
<div class="col-md-2 d-grid gap-2">
<button type="button" class="btn btn-primary" id="btn_add_medicine">Add</button>
<button type="button" class="btn btn-outline-danger" id="btn_clear_medicine">Remove All</button>
<button type="button" class="btn btn-outline-secondary" id="btn_cancel_medicine_edit" style="display:none;">Cancel</button>
</div>
</div>
@ -777,6 +839,8 @@
<div class="d-flex flex-wrap gap-1 mb-2" id="rx_template_suggest_box">
<span class="text-muted small">No suggestion available.</span>
</div>
<label class="form-label">Search Template</label>
<input type="text" class="form-control form-control-sm mb-2" id="rx_template_search" placeholder="Search by name or content...">
<label class="form-label">Select Template</label>
<select class="form-select form-select-sm" id="rx_template_select"></select>
<label class="form-label mt-2">Apply Mode</label>
@ -883,6 +947,20 @@
var latestLocalAssist = null;
var complaintSuggestions = [];
var selectedComplaints = [];
var complaintsMicMode = 'off';
var complaintsSpeechRecognition = null;
var complaintsMicActive = false;
var complaintsMediaRecorder = null;
var complaintsMediaStream = null;
var complaintsMediaChunks = [];
var complaintsBaseText = '';
var medicalSttTarget = '';
var medicalSttRecorder = null;
var medicalSttStream = null;
var medicalSttChunks = [];
var medicalSttActive = false;
var complaintsSttServerHealthUrl = 'http://139.59.13.39:8000/health';
var complaintsSttServerTranscribeUrl = 'http://139.59.13.39:8000/stt/transcribe';
var draftKey = 'opd_rx_draft_' + ($('#opd_id').val() || '0');
var templateLoadState = { target: '', section: '', rows: [] };
var templateSaveState = { section: '', text: '', scope: 'doctor' };
@ -894,6 +972,7 @@
var invCustomProfileKey = 'opd_inv_custom_profiles';
var legacyInvestigationProfiles = {};
var legacyInvestigationProfileList = [];
var investigationBatchActive = false;
var activeMedicineScope = 'active';
var medicineSuggestRows = [];
var investigationProfiles = {
@ -950,6 +1029,421 @@
}
}
function setComplaintsMicMode(mode, note) {
complaintsMicMode = mode;
var $btn = $('#btn_complaints_mic');
if (!$btn.length) {
return;
}
$btn.removeClass('btn-outline-success btn-outline-primary btn-outline-secondary btn-danger');
if (mode === 'server') {
$btn.addClass('btn-outline-success').prop('disabled', false).text('🎙 Server Mic');
} else if (mode === 'browser') {
$btn.addClass('btn-outline-primary').prop('disabled', false).text('🎤 Browser Mic');
} else {
$btn.addClass('btn-outline-secondary').prop('disabled', true).text('⏸ Mic Off');
}
if (note) {
$('.jsError').removeClass('text-danger text-success').addClass('text-muted').text(note);
}
}
function setComplaintsMicListening(listening) {
var $btn = $('#btn_complaints_mic');
if (!$btn.length) {
return;
}
complaintsMicActive = listening;
if (listening) {
$btn.removeClass('btn-outline-success btn-outline-primary btn-outline-secondary').addClass('btn-danger').text('⏹ Stop Mic');
} else {
setComplaintsMicMode(complaintsMicMode);
}
}
function appendTranscriptToComplaints(text) {
var transcript = (text || '').toString().trim();
if (!transcript) {
return;
}
var current = ($('#complaints').val() || '').toString().trim();
var nextVal = current ? (current + '\n' + transcript) : transcript;
$('#complaints').val(nextVal);
refreshCounters();
markDirty('Speech text added in complaints');
scheduleAutoSave();
}
function appendTranscriptToField(targetId, text) {
var transcript = (text || '').toString().trim();
if (!transcript || !targetId || !$('#' + targetId).length) {
return;
}
var current = ($('#' + targetId).val() || '').toString().trim();
var nextVal = current ? (current + '\n' + transcript) : transcript;
$('#' + targetId).val(nextVal);
refreshCounters();
markDirty('Medical speech text added');
scheduleAutoSave();
}
function getMedicalContextForField(targetId) {
if (targetId === 'finding_examinations') {
return 'examination findings';
}
if (targetId === 'diagnosis') {
return 'diagnosis notes';
}
if (targetId === 'provisional_diagnosis') {
return 'provisional diagnosis notes';
}
return 'clinical notes';
}
function setMedicalMicButtonState(activeTarget) {
$('.btn-medical-stt').each(function() {
var $btn = $(this);
var target = ($btn.data('target') || '').toString();
var baseText = ($btn.data('label') || '🎙 Med Mic').toString();
$btn.removeClass('btn-danger').addClass('btn-medical-stt').prop('disabled', false).text(baseText);
if (activeTarget && target === activeTarget) {
$btn.removeClass('btn-medical-stt').addClass('btn-danger').text('⏹ Stop Mic');
}
});
}
function stopMedicalSttStream() {
if (medicalSttStream && medicalSttStream.getTracks) {
medicalSttStream.getTracks().forEach(function(track) {
track.stop();
});
}
medicalSttStream = null;
medicalSttRecorder = null;
medicalSttChunks = [];
}
function sendMedicalAudioToServer(audioBlob, targetId) {
var formData = new FormData();
formData.append('audio', audioBlob, (targetId || 'clinical') + '.webm');
formData.append('lang', 'en-IN');
formData.append('medical_context', getMedicalContextForField(targetId));
return fetch(complaintsSttServerTranscribeUrl, {
method: 'POST',
mode: 'cors',
body: formData
}).then(function(response) {
if (!response.ok) {
return response.json().catch(function() {
return {};
}).then(function(payload) {
var err = new Error(payload.detail || ('Server response: ' + response.status));
err.statusCode = response.status;
throw err;
});
}
return response.json();
}).then(function(payload) {
var text = String((payload && (payload.text || payload.transcript || payload.result)) || '').trim();
if (!text) {
throw new Error('Empty transcript from server');
}
return text;
});
}
function startMedicalServerStt(targetId) {
if (!targetId || !$('#' + targetId).length) {
return;
}
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia || typeof MediaRecorder === 'undefined') {
$('.jsError').removeClass('text-success text-muted').addClass('text-danger').text('Medical speech is not available in this browser.');
return;
}
if (medicalSttActive && medicalSttRecorder) {
if (medicalSttTarget === targetId) {
medicalSttRecorder.stop();
$('.jsError').removeClass('text-danger text-muted').addClass('text-success').text('Processing medical transcript...');
return;
}
$('.jsError').removeClass('text-success text-muted').addClass('text-danger').text('Medical mic is already recording another field. Stop it first.');
return;
}
navigator.mediaDevices.getUserMedia({ audio: true }).then(function(stream) {
medicalSttTarget = targetId;
medicalSttStream = stream;
medicalSttRecorder = new MediaRecorder(stream);
medicalSttChunks = [];
medicalSttRecorder.ondataavailable = function(event) {
if (event.data && event.data.size > 0) {
medicalSttChunks.push(event.data);
}
};
medicalSttRecorder.onstop = function() {
var blob = new Blob(medicalSttChunks, {
type: (medicalSttRecorder && medicalSttRecorder.mimeType) ? medicalSttRecorder.mimeType : 'audio/webm'
});
var targetForTranscript = medicalSttTarget;
stopMedicalSttStream();
medicalSttActive = false;
medicalSttTarget = '';
setMedicalMicButtonState('');
sendMedicalAudioToServer(blob, targetForTranscript).then(function(transcript) {
appendTranscriptToField(targetForTranscript, transcript);
$('.jsError').removeClass('text-danger text-muted').addClass('text-success').text('Medical speech text added.');
}).catch(function(error) {
if (error && error.statusCode && error.statusCode >= 400 && error.statusCode < 500) {
$('.jsError').removeClass('text-success text-muted').addClass('text-danger').text('Medical speech input error: ' + (error.message || 'Invalid request'));
return;
}
$('.jsError').removeClass('text-success text-muted').addClass('text-danger').text('Medical speech server unavailable. Please try again.');
});
};
medicalSttActive = true;
setMedicalMicButtonState(targetId);
$('.jsError').removeClass('text-danger text-muted').addClass('text-success').text('Recording medical speech... click same Mic to stop.');
medicalSttRecorder.start();
}).catch(function() {
medicalSttActive = false;
medicalSttTarget = '';
setMedicalMicButtonState('');
$('.jsError').removeClass('text-success text-muted').addClass('text-danger').text('Microphone permission denied for medical speech.');
});
}
function checkComplaintsSttServer() {
if (!window.fetch || !window.AbortController) {
return Promise.resolve(false);
}
var controller = new AbortController();
var timeoutId = window.setTimeout(function() {
controller.abort();
}, 3500);
return fetch(complaintsSttServerHealthUrl, {
method: 'GET',
mode: 'cors',
signal: controller.signal
}).then(function(response) {
return !!(response && response.ok);
}).catch(function() {
return false;
}).finally(function() {
window.clearTimeout(timeoutId);
});
}
function sendComplaintsAudioToServer(audioBlob) {
var formData = new FormData();
formData.append('audio', audioBlob, 'complaints.webm');
formData.append('lang', 'en-IN');
formData.append('medical_context', (selectedComplaints || []).join(', '));
return fetch(complaintsSttServerTranscribeUrl, {
method: 'POST',
mode: 'cors',
body: formData
}).then(function(response) {
if (!response.ok) {
return response.json().catch(function() {
return {};
}).then(function(payload) {
var err = new Error(payload.detail || ('Server response: ' + response.status));
err.statusCode = response.status;
throw err;
});
}
return response.json();
}).then(function(payload) {
var text = String((payload && (payload.text || payload.transcript || payload.result)) || '').trim();
if (!text) {
throw new Error('Empty transcript from server');
}
return text;
});
}
function stopComplaintsMediaStream() {
if (complaintsMediaStream && complaintsMediaStream.getTracks) {
complaintsMediaStream.getTracks().forEach(function(track) {
track.stop();
});
}
complaintsMediaStream = null;
complaintsMediaRecorder = null;
complaintsMediaChunks = [];
}
function startComplaintsBrowserStt() {
if (!complaintsSpeechRecognition) {
$('.jsError').removeClass('text-success text-muted').addClass('text-danger').text('Browser speech recognition is not available.');
return;
}
if (complaintsMicActive) {
complaintsSpeechRecognition.stop();
return;
}
setComplaintsMicListening(true);
$('.jsError').removeClass('text-danger text-muted').addClass('text-success').text('Listening complaints (browser mode)...');
try {
complaintsSpeechRecognition.start();
} catch (e) {
setComplaintsMicListening(false);
$('.jsError').removeClass('text-success text-muted').addClass('text-danger').text('Could not start browser speech recognition.');
}
}
function startComplaintsServerStt() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia || typeof MediaRecorder === 'undefined') {
if (complaintsSpeechRecognition) {
setComplaintsMicMode('browser', 'Server recording is unavailable. Switched to browser mode.');
startComplaintsBrowserStt();
return;
}
setComplaintsMicMode('off', 'Speech is not available in this browser.');
return;
}
if (complaintsMicActive && complaintsMediaRecorder) {
complaintsMediaRecorder.stop();
$('.jsError').removeClass('text-danger text-muted').addClass('text-success').text('Processing server transcript...');
return;
}
navigator.mediaDevices.getUserMedia({ audio: true }).then(function(stream) {
complaintsMediaStream = stream;
complaintsMediaRecorder = new MediaRecorder(stream);
complaintsMediaChunks = [];
complaintsMediaRecorder.ondataavailable = function(event) {
if (event.data && event.data.size > 0) {
complaintsMediaChunks.push(event.data);
}
};
complaintsMediaRecorder.onstop = function() {
var blob = new Blob(complaintsMediaChunks, {
type: (complaintsMediaRecorder && complaintsMediaRecorder.mimeType) ? complaintsMediaRecorder.mimeType : 'audio/webm'
});
stopComplaintsMediaStream();
setComplaintsMicListening(false);
sendComplaintsAudioToServer(blob).then(function(transcript) {
appendTranscriptToComplaints(transcript);
$('.jsError').removeClass('text-danger text-muted').addClass('text-success').text('Complaints speech text added.');
}).catch(function(error) {
if (error && error.statusCode && error.statusCode >= 400 && error.statusCode < 500) {
$('.jsError').removeClass('text-success text-muted').addClass('text-danger').text('Speech input error: ' + (error.message || 'Invalid request'));
return;
}
if (complaintsSpeechRecognition) {
setComplaintsMicMode('browser', 'Server STT unavailable. Switched to browser mode.');
} else {
setComplaintsMicMode('off', 'Server STT unavailable and browser speech not supported.');
}
});
};
setComplaintsMicListening(true);
$('.jsError').removeClass('text-danger text-muted').addClass('text-success').text('Recording complaints... click Mic again to stop.');
complaintsMediaRecorder.start();
}).catch(function() {
if (complaintsSpeechRecognition) {
setComplaintsMicMode('browser', 'Microphone permission failed for server mode. Switched to browser mode.');
} else {
setComplaintsMicMode('off', 'Microphone permission denied.');
}
});
}
function initComplaintsSpeech() {
var SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (SpeechRecognition) {
complaintsSpeechRecognition = new SpeechRecognition();
complaintsSpeechRecognition.lang = 'en-IN';
complaintsSpeechRecognition.interimResults = true;
complaintsSpeechRecognition.continuous = false;
complaintsSpeechRecognition.onresult = function(event) {
var interimTranscript = '';
var finalTranscript = '';
for (var i = event.resultIndex; i < event.results.length; i++) {
var t = event.results[i][0].transcript;
if (event.results[i].isFinal) {
finalTranscript += t;
} else {
interimTranscript += t;
}
}
if (interimTranscript) {
// Show interim as a non-destructive preview below the textarea
$('#complaints_interim_preview').text('🎤 ' + interimTranscript).show();
}
if (finalTranscript.trim()) {
// Final: clear preview, append once to the base text
$('#complaints_interim_preview').hide().text('');
appendTranscriptToComplaints(finalTranscript.trim());
complaintsBaseText = ($('#complaints').val() || '').toString().trim();
}
};
complaintsSpeechRecognition.onerror = function(event) {
setComplaintsMicListening(false);
$('#complaints_interim_preview').hide().text('');
$('.jsError').removeClass('text-success text-muted').addClass('text-danger').text('Browser speech error: ' + (event.error || 'unknown'));
};
complaintsSpeechRecognition.onend = function() {
setComplaintsMicListening(false);
$('#complaints_interim_preview').hide().text('');
};
}
if (complaintsSpeechRecognition) {
setComplaintsMicMode('browser', 'Complaints Mic is ready (browser mode for local language).');
} else {
setComplaintsMicMode('off', 'Complaints Mic unavailable in this browser.');
}
$('#btn_complaints_mic').on('click', function() {
if (complaintsMicMode === 'browser') {
startComplaintsBrowserStt();
}
});
}
function initMedicalSpeechButtons() {
setMedicalMicButtonState('');
$(document).on('click', '.btn-medical-stt', function() {
var targetId = ($(this).data('target') || '').toString();
startMedicalServerStt(targetId);
});
}
function getCustomInvestigationProfiles() {
try {
var parsed = JSON.parse(localStorage.getItem(invCustomProfileKey) || '[]');
@ -1104,6 +1598,7 @@
function setStatus(type, message) {
var $badge = $('#rx_status_badge');
var $text = $('#rx_status_text');
var statusMessage = (message || '').toString();
$badge.removeClass('bg-secondary bg-warning bg-success bg-danger');
if (type === 'saved') {
@ -1118,7 +1613,7 @@
$badge.addClass('bg-secondary').text('Not Saved');
}
$text.text(message || '');
$text.text(statusMessage).attr('title', statusMessage);
}
function updateScanBanner() {
@ -1879,6 +2374,40 @@
});
}
function renderTemplateSelectOptions(searchQuery) {
var list = Array.isArray(templateLoadState.rows) ? templateLoadState.rows : [];
var query = (searchQuery || '').toString().trim().toLowerCase();
var $sel = $('#rx_template_select');
$sel.empty();
var added = 0;
list.forEach(function(row, idx) {
var name = (row.template_name || ('Template ' + (idx + 1))).toString();
var src = parseInt(row.doc_id || '0', 10) === 0 ? '[Master] ' : '[My] ';
var previewText = (row.template_text || '').toString();
var haystack = (name + ' ' + previewText + ' ' + src).toLowerCase();
if (query && haystack.indexOf(query) === -1) {
return;
}
$sel.append('<option value="' + idx + '">' + $('<div>').text(src + name).html() + '</option>');
added++;
});
if (!added) {
$sel.append('<option value="">No templates match search</option>');
$('#rx_template_preview').val('');
return;
}
var firstVal = ($sel.find('option:first').val() || '').toString();
var firstIdx = parseInt(firstVal, 10);
if (!isNaN(firstIdx) && firstIdx >= 0) {
$sel.val(String(firstIdx));
var firstRow = list[firstIdx] || {};
$('#rx_template_preview').val(firstRow.template_text || '');
}
}
$(document).on('click', '.btn-template-load', function() {
var target = ($(this).data('target') || '').toString();
var section = ($(this).data('section') || '').toString();
@ -1896,22 +2425,14 @@
var rankedRows = rankTemplateRows(rows, target);
templateLoadState = { target: target, section: section, rows: rankedRows };
var $sel = $('#rx_template_select');
$sel.empty();
rankedRows.forEach(function(row, idx) {
var name = row.template_name || ('Template ' + (idx + 1));
var src = parseInt(row.doc_id || '0', 10) === 0 ? '[Master] ' : '[My] ';
$sel.append('<option value="' + idx + '">' + $('<div>').text(src + name).html() + '</option>');
});
$('#rx_template_search').val('');
renderTemplateSelectOptions('');
renderTemplateQuickSuggestions(rankedRows);
var existingText = ($('#' + target).val() || '').toString().trim();
$('#rx_template_apply_mode').val(existingText ? 'append' : 'replace');
var firstText = (rankedRows[0] && rankedRows[0].template_text) ? rankedRows[0].template_text : '';
$('#rx_template_preview').val(firstText);
if (window.bootstrap && window.bootstrap.Modal) {
window.bootstrap.Modal.getOrCreateInstance(document.getElementById('rxTemplateModal')).show();
} else {
@ -1921,11 +2442,19 @@
});
$('#rx_template_select').on('change', function() {
var idx = parseInt($(this).val() || '0', 10);
var idx = parseInt($(this).val() || '-1', 10);
if (isNaN(idx) || idx < 0) {
$('#rx_template_preview').val('');
return;
}
var row = (templateLoadState.rows || [])[idx] || {};
$('#rx_template_preview').val(row.template_text || '');
});
$('#rx_template_search').on('input', function() {
renderTemplateSelectOptions($(this).val() || '');
});
$('#btn_apply_template_choice').on('click', function() {
var idx = parseInt($('#rx_template_select').val() || '0', 10);
var row = (templateLoadState.rows || [])[idx] || null;
@ -1976,6 +2505,10 @@
if (idx < 0) {
return;
}
if (!$('#rx_template_select option[value="' + idx + '"]').length) {
$('#rx_template_search').val('');
renderTemplateSelectOptions('');
}
$('#rx_template_select').val(String(idx)).trigger('change');
});
@ -2255,6 +2788,8 @@
refreshCounters();
setStatus('normal', 'No local changes');
renderComplaintChips();
initComplaintsSpeech();
initMedicalSpeechButtons();
setTimeout(loadPatientScanHistory, 50);
$(document).on('click', '.btn-ai-rewrite', function() {
@ -2620,6 +3155,10 @@
return map;
}
function normalizeInvestigationKey(name) {
return (name || '').toString().trim().toLowerCase();
}
function addInvestigationEntry(name, code, done) {
name = (name || '').trim();
code = (code || '').trim();
@ -2669,10 +3208,23 @@
return;
}
if (investigationBatchActive) {
$('.jsError').removeClass('text-success text-muted').addClass('text-danger').text('Please wait, previous profile is still being added.');
return;
}
var existing = getExistingInvestigationNames();
var localSeen = {};
var queue = rows.filter(function(row) {
var key = ((row && row.name) ? row.name : '').toString().trim().toLowerCase();
return key && !existing[key];
var key = normalizeInvestigationKey((row && row.name) ? row.name : '');
if (!key) {
return false;
}
if (existing[key] || localSeen[key]) {
return false;
}
localSeen[key] = true;
return true;
});
if (!queue.length) {
@ -2680,19 +3232,26 @@
return;
}
investigationBatchActive = true;
var idx = 0;
var added = 0;
function next() {
if (idx >= queue.length) {
investigationBatchActive = false;
loadInvestigationList();
$('.jsError').removeClass('text-danger text-muted').addClass('text-success').text(added + ' test(s) added.');
markDirty('Investigation shortcuts applied');
return;
}
var row = queue[idx++] || {};
var key = normalizeInvestigationKey(row.name || '');
addInvestigationEntry((row.name || '').toString(), (row.code || '').toString(), function(ok) {
if (ok) {
added++;
if (key) {
existing[key] = true;
}
}
next();
});
@ -2757,6 +3316,13 @@
return;
}
var existing = getExistingInvestigationNames();
var key = normalizeInvestigationKey(name);
if (key && existing[key]) {
$('.jsError').removeClass('text-success text-muted').addClass('text-danger').text('This investigation is already added.');
return;
}
addInvestigationEntry(name, code, function(ok) {
if (ok) {
$('#investigation_name').val('');
@ -2916,12 +3482,15 @@
}
rows.forEach(function(row) {
var rowId = parseInt(row.id || 0, 10);
var dosageText = row.dosage_label || row.dosage || '';
var whenText = row.dosage_when_label || row.dosage_when || '';
var freqText = row.dosage_freq_label || row.dosage_freq || '';
$tb.append('<tr>' +
'<td>' + esc(row.med_name) + '</td>' +
'<td>' + esc(row.med_type) + '</td>' +
'<td>' + esc(row.dosage) + '</td>' +
'<td>' + esc(row.dosage_when) + '</td>' +
'<td>' + esc(row.dosage_freq) + '</td>' +
'<td>' + esc(dosageText) + '</td>' +
'<td>' + esc(whenText) + '</td>' +
'<td>' + esc(freqText) + '</td>' +
'<td>' + esc(row.no_of_days) + '</td>' +
'<td>' + esc(row.qty) + '</td>' +
'<td>' + esc(row.remark) + '</td>' +
@ -3453,6 +4022,37 @@
});
});
$('#btn_clear_medicine').on('click', function() {
var ids = [];
$('#tbl_medicine tbody .btn-del-med').each(function() {
var id = parseInt($(this).data('id') || '0', 10);
if (id > 0) {
ids.push(id);
}
});
if (!ids.length) {
return;
}
var idx = 0;
function removeNext() {
if (idx >= ids.length) {
clearMedicineForm(true);
loadMedicineList();
$('.jsError').removeClass('text-danger text-muted').addClass('text-success').text('All medicines removed.');
markDirty('Medicine list cleared');
return;
}
var id = ids[idx++];
apiPost('<?= base_url('Opd_prescription/medicine_remove') ?>/' + id, {}, function() {
removeNext();
});
}
removeNext();
});
$(document).on('click', '.med-chip', function() {
var target = $(this).data('target');
var value = $(this).data('value') || '';
@ -3640,7 +4240,7 @@
});
if (localStorage.getItem(draftKey)) {
setStatus('dirty', 'Draft available (click Restore Draft)');
setStatus('dirty', 'Draft available');
}
loadAdviceList();

View File

@ -76,7 +76,7 @@ footer: html_myFooter;
<p style="margin-top: 20px; font-size: 10px;">
<strong>Investigation:</strong> {{investigation}}<br>
<strong>Advice:</strong> {{advice}}
<strong>Advice:</strong> {{advice}}{{advice_local_line}}
</p>
<div style="margin-top: 40px; text-align: right;">

View File

@ -0,0 +1,599 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Speech To Text Test</title>
<style>
:root {
--bg1: #f0f9ff;
--bg2: #ecfeff;
--card: #ffffff;
--text: #1f2937;
--muted: #64748b;
--line: #dbeafe;
--focus: #0369a1;
--focus-soft: #e0f2fe;
--danger: #dc2626;
--danger-soft: #fee2e2;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "Segoe UI", Tahoma, sans-serif;
color: var(--text);
background: linear-gradient(130deg, var(--bg1), var(--bg2));
min-height: 100vh;
}
.page {
max-width: 900px;
margin: 0 auto;
padding: 24px 16px;
}
.card {
background: var(--card);
border: 1px solid var(--line);
border-radius: 14px;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.08);
padding: 18px;
}
h1 {
margin: 0 0 8px;
font-size: 24px;
}
.note {
margin: 0 0 16px;
color: var(--muted);
font-size: 14px;
}
.field {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
margin-bottom: 12px;
}
.field input {
width: 100%;
height: 44px;
border-radius: 10px;
border: 1px solid var(--line);
padding: 0 12px;
font-size: 14px;
outline: none;
}
.field input:focus {
border-color: var(--focus);
box-shadow: 0 0 0 3px var(--focus-soft);
}
.mic-btn {
width: 44px;
height: 44px;
border: 1px solid var(--line);
background: #fff;
border-radius: 10px;
font-size: 18px;
line-height: 1;
cursor: pointer;
}
.mic-btn:hover {
border-color: var(--focus);
}
.mic-btn.mode-server {
border-color: #059669;
background: #ecfdf5;
color: #065f46;
}
.mic-btn.mode-browser {
border-color: #1d4ed8;
background: #eff6ff;
color: #1e40af;
}
.mic-btn.mode-off {
border-color: #94a3b8;
background: #f8fafc;
color: #64748b;
cursor: not-allowed;
}
.mic-btn.listening {
border-color: var(--danger);
background: var(--danger-soft);
animation: pulse 1s infinite;
}
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(220, 38, 38, 0.35);
}
70% {
box-shadow: 0 0 0 10px rgba(220, 38, 38, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(220, 38, 38, 0);
}
}
.status {
min-height: 20px;
margin-top: 6px;
font-size: 13px;
color: var(--muted);
}
.status.error {
color: var(--danger);
}
.toolbar {
display: flex;
justify-content: space-between;
gap: 10px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.toolbar select {
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
padding: 8px 10px;
font-size: 13px;
}
.hint-wrap {
margin-bottom: 12px;
}
.hint-wrap label {
display: block;
font-size: 12px;
color: var(--muted);
margin-bottom: 6px;
}
.hint-wrap input {
width: 100%;
height: 40px;
border-radius: 10px;
border: 1px solid var(--line);
padding: 0 12px;
font-size: 13px;
outline: none;
}
.hint-wrap input:focus {
border-color: var(--focus);
box-shadow: 0 0 0 3px var(--focus-soft);
}
.legend {
display: flex;
gap: 12px;
flex-wrap: wrap;
font-size: 12px;
color: var(--muted);
margin-bottom: 12px;
}
.legend-item {
display: inline-flex;
align-items: center;
gap: 6px;
}
.legend-dot {
width: 10px;
height: 10px;
border-radius: 999px;
display: inline-block;
}
.legend-dot.server {
background: #059669;
}
.legend-dot.browser {
background: #1d4ed8;
}
.legend-dot.off {
background: #94a3b8;
}
@media (max-width: 560px) {
.field input {
height: 42px;
}
.mic-btn {
width: 42px;
height: 42px;
}
}
</style>
</head>
<body>
<div class="page">
<div class="card">
<h1>Speech To Text Test</h1>
<p class="note">Click the mic beside any textbox and speak. That textbox will be updated.</p>
<div class="toolbar">
<div>
<label for="speech-lang">Language:</label>
<select id="speech-lang">
<option value="en-IN">English (India)</option>
<option value="hi-IN">Hindi (India)</option>
<option value="en-US">English (US)</option>
</select>
</div>
</div>
<div class="legend">
<span class="legend-item"><span class="legend-dot server"></span>Server API mode</span>
<span class="legend-item"><span class="legend-dot browser"></span>Browser fallback mode</span>
<span class="legend-item"><span class="legend-dot off"></span>Speech unavailable</span>
</div>
<div class="hint-wrap">
<label for="medical-context">Optional medical terms hint (comma separated):</label>
<input id="medical-context" type="text" placeholder="Example: Paracetamol, Cefixime, Amoxicillin clavulanate, Hypertension, Diabetes Mellitus">
</div>
<div class="field">
<input id="speech-input-1" type="text" placeholder="Textbox 1">
<button type="button" class="mic-btn" data-target="speech-input-1" aria-label="Speak for textbox 1">🎤</button>
</div>
<div class="field">
<input id="speech-input-2" type="text" placeholder="Textbox 2">
<button type="button" class="mic-btn" data-target="speech-input-2" aria-label="Speak for textbox 2">🎤</button>
</div>
<div class="field">
<input id="speech-input-3" type="text" placeholder="Textbox 3">
<button type="button" class="mic-btn" data-target="speech-input-3" aria-label="Speak for textbox 3">🎤</button>
</div>
<div class="field">
<input id="speech-input-4" type="text" placeholder="Textbox 4">
<button type="button" class="mic-btn" data-target="speech-input-4" aria-label="Speak for textbox 4">🎤</button>
</div>
<div class="field">
<input id="speech-input-5" type="text" placeholder="Textbox 5">
<button type="button" class="mic-btn" data-target="speech-input-5" aria-label="Speak for textbox 5">🎤</button>
</div>
<div id="speech-status" class="status"></div>
</div>
</div>
<script>
(function () {
const statusEl = document.getElementById('speech-status');
const micButtons = document.querySelectorAll('.mic-btn');
const langEl = document.getElementById('speech-lang');
const medicalContextEl = document.getElementById('medical-context');
const SERVER_TRANSCRIBE_URL = 'http://139.59.13.39:8000/stt/transcribe';
const SERVER_HEALTH_URL = 'http://139.59.13.39:8000/health';
const SERVER_TIMEOUT_MS = 4000;
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
const hasBrowserSTT = !!SpeechRecognition;
const hasMediaRecorder = typeof window.MediaRecorder !== 'undefined';
let mode = 'off';
let recognition = null;
let activeInput = null;
let activeButton = null;
let mediaStream = null;
let mediaRecorder = null;
let mediaChunks = [];
let isServerRecording = false;
function setStatus(text, isError) {
statusEl.textContent = text;
if (isError) {
statusEl.classList.add('error');
} else {
statusEl.classList.remove('error');
}
}
function setListeningButton(button) {
micButtons.forEach((item) => item.classList.remove('listening'));
if (button) {
button.classList.add('listening');
}
}
function applyMode(newMode, message, isError) {
mode = newMode;
micButtons.forEach((button) => {
button.classList.remove('mode-server', 'mode-browser', 'mode-off');
if (mode === 'server') {
button.classList.add('mode-server');
button.disabled = false;
} else if (mode === 'browser') {
button.classList.add('mode-browser');
button.disabled = false;
} else {
button.classList.add('mode-off');
button.disabled = true;
}
});
setStatus(message, !!isError);
}
function setupBrowserRecognition() {
if (!hasBrowserSTT) {
return;
}
recognition = new SpeechRecognition();
recognition.lang = langEl ? langEl.value : 'en-IN';
recognition.interimResults = true;
recognition.continuous = false;
recognition.onresult = function (event) {
let transcript = '';
for (let i = event.resultIndex; i < event.results.length; i++) {
transcript += event.results[i][0].transcript;
}
if (activeInput) {
activeInput.value = transcript.trim();
}
};
recognition.onerror = function (event) {
setListeningButton(null);
activeButton = null;
setStatus('Speech error: ' + event.error, true);
};
recognition.onend = function () {
setListeningButton(null);
activeButton = null;
setStatus('Done.', false);
};
}
async function checkServerAvailability() {
if (!window.fetch || !window.AbortController) {
return false;
}
const controller = new AbortController();
const timeoutId = window.setTimeout(function () {
controller.abort();
}, SERVER_TIMEOUT_MS);
try {
const response = await fetch(SERVER_HEALTH_URL, {
method: 'GET',
mode: 'cors',
signal: controller.signal,
});
return response.ok;
} catch (error) {
return false;
} finally {
window.clearTimeout(timeoutId);
}
}
async function sendAudioToServer(audioBlob) {
const formData = new FormData();
formData.append('audio', audioBlob, 'speech.webm');
formData.append('lang', langEl ? langEl.value : 'en-IN');
formData.append('medical_context', medicalContextEl ? medicalContextEl.value : '');
const response = await fetch(SERVER_TRANSCRIBE_URL, {
method: 'POST',
mode: 'cors',
body: formData,
});
if (!response.ok) {
let detail = '';
try {
const errorPayload = await response.json();
detail = String(errorPayload.detail || '').trim();
} catch (ignoreError) {
detail = '';
}
const err = new Error(detail || ('Server response: ' + response.status));
err.statusCode = response.status;
throw err;
}
const payload = await response.json();
const text = String(payload.text || payload.transcript || payload.result || '').trim();
if (!text) {
throw new Error('Empty transcript from server');
}
return text;
}
function stopServerStream() {
if (mediaStream) {
mediaStream.getTracks().forEach(function (track) {
track.stop();
});
}
mediaStream = null;
mediaRecorder = null;
mediaChunks = [];
isServerRecording = false;
}
async function startServerRecording(button, input) {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
applyMode(hasBrowserSTT ? 'browser' : 'off', hasBrowserSTT
? 'Microphone recording not available for server mode. Switched to browser fallback.'
: 'Microphone recording is not available in this browser.', !hasBrowserSTT);
return;
}
if (isServerRecording && activeButton === button && mediaRecorder) {
mediaRecorder.stop();
setStatus('Processing audio on server...', false);
return;
}
if (isServerRecording && mediaRecorder) {
mediaRecorder.stop();
}
activeInput = input;
activeButton = button;
setListeningButton(button);
try {
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorder = new MediaRecorder(mediaStream);
mediaChunks = [];
mediaRecorder.ondataavailable = function (event) {
if (event.data && event.data.size > 0) {
mediaChunks.push(event.data);
}
};
mediaRecorder.onstop = async function () {
const audioBlob = new Blob(mediaChunks, { type: mediaRecorder && mediaRecorder.mimeType ? mediaRecorder.mimeType : 'audio/webm' });
stopServerStream();
setListeningButton(null);
if (!activeInput) {
return;
}
try {
const transcript = await sendAudioToServer(audioBlob);
activeInput.value = transcript;
setStatus('Transcription complete using server API.', false);
} catch (error) {
if (error && error.statusCode && error.statusCode >= 400 && error.statusCode < 500) {
setStatus('Server validation error: ' + (error.message || 'Invalid request'), true);
} else if (hasBrowserSTT) {
applyMode('browser', 'Server API unavailable. Switched to browser fallback mode.', false);
} else {
applyMode('off', 'Server API unavailable and browser fallback is not supported.', true);
}
} finally {
activeButton = null;
}
};
mediaRecorder.start();
isServerRecording = true;
setStatus('Recording... click same mic again to stop and transcribe.', false);
} catch (error) {
stopServerStream();
setListeningButton(null);
if (hasBrowserSTT) {
applyMode('browser', 'Could not start server recording. Switched to browser fallback mode.', false);
} else {
applyMode('off', 'Could not access microphone and fallback is unavailable.', true);
}
}
}
function startBrowserRecognition(button, input) {
if (!recognition) {
applyMode('off', 'Browser speech recognition is not supported in this browser.', true);
return;
}
if (activeButton === button) {
recognition.stop();
setStatus('Stopped listening.', false);
return;
}
activeInput = input;
activeButton = button;
setListeningButton(button);
setStatus('Listening with browser fallback... please speak now.', false);
try {
recognition.start();
} catch (error) {
setStatus('Could not start browser speech recognition. Please try again.', true);
}
}
if (langEl) {
langEl.addEventListener('change', function () {
if (recognition) {
recognition.lang = langEl.value;
}
});
}
micButtons.forEach(function (button) {
button.addEventListener('click', function () {
const targetId = button.getAttribute('data-target');
const input = targetId ? document.getElementById(targetId) : null;
if (!input) {
return;
}
if (mode === 'server') {
startServerRecording(button, input);
return;
}
if (mode === 'browser') {
startBrowserRecognition(button, input);
}
});
});
(async function init() {
setupBrowserRecognition();
const serverAvailable = hasMediaRecorder && await checkServerAvailability();
if (serverAvailable) {
applyMode('server', 'Server API mode active. Mic color indicates server transcription.', false);
return;
}
if (hasBrowserSTT) {
applyMode('browser', 'Server API unavailable. Browser fallback mode is active (medical-term accuracy may be lower).', false);
return;
}
applyMode('off', 'Speech is unavailable: server API is down and browser fallback is not supported.', true);
})();
})();
</script>
</body>
</html>